From 92bb05950d1f65fea084acca644dd43724c395a2 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 5 Dec 2025 13:25:54 +0800 Subject: [PATCH 01/25] fix: enhance provider handling and API key rotation logic in AiProvider (#11586) * fix: enhance provider handling and API key rotation logic in AiProvider * fix * fix(api): enhance API key handling and logging for providers --- src/renderer/src/aiCore/index_new.ts | 9 +- .../src/aiCore/provider/providerConfig.ts | 28 +--- src/renderer/src/services/ApiService.ts | 130 ++++++++++++++---- src/renderer/src/utils/provider.ts | 10 ++ 4 files changed, 120 insertions(+), 57 deletions(-) diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 090b3fc9e1..4379547a3c 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -120,9 +120,12 @@ export default class ModernAiProvider { throw new Error('Model is required for completions. Please use constructor with model parameter.') } - // 每次请求时重新生成配置以确保API key轮换生效 - this.config = providerToAiSdkConfig(this.actualProvider, this.model) - logger.debug('Generated provider config for completions', this.config) + // Config is now set in constructor, ApiService handles key rotation before passing provider + if (!this.config) { + // If config wasn't set in constructor (when provider only), generate it now + this.config = providerToAiSdkConfig(this.actualProvider, this.model!) + } + logger.debug('Using provider config for completions', this.config) // 检查 config 是否存在 if (!this.config) { diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 96759131cc..0be69bdb4f 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -37,32 +37,6 @@ import { azureAnthropicProviderCreator } from './config/azure-anthropic' import { COPILOT_DEFAULT_HEADERS } from './constants' import { getAiSdkProviderId } from './factory' -/** - * 获取轮询的API key - * 复用legacy架构的多key轮询逻辑 - */ -function getRotatedApiKey(provider: Provider): string { - const keys = provider.apiKey.split(',').map((key) => key.trim()) - const keyName = `provider:${provider.id}:last_used_key` - - if (keys.length === 1) { - return keys[0] - } - - const lastUsedKey = window.keyv.get(keyName) - if (!lastUsedKey) { - window.keyv.set(keyName, keys[0]) - return keys[0] - } - - const currentIndex = keys.indexOf(lastUsedKey) - const nextIndex = (currentIndex + 1) % keys.length - const nextKey = keys[nextIndex] - window.keyv.set(keyName, nextKey) - - return nextKey -} - /** * 处理特殊provider的转换逻辑 */ @@ -171,7 +145,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost) const baseConfig = { baseURL: baseURL, - apiKey: getRotatedApiKey(actualProvider) + apiKey: actualProvider.apiKey } const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index c49e88f1ff..0d9e8cd0bf 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -8,8 +8,8 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingMod import { getStoreSetting } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import store from '@renderer/store' -import type { FetchChatCompletionParams } from '@renderer/types' import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types' +import { type FetchChatCompletionParams, isSystemProvider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { type Chunk, ChunkType } from '@renderer/types/chunk' import type { Message, ResponseError } from '@renderer/types/newMessage' @@ -21,7 +21,8 @@ import { purifyMarkdownImages } from '@renderer/utils/markdown' import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools' import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt' -import { isEmpty, takeRight } from 'lodash' +import { NOT_SUPPORT_API_KEY_PROVIDER_TYPES, NOT_SUPPORT_API_KEY_PROVIDERS } from '@renderer/utils/provider' +import { cloneDeep, isEmpty, takeRight } from 'lodash' import type { ModernAiProviderConfig } from '../aiCore/index_new' import AiProviderNew from '../aiCore/index_new' @@ -42,6 +43,8 @@ import { // } from './MessagesService' // import WebSearchService from './WebSearchService' +// FIXME: 这里太多重复逻辑,需要重构 + const logger = loggerService.withContext('ApiService') export async function fetchMcpTools(assistant: Assistant) { @@ -94,7 +97,15 @@ export async function fetchChatCompletion({ modelId: assistant.model?.id, modelName: assistant.model?.name }) - const AI = new AiProviderNew(assistant.model || getDefaultModel()) + + // Get base provider and apply API key rotation + const baseProvider = getProviderByModel(assistant.model || getDefaultModel()) + const providerWithRotatedKey = { + ...cloneDeep(baseProvider), + apiKey: getRotatedApiKey(baseProvider) + } + + const AI = new AiProviderNew(assistant.model || getDefaultModel(), providerWithRotatedKey) const provider = AI.getActualProvider() const mcpTools: MCPTool[] = [] @@ -171,7 +182,13 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages: return null } - const AI = new AiProviderNew(model) + // Apply API key rotation + const providerWithRotatedKey = { + ...cloneDeep(provider), + apiKey: getRotatedApiKey(provider) + } + + const AI = new AiProviderNew(model, providerWithRotatedKey) const topicId = messages?.find((message) => message.topicId)?.topicId || '' @@ -270,7 +287,13 @@ export async function fetchNoteSummary({ content, assistant }: { content: string return null } - const AI = new AiProviderNew(model) + // Apply API key rotation + const providerWithRotatedKey = { + ...cloneDeep(provider), + apiKey: getRotatedApiKey(provider) + } + + const AI = new AiProviderNew(model, providerWithRotatedKey) // only 2000 char and no images const truncatedContent = content.substring(0, 2000) @@ -358,7 +381,13 @@ export async function fetchGenerate({ return '' } - const AI = new AiProviderNew(model) + // Apply API key rotation + const providerWithRotatedKey = { + ...cloneDeep(provider), + apiKey: getRotatedApiKey(provider) + } + + const AI = new AiProviderNew(model, providerWithRotatedKey) const assistant = getDefaultAssistant() assistant.model = model @@ -403,43 +432,91 @@ export async function fetchGenerate({ export function hasApiKey(provider: Provider) { if (!provider) return false - if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true + if (provider.id === 'cherryai') return true + if ( + (isSystemProvider(provider) && NOT_SUPPORT_API_KEY_PROVIDERS.includes(provider.id)) || + NOT_SUPPORT_API_KEY_PROVIDER_TYPES.includes(provider.type) + ) + return true return !isEmpty(provider.apiKey) } /** - * Get the first available embedding model from enabled providers + * Get rotated API key for providers that support multiple keys + * Returns empty string for providers that don't require API keys */ -// function getFirstEmbeddingModel() { -// const providers = store.getState().llm.providers.filter((p) => p.enabled) +function getRotatedApiKey(provider: Provider): string { + // Handle providers that don't require API keys + if (!provider.apiKey || provider.apiKey.trim() === '') { + return '' + } -// for (const provider of providers) { -// const embeddingModel = provider.models.find((model) => isEmbeddingModel(model)) -// if (embeddingModel) { -// return embeddingModel -// } -// } + const keys = provider.apiKey + .split(',') + .map((key) => key.trim()) + .filter(Boolean) -// return undefined -// } + if (keys.length === 0) { + return '' + } + + const keyName = `provider:${provider.id}:last_used_key` + + // If only one key, return it directly + if (keys.length === 1) { + return keys[0] + } + + const lastUsedKey = window.keyv.get(keyName) + if (!lastUsedKey) { + window.keyv.set(keyName, keys[0]) + return keys[0] + } + + const currentIndex = keys.indexOf(lastUsedKey) + + // Log when the last used key is no longer in the list + if (currentIndex === -1) { + logger.debug('Last used API key no longer found in provider keys, falling back to first key', { + providerId: provider.id, + lastUsedKey: lastUsedKey.substring(0, 8) + '...' // Only log first 8 chars for security + }) + } + + const nextIndex = (currentIndex + 1) % keys.length + const nextKey = keys[nextIndex] + window.keyv.set(keyName, nextKey) + + return nextKey +} export async function fetchModels(provider: Provider): Promise { - const AI = new AiProviderNew(provider) + // Apply API key rotation + const providerWithRotatedKey = { + ...cloneDeep(provider), + apiKey: getRotatedApiKey(provider) + } + + const AI = new AiProviderNew(providerWithRotatedKey) try { return await AI.models() } catch (error) { + logger.error('Failed to fetch models from provider', { + providerId: provider.id, + providerName: provider.name, + error: error as Error + }) return [] } } export function checkApiProvider(provider: Provider): void { - if ( - provider.id !== 'ollama' && - provider.id !== 'lmstudio' && - provider.type !== 'vertexai' && - provider.id !== 'copilot' - ) { + const isExcludedProvider = + (isSystemProvider(provider) && NOT_SUPPORT_API_KEY_PROVIDERS.includes(provider.id)) || + NOT_SUPPORT_API_KEY_PROVIDER_TYPES.includes(provider.type) + + if (!isExcludedProvider) { if (!provider.apiKey) { window.toast.error(i18n.t('message.error.enter.api.label')) throw new Error(i18n.t('message.error.enter.api.label')) @@ -460,8 +537,7 @@ export function checkApiProvider(provider: Provider): void { export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise { checkApiProvider(provider) - // Don't pass in provider parameter. We need auto-format URL - const ai = new AiProviderNew(model) + const ai = new AiProviderNew(model, provider) const assistant = getDefaultAssistant() assistant.model = model diff --git a/src/renderer/src/utils/provider.ts b/src/renderer/src/utils/provider.ts index 0586099cff..e36f44ecfe 100644 --- a/src/renderer/src/utils/provider.ts +++ b/src/renderer/src/utils/provider.ts @@ -187,3 +187,13 @@ export const isSupportAPIVersionProvider = (provider: Provider) => { } return provider.apiOptions?.isNotSupportAPIVersion !== false } + +export const NOT_SUPPORT_API_KEY_PROVIDERS: readonly SystemProviderId[] = [ + 'ollama', + 'lmstudio', + 'vertexai', + 'aws-bedrock', + 'copilot' +] + +export const NOT_SUPPORT_API_KEY_PROVIDER_TYPES: readonly ProviderType[] = ['vertexai', 'aws-bedrock'] From ea36b918f1c37c95b74292f3c923d6551896263c Mon Sep 17 00:00:00 2001 From: Phantom Date: Fri, 5 Dec 2025 13:56:54 +0800 Subject: [PATCH 02/25] feat(translate): support document files and refactor file reading logic (#11615) * refactor(FileStorage): extract file reading logic into reusable method Move common file reading functionality from readFile and readExternalFile into a new private readFileCore method Improve error logging by distinguishing between document and text file failures Add comprehensive JSDoc documentation for all file reading methods * feat(translate): support document files and increase size limit Add support for document file types in translation file selection. Increase maximum file size limit to 20MB for documents while keeping text files at 5MB. Implement separate handling for document and text file reading. --- src/main/services/FileStorage.ts | 117 +++++++++++------- .../src/pages/translate/TranslatePage.tsx | 64 +++++++--- 2 files changed, 118 insertions(+), 63 deletions(-) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 3165fcf27e..c8eb6abb03 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -478,13 +478,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 +507,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 +519,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 +594,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 => { 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() From 968210faa71555abcd9e4ca404dd8216e8166eee Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:40:29 +0800 Subject: [PATCH 03/25] fix: correct OVMS API URL path formation (#11701) * Initial plan * fix: correct OVMS API URL path from '../v1/config' to 'config' Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * fix: update OVMSClient to use formatted API URL for model listing --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> Co-authored-by: suyao --- src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 From 9d6d827f887519329ab55da2a1ea107d8a4c0245 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 5 Dec 2025 17:42:44 +0800 Subject: [PATCH 04/25] fix(migrate): normalize provider type for AI gateway (#11703) --- src/renderer/src/store/migrate.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index d10e2dfcbd..909837b3fc 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2945,6 +2945,10 @@ const migrateConfig = { model.provider = SystemProviderIds.gateway } }) + // @ts-ignore + if (provider.type === 'ai-gateway') { + provider.type = 'gateway' + } }) logger.info('migrate 181 success') return state From 3cedb95db33e570d0f09cbb6376e2ca83e3798ee Mon Sep 17 00:00:00 2001 From: Phantom Date: Fri, 5 Dec 2025 19:52:37 +0800 Subject: [PATCH 05/25] fix(stream-options): add user-configurable stream options for OpenAI API (#11693) * refactor(types): rename OpenAISummaryText to OpenAIReasoningSummary for clarity * refactor: move OpenAISettingsGroup to independent folder * refactor(OpenAISettingsGroup): extract settings components into separate files Move ReasoningSummarySetting, ServiceTierSetting and VerbositySetting into individual components to improve code organization and maintainability * feat(stream-options): add stream options configuration for OpenAI completions add includeUsage option to control token usage reporting in streamed responses update provider config and settings UI to support new stream options add migration for existing providers to set default stream options extend toOptionValue utility to handle boolean values * refactor(stream-options): move stream options includeUsage to settings store - Remove streamOptions from Provider type and move includeUsage to settings.openAI - Update migration to initialize streamOptions in settings - Modify providerToAiSdkConfig to read includeUsage from settings - Update StreamOptionsSetting component to use settings store * feat(i18n): add missing translations for 'on' and stream options Add translations for the 'on' state and stream options including token usage in multiple languages * docs(select): update docs * test(providerConfig): add tests for stream options includeUsage add test cases to verify includeUsage stream option behavior for OpenAI provider * Update src/renderer/src/i18n/translate/ru-ru.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/VerbositySetting.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/renderer/src/utils/select.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * test(select): add tests for toOptionValue and toRealValue functions * fix(providerConfig): handle undefined streamOptions in openAI settings Prevent potential runtime errors by safely accessing nested streamOptions properties * test(providerConfig): add tests for Copilot provider includeUsage settings * fix(OpenAISettingsGroup): handle potential undefined streamOptions in selector * docs(aiCoreTypes): add comment for OpenAICompletionsStreamOptions * refactor(select): improve type safety in toOptionValue function Use Exclude to prevent string literals from overlapping with special values --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../provider/__tests__/providerConfig.test.ts | 206 ++++++++++++++- .../src/aiCore/provider/providerConfig.ts | 9 +- src/renderer/src/aiCore/utils/reasoning.ts | 4 +- src/renderer/src/config/constant.ts | 1 + src/renderer/src/i18n/locales/en-us.json | 7 + src/renderer/src/i18n/locales/zh-cn.json | 7 + src/renderer/src/i18n/locales/zh-tw.json | 7 + src/renderer/src/i18n/translate/de-de.json | 7 + src/renderer/src/i18n/translate/el-gr.json | 7 + src/renderer/src/i18n/translate/es-es.json | 7 + src/renderer/src/i18n/translate/fr-fr.json | 7 + src/renderer/src/i18n/translate/ja-jp.json | 7 + src/renderer/src/i18n/translate/pt-pt.json | 7 + src/renderer/src/i18n/translate/ru-ru.json | 7 + .../src/pages/home/Tabs/SettingsTab.tsx | 7 +- .../Tabs/components/OpenAISettingsGroup.tsx | 247 ------------------ .../OpenAISettingsGroup.tsx | 72 +++++ .../ReasoningSummarySetting.tsx | 78 ++++++ .../ServiceTierSetting.tsx | 88 +++++++ .../StreamOptionsSetting.tsx | 72 +++++ .../OpenAISettingsGroup/VerbositySetting.tsx | 94 +++++++ .../components/OpenAISettingsGroup/index.tsx | 3 + src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 22 +- src/renderer/src/store/settings.ts | 28 +- src/renderer/src/types/aiCoreTypes.ts | 7 +- .../src/utils/__tests__/select.test.ts | 163 ++++++++++++ src/renderer/src/utils/select.ts | 53 +++- 28 files changed, 949 insertions(+), 277 deletions(-) delete mode 100644 src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/OpenAISettingsGroup.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/ReasoningSummarySetting.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/ServiceTierSetting.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/StreamOptionsSetting.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/VerbositySetting.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/index.tsx create mode 100644 src/renderer/src/utils/__tests__/select.test.ts diff --git a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts index 430ff52869..43d3cc52b8 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -22,11 +22,15 @@ vi.mock('@renderer/services/AssistantService', () => ({ }) })) -vi.mock('@renderer/store', () => ({ - default: { - getState: () => ({ copilot: { defaultHeaders: {} } }) +vi.mock('@renderer/store', () => { + const mockGetState = vi.fn() + return { + default: { + getState: mockGetState + }, + __mockGetState: mockGetState } -})) +}) vi.mock('@renderer/utils/api', () => ({ formatApiHost: vi.fn((host, isSupportedAPIVersion = true) => { @@ -79,6 +83,8 @@ import { isCherryAIProvider, isPerplexityProvider } from '@renderer/utils/provid import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants' import { getActualProvider, providerToAiSdkConfig } from '../providerConfig' +const { __mockGetState: mockGetState } = vi.mocked(await import('@renderer/store')) as any + const createWindowKeyv = () => { const store = new Map() return { @@ -132,6 +138,16 @@ describe('Copilot responses routing', () => { ...(globalThis as any).window, keyv: createWindowKeyv() } + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) }) it('detects official GPT-5 Codex identifiers case-insensitively', () => { @@ -167,6 +183,16 @@ describe('CherryAI provider configuration', () => { ...(globalThis as any).window, keyv: createWindowKeyv() } + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) vi.clearAllMocks() }) @@ -231,6 +257,16 @@ describe('Perplexity provider configuration', () => { ...(globalThis as any).window, keyv: createWindowKeyv() } + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) vi.clearAllMocks() }) @@ -291,3 +327,165 @@ describe('Perplexity provider configuration', () => { expect(actualProvider.apiHost).toBe('') }) }) + +describe('Stream options includeUsage configuration', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + vi.clearAllMocks() + }) + + const createOpenAIProvider = (): Provider => ({ + id: 'openai-compatible', + type: 'openai', + name: 'OpenAI', + apiKey: 'test-key', + apiHost: 'https://api.openai.com', + models: [], + isSystem: true + }) + + it('uses includeUsage from settings when undefined', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) + + const provider = createOpenAIProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'openai')) + + expect(config.options.includeUsage).toBeUndefined() + }) + + it('uses includeUsage from settings when set to true', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: true + } + } + } + }) + + const provider = createOpenAIProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'openai')) + + expect(config.options.includeUsage).toBe(true) + }) + + it('uses includeUsage from settings when set to false', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: false + } + } + } + }) + + const provider = createOpenAIProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'openai')) + + expect(config.options.includeUsage).toBe(false) + }) + + it('respects includeUsage setting for non-supporting providers', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: true + } + } + } + }) + + const testProvider: Provider = { + id: 'test', + type: 'openai', + name: 'test', + apiKey: 'test-key', + apiHost: 'https://api.test.com', + models: [], + isSystem: false, + apiOptions: { + isNotSupportStreamOptions: true + } + } + + const config = providerToAiSdkConfig(testProvider, createModel('gpt-4', 'GPT-4', 'test')) + + // Even though setting is true, provider doesn't support it, so includeUsage should be undefined + expect(config.options.includeUsage).toBeUndefined() + }) + + it('uses includeUsage from settings for Copilot provider when set to false', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: false + } + } + } + }) + + const provider = createCopilotProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'copilot')) + + expect(config.options.includeUsage).toBe(false) + expect(config.providerId).toBe('github-copilot-openai-compatible') + }) + + it('uses includeUsage from settings for Copilot provider when set to true', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: true + } + } + } + }) + + const provider = createCopilotProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'copilot')) + + expect(config.options.includeUsage).toBe(true) + expect(config.providerId).toBe('github-copilot-openai-compatible') + }) + + it('uses includeUsage from settings for Copilot provider when undefined', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) + + const provider = createCopilotProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'copilot')) + + expect(config.options.includeUsage).toBeUndefined() + expect(config.providerId).toBe('github-copilot-openai-compatible') + }) +}) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 0be69bdb4f..99e4fbd1c9 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -11,6 +11,7 @@ import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useV import { getProviderByModel } from '@renderer/services/AssistantService' import store from '@renderer/store' import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types' +import type { OpenAICompletionsStreamOptions } from '@renderer/types/aiCoreTypes' import { formatApiHost, formatAzureOpenAIApiHost, @@ -147,6 +148,10 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A baseURL: baseURL, apiKey: actualProvider.apiKey } + let includeUsage: OpenAICompletionsStreamOptions['include_usage'] = undefined + if (isSupportStreamOptionsProvider(actualProvider)) { + includeUsage = store.getState().settings.openAI?.streamOptions?.includeUsage + } const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot if (isCopilotProvider) { @@ -158,7 +163,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A ...actualProvider.extra_headers }, name: actualProvider.id, - includeUsage: true + includeUsage }) return { @@ -261,7 +266,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A ...options, name: actualProvider.id, ...extraOptions, - includeUsage: isSupportStreamOptionsProvider(actualProvider) + includeUsage } } } diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 46350b085f..1e74db24df 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -37,7 +37,7 @@ import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' import type { Assistant, Model } from '@renderer/types' import { EFFORT_RATIO, isSystemProvider, SystemProviderIds } from '@renderer/types' -import type { OpenAISummaryText } from '@renderer/types/aiCoreTypes' +import type { OpenAIReasoningSummary } from '@renderer/types/aiCoreTypes' import type { ReasoningEffortOptionalParams } from '@renderer/types/sdk' import { isSupportEnableThinkingProvider } from '@renderer/utils/provider' import { toInteger } from 'lodash' @@ -448,7 +448,7 @@ export function getOpenAIReasoningParams( const openAI = getStoreSetting('openAI') const summaryText = openAI.summaryText - let reasoningSummary: OpenAISummaryText = undefined + let reasoningSummary: OpenAIReasoningSummary = undefined if (model.id.includes('o1-pro')) { reasoningSummary = undefined diff --git a/src/renderer/src/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/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 427cbdcffd..2ebddb6889 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": { @@ -4271,6 +4272,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..4218c68f55 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": { @@ -4271,6 +4272,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..bcf12aa63e 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": { @@ -4271,6 +4272,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..963f065515 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": { @@ -4271,6 +4272,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..6bc8b318db 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": { @@ -4271,6 +4272,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..925977529b 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": { @@ -4271,6 +4272,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..5bd57f7773 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": { @@ -4271,6 +4272,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..9d0926c48b 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": { @@ -4271,6 +4272,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..b84971d725 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": { @@ -4271,6 +4272,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..50db747396 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": { @@ -4271,6 +4272,12 @@ "tip": "Указывает уровень задержки, который следует использовать для обработки запроса", "title": "Уровень сервиса" }, + "stream_options": { + "include_usage": { + "tip": "Включено ли использование токенов (применимо только к API завершения чата OpenAI)", + "title": "Включить использование" + } + }, "summary_text_mode": { "auto": "Авто", "concise": "Краткий", 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/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 909837b3fc..a80336e697 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1,6 +1,11 @@ import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' -import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, isMac } from '@renderer/config/constant' +import { + DEFAULT_CONTEXTCOUNT, + DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE, + DEFAULT_TEMPERATURE, + isMac +} from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { glm45FlashModel, @@ -2956,6 +2961,21 @@ const migrateConfig = { logger.error('migrate 181 error', error as Error) return state } + }, + '182': (state: RootState) => { + try { + // Initialize streamOptions in settings.openAI if not exists + if (!state.settings.openAI.streamOptions) { + state.settings.openAI.streamOptions = { + includeUsage: DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE + } + } + logger.info('migrate 182 success') + return state + } catch (error) { + logger.error('migrate 182 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 36a478853a..572f722746 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -1,6 +1,6 @@ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' -import { isMac } from '@renderer/config/constant' +import { DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE, isMac } from '@renderer/config/constant' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import type { @@ -16,7 +16,11 @@ import type { TranslateLanguageCode } from '@renderer/types' import { ThemeMode } from '@renderer/types' -import type { OpenAISummaryText, OpenAIVerbosity } from '@renderer/types/aiCoreTypes' +import type { + OpenAICompletionsStreamOptions, + OpenAIReasoningSummary, + OpenAIVerbosity +} from '@renderer/types/aiCoreTypes' import { uuid } from '@renderer/utils' import { API_SERVER_DEFAULTS, UpgradeChannel } from '@shared/config/constant' @@ -193,10 +197,14 @@ export interface SettingsState { } // OpenAI openAI: { - summaryText: OpenAISummaryText + // TODO: it's a bad naming. rename it to reasoningSummary in v2. + summaryText: OpenAIReasoningSummary /** @deprecated 现在该设置迁移到Provider对象中 */ serviceTier: OpenAIServiceTier verbosity: OpenAIVerbosity + streamOptions: { + includeUsage: OpenAICompletionsStreamOptions['include_usage'] + } } // Notification notification: { @@ -376,7 +384,10 @@ export const initialState: SettingsState = { openAI: { summaryText: 'auto', serviceTier: 'auto', - verbosity: undefined + verbosity: undefined, + streamOptions: { + includeUsage: DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE + } }, notification: { assistant: false, @@ -791,12 +802,18 @@ const settingsSlice = createSlice({ setDisableHardwareAcceleration: (state, action: PayloadAction) => { state.disableHardwareAcceleration = action.payload }, - setOpenAISummaryText: (state, action: PayloadAction) => { + setOpenAISummaryText: (state, action: PayloadAction) => { state.openAI.summaryText = action.payload }, setOpenAIVerbosity: (state, action: PayloadAction) => { state.openAI.verbosity = action.payload }, + setOpenAIStreamOptionsIncludeUsage: ( + state, + action: PayloadAction + ) => { + state.openAI.streamOptions.includeUsage = action.payload + }, setNotificationSettings: (state, action: PayloadAction) => { state.notification = action.payload }, @@ -967,6 +984,7 @@ export const { setDisableHardwareAcceleration, setOpenAISummaryText, setOpenAIVerbosity, + setOpenAIStreamOptionsIncludeUsage, setNotificationSettings, // Local backup settings setLocalBackupDir, diff --git a/src/renderer/src/types/aiCoreTypes.ts b/src/renderer/src/types/aiCoreTypes.ts index 6281905cbb..28250e4053 100644 --- a/src/renderer/src/types/aiCoreTypes.ts +++ b/src/renderer/src/types/aiCoreTypes.ts @@ -50,7 +50,12 @@ export type OpenAIReasoningEffort = OpenAI.ReasoningEffort * When undefined, the parameter is omitted from the request. * When null, verbosity is explicitly disabled. */ -export type OpenAISummaryText = OpenAI.Reasoning['summary'] +export type OpenAIReasoningSummary = OpenAI.Reasoning['summary'] + +/** + * Options for streaming response. Only set this when you set `stream: true`. + */ +export type OpenAICompletionsStreamOptions = OpenAI.ChatCompletionStreamOptions const AiSdkParamsSchema = z.enum([ 'maxOutputTokens', diff --git a/src/renderer/src/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/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 } From 8d1d09b1eca6ade429da4cf5a28dca74acade83c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:44:57 +0800 Subject: [PATCH 06/25] fix: eliminate UI freeze on multi-file selection via batch processing (#11377) * Initial plan * fix: improve file upload performance with batch processing and progress feedback - Add batch processing (5 files concurrently) to uploadNotes function - Use Promise.allSettled for parallel file processing - Add setTimeout(0) between batches to yield to event loop - Show loading toast when uploading more than 5 files - Add translation keys for uploading progress (en, zh-cn, zh-tw) Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * feat: add batch upload and file watcher control functionalities * feat: add hint node type and implement TreeNode component for notes - Updated NotesTreeNode type to include 'hint' as a node type. - Implemented TreeNode component to handle rendering of notes and folders, including hint nodes. - Added drag-and-drop functionality for organizing notes. - Created context hooks for managing notes actions, selection, editing, drag-and-drop, search, and UI state. - Developed file upload handling for drag-and-drop and file selection. - Enhanced menu options for notes with actions like create, rename, delete, and export. - Integrated auto-renaming feature for notes based on content. * clean comment * feat: add pause and resume functionality to file watcher; enhance error handling in useInPlaceEdit hook * fix: adjust padding in item container style for improved layout --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> Co-authored-by: suyao --- packages/shared/IpcChannel.ts | 3 + packages/shared/config/types.ts | 2 +- src/main/ipc.ts | 3 + src/main/services/FileStorage.ts | 166 ++ src/preload/index.ts | 4 + .../__tests__/DynamicVirtualList.test.tsx | 6 +- .../DynamicVirtualList.test.tsx.snap | 6 +- .../src/components/VirtualList/dynamic.tsx | 97 +- src/renderer/src/hooks/useInPlaceEdit.ts | 118 +- src/renderer/src/i18n/locales/en-us.json | 5 +- src/renderer/src/i18n/locales/zh-cn.json | 5 +- src/renderer/src/i18n/locales/zh-tw.json | 5 +- src/renderer/src/i18n/translate/de-de.json | 5 +- src/renderer/src/i18n/translate/el-gr.json | 5 +- src/renderer/src/i18n/translate/es-es.json | 5 +- src/renderer/src/i18n/translate/fr-fr.json | 5 +- src/renderer/src/i18n/translate/ja-jp.json | 5 +- src/renderer/src/i18n/translate/pt-pt.json | 5 +- src/renderer/src/i18n/translate/ru-ru.json | 5 +- .../home/Tabs/components/SessionItem.tsx | 11 +- .../src/pages/home/Tabs/components/Topics.tsx | 18 +- src/renderer/src/pages/notes/NotesPage.tsx | 32 +- src/renderer/src/pages/notes/NotesSidebar.tsx | 1617 +++-------------- .../src/pages/notes/components/TreeNode.tsx | 498 +++++ .../src/pages/notes/context/NotesContexts.tsx | 109 ++ .../pages/notes/hooks/useNotesDragAndDrop.ts | 101 + .../src/pages/notes/hooks/useNotesEditing.ts | 94 + .../pages/notes/hooks/useNotesFileUpload.ts | 112 ++ .../src/pages/notes/hooks/useNotesMenu.tsx | 263 +++ src/renderer/src/services/NotesService.ts | 101 +- src/renderer/src/types/note.ts | 2 +- 31 files changed, 1963 insertions(+), 1450 deletions(-) create mode 100644 src/renderer/src/pages/notes/components/TreeNode.tsx create mode 100644 src/renderer/src/pages/notes/context/NotesContexts.tsx create mode 100644 src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts create mode 100644 src/renderer/src/pages/notes/hooks/useNotesEditing.ts create mode 100644 src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts create mode 100644 src/renderer/src/pages/notes/hooks/useNotesMenu.tsx 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/src/main/ipc.ts b/src/main/ipc.ts index e537b85261..f91e61eaa4 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -595,6 +595,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 c8eb6abb03..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() @@ -1479,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 } @@ -1636,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/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/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/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 2ebddb6889..e8fdad0afb 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2220,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", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 4218c68f55..0ce7627392 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2220,7 +2220,10 @@ "untitled_folder": "新文件夹", "untitled_note": "无标题笔记", "upload_failed": "笔记上传失败", - "upload_success": "笔记上传成功" + "upload_files": "上传文件", + "upload_folder": "上传文件夹", + "upload_success": "笔记上传成功", + "uploading_files": "正在上传 {{count}} 个文件..." }, "notification": { "assistant": "助手响应", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index bcf12aa63e..20a3d84df2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2220,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": "助手回應", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 963f065515..aaed5b498e 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -2220,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", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 6bc8b318db..f8125631a9 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -2220,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": "Απάντηση Βοηθού", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 925977529b..2a5874f6b6 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -2220,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", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 5bd57f7773..bc884c8c69 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -2220,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", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 9d0926c48b..f1c0fe575d 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -2220,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": "助手回應", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index b84971d725..33976b2e1f 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -2220,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", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 50db747396..fbdffdb379 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -2220,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": "Ответ ассистента", 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/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/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[] From 8f39ecf762146626f7a628e47881d0fb38b5097e Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 7 Dec 2025 14:01:11 +0800 Subject: [PATCH 07/25] fix(models): update assistant default model when editing model capabilities (#11732) * fix(ProviderSettings): update assistant default model when model changes Ensure assistant's default model is updated when the underlying model is modified to maintain consistency * refactor(EditModelPopup): simplify assistant model update logic Replace manual model updates with a single map operation to update both model and defaultModel fields. This makes the code more concise and easier to maintain. * refactor(EditModelPopup): remove unused dispatch import and variable * feat(EditModelPopup): add support for translate and quick model updates Update the EditModelPopup component to handle updates for translate and quick models in addition to the default model. This ensures consistency across all model types when changes are made. --- .../EditModelPopup/EditModelPopup.tsx | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) 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 ( From ebfc60b039b9251437637b8e782e531b742cf288 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sun, 7 Dec 2025 16:42:00 +0800 Subject: [PATCH 08/25] fix(windows): improve Git Bash detection for portable installations (#11671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): improve Git Bash detection for portable installations Enhance Git Bash detection on Windows to support portable Git installations and custom installation paths. The previous implementation only checked fixed paths and failed to detect Git when installed to custom locations or added to PATH manually. Key improvements: - Use where.exe to find git.exe in PATH and derive bash.exe location - Support CHERRY_STUDIO_GIT_BASH_PATH environment variable override - Add security check to skip executables in current directory - Implement three-tier fallback strategy (env var -> git derivation -> common paths) - Add detailed logging for troubleshooting This fixes the issue where users with portable Git installations could run git.exe from command line but the app failed to detect Git Bash. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(windows): improve Git Bash detection for portable installations Enhance Git Bash detection on Windows to support portable Git installations and custom installation paths. The previous implementation only checked fixed paths and failed to detect Git when installed to custom locations or added to PATH manually. Key improvements: - Move findExecutable and findGitBash to utils/process.ts for better code organization - Use where.exe to find git.exe in PATH and derive bash.exe location - Add security check to skip executables in current directory - Implement two-tier fallback strategy (git derivation -> common paths) - Add detailed logging for troubleshooting - Remove environment variable override to simplify implementation This fixes the issue where users with portable Git installations could run git.exe from command line but the app failed to detect Git Bash. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(windows): improve Git Bash detection for portable installations Enhance Git Bash detection on Windows to support portable Git installations and custom installation paths. The previous implementation only checked fixed paths and failed to detect Git when installed to custom locations or added to PATH manually. Key improvements: - Move findExecutable and findGitBash to utils/process.ts for better code organization - Use where.exe to find git.exe in PATH and derive bash.exe location - Add security check to skip executables in current directory - Implement two-tier fallback strategy (git derivation -> common paths) - Add detailed logging for troubleshooting - Remove environment variable override to simplify implementation This fixes the issue where users with portable Git installations could run git.exe from command line but the app failed to detect Git Bash. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * update iswin * test: add comprehensive test coverage for findExecutable and findGitBash Add 33 test cases covering: - Git found in common paths (Program Files, Program Files (x86)) - Git found via where.exe in PATH - Windows/Unix line ending handling (CRLF/LF) - Whitespace trimming from where.exe output - Security checks to skip executables in current directory - Multiple Git installation structures (Standard, Portable, MSYS2) - Bash.exe path derivation from git.exe location - Common paths fallback when git.exe not found - LOCALAPPDATA environment variable handling - Priority order (derivation over common paths) - Error scenarios (Git not installed, bash.exe missing) - Real-world scenarios (official installer, portable, Scoop) All tests pass with proper mocking of fs, path, and child_process modules. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor: clarify path navigation comments in findGitBash Replace confusing arrow notation showing intermediate directories with clearer descriptions of the navigation intent: - "navigate up 2 levels" instead of showing "-> Git/cmd -> Git ->" - "bash.exe in same directory" for portable installations - Emphasizes the intent rather than the intermediate steps Makes the code more maintainable by clearly stating what each path pattern is checking for. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * test: skip process utility tests on non-Windows platforms Use describe.skipIf to skip all tests when not on Windows since findExecutable and findGitBash have platform guards that return null on non-Windows systems. Remove redundant platform mocking in nested describe blocks since the entire suite is already Windows-only. This fixes test failures on macOS and Linux where all 33 tests were failing because the functions correctly return null on those platforms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * format * fix: improve Git Bash detection error handling and logging - Add try-catch wrapper in IPC handler to handle unexpected errors - Fix inaccurate comment: usr/bin/bash.exe is for MSYS2, not Git 2.x - Change log level from INFO to DEBUG for internal "not found" message - Keep WARN level only in IPC handler for user-facing message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/main/ipc.ts | 30 +- src/main/utils/__tests__/process.test.ts | 572 +++++++++++++++++++++++ src/main/utils/process.ts | 125 ++++- 3 files changed, 701 insertions(+), 26 deletions(-) create mode 100644 src/main/utils/__tests__/process.test.ts diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f91e61eaa4..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 } }) 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 +} From b58a2fce038f9b5d34b30fc0f9c22cd728e05b85 Mon Sep 17 00:00:00 2001 From: chenxue Date: Sun, 7 Dec 2025 21:03:19 +0800 Subject: [PATCH 09/25] feat(aihubmix): fix website domain (#11734) fix domain --- src/main/services/WindowService.ts | 6 +++--- src/renderer/src/pages/code/index.ts | 2 +- src/renderer/src/utils/oauth.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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/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/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 }, From 516b8479d6b82a841fa5ffe32d16c09ef819f6ea Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 7 Dec 2025 21:04:40 +0800 Subject: [PATCH 10/25] style: update gemini logo images (#11731) * style: update gemini logo images and fix model logo condition Update the Gemini logo images in both apps and models directories Remove or fix the always-true isLight condition in getModelLogoById * style: downsample gemini icon * style(minapp): Add bordered property for gemini minapp Add FIXME comment to indicate 'bodered' should be 'bordered' and update config to use correct property --- .../src/assets/images/apps/gemini.png | Bin 3259 -> 19756 bytes .../src/assets/images/models/gemini.png | Bin 3259 -> 19756 bytes src/renderer/src/config/minapps.ts | 3 ++- src/renderer/src/config/models/logo.ts | 1 + src/renderer/src/types/index.ts | 1 + 5 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/assets/images/apps/gemini.png b/src/renderer/src/assets/images/apps/gemini.png index 63c42078969844b82aeeb15a07ea88058ef90183..df8b95ced9ca4544bddb77e60ddaed3b9bbde06a 100644 GIT binary patch literal 19756 zcmZ6y1y~$GvnaZ|xVr^g2(F8}li(KI-642zSlnH*I01qLmjrkB;3T-ayXNwrbKbe{ zy_v74YHCWlx_eq?s-skuWiU|Rp#lH^3^`fpPj7JOKM5J}?OLf*mHP&OZl7c%0aX)Z z2X8l_mO64)N=g8xHyjy&0K^5r{fFi41OVRw5dI4X0OWzB|BHVDGW-t@2mpw%1;GCg zkKP;n&msGk-lYEr!sP@1uf}}P|8fuI!~I|UKVmkfs_Ab4#aUL@4FEvH`%eM_GP8-_ zwCUPv=(y`BDGHi9IkK5rIGI_pc{w`&M+zY9CHRIqTDqHpy&N4ry9s)UQ2!T);2ZuQ zGCMW+zgXPuMW}U@RKZeCu9je4HV7MpS`-xw1`E4dSP6cT{`f!QZ+9ZpHtz1ug6!;` zo}O%;Tx?FR*6bVt0s`z1PIgXC);A7TH}B8xre3U{-QNGVlK)4Kw56N5tF5!Ut&utC`WSNEH!@PDX+Dz3Jc zZEza|F6vax2OL_eREY5Rha#Mi%k@j#ApKv01yYrNlR#W z0Z;rwZnsXu|_1cq0>Ob|> zjs3MXJ8wj9+Ri-FI3Cg!z+c7Lf($^-@UAG^xI?sw|9`@7mY_xA1Yb9YfG=R$b;_Zb zy#+0lzO?90(}zpy31rL!(}A7KVOq!;EjAW9z7=kdgDqFu@RksBQ1*ln^Q6d+IKX*$ zI5pnjDtq7_2GOnbR!MCzN9W!|p~~}fiDJJ!jrVQouCNftCi@)Yv+&W zK(!!O{G=c4t#%*!nU42uV0CAH9ZYOyN2D+ClFIw3Imb7jVoCPH;!|iXl2|RIH0M*f zlBT_>PI=gko=#S#o=$1bb+GZOZ_aY=xNrVv4^d-2q#>Z!3sI|v&&dQw$HOhChjb#(LXCZZ5dK%j?Uu9$qy~D1eb<_bdh7;(B}kwvlR1 z@?e-I@UA{IY&vTk7LzIEzGwUuZ`*tsDUc+NnJ9pBbS?kgo5=kl(7HOnhyzmv^JuzF z-(dlm%o=xXR=Eo4h*GxFRaMRAyR)L@ZhR|CH|8~m6Ek^M)W)hqNn~>c$=b#`0lgl0 zET37W6|^1%2@{`p=5V~DELOS-v=0ZOa$uw((I}@oR5xz6@Sh8cw6~*Ev(P9TBZ({P zKkpzN)YSlIe8p{|XFa$?yIDkj?p%+Z_kPwmMo1}E{<7q46VFw47Sf3CL~mF4a#oM# z^2mAfPw2RAnckCk0lJ_PA50e8Kcl|t^v1OM{S#OVJjtA)ZLQ=|=f;`{`cF*;sn%

)=dvn|N26A9o~H&# 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 diff --git a/src/renderer/src/assets/images/models/gemini.png b/src/renderer/src/assets/images/models/gemini.png index 63c42078969844b82aeeb15a07ea88058ef90183..df8b95ced9ca4544bddb77e60ddaed3b9bbde06a 100644 GIT binary patch literal 19756 zcmZ6y1y~$GvnaZ|xVr^g2(F8}li(KI-642zSlnH*I01qLmjrkB;3T-ayXNwrbKbe{ zy_v74YHCWlx_eq?s-skuWiU|Rp#lH^3^`fpPj7JOKM5J}?OLf*mHP&OZl7c%0aX)Z z2X8l_mO64)N=g8xHyjy&0K^5r{fFi41OVRw5dI4X0OWzB|BHVDGW-t@2mpw%1;GCg zkKP;n&msGk-lYEr!sP@1uf}}P|8fuI!~I|UKVmkfs_Ab4#aUL@4FEvH`%eM_GP8-_ zwCUPv=(y`BDGHi9IkK5rIGI_pc{w`&M+zY9CHRIqTDqHpy&N4ry9s)UQ2!T);2ZuQ zGCMW+zgXPuMW}U@RKZeCu9je4HV7MpS`-xw1`E4dSP6cT{`f!QZ+9ZpHtz1ug6!;` zo}O%;Tx?FR*6bVt0s`z1PIgXC);A7TH}B8xre3U{-QNGVlK)4Kw56N5tF5!Ut&utC`WSNEH!@PDX+Dz3Jc zZEza|F6vax2OL_eREY5Rha#Mi%k@j#ApKv01yYrNlR#W z0Z;rwZnsXu|_1cq0>Ob|> zjs3MXJ8wj9+Ri-FI3Cg!z+c7Lf($^-@UAG^xI?sw|9`@7mY_xA1Yb9YfG=R$b;_Zb zy#+0lzO?90(}zpy31rL!(}A7KVOq!;EjAW9z7=kdgDqFu@RksBQ1*ln^Q6d+IKX*$ zI5pnjDtq7_2GOnbR!MCzN9W!|p~~}fiDJJ!jrVQouCNftCi@)Yv+&W zK(!!O{G=c4t#%*!nU42uV0CAH9ZYOyN2D+ClFIw3Imb7jVoCPH;!|iXl2|RIH0M*f zlBT_>PI=gko=#S#o=$1bb+GZOZ_aY=xNrVv4^d-2q#>Z!3sI|v&&dQw$HOhChjb#(LXCZZ5dK%j?Uu9$qy~D1eb<_bdh7;(B}kwvlR1 z@?e-I@UA{IY&vTk7LzIEzGwUuZ`*tsDUc+NnJ9pBbS?kgo5=kl(7HOnhyzmv^JuzF z-(dlm%o=xXR=Eo4h*GxFRaMRAyR)L@ZhR|CH|8~m6Ek^M)W)hqNn~>c$=b#`0lgl0 zET37W6|^1%2@{`p=5V~DELOS-v=0ZOa$uw((I}@oR5xz6@Sh8cw6~*Ev(P9TBZ({P zKkpzN)YSlIe8p{|XFa$?yIDkj?p%+Z_kPwmMo1}E{<7q46VFw47Sf3CL~mF4a#oM# z^2mAfPw2RAnckCk0lJ_PA50e8Kcl|t^v1OM{S#OVJjtA)ZLQ=|=f;`{`cF*;sn%

)=dvn|N26A9o~H&# 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 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/types/index.ts b/src/renderer/src/types/index.ts index 5b72a4181c..128d2be707 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -439,6 +439,7 @@ export type MinAppType = { name: string logo?: string url: string + // FIXME: It should be `bordered` bodered?: boolean background?: string style?: CSSProperties From bba7ecae6ea4fbae1bf2e4d27be81b25dba4a487 Mon Sep 17 00:00:00 2001 From: Phantom Date: Mon, 8 Dec 2025 10:58:56 +0800 Subject: [PATCH 11/25] feat(agent): add tooltip for model selection and improve i18n (#11738) * refactor(settings): rename actions prop to contentAfter for clarity The prop name 'actions' was misleading as it could imply functionality rather than layout. 'contentAfter' better describes its purpose of displaying content after the title. * feat(agent): add tooltip for model selection in agent settings Add tooltip to explain that only Anthropic endpoint models are supported for agents * feat(i18n): add model tooltip and translate upload strings Add tooltip message about Anthropic endpoint model requirement for Agent feature Translate previously untranslated upload-related strings in multiple languages --- .../src/components/Popups/agent/AgentModal.tsx | 10 +++++++--- src/renderer/src/i18n/locales/en-us.json | 3 +++ src/renderer/src/i18n/locales/zh-cn.json | 3 +++ src/renderer/src/i18n/locales/zh-tw.json | 7 +++++-- src/renderer/src/i18n/translate/de-de.json | 9 ++++++--- src/renderer/src/i18n/translate/el-gr.json | 9 ++++++--- src/renderer/src/i18n/translate/es-es.json | 9 ++++++--- src/renderer/src/i18n/translate/fr-fr.json | 9 ++++++--- src/renderer/src/i18n/translate/ja-jp.json | 9 ++++++--- src/renderer/src/i18n/translate/pt-pt.json | 9 ++++++--- src/renderer/src/i18n/translate/ru-ru.json | 9 ++++++--- .../settings/AgentSettings/AccessibleDirsSetting.tsx | 2 +- .../pages/settings/AgentSettings/AdvancedSettings.tsx | 2 +- .../src/pages/settings/AgentSettings/ModelSetting.tsx | 5 ++++- .../src/pages/settings/AgentSettings/shared.tsx | 8 ++++---- 15 files changed, 70 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index e72433e88a..0d3ce94731 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -1,5 +1,6 @@ import { loggerService } from '@logger' import { ErrorBoundary } from '@renderer/components/ErrorBoundary' +import { HelpTooltip } from '@renderer/components/TooltipIcons' import { TopView } from '@renderer/components/TopView' import { permissionModeCards } from '@renderer/config/agent' import { useAgents } from '@renderer/hooks/agents/useAgents' @@ -340,9 +341,12 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { - +

+ + +
,