diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 67bd137b8e..167721a7f0 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 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/provider/sdk-config.ts b/packages/shared/provider/sdk-config.ts index e884bc7190..dfbb1dff29 100644 --- a/packages/shared/provider/sdk-config.ts +++ b/packages/shared/provider/sdk-config.ts @@ -32,6 +32,18 @@ export interface AiSdkConfigContext { */ isOpenAIChatCompletionOnlyModel?: (modelId: string) => boolean + /** + * Check if provider supports stream options + * Default: returns true + */ + isSupportStreamOptionsProvider?: (provider: MinimalProvider) => boolean + + /** + * Get includeUsage setting for stream options + * Default: returns undefined + */ + getIncludeUsageSetting?: () => boolean | undefined | Promise + /** * Get Copilot default headers (constants) * Default: returns empty object @@ -106,6 +118,8 @@ export function providerToAiSdkConfig( context: AiSdkConfigContext = {} ): AiSdkConfig { const isOpenAIChatCompletionOnlyModel = context.isOpenAIChatCompletionOnlyModel || (() => false) + const isSupportStreamOptionsProvider = context.isSupportStreamOptionsProvider || (() => true) + const getIncludeUsageSetting = context.getIncludeUsageSetting || (() => undefined) const aiSdkProviderId = getAiSdkProviderId(provider) @@ -116,6 +130,12 @@ export function providerToAiSdkConfig( apiKey: provider.apiKey } + let includeUsage: boolean | undefined = undefined + if (isSupportStreamOptionsProvider(provider)) { + const setting = getIncludeUsageSetting() + includeUsage = setting instanceof Promise ? undefined : setting + } + // Handle Copilot specially if (provider.id === SystemProviderIds.copilot) { const defaultHeaders = context.getCopilotDefaultHeaders?.() ?? {} @@ -127,7 +147,7 @@ export function providerToAiSdkConfig( ...provider.extra_headers }, name: provider.id, - includeUsage: true + includeUsage } if (context.fetch) { copilotExtraOptions.fetch = context.fetch @@ -253,7 +273,7 @@ export function providerToAiSdkConfig( ...options, name: provider.id, ...extraOptions, - includeUsage: true + includeUsage } } } diff --git a/src/main/apiServer/services/unified-messages.ts b/src/main/apiServer/services/unified-messages.ts index 27037f1a84..5525d85c44 100644 --- a/src/main/apiServer/services/unified-messages.ts +++ b/src/main/apiServer/services/unified-messages.ts @@ -27,6 +27,7 @@ import { isAnthropicProvider, isGeminiProvider, isOpenAIProvider, + type MinimalProvider, type ProviderFormatContext, providerToAiSdkConfig as sharedProviderToAiSdkConfig, resolveActualProvider, @@ -97,7 +98,15 @@ function getMainProcessFormatContext(): ProviderFormatContext { } } +function isSupportStreamOptionsProvider(provider: MinimalProvider): boolean { + const NOT_SUPPORT_STREAM_OPTIONS_PROVIDERS = ['mistral'] as const + return !NOT_SUPPORT_STREAM_OPTIONS_PROVIDERS.some((pid) => pid === provider.id) +} + const mainProcessSdkContext: AiSdkConfigContext = { + isSupportStreamOptionsProvider, + getIncludeUsageSetting: () => + reduxService.selectSync('state.settings.openAI?.streamOptions?.includeUsage'), fetch: net.fetch as typeof globalThis.fetch } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e537b85261..444ca5fb8e 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 } }) @@ -595,6 +577,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/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/preload/index.ts b/src/preload/index.ts index 92f44075aa..25b1064d49 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -221,6 +221,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') { 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 22ef654da8..c63c125bd4 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -22,21 +22,15 @@ vi.mock('@renderer/services/AssistantService', () => ({ }) })) -vi.mock('@renderer/store', () => ({ - default: { - getState: () => ({ - copilot: { defaultHeaders: {} }, - llm: { - settings: { - vertexai: { - projectId: 'test-project', - location: 'us-central1' - } - } - } - }) +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) => { @@ -103,6 +97,8 @@ import { formatApiHost } from '@shared/api' 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 { @@ -156,6 +152,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', () => { @@ -191,6 +197,16 @@ describe('CherryAI provider configuration', () => { ...(globalThis as any).window, keyv: createWindowKeyv() } + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) vi.clearAllMocks() }) @@ -255,6 +271,16 @@ describe('Perplexity provider configuration', () => { ...(globalThis as any).window, keyv: createWindowKeyv() } + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) vi.clearAllMocks() }) @@ -315,3 +341,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 386e304e32..e4724997e9 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 } from '@renderer/types' +import { isSupportStreamOptionsProvider } from '@renderer/utils/provider' import { type AiSdkConfigContext, formatProviderApiHost as sharedFormatProviderApiHost, @@ -31,6 +32,8 @@ import { getAiSdkProviderId } from './factory' function createRendererSdkContext(model: Model): AiSdkConfigContext { return { isOpenAIChatCompletionOnlyModel: () => isOpenAIChatCompletionOnlyModel(model), + isSupportStreamOptionsProvider: (provider) => isSupportStreamOptionsProvider(provider as Provider), + getIncludeUsageSetting: () => store.getState().settings.openAI?.streamOptions?.includeUsage, getCopilotDefaultHeaders: () => COPILOT_DEFAULT_HEADERS, getCopilotStoredHeaders: () => store.getState().copilot.defaultHeaders ?? {}, getAwsBedrockConfig: () => { 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/VirtualList/__tests__/DynamicVirtualList.test.tsx b/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx index c9cbbf7ddc..cf1ff29544 100644 --- a/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx +++ b/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx @@ -140,11 +140,11 @@ describe('DynamicVirtualList', () => { // Should call isSticky function during rendering expect(isSticky).toHaveBeenCalled() - // Should apply sticky styles to sticky items + // Sticky items within visible range should have proper z-index but may be absolute until scrolled const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement expect(stickyItem).toBeInTheDocument() - expect(stickyItem).toHaveStyle('position: sticky') - expect(stickyItem).toHaveStyle('z-index: 1') + // When sticky item is in visible range, it gets z-index but may not be sticky yet + expect(stickyItem).toHaveStyle('z-index: 999') }) it('should apply absolute positioning to non-sticky items', () => { diff --git a/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap b/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap index c5567f9b08..7bf4582822 100644 --- a/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap +++ b/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap @@ -24,7 +24,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = ` >
basic rendering > snapshot test 1`] = `
basic rendering > snapshot test 1`] = `
extends InheritedVirtualizerOptions */ isSticky?: (index: number) => boolean + /** + * Get the depth/level of an item for hierarchical sticky positioning + * Used with isSticky to determine ancestor relationships + */ + getItemDepth?: (index: number) => number + /** * Range extractor function, cannot be used with isSticky */ @@ -101,6 +107,7 @@ function DynamicVirtualList(props: DynamicVirtualListProps) { size, estimateSize, isSticky, + getItemDepth, rangeExtractor: customRangeExtractor, itemContainerStyle, scrollerStyle, @@ -115,7 +122,7 @@ function DynamicVirtualList(props: DynamicVirtualListProps) { const internalScrollerRef = useRef(null) const scrollerRef = internalScrollerRef - const activeStickyIndexRef = useRef(0) + const activeStickyIndexesRef = useRef([]) const stickyIndexes = useMemo(() => { if (!isSticky) return [] @@ -124,21 +131,54 @@ function DynamicVirtualList(props: DynamicVirtualListProps) { const internalStickyRangeExtractor = useCallback( (range: Range) => { - // The active sticky index is the last one that is before or at the start of the visible range - const newActiveStickyIndex = - [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? stickyIndexes[0] ?? 0 + const activeStickies: number[] = [] - if (newActiveStickyIndex !== activeStickyIndexRef.current) { - activeStickyIndexRef.current = newActiveStickyIndex + if (getItemDepth) { + // With depth information, we can build a proper ancestor chain + // Find all sticky items before the visible range + const stickiesBeforeRange = stickyIndexes.filter((index) => index < range.startIndex) + + if (stickiesBeforeRange.length > 0) { + // Find the depth of the first visible item (or last sticky before it) + const firstVisibleIndex = range.startIndex + const referenceDepth = getItemDepth(firstVisibleIndex) + + // Build ancestor chain: include all sticky parents + const ancestorChain: number[] = [] + let minDepth = referenceDepth + + // Walk backwards from the last sticky before visible range + for (let i = stickiesBeforeRange.length - 1; i >= 0; i--) { + const stickyIndex = stickiesBeforeRange[i] + const stickyDepth = getItemDepth(stickyIndex) + + // Include this sticky if it's a parent (smaller depth) of our reference + if (stickyDepth < minDepth) { + ancestorChain.unshift(stickyIndex) + minDepth = stickyDepth + } + } + + activeStickies.push(...ancestorChain) + } + } else { + // Fallback: without depth info, just use the last sticky before range + const lastStickyBeforeRange = [...stickyIndexes].reverse().find((index) => index < range.startIndex) + if (lastStickyBeforeRange !== undefined) { + activeStickies.push(lastStickyBeforeRange) + } } - // Merge the active sticky index and the default range extractor - const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)]) + // Update the ref with current active stickies + activeStickyIndexesRef.current = activeStickies + + // Merge the active sticky indexes and the default range extractor + const next = new Set([...activeStickyIndexesRef.current, ...defaultRangeExtractor(range)]) // Sort the set to maintain proper order return [...next].sort((a, b) => a - b) }, - [stickyIndexes] + [stickyIndexes, getItemDepth] ) const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined) @@ -221,14 +261,47 @@ function DynamicVirtualList(props: DynamicVirtualListProps) { }}> {virtualItems.map((virtualItem) => { const isItemSticky = stickyIndexes.includes(virtualItem.index) - const isItemActiveSticky = isItemSticky && activeStickyIndexRef.current === virtualItem.index + const isItemActiveSticky = isItemSticky && activeStickyIndexesRef.current.includes(virtualItem.index) + + // Calculate the sticky offset for multi-level sticky headers + const activeStickyIndex = isItemActiveSticky ? activeStickyIndexesRef.current.indexOf(virtualItem.index) : -1 + + // Calculate cumulative offset based on actual sizes of previous sticky items + let stickyOffset = 0 + if (activeStickyIndex >= 0) { + for (let i = 0; i < activeStickyIndex; i++) { + const prevStickyIndex = activeStickyIndexesRef.current[i] + stickyOffset += estimateSize(prevStickyIndex) + } + } + + // Check if this item is visually covered by sticky items + // If covered, disable pointer events to prevent hover/click bleeding through + const isCoveredBySticky = (() => { + if (!activeStickyIndexesRef.current.length) return false + if (isItemActiveSticky) return false // Sticky items themselves are not covered + + // Calculate if this item's visual position is under any sticky header + const itemVisualTop = virtualItem.start + let totalStickyHeight = 0 + for (const stickyIdx of activeStickyIndexesRef.current) { + totalStickyHeight += estimateSize(stickyIdx) + } + + // If item starts within the sticky area, it's covered + return itemVisualTop < totalStickyHeight + })() const style: React.CSSProperties = { ...itemContainerStyle, position: isItemActiveSticky ? 'sticky' : 'absolute', - top: 0, + top: isItemActiveSticky ? stickyOffset : 0, left: 0, - zIndex: isItemSticky ? 1 : undefined, + zIndex: isItemActiveSticky ? 1000 + (100 - activeStickyIndex) : isItemSticky ? 999 : 0, + pointerEvents: isCoveredBySticky ? 'none' : 'auto', + ...(isItemActiveSticky && { + backgroundColor: 'var(--color-background)' + }), ...(horizontal ? { transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`, 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/hooks/useInPlaceEdit.ts b/src/renderer/src/hooks/useInPlaceEdit.ts index 675de75c7c..ab614f9528 100644 --- a/src/renderer/src/hooks/useInPlaceEdit.ts +++ b/src/renderer/src/hooks/useInPlaceEdit.ts @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react' - -import { useTimer } from './useTimer' +import { loggerService } from '@logger' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +const logger = loggerService.withContext('useInPlaceEdit') export interface UseInPlaceEditOptions { onSave: ((value: string) => void) | ((value: string) => Promise) onCancel?: () => void + onError?: (error: unknown) => void autoSelectOnStart?: boolean trimOnSave?: boolean } @@ -12,14 +14,10 @@ export interface UseInPlaceEditOptions { export interface UseInPlaceEditReturn { isEditing: boolean isSaving: boolean - editValue: string - inputRef: React.RefObject startEdit: (initialValue: string) => void saveEdit: () => void cancelEdit: () => void - handleKeyDown: (e: React.KeyboardEvent) => void - handleInputChange: (e: React.ChangeEvent) => void - handleValueChange: (value: string) => void + inputProps: React.InputHTMLAttributes & { ref: React.RefObject } } /** @@ -32,63 +30,69 @@ export interface UseInPlaceEditReturn { * @returns An object containing the editing state and handler functions */ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditReturn { - const { onSave, onCancel, autoSelectOnStart = true, trimOnSave = true } = options + const { onSave, onCancel, onError, autoSelectOnStart = true, trimOnSave = true } = options + const { t } = useTranslation() const [isSaving, setIsSaving] = useState(false) const [isEditing, setIsEditing] = useState(false) const [editValue, setEditValue] = useState('') - const [originalValue, setOriginalValue] = useState('') + const originalValueRef = useRef('') const inputRef = useRef(null) - const { setTimeoutTimer } = useTimer() - const startEdit = useCallback( - (initialValue: string) => { - setIsEditing(true) - setEditValue(initialValue) - setOriginalValue(initialValue) + const startEdit = useCallback((initialValue: string) => { + setIsEditing(true) + setEditValue(initialValue) + originalValueRef.current = initialValue + }, []) - setTimeoutTimer( - 'startEdit', - () => { - inputRef.current?.focus() - if (autoSelectOnStart) { - inputRef.current?.select() - } - }, - 0 - ) - }, - [autoSelectOnStart, setTimeoutTimer] - ) + useLayoutEffect(() => { + if (isEditing) { + inputRef.current?.focus() + if (autoSelectOnStart) { + inputRef.current?.select() + } + } + }, [autoSelectOnStart, isEditing]) const saveEdit = useCallback(async () => { if (isSaving) return + const finalValue = trimOnSave ? editValue.trim() : editValue + if (finalValue === originalValueRef.current) { + setIsEditing(false) + return + } + setIsSaving(true) try { - const finalValue = trimOnSave ? editValue.trim() : editValue - if (finalValue !== originalValue) { - await onSave(finalValue) - } + await onSave(finalValue) setIsEditing(false) setEditValue('') - setOriginalValue('') + } catch (error) { + logger.error('Error saving in-place edit', { error }) + + // Call custom error handler if provided, otherwise show default toast + if (onError) { + onError(error) + } else { + window.toast.error(t('common.save_failed') || 'Failed to save') + } } finally { setIsSaving(false) } - }, [isSaving, trimOnSave, editValue, originalValue, onSave]) + }, [isSaving, trimOnSave, editValue, onSave, onError, t]) const cancelEdit = useCallback(() => { setIsEditing(false) setEditValue('') - setOriginalValue('') onCancel?.() }, [onCancel]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + if (e.nativeEvent.isComposing) return + if (e.key === 'Enter') { e.preventDefault() saveEdit() } else if (e.key === 'Escape') { @@ -104,37 +108,29 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe setEditValue(e.target.value) }, []) - const handleValueChange = useCallback((value: string) => { - setEditValue(value) - }, []) - - // Handle clicks outside the input to save - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (isEditing && inputRef.current && !inputRef.current.contains(event.target as Node)) { - saveEdit() - } + const handleBlur = useCallback(() => { + // 这里的逻辑需要注意: + // 如果点击了“取消”按钮,可能会先触发 Blur 保存。 + // 通常 InPlaceEdit 的逻辑是 Blur 即 Save。 + // 如果不想 Blur 保存,可以去掉这一行,或者判断 relatedTarget。 + if (!isSaving) { + saveEdit() } - - if (isEditing) { - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - } - return - }, [isEditing, saveEdit]) + }, [saveEdit, isSaving]) return { isEditing, isSaving, - editValue, - inputRef, startEdit, saveEdit, cancelEdit, - handleKeyDown, - handleInputChange, - handleValueChange + inputProps: { + ref: inputRef, + value: editValue, + onChange: handleInputChange, + onKeyDown: handleKeyDown, + onBlur: handleBlur, + disabled: isSaving // 保存时禁用输入 + } } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 427cbdcffd..e8fdad0afb 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "New Folder", "untitled_note": "Untitled Note", "upload_failed": "Note upload failed", - "upload_success": "Note uploaded success" + "upload_files": "Upload Files", + "upload_folder": "Upload Folder", + "upload_success": "Note uploaded success", + "uploading_files": "Uploading {{count}} files..." }, "notification": { "assistant": "Assistant Response", @@ -4271,6 +4275,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 69f4e63ee4..0ce7627392 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "新文件夹", "untitled_note": "无标题笔记", "upload_failed": "笔记上传失败", - "upload_success": "笔记上传成功" + "upload_files": "上传文件", + "upload_folder": "上传文件夹", + "upload_success": "笔记上传成功", + "uploading_files": "正在上传 {{count}} 个文件..." }, "notification": { "assistant": "助手响应", @@ -4271,6 +4275,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 d21f66ccb3..20a3d84df2 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "新資料夾", "untitled_note": "無標題筆記", "upload_failed": "筆記上傳失敗", - "upload_success": "筆記上傳成功" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "筆記上傳成功", + "uploading_files": "正在上傳 {{count}} 個檔案..." }, "notification": { "assistant": "助手回應", @@ -4271,6 +4275,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 7d11af7a11..aaed5b498e 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "Neuer Ordner", "untitled_note": "Unbenannte Notiz", "upload_failed": "Notizen-Upload fehlgeschlagen", - "upload_success": "Notizen erfolgreich hochgeladen" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Notizen erfolgreich hochgeladen", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Assistenten-Antwort", @@ -4271,6 +4275,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 f451e8af33..f8125631a9 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "Νέος φάκελος", "untitled_note": "σημείωση χωρίς τίτλο", "upload_failed": "Η σημείωση δεν ανέβηκε", - "upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Απάντηση Βοηθού", @@ -4271,6 +4275,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 ee2b03d06b..2a5874f6b6 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "Nueva carpeta", "untitled_note": "Nota sin título", "upload_failed": "Error al cargar la nota", - "upload_success": "Nota cargada con éxito" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Nota cargada con éxito", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Respuesta del asistente", @@ -4271,6 +4275,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 e909edc257..bc884c8c69 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "nouveau dossier", "untitled_note": "Note sans titre", "upload_failed": "Échec du téléchargement de la note", - "upload_success": "Note téléchargée avec succès" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Note téléchargée avec succès", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Réponse de l'assistant", @@ -4271,6 +4275,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 aa705da38d..f1c0fe575d 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "新ファイル夹", "untitled_note": "無題のメモ", "upload_failed": "ノートのアップロードに失敗しました", - "upload_success": "ノートのアップロードが成功しました" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "ノートのアップロードが成功しました", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "助手回應", @@ -4271,6 +4275,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 056306838e..33976b2e1f 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "Nova pasta", "untitled_note": "Nota sem título", "upload_failed": "Falha ao carregar a nota", - "upload_success": "Nota carregada com sucesso" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Nota carregada com sucesso", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Resposta do assistente", @@ -4271,6 +4275,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 12d696ec86..fbdffdb379 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": { @@ -2219,7 +2220,10 @@ "untitled_folder": "Новая папка", "untitled_note": "Незаглавленная заметка", "upload_failed": "Не удалось загрузить заметку", - "upload_success": "Заметка успешно загружена" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Заметка успешно загружена", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Ответ ассистента", @@ -4271,6 +4275,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/home/Tabs/components/SessionItem.tsx b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx index 2c719fa132..2fb9652d46 100644 --- a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx @@ -42,7 +42,7 @@ const SessionItem: FC = ({ session, agentId, onDelete, onPress const targetSession = useDeferredValue(_targetSession) const dispatch = useAppDispatch() - const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({ + const { isEditing, isSaving, startEdit, inputProps } = useInPlaceEdit({ onSave: async (value) => { if (value !== session.name) { await updateSession({ id: session.id, name: value }) @@ -179,14 +179,7 @@ const SessionItem: FC = ({ session, agentId, onDelete, onPress {isFulfilled && !isActive && } {isEditing ? ( - ) => handleValueChange(e.target.value)} - onKeyDown={handleKeyDown} - onClick={(e: React.MouseEvent) => e.stopPropagation()} - style={{ opacity: isSaving ? 0.5 : 1 }} - /> + ) : ( <> diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index 7219f7d383..c9b4b5ea41 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -81,7 +81,7 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se const deleteTimerRef = useRef(null) const [editingTopicId, setEditingTopicId] = useState(null) - const topicEdit = useInPlaceEdit({ + const { startEdit, isEditing, inputProps } = useInPlaceEdit({ onSave: (name: string) => { const topic = assistant.topics.find((t) => t.id === editingTopicId) if (topic && name !== topic.name) { @@ -526,29 +526,23 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se setTargetTopic(topic)} className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')} - onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)} + onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)} style={{ borderRadius, - cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer' + cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer' }}> {isPending(topic.id) && !isActive && } {isFulfilled(topic.id) && !isActive && } - {editingTopicId === topic.id && topicEdit.isEditing ? ( - e.stopPropagation()} - /> + {editingTopicId === topic.id && isEditing ? ( + e.stopPropagation()} /> ) : ( { setEditingTopicId(topic.id) - topicEdit.startEdit(topic.name) + startEdit(topic.name) }}> {topicName} diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index 105ceee36a..7692aa9975 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -295,6 +295,16 @@ const NotesPage: FC = () => { break } + case 'refresh': { + // 批量操作完成后的单次刷新 + logger.debug('Received refresh event, triggering tree refresh') + const refresh = refreshTreeRef.current + if (refresh) { + await refresh() + } + break + } + case 'add': case 'addDir': case 'unlink': @@ -621,7 +631,27 @@ const NotesPage: FC = () => { throw new Error('No folder path selected') } - const result = await uploadNotes(files, targetFolderPath) + // Validate uploadNotes function is available + if (typeof uploadNotes !== 'function') { + logger.error('uploadNotes function is not available', { uploadNotes }) + window.toast.error(t('notes.upload_failed')) + return + } + + let result: Awaited> + try { + result = await uploadNotes(files, targetFolderPath) + } catch (uploadError) { + logger.error('Upload operation failed:', uploadError as Error) + throw uploadError + } + + // Validate result object + if (!result || typeof result !== 'object') { + logger.error('Invalid upload result:', { result }) + window.toast.error(t('notes.upload_failed')) + return + } // 检查上传结果 if (result.fileCount === 0) { diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 8663d9625d..6ed144dd7e 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -1,47 +1,31 @@ -import { loggerService } from '@logger' -import HighlightText from '@renderer/components/HighlightText' -import { DeleteIcon } from '@renderer/components/Icons' -import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' -import Scrollbar from '@renderer/components/Scrollbar' -import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit' -import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { DynamicVirtualList } from '@renderer/components/VirtualList' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' -import { fetchNoteSummary } from '@renderer/services/ApiService' -import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import type { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService' -import type { RootState } from '@renderer/store' import { useAppSelector } from '@renderer/store' import { selectSortType } from '@renderer/store/note' import type { NotesSortType, NotesTreeNode } from '@renderer/types/note' -import { exportNote } from '@renderer/utils/export' -import { useVirtualizer } from '@tanstack/react-virtual' -import type { InputRef, MenuProps } from 'antd' -import { Dropdown, Input } from 'antd' -import type { ItemType, MenuItemType } from 'antd/es/menu/interface' -import { - ChevronDown, - ChevronRight, - Edit3, - File, - FilePlus, - FileSearch, - Folder, - FolderOpen, - Loader2, - Sparkles, - Star, - StarOff, - UploadIcon, - X -} from 'lucide-react' -import type { FC, Ref } from 'react' +import type { MenuProps } from 'antd' +import { Dropdown } from 'antd' +import { FilePlus, Folder, FolderUp, Loader2, Upload, X } from 'lucide-react' +import type { FC } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector } from 'react-redux' import styled from 'styled-components' +import TreeNode from './components/TreeNode' +import { + NotesActionsContext, + NotesDragContext, + NotesEditingContext, + NotesSearchContext, + NotesSelectionContext, + NotesUIContext +} from './context/NotesContexts' import { useFullTextSearch } from './hooks/useFullTextSearch' +import { useNotesDragAndDrop } from './hooks/useNotesDragAndDrop' +import { useNotesEditing } from './hooks/useNotesEditing' +import { useNotesFileUpload } from './hooks/useNotesFileUpload' +import { useNotesMenu } from './hooks/useNotesMenu' interface NotesSidebarProps { onCreateFolder: (name: string, targetFolderId?: string) => void @@ -58,278 +42,6 @@ interface NotesSidebarProps { selectedFolderId?: string | null } -const logger = loggerService.withContext('NotesSidebar') - -interface TreeNodeProps { - node: NotesTreeNode | SearchResult - depth: number - selectedFolderId?: string | null - activeNodeId?: string - editingNodeId: string | null - renamingNodeIds: Set - newlyRenamedNodeIds: Set - draggedNodeId: string | null - dragOverNodeId: string | null - dragPosition: 'before' | 'inside' | 'after' - inPlaceEdit: any - getMenuItems: (node: NotesTreeNode) => any[] - onSelectNode: (node: NotesTreeNode) => void - onToggleExpanded: (nodeId: string) => void - onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void - onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void - onDragLeave: () => void - onDrop: (e: React.DragEvent, node: NotesTreeNode) => void - onDragEnd: () => void - renderChildren?: boolean // 控制是否渲染子节点 - searchKeyword?: string // 搜索关键词,用于高亮 - showMatches?: boolean // 是否显示匹配预览 - openDropdownKey: string | null - onDropdownOpenChange: (key: string | null) => void -} - -const TreeNode = memo( - ({ - node, - depth, - selectedFolderId, - activeNodeId, - editingNodeId, - renamingNodeIds, - newlyRenamedNodeIds, - draggedNodeId, - dragOverNodeId, - dragPosition, - inPlaceEdit, - getMenuItems, - onSelectNode, - onToggleExpanded, - onDragStart, - onDragOver, - onDragLeave, - onDrop, - onDragEnd, - renderChildren = true, - searchKeyword = '', - showMatches = false, - openDropdownKey, - onDropdownOpenChange - }) => { - const { t } = useTranslation() - const [showAllMatches, setShowAllMatches] = useState(false) - - // 检查是否是搜索结果 - const searchResult = 'matchType' in node ? (node as SearchResult) : null - const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0 - - // 处理匹配项点击 - const handleMatchClick = useCallback( - (match: SearchMatch) => { - // 发送定位事件 - EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, { - noteId: node.id, - lineNumber: match.lineNumber, - lineContent: match.lineContent - }) - }, - [node] - ) - - const isActive = selectedFolderId - ? node.type === 'folder' && node.id === selectedFolderId - : node.id === activeNodeId - const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing - const isRenaming = renamingNodeIds.has(node.id) - const isNewlyRenamed = newlyRenamedNodeIds.has(node.id) - const hasChildren = node.children && node.children.length > 0 - const isDragging = draggedNodeId === node.id - const isDragOver = dragOverNodeId === node.id - const isDragBefore = isDragOver && dragPosition === 'before' - const isDragInside = isDragOver && dragPosition === 'inside' - const isDragAfter = isDragOver && dragPosition === 'after' - - const getNodeNameClassName = () => { - if (isRenaming) return 'shimmer' - if (isNewlyRenamed) return 'typing' - return '' - } - - const displayName = useMemo(() => { - if (!searchKeyword) { - return node.name - } - - const name = node.name ?? '' - if (!name) { - return name - } - - const keyword = searchKeyword - const nameLower = name.toLowerCase() - const keywordLower = keyword.toLowerCase() - const matchStart = nameLower.indexOf(keywordLower) - - if (matchStart === -1) { - return name - } - - const matchEnd = matchStart + keyword.length - const beforeMatch = Math.min(2, matchStart) - const contextStart = matchStart - beforeMatch - const contextLength = 50 - const contextEnd = Math.min(name.length, matchEnd + contextLength) - - const prefix = contextStart > 0 ? '...' : '' - const suffix = contextEnd < name.length ? '...' : '' - - return prefix + name.substring(contextStart, contextEnd) + suffix - }, [node.name, searchKeyword]) - - return ( -
- onDropdownOpenChange(open ? node.id : null)}> -
e.stopPropagation()}> - onDragStart(e, node)} - onDragOver={(e) => onDragOver(e, node)} - onDragLeave={onDragLeave} - onDrop={(e) => onDrop(e, node)} - onDragEnd={onDragEnd}> - onSelectNode(node)}> - - - {node.type === 'folder' && ( - { - e.stopPropagation() - onToggleExpanded(node.id) - }} - title={node.expanded ? t('notes.collapse') : t('notes.expand')}> - {node.expanded ? : } - - )} - - - {node.type === 'folder' ? ( - node.expanded ? ( - - ) : ( - - ) - ) : ( - - )} - - - {isEditing ? ( - } - value={inPlaceEdit.editValue} - onChange={inPlaceEdit.handleInputChange} - onBlur={inPlaceEdit.saveEdit} - onKeyDown={inPlaceEdit.handleKeyDown} - onClick={(e) => e.stopPropagation()} - autoFocus - size="small" - /> - ) : ( - - - {searchKeyword ? : node.name} - - {searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && ( - - {searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')} - - )} - - )} - - -
-
- - {showMatches && hasMatches && ( - - {(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => ( - handleMatchClick(match)}> - {match.lineNumber} - - - - - ))} - {searchResult!.matches!.length > 3 && ( - { - e.stopPropagation() - setShowAllMatches(!showAllMatches) - }}> - {showAllMatches ? ( - <> - - {t('notes.search.show_less')} - - ) : ( - <> - +{searchResult!.matches!.length - 3}{' '} - {t('notes.search.more_matches')} - - )} - - )} - - )} - - {renderChildren && node.type === 'folder' && node.expanded && hasChildren && ( -
- {node.children!.map((child) => ( - - ))} -
- )} -
- ) - } -) - const NotesSidebar: FC = ({ onCreateFolder, onCreateNote, @@ -345,28 +57,52 @@ const NotesSidebar: FC = ({ selectedFolderId }) => { const { t } = useTranslation() - const { bases } = useKnowledgeBases() const { activeNode } = useActiveNode(notesTree) const sortType = useAppSelector(selectSortType) - const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions) - const [editingNodeId, setEditingNodeId] = useState(null) - const [renamingNodeIds, setRenamingNodeIds] = useState>(new Set()) - const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState>(new Set()) - const [draggedNodeId, setDraggedNodeId] = useState(null) - const [dragOverNodeId, setDragOverNodeId] = useState(null) - const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside') + const [isShowStarred, setIsShowStarred] = useState(false) const [isShowSearch, setIsShowSearch] = useState(false) const [searchKeyword, setSearchKeyword] = useState('') const [isDragOverSidebar, setIsDragOverSidebar] = useState(false) const [openDropdownKey, setOpenDropdownKey] = useState(null) - const dragNodeRef = useRef(null) - const scrollbarRef = useRef(null) + const notesTreeRef = useRef(notesTree) + const virtualListRef = useRef(null) const trimmedSearchKeyword = useMemo(() => searchKeyword.trim(), [searchKeyword]) const hasSearchKeyword = trimmedSearchKeyword.length > 0 - // 全文搜索配置 + const { editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit, handleStartEdit, handleAutoRename } = + useNotesEditing({ onRenameNode }) + + const { + draggedNodeId, + dragOverNodeId, + dragPosition, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd + } = useNotesDragAndDrop({ onMoveNode }) + + const { handleDropFiles, handleSelectFiles, handleSelectFolder } = useNotesFileUpload({ + onUploadFiles, + setIsDragOverSidebar + }) + + const { getMenuItems } = useNotesMenu({ + renamingNodeIds, + onCreateNote, + onCreateFolder, + onRenameNode, + onToggleStar, + onDeleteNode, + onSelectNode, + handleStartEdit, + handleAutoRename, + activeNode + }) + const searchOptions = useMemo( () => ({ debounceMs: 300, @@ -388,268 +124,10 @@ const NotesSidebar: FC = ({ stats: searchStats } = useFullTextSearch(searchOptions) - const inPlaceEdit = useInPlaceEdit({ - onSave: (newName: string) => { - if (editingNodeId && newName) { - onRenameNode(editingNodeId, newName) - logger.debug(`Renamed node ${editingNodeId} to "${newName}"`) - } - setEditingNodeId(null) - }, - onCancel: () => { - setEditingNodeId(null) - } - }) - - // 滚动到活动节点 - useEffect(() => { - if (activeNode?.id && !isShowStarred && !isShowSearch && scrollbarRef.current) { - // 延迟一下确保DOM已更新 - setTimeout(() => { - const scrollContainer = scrollbarRef.current as HTMLElement - if (scrollContainer) { - const activeElement = scrollContainer.querySelector(`[data-node-id="${activeNode.id}"]`) as HTMLElement - if (activeElement) { - // 获取元素相对于滚动容器的位置 - const containerHeight = scrollContainer.clientHeight - const elementOffsetTop = activeElement.offsetTop - const elementHeight = activeElement.offsetHeight - const currentScrollTop = scrollContainer.scrollTop - - // 检查元素是否在可视区域内 - const elementTop = elementOffsetTop - const elementBottom = elementOffsetTop + elementHeight - const viewTop = currentScrollTop - const viewBottom = currentScrollTop + containerHeight - - // 如果元素不在可视区域内,滚动到中心位置 - if (elementTop < viewTop || elementBottom > viewBottom) { - const targetScrollTop = elementOffsetTop - (containerHeight - elementHeight) / 2 - scrollContainer.scrollTo({ - top: Math.max(0, targetScrollTop), - behavior: 'instant' - }) - } - } - } - }, 200) - } - }, [activeNode?.id, isShowStarred, isShowSearch]) - - const handleCreateFolder = useCallback(() => { - onCreateFolder(t('notes.untitled_folder')) - }, [onCreateFolder, t]) - - const handleCreateNote = useCallback(() => { - onCreateNote(t('notes.untitled_note')) - }, [onCreateNote, t]) - - const handleSelectSortType = useCallback( - (selectedSortType: NotesSortType) => { - onSortNodes(selectedSortType) - }, - [onSortNodes] - ) - - const handleStartEdit = useCallback( - (node: NotesTreeNode) => { - setEditingNodeId(node.id) - inPlaceEdit.startEdit(node.name) - }, - [inPlaceEdit] - ) - - const handleDeleteNode = useCallback( - (node: NotesTreeNode) => { - const confirmText = - node.type === 'folder' - ? t('notes.delete_folder_confirm', { name: node.name }) - : t('notes.delete_note_confirm', { name: node.name }) - - window.modal.confirm({ - title: t('notes.delete'), - content: confirmText, - centered: true, - okButtonProps: { danger: true }, - onOk: () => { - onDeleteNode(node.id) - } - }) - }, - [onDeleteNode, t] - ) - - const handleExportKnowledge = useCallback( - async (note: NotesTreeNode) => { - try { - if (bases.length === 0) { - window.toast.warning(t('chat.save.knowledge.empty.no_knowledge_base')) - return - } - - const result = await SaveToKnowledgePopup.showForNote(note) - - if (result?.success) { - window.toast.success(t('notes.export_success', { count: result.savedCount })) - } - } catch (error) { - window.toast.error(t('notes.export_failed')) - logger.error(`Failed to export note to knowledge base: ${error}`) - } - }, - [bases.length, t] - ) - - const handleImageAction = useCallback( - async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => { - try { - if (activeNode?.id !== node.id) { - onSelectNode(node) - await new Promise((resolve) => setTimeout(resolve, 500)) - } - - await exportNote({ node, platform }) - } catch (error) { - logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error) - window.toast.error(t('common.copy_failed')) - } - }, - [activeNode, onSelectNode, t] - ) - - const handleAutoRename = useCallback( - async (note: NotesTreeNode) => { - if (note.type !== 'file') return - - setRenamingNodeIds((prev) => new Set(prev).add(note.id)) - try { - const content = await window.api.file.readExternal(note.externalPath) - if (!content || content.trim().length === 0) { - window.toast.warning(t('notes.auto_rename.empty_note')) - return - } - - const summaryText = await fetchNoteSummary({ content }) - if (summaryText) { - onRenameNode(note.id, summaryText) - window.toast.success(t('notes.auto_rename.success')) - } else { - window.toast.error(t('notes.auto_rename.failed')) - } - } catch (error) { - window.toast.error(t('notes.auto_rename.failed')) - logger.error(`Failed to auto-rename note: ${error}`) - } finally { - setRenamingNodeIds((prev) => { - const next = new Set(prev) - next.delete(note.id) - return next - }) - - setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id)) - - setTimeout(() => { - setNewlyRenamedNodeIds((prev) => { - const next = new Set(prev) - next.delete(note.id) - return next - }) - }, 700) - } - }, - [onRenameNode, t] - ) - - const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => { - setDraggedNodeId(node.id) - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', node.id) - - dragNodeRef.current = e.currentTarget as HTMLDivElement - - if (e.currentTarget.parentElement) { - const rect = e.currentTarget.getBoundingClientRect() - const ghostElement = e.currentTarget.cloneNode(true) as HTMLElement - ghostElement.style.width = `${rect.width}px` - ghostElement.style.opacity = '0.7' - ghostElement.style.position = 'absolute' - ghostElement.style.top = '-1000px' - document.body.appendChild(ghostElement) - e.dataTransfer.setDragImage(ghostElement, 10, 10) - setTimeout(() => { - document.body.removeChild(ghostElement) - }, 0) - } - }, []) - - const handleDragOver = useCallback( - (e: React.DragEvent, node: NotesTreeNode) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - - if (draggedNodeId === node.id) { - return - } - - setDragOverNodeId(node.id) - - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() - const mouseY = e.clientY - const thresholdTop = rect.top + rect.height * 0.3 - const thresholdBottom = rect.bottom - rect.height * 0.3 - - if (mouseY < thresholdTop) { - setDragPosition('before') - } else if (mouseY > thresholdBottom) { - setDragPosition('after') - } else { - setDragPosition(node.type === 'folder' ? 'inside' : 'after') - } - }, - [draggedNodeId] - ) - - const handleDragLeave = useCallback(() => { - setDragOverNodeId(null) - setDragPosition('inside') - }, []) - - const handleDrop = useCallback( - (e: React.DragEvent, targetNode: NotesTreeNode) => { - e.preventDefault() - const draggedId = e.dataTransfer.getData('text/plain') - - if (draggedId && draggedId !== targetNode.id) { - onMoveNode(draggedId, targetNode.id, dragPosition) - } - - setDraggedNodeId(null) - setDragOverNodeId(null) - setDragPosition('inside') - }, - [onMoveNode, dragPosition] - ) - - const handleDragEnd = useCallback(() => { - setDraggedNodeId(null) - setDragOverNodeId(null) - setDragPosition('inside') - }, []) - - const handleToggleStarredView = useCallback(() => { - setIsShowStarred(!isShowStarred) - }, [isShowStarred]) - - const handleToggleSearchView = useCallback(() => { - setIsShowSearch(!isShowSearch) - }, [isShowSearch]) - - // 同步 notesTree 到 ref useEffect(() => { notesTreeRef.current = notesTree }, [notesTree]) - // 触发全文搜索 useEffect(() => { if (!isShowSearch) { reset() @@ -663,6 +141,61 @@ const NotesSidebar: FC = ({ } }, [isShowSearch, hasSearchKeyword, trimmedSearchKeyword, search, reset]) + // --- Logic --- + + const handleCreateFolder = useCallback(() => { + onCreateFolder(t('notes.untitled_folder')) + }, [onCreateFolder, t]) + + const handleCreateNote = useCallback(() => { + onCreateNote(t('notes.untitled_note')) + }, [onCreateNote, t]) + + const handleToggleStarredView = useCallback(() => { + setIsShowStarred(!isShowStarred) + }, [isShowStarred]) + + const handleToggleSearchView = useCallback(() => { + setIsShowSearch(!isShowSearch) + }, [isShowSearch]) + + const handleSelectSortType = useCallback( + (selectedSortType: NotesSortType) => { + onSortNodes(selectedSortType) + }, + [onSortNodes] + ) + + const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => { + return [ + { + label: t('notes.new_note'), + key: 'new_note', + icon: , + onClick: handleCreateNote + }, + { + label: t('notes.new_folder'), + key: 'new_folder', + icon: , + onClick: handleCreateFolder + }, + { type: 'divider' }, + { + label: t('notes.upload_files'), + key: 'upload_files', + icon: , + onClick: handleSelectFiles + }, + { + label: t('notes.upload_folder'), + key: 'upload_folder', + icon: , + onClick: handleSelectFolder + } + ] + }, [t, handleCreateNote, handleCreateFolder, handleSelectFiles, handleSelectFolder]) + // Flatten tree nodes for virtualization and filtering const flattenedNodes = useMemo(() => { const flattenForVirtualization = ( @@ -706,493 +239,210 @@ const NotesSidebar: FC = ({ } if (isShowStarred) { - // For filtered views, return flat list without virtualization for simplicity const filteredNodes = flattenForFiltering(notesTree) return filteredNodes.map((node) => ({ node, depth: 0 })) } - // For normal tree view, use hierarchical flattening for virtualization return flattenForVirtualization(notesTree) }, [notesTree, isShowStarred, isShowSearch, hasSearchKeyword, searchResults]) - // Use virtualization only for normal tree view with many items - const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100 - - const parentRef = useRef(null) - - const virtualizer = useVirtualizer({ - count: flattenedNodes.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 28, // Estimated height of each tree item - overscan: 10 - }) - - const filteredTree = useMemo(() => { - if (isShowStarred || isShowSearch) { - return flattenedNodes.map(({ node }) => node) + // Scroll to active node + useEffect(() => { + if (activeNode?.id && !isShowStarred && !isShowSearch && virtualListRef.current) { + setTimeout(() => { + const activeIndex = flattenedNodes.findIndex(({ node }) => node.id === activeNode.id) + if (activeIndex !== -1) { + virtualListRef.current?.scrollToIndex(activeIndex, { + align: 'center', + behavior: 'auto' + }) + } + }, 200) } - return notesTree - }, [flattenedNodes, isShowStarred, isShowSearch, notesTree]) + }, [activeNode?.id, isShowStarred, isShowSearch, flattenedNodes]) - const getMenuItems = useCallback( - (node: NotesTreeNode) => { - const baseMenuItems: MenuProps['items'] = [] + // Determine which items should be sticky (only folders in normal view) + const isSticky = useCallback( + (index: number) => { + const item = flattenedNodes[index] + if (!item) return false - // only show auto rename for file for now - if (node.type !== 'folder') { - baseMenuItems.push({ - label: t('notes.auto_rename.label'), - key: 'auto-rename', - icon: , - disabled: renamingNodeIds.has(node.id), - onClick: () => { - handleAutoRename(node) - } - }) - } - - if (node.type === 'folder') { - baseMenuItems.push( - { - label: t('notes.new_note'), - key: 'new_note', - icon: , - onClick: () => { - onCreateNote(t('notes.untitled_note'), node.id) - } - }, - { - label: t('notes.new_folder'), - key: 'new_folder', - icon: , - onClick: () => { - onCreateFolder(t('notes.untitled_folder'), node.id) - } - }, - { type: 'divider' } - ) - } - - baseMenuItems.push( - { - label: t('notes.rename'), - key: 'rename', - icon: , - onClick: () => { - handleStartEdit(node) - } - }, - { - label: t('notes.open_outside'), - key: 'open_outside', - icon: , - onClick: () => { - window.api.openPath(node.externalPath) - } - } - ) - if (node.type !== 'folder') { - baseMenuItems.push( - { - label: node.isStarred ? t('notes.unstar') : t('notes.star'), - key: 'star', - icon: node.isStarred ? : , - onClick: () => { - onToggleStar(node.id) - } - }, - { - label: t('notes.export_knowledge'), - key: 'export_knowledge', - icon: , - onClick: () => { - handleExportKnowledge(node) - } - }, - { - label: t('chat.topics.export.title'), - key: 'export', - icon: , - children: [ - exportMenuOptions.image && { - label: t('chat.topics.copy.image'), - key: 'copy-image', - onClick: () => handleImageAction(node, 'copyImage') - }, - exportMenuOptions.image && { - label: t('chat.topics.export.image'), - key: 'export-image', - onClick: () => handleImageAction(node, 'exportImage') - }, - exportMenuOptions.markdown && { - label: t('chat.topics.export.md.label'), - key: 'markdown', - onClick: () => exportNote({ node, platform: 'markdown' }) - }, - exportMenuOptions.docx && { - label: t('chat.topics.export.word'), - key: 'word', - onClick: () => exportNote({ node, platform: 'docx' }) - }, - exportMenuOptions.notion && { - label: t('chat.topics.export.notion'), - key: 'notion', - onClick: () => exportNote({ node, platform: 'notion' }) - }, - exportMenuOptions.yuque && { - label: t('chat.topics.export.yuque'), - key: 'yuque', - onClick: () => exportNote({ node, platform: 'yuque' }) - }, - exportMenuOptions.obsidian && { - label: t('chat.topics.export.obsidian'), - key: 'obsidian', - onClick: () => exportNote({ node, platform: 'obsidian' }) - }, - exportMenuOptions.joplin && { - label: t('chat.topics.export.joplin'), - key: 'joplin', - onClick: () => exportNote({ node, platform: 'joplin' }) - }, - exportMenuOptions.siyuan && { - label: t('chat.topics.export.siyuan'), - key: 'siyuan', - onClick: () => exportNote({ node, platform: 'siyuan' }) - } - ].filter(Boolean) as ItemType[] - } - ) - } - baseMenuItems.push( - { type: 'divider' }, - { - label: t('notes.delete'), - danger: true, - key: 'delete', - icon: , - onClick: () => { - handleDeleteNode(node) - } - } - ) - - return baseMenuItems + // Only folders should be sticky, and only in normal view (not search or starred) + return item.node.type === 'folder' && !isShowSearch && !isShowStarred }, - [ - t, - handleStartEdit, - onToggleStar, - handleExportKnowledge, - handleImageAction, - handleDeleteNode, + [flattenedNodes, isShowSearch, isShowStarred] + ) + + // Get the depth of an item for hierarchical sticky positioning + const getItemDepth = useCallback( + (index: number) => { + const item = flattenedNodes[index] + return item?.depth ?? 0 + }, + [flattenedNodes] + ) + + const actionsValue = useMemo( + () => ({ + getMenuItems, + onSelectNode, + onToggleExpanded, + onDropdownOpenChange: setOpenDropdownKey + }), + [getMenuItems, onSelectNode, onToggleExpanded] + ) + + const selectionValue = useMemo( + () => ({ + selectedFolderId, + activeNodeId: activeNode?.id + }), + [selectedFolderId, activeNode?.id] + ) + + const editingValue = useMemo( + () => ({ + editingNodeId, renamingNodeIds, - handleAutoRename, - exportMenuOptions, - onCreateNote, - onCreateFolder + newlyRenamedNodeIds, + inPlaceEdit + }), + [editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit] + ) + + const dragValue = useMemo( + () => ({ + draggedNodeId, + dragOverNodeId, + dragPosition, + onDragStart: handleDragStart, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + onDragEnd: handleDragEnd + }), + [ + draggedNodeId, + dragOverNodeId, + dragPosition, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd ] ) - const handleDropFiles = useCallback( - async (e: React.DragEvent) => { - e.preventDefault() - setIsDragOverSidebar(false) - - // 处理文件夹拖拽:从 dataTransfer.items 获取完整的文件路径信息 - const items = Array.from(e.dataTransfer.items) - const files: File[] = [] - - const processEntry = async (entry: FileSystemEntry, path: string = '') => { - if (entry.isFile) { - const fileEntry = entry as FileSystemFileEntry - return new Promise((resolve) => { - fileEntry.file((file) => { - // 手动设置 webkitRelativePath 以保持文件夹结构 - Object.defineProperty(file, 'webkitRelativePath', { - value: path + file.name, - writable: false - }) - files.push(file) - resolve() - }) - }) - } else if (entry.isDirectory) { - const dirEntry = entry as FileSystemDirectoryEntry - const reader = dirEntry.createReader() - return new Promise((resolve) => { - reader.readEntries(async (entries) => { - const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/')) - await Promise.all(promises) - resolve() - }) - }) - } - } - - // 如果支持 DataTransferItem API(文件夹拖拽) - if (items.length > 0 && items[0].webkitGetAsEntry()) { - const promises = items.map((item) => { - const entry = item.webkitGetAsEntry() - return entry ? processEntry(entry) : Promise.resolve() - }) - - await Promise.all(promises) - - if (files.length > 0) { - onUploadFiles(files) - } - } else { - const regularFiles = Array.from(e.dataTransfer.files) - if (regularFiles.length > 0) { - onUploadFiles(regularFiles) - } - } - }, - [onUploadFiles] + const searchValue = useMemo( + () => ({ + searchKeyword: isShowSearch ? trimmedSearchKeyword : '', + showMatches: isShowSearch + }), + [isShowSearch, trimmedSearchKeyword] ) - const handleClickToSelectFiles = useCallback(() => { - const fileInput = document.createElement('input') - fileInput.type = 'file' - fileInput.multiple = true - fileInput.accept = '.md,.markdown' - fileInput.webkitdirectory = false - - fileInput.onchange = (e) => { - const target = e.target as HTMLInputElement - if (target.files && target.files.length > 0) { - const selectedFiles = Array.from(target.files) - onUploadFiles(selectedFiles) - } - fileInput.remove() - } - - fileInput.click() - }, [onUploadFiles]) - - const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => { - return [ - { - label: t('notes.new_note'), - key: 'new_note', - icon: , - onClick: handleCreateNote - }, - { - label: t('notes.new_folder'), - key: 'new_folder', - icon: , - onClick: handleCreateFolder - } - ] - }, [t, handleCreateNote, handleCreateFolder]) - return ( - { - e.preventDefault() - if (!draggedNodeId) { - setIsDragOverSidebar(true) - } - }} - onDragLeave={() => setIsDragOverSidebar(false)} - onDrop={(e) => { - if (!draggedNodeId) { - handleDropFiles(e) - } - }}> - + + + + + + + { + e.preventDefault() + if (!draggedNodeId) { + setIsDragOverSidebar(true) + } + }} + onDragLeave={() => setIsDragOverSidebar(false)} + onDrop={(e) => { + if (!draggedNodeId) { + handleDropFiles(e) + } + }}> + - - {isShowSearch && isSearching && ( - - - {t('notes.search.searching')} - - - - - )} - {isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && ( - - - {t('notes.search.found_results', { - count: searchStats.total, - nameCount: searchStats.fileNameMatches, - contentCount: searchStats.contentMatches + searchStats.bothMatches - })} - - - )} - {shouldUseVirtualization ? ( - setOpenDropdownKey(open ? 'empty-area' : null)}> - -
- {virtualizer.getVirtualItems().map((virtualItem) => { - const { node, depth } = flattenedNodes[virtualItem.index] - return ( -
-
+ + {isShowSearch && isSearching && ( + + + {t('notes.search.searching')} + + + + + )} + {isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && ( + + + {t('notes.search.found_results', { + count: searchStats.total, + nameCount: searchStats.fileNameMatches, + contentCount: searchStats.contentMatches + searchStats.bothMatches + })} + + + )} + setOpenDropdownKey(open ? 'empty-area' : null)}> + 28} + itemContainerStyle={{ padding: '8px 8px 0 8px' }} + overscan={10} + isSticky={isSticky} + getItemDepth={getItemDepth}> + {({ node, depth }) => } + + + {!isShowStarred && !isShowSearch && ( +
-
- ) - })} -
- {!isShowStarred && !isShowSearch && ( - - - - - - - {t('notes.drop_markdown_hint')} - - - - )} - - - ) : ( - setOpenDropdownKey(open ? 'empty-area' : null)}> - - - {isShowStarred || isShowSearch - ? filteredTree.map((node) => ( - - )) - : notesTree.map((node) => ( - - ))} - {!isShowStarred && !isShowSearch && ( - - - - - - - {t('notes.drop_markdown_hint')} - - - - )} - - - - )} - + )} + - {isDragOverSidebar && } - + {isDragOverSidebar && } + + + + + + + ) } -const SidebarContainer = styled.div` +export const SidebarContainer = styled.div` width: 250px; min-width: 250px; height: calc(100vh - var(--navbar-height)); @@ -1204,7 +454,7 @@ const SidebarContainer = styled.div` position: relative; ` -const NotesTreeContainer = styled.div` +export const NotesTreeContainer = styled.div` flex: 1; overflow: hidden; display: flex; @@ -1212,183 +462,7 @@ const NotesTreeContainer = styled.div` height: calc(100vh - var(--navbar-height) - 45px); ` -const VirtualizedTreeContainer = styled.div` - flex: 1; - height: 100%; - overflow: auto; - position: relative; - padding-top: 10px; -` - -const StyledScrollbar = styled(Scrollbar)` - flex: 1; - height: 100%; - min-height: 0; -` - -const TreeContent = styled.div` - padding: 8px; -` - -const TreeNodeContainer = styled.div<{ - active: boolean - depth: number - isDragging?: boolean - isDragOver?: boolean - isDragBefore?: boolean - isDragInside?: boolean - isDragAfter?: boolean -}>` - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 6px; - border-radius: 4px; - cursor: pointer; - margin-bottom: 2px; - background-color: ${(props) => { - if (props.isDragInside) return 'var(--color-primary-background)' - if (props.active) return 'var(--color-background-soft)' - return 'transparent' - }}; - border: 0.5px solid - ${(props) => { - if (props.isDragInside) return 'var(--color-primary)' - if (props.active) return 'var(--color-border)' - return 'transparent' - }}; - opacity: ${(props) => (props.isDragging ? 0.5 : 1)}; - transition: all 0.2s ease; - position: relative; - - &:hover { - background-color: var(--color-background-soft); - - .node-actions { - opacity: 1; - } - } - - /* 添加拖拽指示线 */ - ${(props) => - props.isDragBefore && - ` - &::before { - content: ''; - position: absolute; - top: -2px; - left: 0; - right: 0; - height: 2px; - background-color: var(--color-primary); - border-radius: 1px; - } - `} - - ${(props) => - props.isDragAfter && - ` - &::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - right: 0; - height: 2px; - background-color: var(--color-primary); - border-radius: 1px; - } - `} -` - -const TreeNodeContent = styled.div` - display: flex; - align-items: center; - flex: 1; - min-width: 0; -` - -const NodeIndent = styled.div<{ depth: number }>` - width: ${(props) => props.depth * 16}px; - flex-shrink: 0; -` - -const ExpandIcon = styled.div` - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-2); - margin-right: 4px; - - &:hover { - color: var(--color-text); - } -` - -const NodeIcon = styled.div` - display: flex; - align-items: center; - justify-content: center; - margin-right: 8px; - color: var(--color-text-2); - flex-shrink: 0; -` - -const NodeName = styled.div` - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 13px; - color: var(--color-text); - position: relative; - will-change: background-position, width; - - --color-shimmer-mid: var(--color-text-1); - --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); - - &.shimmer { - background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end)); - background-size: 200% 100%; - background-clip: text; - color: transparent; - animation: shimmer 3s linear infinite; - } - - &.typing { - display: block; - white-space: nowrap; - overflow: hidden; - animation: typewriter 0.5s steps(40, end); - } - - @keyframes shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } - } - - @keyframes typewriter { - from { - width: 0; - } - to { - width: 100%; - } - } -` - -const EditInput = styled(Input)` - flex: 1; - font-size: 13px; -` - -const DragOverIndicator = styled.div` +export const DragOverIndicator = styled.div` position: absolute; top: 0; right: 0; @@ -1400,31 +474,14 @@ const DragOverIndicator = styled.div` pointer-events: none; ` -const DropHintNode = styled.div` - margin: 6px 0; - margin-bottom: 20px; - - ${TreeNodeContainer} { - background-color: transparent; - border: 1px dashed var(--color-border); - cursor: default; - opacity: 0.6; - - &:hover { - background-color: var(--color-background-soft); - opacity: 0.8; - } - } -` - -const DropHintText = styled.div` +export const DropHintText = styled.div` color: var(--color-text-3); font-size: 12px; font-style: italic; ` // 搜索相关样式 -const SearchStatusBar = styled.div` +export const SearchStatusBar = styled.div` display: flex; align-items: center; gap: 8px; @@ -1448,7 +505,7 @@ const SearchStatusBar = styled.div` } ` -const CancelButton = styled.button` +export const CancelButton = styled.button` margin-left: auto; display: flex; align-items: center; @@ -1473,98 +530,4 @@ const CancelButton = styled.button` } ` -const NodeNameContainer = styled.div` - display: flex; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; -` - -const MatchBadge = styled.span<{ matchType: string }>` - display: inline-flex; - align-items: center; - padding: 0 4px; - height: 16px; - font-size: 10px; - line-height: 1; - border-radius: 2px; - background-color: ${(props) => - props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'}; - color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')}; - font-weight: 500; - flex-shrink: 0; -` - -const SearchMatchesContainer = styled.div<{ depth: number }>` - margin-left: ${(props) => props.depth * 16 + 40}px; - margin-top: 4px; - margin-bottom: 8px; - padding: 6px 8px; - background-color: var(--color-background-mute); - border-radius: 4px; - border-left: 2px solid var(--color-primary-soft); -` - -const MatchItem = styled.div` - display: flex; - gap: 8px; - margin-bottom: 4px; - font-size: 12px; - padding: 4px 6px; - margin-left: -6px; - margin-right: -6px; - border-radius: 3px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background-color: var(--color-background-soft); - transform: translateX(2px); - } - - &:active { - background-color: var(--color-active); - } - - &:last-child { - margin-bottom: 0; - } -` - -const MatchLineNumber = styled.span` - color: var(--color-text-3); - font-family: monospace; - flex-shrink: 0; - width: 30px; -` - -const MatchContext = styled.div` - color: var(--color-text-2); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: monospace; -` - -const MoreMatches = styled.div<{ depth: number }>` - margin-top: 4px; - padding: 4px 6px; - margin-left: -6px; - margin-right: -6px; - font-size: 11px; - color: var(--color-text-3); - border-radius: 3px; - cursor: pointer; - display: flex; - align-items: center; - transition: all 0.15s ease; - - &:hover { - color: var(--color-text-2); - background-color: var(--color-background-soft); - } -` - export default memo(NotesSidebar) diff --git a/src/renderer/src/pages/notes/components/TreeNode.tsx b/src/renderer/src/pages/notes/components/TreeNode.tsx new file mode 100644 index 0000000000..0801d31050 --- /dev/null +++ b/src/renderer/src/pages/notes/components/TreeNode.tsx @@ -0,0 +1,498 @@ +import HighlightText from '@renderer/components/HighlightText' +import { + useNotesActions, + useNotesDrag, + useNotesEditing, + useNotesSearch, + useNotesSelection, + useNotesUI +} from '@renderer/pages/notes/context/NotesContexts' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import type { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService' +import type { NotesTreeNode } from '@renderer/types/note' +import { Dropdown } from 'antd' +import { ChevronDown, ChevronRight, File, FilePlus, Folder, FolderOpen } from 'lucide-react' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface TreeNodeProps { + node: NotesTreeNode | SearchResult + depth: number + renderChildren?: boolean + onHintClick?: () => void +} + +const TreeNode = memo(({ node, depth, renderChildren = true, onHintClick }) => { + const { t } = useTranslation() + + // Use split contexts - only subscribe to what this node needs + const { selectedFolderId, activeNodeId } = useNotesSelection() + const { editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit } = useNotesEditing() + const { draggedNodeId, dragOverNodeId, dragPosition, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd } = + useNotesDrag() + const { searchKeyword, showMatches } = useNotesSearch() + const { openDropdownKey } = useNotesUI() + const { getMenuItems, onSelectNode, onToggleExpanded, onDropdownOpenChange } = useNotesActions() + + const [showAllMatches, setShowAllMatches] = useState(false) + const { isEditing: isInputEditing, inputProps } = inPlaceEdit + + // 检查是否是 hint 节点 + const isHintNode = node.type === 'hint' + + // 检查是否是搜索结果 + const searchResult = 'matchType' in node ? (node as SearchResult) : null + const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0 + + // 处理匹配项点击 + const handleMatchClick = useCallback( + (match: SearchMatch) => { + // 发送定位事件 + EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, { + noteId: node.id, + lineNumber: match.lineNumber, + lineContent: match.lineContent + }) + }, + [node] + ) + + const isActive = selectedFolderId ? node.type === 'folder' && node.id === selectedFolderId : node.id === activeNodeId + const isEditing = editingNodeId === node.id && isInputEditing + const isRenaming = renamingNodeIds.has(node.id) + const isNewlyRenamed = newlyRenamedNodeIds.has(node.id) + const hasChildren = node.children && node.children.length > 0 + const isDragging = draggedNodeId === node.id + const isDragOver = dragOverNodeId === node.id + const isDragBefore = isDragOver && dragPosition === 'before' + const isDragInside = isDragOver && dragPosition === 'inside' + const isDragAfter = isDragOver && dragPosition === 'after' + + const getNodeNameClassName = () => { + if (isRenaming) return 'shimmer' + if (isNewlyRenamed) return 'typing' + return '' + } + + const displayName = useMemo(() => { + if (!searchKeyword) { + return node.name + } + + const name = node.name ?? '' + if (!name) { + return name + } + + const keyword = searchKeyword + const nameLower = name.toLowerCase() + const keywordLower = keyword.toLowerCase() + const matchStart = nameLower.indexOf(keywordLower) + + if (matchStart === -1) { + return name + } + + const matchEnd = matchStart + keyword.length + const beforeMatch = Math.min(2, matchStart) + const contextStart = matchStart - beforeMatch + const contextLength = 50 + const contextEnd = Math.min(name.length, matchEnd + contextLength) + + const prefix = contextStart > 0 ? '...' : '' + const suffix = contextEnd < name.length ? '...' : '' + + return prefix + name.substring(contextStart, contextEnd) + suffix + }, [node.name, searchKeyword]) + + // Special render for hint nodes + if (isHintNode) { + return ( +
+ + + + + + {t('notes.drop_markdown_hint')} + + +
+ ) + } + + return ( +
+ onDropdownOpenChange(open ? node.id : null)}> +
e.stopPropagation()}> + onDragStart(e, node as NotesTreeNode)} + onDragOver={(e) => onDragOver(e, node as NotesTreeNode)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, node as NotesTreeNode)} + onDragEnd={onDragEnd}> + onSelectNode(node as NotesTreeNode)}> + + + {node.type === 'folder' && ( + { + e.stopPropagation() + onToggleExpanded(node.id) + }} + title={node.expanded ? t('notes.collapse') : t('notes.expand')}> + {node.expanded ? : } + + )} + + + {node.type === 'folder' ? ( + node.expanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + {isEditing ? ( + e.stopPropagation()} autoFocus /> + ) : ( + + + {searchKeyword ? : node.name} + + {searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && ( + + {searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')} + + )} + + )} + + +
+
+ + {showMatches && hasMatches && ( + + {(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => ( + handleMatchClick(match)}> + {match.lineNumber} + + + + + ))} + {searchResult!.matches!.length > 3 && ( + { + e.stopPropagation() + setShowAllMatches(!showAllMatches) + }}> + {showAllMatches ? ( + <> + + {t('notes.search.show_less')} + + ) : ( + <> + +{searchResult!.matches!.length - 3}{' '} + {t('notes.search.more_matches')} + + )} + + )} + + )} + + {renderChildren && node.type === 'folder' && node.expanded && hasChildren && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ) +}) + +export const TreeNodeContainer = styled.div<{ + active: boolean + depth: number + isDragging?: boolean + isDragOver?: boolean + isDragBefore?: boolean + isDragInside?: boolean + isDragAfter?: boolean +}>` + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + margin-bottom: 2px; + /* CRITICAL: Must have fully opaque background for sticky to work properly */ + /* Transparent/semi-transparent backgrounds will show content bleeding through when sticky */ + background-color: ${(props) => { + if (props.isDragInside) return 'var(--color-primary-background)' + // Use hover color for active state - it's guaranteed to be opaque + if (props.active) return 'var(--color-hover, var(--color-background-mute))' + return 'var(--color-background)' + }}; + border: 0.5px solid + ${(props) => { + if (props.isDragInside) return 'var(--color-primary)' + if (props.active) return 'var(--color-border)' + return 'transparent' + }}; + opacity: ${(props) => (props.isDragging ? 0.5 : 1)}; + transition: all 0.2s ease; + position: relative; + + &:hover { + background-color: var(--color-background-soft); + + .node-actions { + opacity: 1; + } + } + + /* 添加拖拽指示线 */ + ${(props) => + props.isDragBefore && + ` + &::before { + content: ''; + position: absolute; + top: -2px; + left: 0; + right: 0; + height: 2px; + background-color: var(--color-primary); + border-radius: 1px; + } + `} + + ${(props) => + props.isDragAfter && + ` + &::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 2px; + background-color: var(--color-primary); + border-radius: 1px; + } + `} +` + +export const TreeNodeContent = styled.div` + display: flex; + align-items: center; + flex: 1; + min-width: 0; +` + +export const NodeIndent = styled.div<{ depth: number }>` + width: ${(props) => props.depth * 16}px; + flex-shrink: 0; +` + +export const ExpandIcon = styled.div` + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-2); + margin-right: 4px; + + &:hover { + color: var(--color-text); + } +` + +export const NodeIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + color: var(--color-text-2); + flex-shrink: 0; +` + +export const NodeName = styled.div` + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + color: var(--color-text); + position: relative; + will-change: background-position, width; + + --color-shimmer-mid: var(--color-text-1); + --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); + + &.shimmer { + background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end)); + background-size: 200% 100%; + background-clip: text; + color: transparent; + animation: shimmer 3s linear infinite; + } + + &.typing { + display: block; + white-space: nowrap; + overflow: hidden; + animation: typewriter 0.5s steps(40, end); + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + + @keyframes typewriter { + from { + width: 0; + } + to { + width: 100%; + } + } +` + +export const SearchMatchesContainer = styled.div<{ depth: number }>` + margin-left: ${(props) => props.depth * 16 + 40}px; + margin-top: 4px; + margin-bottom: 8px; + padding: 6px 8px; + background-color: var(--color-background-mute); + border-radius: 4px; + border-left: 2px solid var(--color-primary-soft); +` + +export const NodeNameContainer = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +` + +export const MatchBadge = styled.span<{ matchType: string }>` + display: inline-flex; + align-items: center; + padding: 0 4px; + height: 16px; + font-size: 10px; + line-height: 1; + border-radius: 2px; + background-color: ${(props) => + props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'}; + color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')}; + font-weight: 500; + flex-shrink: 0; +` + +export const MatchItem = styled.div` + display: flex; + gap: 8px; + margin-bottom: 4px; + font-size: 12px; + padding: 4px 6px; + margin-left: -6px; + margin-right: -6px; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background-color: var(--color-background-soft); + transform: translateX(2px); + } + + &:active { + background-color: var(--color-active); + } + + &:last-child { + margin-bottom: 0; + } +` + +export const MatchLineNumber = styled.span` + color: var(--color-text-3); + font-family: monospace; + flex-shrink: 0; + width: 30px; +` + +export const MatchContext = styled.div` + color: var(--color-text-2); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; +` + +export const MoreMatches = styled.div<{ depth: number }>` + margin-top: 4px; + padding: 4px 6px; + margin-left: -6px; + margin-right: -6px; + font-size: 11px; + color: var(--color-text-3); + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + transition: all 0.15s ease; + + &:hover { + color: var(--color-text-2); + background-color: var(--color-background-soft); + } +` + +const EditInput = styled.input` + flex: 1; + font-size: 13px; +` + +const DropHintText = styled.div` + color: var(--color-text-3); + font-size: 12px; + font-style: italic; +` + +export default TreeNode diff --git a/src/renderer/src/pages/notes/context/NotesContexts.tsx b/src/renderer/src/pages/notes/context/NotesContexts.tsx new file mode 100644 index 0000000000..6bbb86c8d1 --- /dev/null +++ b/src/renderer/src/pages/notes/context/NotesContexts.tsx @@ -0,0 +1,109 @@ +import type { UseInPlaceEditReturn } from '@renderer/hooks/useInPlaceEdit' +import type { NotesTreeNode } from '@renderer/types/note' +import type { MenuProps } from 'antd' +import { createContext, use } from 'react' + +// ==================== 1. Actions Context (Static, rarely changes) ==================== +export interface NotesActionsContextType { + getMenuItems: (node: NotesTreeNode) => MenuProps['items'] + onSelectNode: (node: NotesTreeNode) => void + onToggleExpanded: (nodeId: string) => void + onDropdownOpenChange: (key: string | null) => void +} + +export const NotesActionsContext = createContext(null) + +export const useNotesActions = () => { + const context = use(NotesActionsContext) + if (!context) { + throw new Error('useNotesActions must be used within NotesActionsContext.Provider') + } + return context +} + +// ==================== 2. Selection Context (Low frequency updates) ==================== +export interface NotesSelectionContextType { + selectedFolderId?: string | null + activeNodeId?: string +} + +export const NotesSelectionContext = createContext(null) + +export const useNotesSelection = () => { + const context = use(NotesSelectionContext) + if (!context) { + throw new Error('useNotesSelection must be used within NotesSelectionContext.Provider') + } + return context +} + +// ==================== 3. Editing Context (Medium frequency updates) ==================== +export interface NotesEditingContextType { + editingNodeId: string | null + renamingNodeIds: Set + newlyRenamedNodeIds: Set + inPlaceEdit: UseInPlaceEditReturn +} + +export const NotesEditingContext = createContext(null) + +export const useNotesEditing = () => { + const context = use(NotesEditingContext) + if (!context) { + throw new Error('useNotesEditing must be used within NotesEditingContext.Provider') + } + return context +} + +// ==================== 4. Drag Context (High frequency updates) ==================== +export interface NotesDragContextType { + draggedNodeId: string | null + dragOverNodeId: string | null + dragPosition: 'before' | 'inside' | 'after' + onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void + onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void + onDragLeave: () => void + onDrop: (e: React.DragEvent, node: NotesTreeNode) => void + onDragEnd: () => void +} + +export const NotesDragContext = createContext(null) + +export const useNotesDrag = () => { + const context = use(NotesDragContext) + if (!context) { + throw new Error('useNotesDrag must be used within NotesDragContext.Provider') + } + return context +} + +// ==================== 5. Search Context (Medium frequency updates) ==================== +export interface NotesSearchContextType { + searchKeyword: string + showMatches: boolean +} + +export const NotesSearchContext = createContext(null) + +export const useNotesSearch = () => { + const context = use(NotesSearchContext) + if (!context) { + throw new Error('useNotesSearch must be used within NotesSearchContext.Provider') + } + return context +} + +// ==================== 6. UI Context (Medium frequency updates) ==================== +export interface NotesUIContextType { + openDropdownKey: string | null +} + +export const NotesUIContext = createContext(null) + +export const useNotesUI = () => { + const context = use(NotesUIContext) + if (!context) { + throw new Error('useNotesUI must be used within NotesUIContext.Provider') + } + return context +} diff --git a/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts b/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts new file mode 100644 index 0000000000..1822c00e9d --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts @@ -0,0 +1,101 @@ +import type { NotesTreeNode } from '@renderer/types/note' +import { useCallback, useRef, useState } from 'react' + +interface UseNotesDragAndDropProps { + onMoveNode: (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => void +} + +export const useNotesDragAndDrop = ({ onMoveNode }: UseNotesDragAndDropProps) => { + const [draggedNodeId, setDraggedNodeId] = useState(null) + const [dragOverNodeId, setDragOverNodeId] = useState(null) + const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside') + const dragNodeRef = useRef(null) + + const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => { + setDraggedNodeId(node.id) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', node.id) + + dragNodeRef.current = e.currentTarget as HTMLDivElement + + // Create ghost element + if (e.currentTarget.parentElement) { + const rect = e.currentTarget.getBoundingClientRect() + const ghostElement = e.currentTarget.cloneNode(true) as HTMLElement + ghostElement.style.width = `${rect.width}px` + ghostElement.style.opacity = '0.7' + ghostElement.style.position = 'absolute' + ghostElement.style.top = '-1000px' + document.body.appendChild(ghostElement) + e.dataTransfer.setDragImage(ghostElement, 10, 10) + setTimeout(() => { + document.body.removeChild(ghostElement) + }, 0) + } + }, []) + + const handleDragOver = useCallback( + (e: React.DragEvent, node: NotesTreeNode) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + + if (draggedNodeId === node.id) { + return + } + + setDragOverNodeId(node.id) + + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const mouseY = e.clientY + const thresholdTop = rect.top + rect.height * 0.3 + const thresholdBottom = rect.bottom - rect.height * 0.3 + + if (mouseY < thresholdTop) { + setDragPosition('before') + } else if (mouseY > thresholdBottom) { + setDragPosition('after') + } else { + setDragPosition(node.type === 'folder' ? 'inside' : 'after') + } + }, + [draggedNodeId] + ) + + const handleDragLeave = useCallback(() => { + setDragOverNodeId(null) + setDragPosition('inside') + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent, targetNode: NotesTreeNode) => { + e.preventDefault() + const draggedId = e.dataTransfer.getData('text/plain') + + if (draggedId && draggedId !== targetNode.id) { + onMoveNode(draggedId, targetNode.id, dragPosition) + } + + setDraggedNodeId(null) + setDragOverNodeId(null) + setDragPosition('inside') + }, + [onMoveNode, dragPosition] + ) + + const handleDragEnd = useCallback(() => { + setDraggedNodeId(null) + setDragOverNodeId(null) + setDragPosition('inside') + }, []) + + return { + draggedNodeId, + dragOverNodeId, + dragPosition, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd + } +} diff --git a/src/renderer/src/pages/notes/hooks/useNotesEditing.ts b/src/renderer/src/pages/notes/hooks/useNotesEditing.ts new file mode 100644 index 0000000000..58cbdee9e3 --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useNotesEditing.ts @@ -0,0 +1,94 @@ +import { loggerService } from '@logger' +import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit' +import { fetchNoteSummary } from '@renderer/services/ApiService' +import type { NotesTreeNode } from '@renderer/types/note' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const logger = loggerService.withContext('UseNotesEditing') + +interface UseNotesEditingProps { + onRenameNode: (nodeId: string, newName: string) => void +} + +export const useNotesEditing = ({ onRenameNode }: UseNotesEditingProps) => { + const { t } = useTranslation() + const [editingNodeId, setEditingNodeId] = useState(null) + const [renamingNodeIds, setRenamingNodeIds] = useState>(new Set()) + const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState>(new Set()) + + const inPlaceEdit = useInPlaceEdit({ + onSave: (newName: string) => { + if (editingNodeId && newName) { + onRenameNode(editingNodeId, newName) + window.toast.success(t('common.saved')) + logger.debug(`Renamed node ${editingNodeId} to "${newName}"`) + } + setEditingNodeId(null) + }, + onCancel: () => { + setEditingNodeId(null) + } + }) + + const handleStartEdit = useCallback( + (node: NotesTreeNode) => { + setEditingNodeId(node.id) + inPlaceEdit.startEdit(node.name) + }, + [inPlaceEdit] + ) + + const handleAutoRename = useCallback( + async (note: NotesTreeNode) => { + if (note.type !== 'file') return + + setRenamingNodeIds((prev) => new Set(prev).add(note.id)) + try { + const content = await window.api.file.readExternal(note.externalPath) + if (!content || content.trim().length === 0) { + window.toast.warning(t('notes.auto_rename.empty_note')) + return + } + + const summaryText = await fetchNoteSummary({ content }) + if (summaryText) { + onRenameNode(note.id, summaryText) + window.toast.success(t('notes.auto_rename.success')) + } else { + window.toast.error(t('notes.auto_rename.failed')) + } + } catch (error) { + window.toast.error(t('notes.auto_rename.failed')) + logger.error(`Failed to auto-rename note: ${error}`) + } finally { + setRenamingNodeIds((prev) => { + const next = new Set(prev) + next.delete(note.id) + return next + }) + + setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id)) + + setTimeout(() => { + setNewlyRenamedNodeIds((prev) => { + const next = new Set(prev) + next.delete(note.id) + return next + }) + }, 700) + } + }, + [onRenameNode, t] + ) + + return { + editingNodeId, + renamingNodeIds, + newlyRenamedNodeIds, + inPlaceEdit, + handleStartEdit, + handleAutoRename, + setEditingNodeId + } +} diff --git a/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts b/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts new file mode 100644 index 0000000000..aba1a90992 --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts @@ -0,0 +1,112 @@ +import { useCallback } from 'react' + +interface UseNotesFileUploadProps { + onUploadFiles: (files: File[]) => void + setIsDragOverSidebar: (isDragOver: boolean) => void +} + +export const useNotesFileUpload = ({ onUploadFiles, setIsDragOverSidebar }: UseNotesFileUploadProps) => { + const handleDropFiles = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + setIsDragOverSidebar(false) + + // 处理文件夹拖拽:从 dataTransfer.items 获取完整文件路径信息 + const items = Array.from(e.dataTransfer.items) + const files: File[] = [] + + const processEntry = async (entry: FileSystemEntry, path: string = '') => { + if (entry.isFile) { + const fileEntry = entry as FileSystemFileEntry + return new Promise((resolve) => { + fileEntry.file((file) => { + // 手动设置 webkitRelativePath 以保持文件夹结构 + Object.defineProperty(file, 'webkitRelativePath', { + value: path + file.name, + writable: false + }) + files.push(file) + resolve() + }) + }) + } else if (entry.isDirectory) { + const dirEntry = entry as FileSystemDirectoryEntry + const reader = dirEntry.createReader() + return new Promise((resolve) => { + reader.readEntries(async (entries) => { + const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/')) + await Promise.all(promises) + resolve() + }) + }) + } + } + + // 如果支持 DataTransferItem API(文件夹拖拽) + if (items.length > 0 && items[0].webkitGetAsEntry()) { + const promises = items.map((item) => { + const entry = item.webkitGetAsEntry() + return entry ? processEntry(entry) : Promise.resolve() + }) + + await Promise.all(promises) + + if (files.length > 0) { + onUploadFiles(files) + } + } else { + const regularFiles = Array.from(e.dataTransfer.files) + if (regularFiles.length > 0) { + onUploadFiles(regularFiles) + } + } + }, + [onUploadFiles, setIsDragOverSidebar] + ) + + const handleSelectFiles = useCallback(() => { + const fileInput = document.createElement('input') + fileInput.type = 'file' + fileInput.multiple = true + fileInput.accept = '.md,.markdown' + fileInput.webkitdirectory = false + + fileInput.onchange = (e) => { + const target = e.target as HTMLInputElement + if (target.files && target.files.length > 0) { + const selectedFiles = Array.from(target.files) + onUploadFiles(selectedFiles) + } + fileInput.remove() + } + + fileInput.click() + }, [onUploadFiles]) + + const handleSelectFolder = useCallback(() => { + const folderInput = document.createElement('input') + folderInput.type = 'file' + // @ts-ignore - webkitdirectory is a non-standard attribute + folderInput.webkitdirectory = true + // @ts-ignore - directory is a non-standard attribute + folderInput.directory = true + folderInput.multiple = true + + folderInput.onchange = (e) => { + const target = e.target as HTMLInputElement + if (target.files && target.files.length > 0) { + const selectedFiles = Array.from(target.files) + onUploadFiles(selectedFiles) + } + folderInput.remove() + } + + folderInput.click() + }, [onUploadFiles]) + + return { + handleDropFiles, + handleSelectFiles, + handleSelectFolder + } +} diff --git a/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx b/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx new file mode 100644 index 0000000000..f08f9b1505 --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx @@ -0,0 +1,263 @@ +import { loggerService } from '@logger' +import { DeleteIcon } from '@renderer/components/Icons' +import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' +import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import type { RootState } from '@renderer/store' +import type { NotesTreeNode } from '@renderer/types/note' +import { exportNote } from '@renderer/utils/export' +import type { MenuProps } from 'antd' +import type { ItemType, MenuItemType } from 'antd/es/menu/interface' +import { Edit3, FilePlus, FileSearch, Folder, FolderOpen, Sparkles, Star, StarOff, UploadIcon } from 'lucide-react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +const logger = loggerService.withContext('UseNotesMenu') + +interface UseNotesMenuProps { + renamingNodeIds: Set + onCreateNote: (name: string, targetFolderId?: string) => void + onCreateFolder: (name: string, targetFolderId?: string) => void + onRenameNode: (nodeId: string, newName: string) => void + onToggleStar: (nodeId: string) => void + onDeleteNode: (nodeId: string) => void + onSelectNode: (node: NotesTreeNode) => void + handleStartEdit: (node: NotesTreeNode) => void + handleAutoRename: (node: NotesTreeNode) => void + activeNode?: NotesTreeNode | null +} + +export const useNotesMenu = ({ + renamingNodeIds, + onCreateNote, + onCreateFolder, + onToggleStar, + onDeleteNode, + onSelectNode, + handleStartEdit, + handleAutoRename, + activeNode +}: UseNotesMenuProps) => { + const { t } = useTranslation() + const { bases } = useKnowledgeBases() + const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions) + + const handleExportKnowledge = useCallback( + async (note: NotesTreeNode) => { + try { + if (bases.length === 0) { + window.toast.warning(t('chat.save.knowledge.empty.no_knowledge_base')) + return + } + + const result = await SaveToKnowledgePopup.showForNote(note) + + if (result?.success) { + window.toast.success(t('notes.export_success', { count: result.savedCount })) + } + } catch (error) { + window.toast.error(t('notes.export_failed')) + logger.error(`Failed to export note to knowledge base: ${error}`) + } + }, + [bases.length, t] + ) + + const handleImageAction = useCallback( + async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => { + try { + if (activeNode?.id !== node.id) { + onSelectNode(node) + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + await exportNote({ node, platform }) + } catch (error) { + logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error) + window.toast.error(t('common.copy_failed')) + } + }, + [activeNode, onSelectNode, t] + ) + + const handleDeleteNodeWrapper = useCallback( + (node: NotesTreeNode) => { + const confirmText = + node.type === 'folder' + ? t('notes.delete_folder_confirm', { name: node.name }) + : t('notes.delete_note_confirm', { name: node.name }) + + window.modal.confirm({ + title: t('notes.delete'), + content: confirmText, + centered: true, + okButtonProps: { danger: true }, + onOk: () => { + onDeleteNode(node.id) + } + }) + }, + [onDeleteNode, t] + ) + + const getMenuItems = useCallback( + (node: NotesTreeNode) => { + const baseMenuItems: MenuProps['items'] = [] + + // only show auto rename for file for now + if (node.type !== 'folder') { + baseMenuItems.push({ + label: t('notes.auto_rename.label'), + key: 'auto-rename', + icon: , + disabled: renamingNodeIds.has(node.id), + onClick: () => { + handleAutoRename(node) + } + }) + } + + if (node.type === 'folder') { + baseMenuItems.push( + { + label: t('notes.new_note'), + key: 'new_note', + icon: , + onClick: () => { + onCreateNote(t('notes.untitled_note'), node.id) + } + }, + { + label: t('notes.new_folder'), + key: 'new_folder', + icon: , + onClick: () => { + onCreateFolder(t('notes.untitled_folder'), node.id) + } + }, + { type: 'divider' } + ) + } + + baseMenuItems.push( + { + label: t('notes.rename'), + key: 'rename', + icon: , + onClick: () => { + handleStartEdit(node) + } + }, + { + label: t('notes.open_outside'), + key: 'open_outside', + icon: , + onClick: () => { + window.api.openPath(node.externalPath) + } + } + ) + if (node.type !== 'folder') { + baseMenuItems.push( + { + label: node.isStarred ? t('notes.unstar') : t('notes.star'), + key: 'star', + icon: node.isStarred ? : , + onClick: () => { + onToggleStar(node.id) + } + }, + { + label: t('notes.export_knowledge'), + key: 'export_knowledge', + icon: , + onClick: () => { + handleExportKnowledge(node) + } + }, + { + label: t('chat.topics.export.title'), + key: 'export', + icon: , + children: [ + exportMenuOptions.image && { + label: t('chat.topics.copy.image'), + key: 'copy-image', + onClick: () => handleImageAction(node, 'copyImage') + }, + exportMenuOptions.image && { + label: t('chat.topics.export.image'), + key: 'export-image', + onClick: () => handleImageAction(node, 'exportImage') + }, + exportMenuOptions.markdown && { + label: t('chat.topics.export.md.label'), + key: 'markdown', + onClick: () => exportNote({ node, platform: 'markdown' }) + }, + exportMenuOptions.docx && { + label: t('chat.topics.export.word'), + key: 'word', + onClick: () => exportNote({ node, platform: 'docx' }) + }, + exportMenuOptions.notion && { + label: t('chat.topics.export.notion'), + key: 'notion', + onClick: () => exportNote({ node, platform: 'notion' }) + }, + exportMenuOptions.yuque && { + label: t('chat.topics.export.yuque'), + key: 'yuque', + onClick: () => exportNote({ node, platform: 'yuque' }) + }, + exportMenuOptions.obsidian && { + label: t('chat.topics.export.obsidian'), + key: 'obsidian', + onClick: () => exportNote({ node, platform: 'obsidian' }) + }, + exportMenuOptions.joplin && { + label: t('chat.topics.export.joplin'), + key: 'joplin', + onClick: () => exportNote({ node, platform: 'joplin' }) + }, + exportMenuOptions.siyuan && { + label: t('chat.topics.export.siyuan'), + key: 'siyuan', + onClick: () => exportNote({ node, platform: 'siyuan' }) + } + ].filter(Boolean) as ItemType[] + } + ) + } + baseMenuItems.push( + { type: 'divider' }, + { + label: t('notes.delete'), + danger: true, + key: 'delete', + icon: , + onClick: () => { + handleDeleteNodeWrapper(node) + } + } + ) + + return baseMenuItems + }, + [ + t, + handleStartEdit, + onToggleStar, + handleExportKnowledge, + handleImageAction, + handleDeleteNodeWrapper, + renamingNodeIds, + handleAutoRename, + exportMenuOptions, + onCreateNote, + onCreateFolder + ] + ) + + return { getMenuItems } +} 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/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 61b49893fa..dd47d41c9b 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -39,6 +39,7 @@ import { detectLanguage, determineTargetLanguage } from '@renderer/utils/translate' +import { documentExts } from '@shared/config/constant' import { imageExts, MB, textExts } from '@shared/config/constant' import { Button, Flex, FloatButton, Popover, Tooltip, Typography } from 'antd' import type { TextAreaRef } from 'antd/es/input/TextArea' @@ -66,7 +67,7 @@ const TranslatePage: FC = () => { const { prompt, getLanguageByLangcode, settings } = useTranslate() const { autoCopy } = settings const { shikiMarkdownIt } = useCodeStyle() - const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] }) + const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts, ...documentExts] }) const { ocr } = useOcr() const { setTimeoutTimer } = useTimer() @@ -484,33 +485,56 @@ const TranslatePage: FC = () => { const readFile = useCallback( async (file: FileMetadata) => { const _readFile = async () => { - let isText: boolean try { - // 检查文件是否为文本文件 - isText = await isTextFile(file.path) - } catch (e) { - logger.error('Failed to check if file is text.', e as Error) - window.toast.error(t('translate.files.error.check_type') + ': ' + formatErrorMessage(e)) - return - } + const fileExtension = getFileExtension(file.path) - if (!isText) { - window.toast.error(t('common.file.not_supported', { type: getFileExtension(file.path) })) - logger.error('Unsupported file type.') - return - } + // Check if file is supported format (text file or document file) + let isText: boolean + const isDocument: boolean = documentExts.includes(fileExtension) - // the threshold may be too large - if (file.size > 5 * MB) { - window.toast.error(t('translate.files.error.too_large') + ' (0 ~ 5 MB)') - } else { + if (!isDocument) { + try { + // For non-document files, check if it's a text file + isText = await isTextFile(file.path) + } catch (e) { + logger.error('Failed to check file type.', e as Error) + window.toast.error(t('translate.files.error.check_type') + ': ' + formatErrorMessage(e)) + return + } + } else { + isText = false + } + + if (!isText && !isDocument) { + window.toast.error(t('common.file.not_supported', { type: fileExtension })) + logger.error('Unsupported file type.') + return + } + + // File size check - document files allowed to be larger + const maxSize = isDocument ? 20 * MB : 5 * MB + if (file.size > maxSize) { + window.toast.error(t('translate.files.error.too_large') + ` (0 ~ ${maxSize / MB} MB)`) + return + } + + let result: string try { - const result = await window.api.fs.readText(file.path) + if (isDocument) { + // Use the new document reading API + result = await window.api.file.readExternal(file.path, true) + } else { + // Read text file + result = await window.api.fs.readText(file.path) + } setText(text + result) } catch (e) { - logger.error('Failed to read text file.', e as Error) + logger.error('Failed to read file.', e as Error) window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) } + } catch (e) { + logger.error('Failed to read file.', e as Error) + window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) } } const promise = _readFile() diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts index 940c8db106..4b71941fe8 100644 --- a/src/renderer/src/services/NotesService.ts +++ b/src/renderer/src/services/NotesService.ts @@ -83,6 +83,68 @@ export async function renameNode(node: NotesTreeNode, newName: string): Promise< } export async function uploadNotes(files: File[], targetPath: string): Promise { + const basePath = normalizePath(targetPath) + const totalFiles = files.length + + if (files.length === 0) { + return { + uploadedNodes: [], + totalFiles: 0, + skippedFiles: 0, + fileCount: 0, + folderCount: 0 + } + } + + try { + // Get file paths from File objects + // For browser File objects from drag-and-drop, we need to use FileReader to save temporarily + // However, for directory uploads, the files already have paths + const filePaths: string[] = [] + + for (const file of files) { + // @ts-ignore - webkitRelativePath exists on File objects from directory uploads + if (file.path) { + // @ts-ignore - Electron File objects have .path property + filePaths.push(file.path) + } else { + // For browser File API, we'd need to use FileReader and create temp files + // For now, fall back to the old method for these cases + logger.warn('File without path detected, using fallback method') + return uploadNotesLegacy(files, targetPath) + } + } + + // Pause file watcher to prevent N refresh events + await window.api.file.pauseFileWatcher() + + try { + // Use the new optimized batch upload API that runs in Main process + const result = await window.api.file.batchUploadMarkdown(filePaths, basePath) + + return { + uploadedNodes: [], + totalFiles, + skippedFiles: result.skippedFiles, + fileCount: result.fileCount, + folderCount: result.folderCount + } + } finally { + // Resume watcher and trigger single refresh + await window.api.file.resumeFileWatcher() + } + } catch (error) { + logger.error('Batch upload failed, falling back to legacy method:', error as Error) + // Fall back to old method if new method fails + return uploadNotesLegacy(files, targetPath) + } +} + +/** + * Legacy upload method using Renderer process + * Kept as fallback for browser File API files without paths + */ +async function uploadNotesLegacy(files: File[], targetPath: string): Promise { const basePath = normalizePath(targetPath) const markdownFiles = filterMarkdown(files) const skippedFiles = files.length - markdownFiles.length @@ -101,18 +163,37 @@ export async function uploadNotes(files: File[], targetPath: string): Promise { + const { dir, name } = resolveFileTarget(file, basePath) + const { safeName } = await window.api.file.checkFileName(dir, name, true) + const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}` + + const content = await file.text() + await window.api.file.write(finalPath, content) + return true + }) + ) + + // Count successful uploads + results.forEach((result) => { + if (result.status === 'fulfilled') { + fileCount += 1 + } else { + logger.error('Failed to write uploaded file:', result.reason) + } + }) + + // Yield to the event loop between batches to keep UI responsive + if (i + BATCH_SIZE < markdownFiles.length) { + await new Promise((resolve) => setTimeout(resolve, 0)) } } 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 0e7cda0a4a..6539400630 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 @@ -2955,6 +2964,12 @@ const migrateConfig = { }, '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 + } + } state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.ppio) { provider.anthropicApiHost = 'https://api.ppinfra.com/anthropic' 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 3db7b01d1a..751ee83e17 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -440,6 +440,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/types/note.ts b/src/renderer/src/types/note.ts index fda85e63d8..83bbd74e5d 100644 --- a/src/renderer/src/types/note.ts +++ b/src/renderer/src/types/note.ts @@ -13,7 +13,7 @@ export type NotesSortType = export interface NotesTreeNode { id: string name: string // 不包含扩展名 - type: 'folder' | 'file' + type: 'folder' | 'file' | 'hint' treePath: string // 相对路径 externalPath: string // 绝对路径 children?: NotesTreeNode[] 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 }