diff --git a/README.md b/README.md index b589376b7b..7ce7ada20f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ +
+
+ 🌐 Language +
+
+

English

+

简体中文

+

繁體中文

+

日本語

+

한국어

+

हिन्दी

+

ไทย

+

Français

+

Deutsch

+

Español

+

Itapano

+

Русский

+

Português

+

Nederlands

+

Polski

+

العربية

+

فارسی

+

Türkçe

+

Tiếng Việt

+

Bahasa Indonesia

+
+
+
+
+

banner
diff --git a/electron-builder.yml b/electron-builder.yml index 0da2d7f5fe..d1a70bf896 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -107,9 +107,10 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - - 新功能:可选数据保存目录 - - 快捷助手:支持单独选择助手,支持暂停、上下文、思考过程、流式 - - 划词助手:系统托盘菜单开关 - - 翻译:新增 Markdown 预览选项 - - 新供应商:新增 Vertex AI 服务商 - - 错误修复和界面优化 + 界面优化:优化多处界面样式,气泡样式改版,自动调整代码预览边栏宽度 + 知识库:修复知识库引用不显示问题,修复部分嵌入模型适配问题 + 备份与恢复:修复超过 2GB 大文件无法恢复问题 + 文件处理:添加 .doc 文件支持 + 划词助手:支持自定义 CSS 样式 + MCP:基于 Pyodide 实现 Python MCP 服务 + 其他错误修复和优化 diff --git a/package.json b/package.json index 329a527914..664de705ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.4.4", + "version": "1.4.7", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -62,6 +62,7 @@ "@libsql/win32-x64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7", "jsdom": "26.1.0", + "node-stream-zip": "^1.15.0", "notion-helper": "^1.3.22", "os-proxy-config": "^1.1.2", "selection-hook": "^0.9.23", @@ -124,6 +125,7 @@ "@types/react-infinite-scroll-component": "^5.0.0", "@types/react-window": "^1", "@types/tinycolor2": "^1", + "@types/word-extractor": "^1", "@uiw/codemirror-extensions-langs": "^4.23.12", "@uiw/codemirror-themes-all": "^4.23.12", "@uiw/react-codemirror": "^4.23.12", @@ -177,10 +179,10 @@ "mermaid": "^11.6.0", "mime": "^4.0.4", "motion": "^12.10.5", - "node-stream-zip": "^1.15.0", "npx-scope-finder": "^1.2.0", "officeparser": "^4.1.1", "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", + "opendal": "0.47.11", "p-queue": "^8.1.0", "playwright": "^1.52.0", "prettier": "^3.5.3", @@ -213,12 +215,13 @@ "styled-components": "^6.1.11", "tar": "^7.4.3", "tiny-pinyin": "^1.3.2", - "tokenx": "^0.4.1", + "tokenx": "^1.1.0", "typescript": "^5.6.2", "uuid": "^10.0.0", "vite": "6.2.6", "vitest": "^3.1.4", "webdav": "^5.8.0", + "word-extractor": "^1.0.4", "zipread": "^1.3.3" }, "resolutions": { diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index d02cd15be8..ca49bd40c5 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -3,6 +3,8 @@ export enum IpcChannel { App_ClearCache = 'app:clear-cache', App_SetLaunchOnBoot = 'app:set-launch-on-boot', App_SetLanguage = 'app:set-language', + App_SetEnableSpellCheck = 'app:set-enable-spell-check', + App_SetSpellCheckLanguages = 'app:set-spell-check-languages', App_ShowUpdateDialog = 'app:show-update-dialog', App_CheckForUpdate = 'app:check-for-update', App_Reload = 'app:reload', @@ -13,13 +15,17 @@ export enum IpcChannel { App_SetTrayOnClose = 'app:set-tray-on-close', App_SetTheme = 'app:set-theme', App_SetAutoUpdate = 'app:set-auto-update', - App_SetFeedUrl = 'app:set-feed-url', + App_SetTestPlan = 'app:set-test-plan', + App_SetTestChannel = 'app:set-test-channel', App_HandleZoomFactor = 'app:handle-zoom-factor', App_Select = 'app:select', App_HasWritePermission = 'app:has-write-permission', App_Copy = 'app:copy', App_SetStopQuitApp = 'app:set-stop-quit-app', App_SetAppDataPath = 'app:set-app-data-path', + App_GetDataPathFromArgs = 'app:get-data-path-from-args', + App_FlushAppData = 'app:flush-app-data', + App_IsNotEmptyDir = 'app:is-not-empty-dir', App_RelaunchApp = 'app:relaunch-app', App_IsBinaryExist = 'app:is-binary-exist', App_GetBinaryPath = 'app:get-binary-path', @@ -32,6 +38,7 @@ export enum IpcChannel { Notification_OnClick = 'notification:on-click', Webview_SetOpenLinkExternal = 'webview:set-open-link-external', + Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled', // Open Open_Path = 'open:path', @@ -64,6 +71,9 @@ export enum IpcChannel { Mcp_ServersUpdated = 'mcp:servers-updated', Mcp_CheckConnectivity = 'mcp:check-connectivity', + // Python + Python_Execute = 'python:execute', + //copilot Copilot_GetAuthMessage = 'copilot:get-auth-message', Copilot_GetCopilotToken = 'copilot:get-copilot-token', @@ -143,6 +153,11 @@ export enum IpcChannel { Backup_CheckConnection = 'backup:checkConnection', Backup_CreateDirectory = 'backup:createDirectory', Backup_DeleteWebdavFile = 'backup:deleteWebdavFile', + Backup_BackupToS3 = 'backup:backupToS3', + Backup_RestoreFromS3 = 'backup:restoreFromS3', + Backup_ListS3Files = 'backup:listS3Files', + Backup_DeleteS3File = 'backup:deleteS3File', + Backup_CheckS3Connection = 'backup:checkS3Connection', // zip Zip_Compress = 'zip:compress', diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 5a3465f648..e4545d44cb 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -1,7 +1,7 @@ export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac'] -export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods'] +export const documentExts = ['.pdf', '.doc', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods'] export const thirdPartyApplicationExts = ['.draftsExport'] export const bookExts = ['.epub'] const textExtsByCategory = new Map([ @@ -406,6 +406,16 @@ export const defaultLanguage = 'en-US' export enum FeedUrl { PRODUCTION = 'https://releases.cherry-ai.com', - EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download' + GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download', + PRERELEASE_LOWEST = 'https://github.com/CherryHQ/cherry-studio/releases/download/v1.4.0' } + +export enum UpgradeChannel { + LATEST = 'latest', // 最新稳定版本 + RC = 'rc', // 公测版本 + BETA = 'beta' // 预览版本 +} + export const defaultTimeout = 5 * 1000 * 60 + +export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network'] diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js index 9637c60f3a..8e232dfa9c 100644 --- a/resources/scripts/install-bun.js +++ b/resources/scripts/install-bun.js @@ -2,12 +2,12 @@ const fs = require('fs') const path = require('path') const os = require('os') const { execSync } = require('child_process') -const AdmZip = require('adm-zip') +const StreamZip = require('node-stream-zip') const { downloadWithRedirects } = require('./download') // Base URL for downloading bun binaries const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download' -const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version +const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version // Mapping of platform+arch to binary package name const BUN_PACKAGES = { @@ -66,35 +66,36 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION, // Extract the zip file using adm-zip console.log(`Extracting ${packageName} to ${binDir}...`) - const zip = new AdmZip(tempFilename) - zip.extractAllTo(tempdir, true) + const zip = new StreamZip.async({ file: tempFilename }) - // Move files using Node.js fs - const sourceDir = path.join(tempdir, packageName.split('.')[0]) - const files = fs.readdirSync(sourceDir) + // Get all entries in the zip file + const entries = await zip.entries() - for (const file of files) { - const sourcePath = path.join(sourceDir, file) - const destPath = path.join(binDir, file) + // Extract files directly to binDir, flattening the directory structure + for (const entry of Object.values(entries)) { + if (!entry.isDirectory) { + // Get just the filename without path + const filename = path.basename(entry.name) + const outputPath = path.join(binDir, filename) - fs.copyFileSync(sourcePath, destPath) - fs.unlinkSync(sourcePath) - - // Set executable permissions for non-Windows platforms - if (platform !== 'win32') { - try { - // 755 permission: rwxr-xr-x - fs.chmodSync(destPath, '755') - } catch (error) { - console.warn(`Warning: Failed to set executable permissions: ${error.message}`) + console.log(`Extracting ${entry.name} -> ${filename}`) + await zip.extract(entry.name, outputPath) + // Make executable files executable on Unix-like systems + if (platform !== 'win32') { + try { + fs.chmodSync(outputPath, 0o755) + } catch (chmodError) { + console.error(`Warning: Failed to set executable permissions on ${filename}`) + return false + } } + console.log(`Extracted ${entry.name} -> ${outputPath}`) } } + await zip.close() // Clean up fs.unlinkSync(tempFilename) - fs.rmSync(sourceDir, { recursive: true }) - console.log(`Successfully installed bun ${version} for ${platformKey}`) return true } catch (error) { diff --git a/resources/scripts/install-uv.js b/resources/scripts/install-uv.js index 32892b9c63..2c882d07da 100644 --- a/resources/scripts/install-uv.js +++ b/resources/scripts/install-uv.js @@ -2,34 +2,33 @@ const fs = require('fs') const path = require('path') const os = require('os') const { execSync } = require('child_process') -const tar = require('tar') -const AdmZip = require('adm-zip') +const StreamZip = require('node-stream-zip') const { downloadWithRedirects } = require('./download') // Base URL for downloading uv binaries const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download' -const DEFAULT_UV_VERSION = '0.6.14' +const DEFAULT_UV_VERSION = '0.7.13' // Mapping of platform+arch to binary package name const UV_PACKAGES = { - 'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz', - 'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz', + 'darwin-arm64': 'uv-aarch64-apple-darwin.zip', + 'darwin-x64': 'uv-x86_64-apple-darwin.zip', 'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip', 'win32-ia32': 'uv-i686-pc-windows-msvc.zip', 'win32-x64': 'uv-x86_64-pc-windows-msvc.zip', - 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz', - 'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz', - 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz', - 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz', - 'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz', - 'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz', - 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz', + 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip', + 'linux-ia32': 'uv-i686-unknown-linux-gnu.zip', + 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip', + 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip', + 'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip', + 'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip', + 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip', // MUSL variants - 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz', - 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz', - 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz', - 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz', - 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz' + 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip', + 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip', + 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip', + 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip', + 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip' } /** @@ -66,46 +65,35 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is console.log(`Extracting ${packageName} to ${binDir}...`) - // 根据文件扩展名选择解压方法 - if (packageName.endsWith('.zip')) { - // 使用 adm-zip 处理 zip 文件 - const zip = new AdmZip(tempFilename) - zip.extractAllTo(binDir, true) - fs.unlinkSync(tempFilename) - console.log(`Successfully installed uv ${version} for ${platform}-${arch}`) - return true - } else { - // tar.gz 文件的处理保持不变 - await tar.x({ - file: tempFilename, - cwd: tempdir, - z: true - }) + const zip = new StreamZip.async({ file: tempFilename }) - // Move files using Node.js fs - const sourceDir = path.join(tempdir, packageName.split('.')[0]) - const files = fs.readdirSync(sourceDir) - for (const file of files) { - const sourcePath = path.join(sourceDir, file) - const destPath = path.join(binDir, file) - fs.copyFileSync(sourcePath, destPath) - fs.unlinkSync(sourcePath) + // Get all entries in the zip file + const entries = await zip.entries() - // Set executable permissions for non-Windows platforms + // Extract files directly to binDir, flattening the directory structure + for (const entry of Object.values(entries)) { + if (!entry.isDirectory) { + // Get just the filename without path + const filename = path.basename(entry.name) + const outputPath = path.join(binDir, filename) + + console.log(`Extracting ${entry.name} -> ${filename}`) + await zip.extract(entry.name, outputPath) + // Make executable files executable on Unix-like systems if (platform !== 'win32') { try { - fs.chmodSync(destPath, '755') - } catch (error) { - console.warn(`Warning: Failed to set executable permissions: ${error.message}`) + fs.chmodSync(outputPath, 0o755) + } catch (chmodError) { + console.error(`Warning: Failed to set executable permissions on ${filename}`) + return false } } + console.log(`Extracted ${entry.name} -> ${outputPath}`) } - - // Clean up - fs.unlinkSync(tempFilename) - fs.rmSync(sourceDir, { recursive: true }) } + await zip.close() + fs.unlinkSync(tempFilename) console.log(`Successfully installed uv ${version} for ${platform}-${arch}`) return true } catch (error) { diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts new file mode 100644 index 0000000000..15648f6ffc --- /dev/null +++ b/src/main/bootstrap.ts @@ -0,0 +1,33 @@ +import { occupiedDirs } from '@shared/config/constant' +import { app } from 'electron' +import fs from 'fs' +import path from 'path' + +import { initAppDataDir } from './utils/file' + +app.isPackaged && initAppDataDir() + +// 在主进程中复制 appData 中某些一直被占用的文件 +// 在renderer进程还没有启动时,主进程可以复制这些文件到新的appData中 +function copyOccupiedDirsInMainProcess() { + const newAppDataPath = process.argv + .slice(1) + .find((arg) => arg.startsWith('--new-data-path=')) + ?.split('--new-data-path=')[1] + if (!newAppDataPath) { + return + } + + if (process.platform === 'win32') { + const appDataPath = app.getPath('userData') + occupiedDirs.forEach((dir) => { + const dirPath = path.join(appDataPath, dir) + const newDirPath = path.join(newAppDataPath, dir) + if (fs.existsSync(dirPath)) { + fs.cpSync(dirPath, newDirPath, { recursive: true }) + } + }) + } +} + +copyOccupiedDirsInMainProcess() diff --git a/src/main/index.ts b/src/main/index.ts index 102264317a..3699335a90 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,11 @@ +// don't reorder this file, it's used to initialize the app data dir and +// other which should be run before the main process is ready +// eslint-disable-next-line +import './bootstrap' + import '@main/config' import { electronApp, optimizer } from '@electron-toolkit/utils' -import { initAppDataDir } from '@main/utils/file' import { replaceDevtoolsFont } from '@main/utils/windowUtil' import { app } from 'electron' import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer' @@ -22,7 +26,6 @@ import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' -initAppDataDir() Logger.initialize() /** diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 1fc398b8c6..af043c7c8c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,13 +1,14 @@ import fs from 'node:fs' import { arch } from 'node:os' +import path from 'node:path' import { isMac, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' -import { FeedUrl } from '@shared/config/constant' +import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Shortcut, ThemeMode } from '@types' -import { BrowserWindow, dialog, ipcMain, session, shell } from 'electron' +import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron' import log from 'electron-log' import { Notification } from 'src/renderer/src/types/notification' @@ -24,6 +25,7 @@ import NotificationService from './services/NotificationService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' +import { pythonService } from './services/PythonService' import { searchService } from './services/SearchService' import { SelectionService } from './services/SelectionService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' @@ -34,7 +36,7 @@ import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' import { decrypt, encrypt } from './utils/aes' -import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateConfig } from './utils/file' +import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file' import { compress, decompress } from './utils/zip' const fileManager = new FileStorage() @@ -47,6 +49,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater(mainWindow) const notificationService = new NotificationService(mainWindow) + // Initialize Python service with main window + pythonService.setMainWindow(mainWindow) + ipcMain.handle(IpcChannel.App_Info, () => ({ version: app.getVersion(), isPackaged: app.isPackaged, @@ -57,7 +62,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { resourcesPath: getResourcePath(), logsPath: log.transports.file.getFile().path, arch: arch(), - isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env + isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env, + installPath: path.dirname(app.getPath('exe')) })) ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => { @@ -85,6 +91,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setLanguage(language) }) + // spell check + ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => { + // disable spell check for all webviews + const webviews = webContents.getAllWebContents() + webviews.forEach((webview) => { + webview.session.setSpellCheckerEnabled(isEnable) + }) + }) + + // spell check languages + ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => { + if (languages.length === 0) { + return + } + const windows = BrowserWindow.getAllWindows() + windows.forEach((window) => { + window.webContents.session.setSpellCheckerLanguages(languages) + }) + configManager.set('spellCheckLanguages', languages) + }) + // launch on boot ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => { // Set login item settings for windows and mac @@ -115,8 +142,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setAutoUpdate(isActive) }) - ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => { - appUpdater.setFeedUrl(feedUrl) + ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => { + log.info('set test plan', isActive) + if (isActive !== configManager.getTestPlan()) { + appUpdater.cancelDownload() + configManager.setTestPlan(isActive) + } + }) + + ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => { + log.info('set test channel', channel) + if (channel !== configManager.getTestChannel()) { + appUpdater.cancelDownload() + configManager.setTestChannel(channel) + } }) ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { @@ -218,14 +257,46 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // Set app data path ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => { - updateConfig(filePath) + updateAppDataConfig(filePath) app.setPath('userData', filePath) }) + ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => { + return process.argv + .slice(1) + .find((arg) => arg.startsWith('--new-data-path=')) + ?.split('--new-data-path=')[1] + }) + + ipcMain.handle(IpcChannel.App_FlushAppData, () => { + BrowserWindow.getAllWindows().forEach((w) => { + w.webContents.session.flushStorageData() + w.webContents.session.cookies.flushStore() + + w.webContents.session.closeAllConnections() + }) + + session.defaultSession.flushStorageData() + session.defaultSession.cookies.flushStore() + session.defaultSession.closeAllConnections() + }) + + ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => { + return fs.readdirSync(path).length > 0 + }) + // Copy user data to new location - ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => { + ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string, occupiedDirs: string[] = []) => { try { - await fs.promises.cp(oldPath, newPath, { recursive: true }) + await fs.promises.cp(oldPath, newPath, { + recursive: true, + filter: (src) => { + if (occupiedDirs.some((dir) => src.startsWith(path.resolve(dir)))) { + return false + } + return true + } + }) return { success: true } } catch (error: any) { log.error('Failed to copy user data:', error) @@ -234,8 +305,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { }) // Relaunch app - ipcMain.handle(IpcChannel.App_RelaunchApp, () => { - app.relaunch() + ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => { + app.relaunch(options) app.exit(0) }) @@ -273,6 +344,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection) ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory) ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile) + ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3) + ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3) + ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files) + ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File) + ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection) // file ipcMain.handle(IpcChannel.File_Open, fileManager.open) @@ -377,6 +453,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo) ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity) + // Register Python execution handler + ipcMain.handle( + IpcChannel.Python_Execute, + async (_, script: string, context?: Record, timeout?: number) => { + return await pythonService.executeScript(script, context, timeout) + } + ) + ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name)) ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name)) ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js')) @@ -422,6 +506,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { setOpenLinkExternal(webviewId, isExternal) ) + ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => { + const webview = webContents.fromId(webviewId) + if (!webview) return + webview.session.setSpellCheckerEnabled(isEnable) + }) + // store sync storeSyncService.registerIpcHandler() diff --git a/src/main/loader/index.ts b/src/main/loader/index.ts index db837f414f..ba66b33e3d 100644 --- a/src/main/loader/index.ts +++ b/src/main/loader/index.ts @@ -16,6 +16,7 @@ const FILE_LOADER_MAP: Record = { // 内置类型 '.pdf': 'common', '.csv': 'common', + '.doc': 'common', '.docx': 'common', '.pptx': 'common', '.xlsx': 'common', diff --git a/src/main/loader/noteLoader.ts b/src/main/loader/noteLoader.ts new file mode 100644 index 0000000000..693f5f3c0a --- /dev/null +++ b/src/main/loader/noteLoader.ts @@ -0,0 +1,44 @@ +import { BaseLoader } from '@cherrystudio/embedjs-interfaces' +import { cleanString } from '@cherrystudio/embedjs-utils' +import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters' +import md5 from 'md5' + +export class NoteLoader extends BaseLoader<{ type: 'NoteLoader' }> { + private readonly text: string + private readonly sourceUrl?: string + + constructor({ + text, + sourceUrl, + chunkSize, + chunkOverlap + }: { + text: string + sourceUrl?: string + chunkSize?: number + chunkOverlap?: number + }) { + super(`NoteLoader_${md5(text + (sourceUrl || ''))}`, { text, sourceUrl }, chunkSize ?? 2000, chunkOverlap ?? 0) + this.text = text + this.sourceUrl = sourceUrl + } + + override async *getUnfilteredChunks() { + const chunker = new RecursiveCharacterTextSplitter({ + chunkSize: this.chunkSize, + chunkOverlap: this.chunkOverlap + }) + + const chunks = await chunker.splitText(cleanString(this.text)) + + for (const chunk of chunks) { + yield { + pageContent: chunk, + metadata: { + type: 'NoteLoader' as const, + source: this.sourceUrl || 'note' + } + } + } + } +} diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 1c508f8844..2376ef223e 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -6,6 +6,7 @@ import DifyKnowledgeServer from './dify-knowledge' import FetchServer from './fetch' import FileSystemServer from './filesystem' import MemoryServer from './memory' +import PythonServer from './python' import ThinkingServer from './sequentialthinking' export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record = {}): Server { @@ -31,6 +32,9 @@ export function createInMemoryMCPServer(name: string, args: string[] = [], envs: const difyKey = envs.DIFY_KEY return new DifyKnowledgeServer(difyKey, args).server } + case '@cherry/python': { + return new PythonServer().server + } default: throw new Error(`Unknown in-memory MCP server: ${name}`) } diff --git a/src/main/mcpServers/python.ts b/src/main/mcpServers/python.ts new file mode 100644 index 0000000000..6fe0b80db1 --- /dev/null +++ b/src/main/mcpServers/python.ts @@ -0,0 +1,113 @@ +import { pythonService } from '@main/services/PythonService' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js' +import Logger from 'electron-log' + +/** + * Python MCP Server for executing Python code using Pyodide + */ +class PythonServer { + public server: Server + + constructor() { + this.server = new Server( + { + name: 'python-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ) + + this.setupRequestHandlers() + } + + private setupRequestHandlers() { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'python_execute', + description: `Execute Python code using Pyodide in a sandboxed environment. Supports most Python standard library and scientific packages. +The code will be executed with Python 3.12. +Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start +with a comment of the form: +# /// script +# dependencies = ['pydantic'] +# /// +print('python code here')`, + inputSchema: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'The Python code to execute' + }, + context: { + type: 'object', + description: 'Optional context variables to pass to the Python execution environment', + additionalProperties: true + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds (default: 60000)', + default: 60000 + } + }, + required: ['code'] + } + } + ] + } + }) + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + + if (name !== 'python_execute') { + throw new McpError(ErrorCode.MethodNotFound, `Tool ${name} not found`) + } + + try { + const { + code, + context = {}, + timeout = 60000 + } = args as { + code: string + context?: Record + timeout?: number + } + + if (!code || typeof code !== 'string') { + throw new McpError(ErrorCode.InvalidParams, 'Code parameter is required and must be a string') + } + + Logger.info('Executing Python code via Pyodide') + + const result = await pythonService.executeScript(code, context, timeout) + + return { + content: [ + { + type: 'text', + text: result + } + ] + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + Logger.error('Python execution error:', errorMessage) + + throw new McpError(ErrorCode.InternalError, `Python execution failed: ${errorMessage}`) + } + }) + } +} + +export default PythonServer diff --git a/src/main/mcpServers/sequentialthinking.ts b/src/main/mcpServers/sequentialthinking.ts index 4589c0bf34..bcda96e192 100644 --- a/src/main/mcpServers/sequentialthinking.ts +++ b/src/main/mcpServers/sequentialthinking.ts @@ -106,6 +106,7 @@ class SequentialThinkingServer { type: 'text', text: JSON.stringify( { + thought: validatedInput.thought, thoughtNumber: validatedInput.thoughtNumber, totalThoughts: validatedInput.totalThoughts, nextThoughtNeeded: validatedInput.nextThoughtNeeded, diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index 16d36f87fc..83d241fe85 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -17,7 +17,7 @@ export default abstract class BaseReranker { * Get Rerank Request Url */ protected getRerankUrl() { - if (this.base.rerankModelProvider === 'dashscope') { + if (this.base.rerankModelProvider === 'bailian') { return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' } @@ -50,7 +50,7 @@ export default abstract class BaseReranker { documents, top_k: topN } - } else if (provider === 'dashscope') { + } else if (provider === 'bailian') { return { model: this.base.rerankModel, input: { @@ -82,11 +82,11 @@ export default abstract class BaseReranker { */ protected extractRerankResult(data: any) { const provider = this.base.rerankModelProvider - if (provider === 'dashscope') { + if (provider === 'bailian') { return data.output.results } else if (provider === 'voyageai') { return data.data - } else if (provider === 'mis-tei') { + } else if (provider?.includes('tei')) { return data.map((item: any) => { return { index: item.index, diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 4c71a05556..82165fd715 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -1,11 +1,11 @@ import { isWin } from '@main/constant' import { locales } from '@main/utils/locales' -import { FeedUrl } from '@shared/config/constant' +import { FeedUrl, UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { UpdateInfo } from 'builder-util-runtime' +import { CancellationToken, UpdateInfo } from 'builder-util-runtime' import { app, BrowserWindow, dialog } from 'electron' import logger from 'electron-log' -import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater } from 'electron-updater' +import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater, UpdateCheckResult } from 'electron-updater' import path from 'path' import icon from '../../../build/icon.png?asset' @@ -14,6 +14,8 @@ import { configManager } from './ConfigManager' export default class AppUpdater { autoUpdater: _AppUpdater = autoUpdater private releaseInfo: UpdateInfo | undefined + private cancellationToken: CancellationToken = new CancellationToken() + private updateCheckResult: UpdateCheckResult | null = null constructor(mainWindow: BrowserWindow) { logger.transports.file.level = 'info' @@ -22,9 +24,7 @@ export default class AppUpdater { autoUpdater.forceDevUpdateConfig = !app.isPackaged autoUpdater.autoDownload = configManager.getAutoUpdate() autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate() - autoUpdater.setFeedURL(configManager.getFeedUrl()) - // 检测下载错误 autoUpdater.on('error', (error) => { // 简单记录错误信息和时间戳 logger.error('更新异常', { @@ -64,6 +64,35 @@ export default class AppUpdater { this.autoUpdater = autoUpdater } + private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) { + try { + logger.info('get pre release version from github', channel) + const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Accept-Language': 'en-US,en;q=0.9' + } + }) + const data = (await responses.json()) as GithubReleaseInfo[] + const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => { + return item.prerelease && item.tag_name.includes(`-${channel}.`) + }) + + logger.info('release info', release) + + if (!release) { + return null + } + + logger.info('release info', release.tag_name) + return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}` + } catch (error) { + logger.error('Failed to get latest not draft version from github:', error) + return null + } + } + private async _getIpCountry() { try { // add timeout using AbortController @@ -93,9 +122,72 @@ export default class AppUpdater { autoUpdater.autoInstallOnAppQuit = isActive } - public setFeedUrl(feedUrl: FeedUrl) { - autoUpdater.setFeedURL(feedUrl) - configManager.setFeedUrl(feedUrl) + private _getChannelByVersion(version: string) { + if (version.includes(`-${UpgradeChannel.BETA}.`)) { + return UpgradeChannel.BETA + } + if (version.includes(`-${UpgradeChannel.RC}.`)) { + return UpgradeChannel.RC + } + return UpgradeChannel.LATEST + } + + private _getTestChannel() { + const currentChannel = this._getChannelByVersion(app.getVersion()) + const savedChannel = configManager.getTestChannel() + + if (currentChannel === UpgradeChannel.LATEST) { + return savedChannel || UpgradeChannel.RC + } + + if (savedChannel === currentChannel) { + return savedChannel + } + + // if the upgrade channel is not equal to the current channel, use the latest channel + return UpgradeChannel.LATEST + } + + private async _setFeedUrl() { + const testPlan = configManager.getTestPlan() + if (testPlan) { + const channel = this._getTestChannel() + + if (channel === UpgradeChannel.LATEST) { + this.autoUpdater.channel = UpgradeChannel.LATEST + this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST) + return + } + + const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel) + if (preReleaseUrl) { + this.autoUpdater.setFeedURL(preReleaseUrl) + this.autoUpdater.channel = channel + return + } + + // if no prerelease url, use lowest prerelease version to avoid error + this.autoUpdater.setFeedURL(FeedUrl.PRERELEASE_LOWEST) + this.autoUpdater.channel = UpgradeChannel.LATEST + return + } + + this.autoUpdater.channel = UpgradeChannel.LATEST + this.autoUpdater.setFeedURL(FeedUrl.PRODUCTION) + + const ipCountry = await this._getIpCountry() + logger.info('ipCountry', ipCountry) + if (ipCountry.toLowerCase() !== 'cn') { + this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST) + } + } + + public cancelDownload() { + this.cancellationToken.cancel() + this.cancellationToken = new CancellationToken() + if (this.autoUpdater.autoDownload) { + this.updateCheckResult?.cancellationToken?.cancel() + } } public async checkForUpdates() { @@ -106,23 +198,26 @@ export default class AppUpdater { } } - const ipCountry = await this._getIpCountry() - logger.info('ipCountry', ipCountry) - if (ipCountry !== 'CN') { - this.autoUpdater.setFeedURL(FeedUrl.EARLY_ACCESS) - } + await this._setFeedUrl() + + // disable downgrade after change the channel + this.autoUpdater.allowDowngrade = false + + // github and gitcode don't support multiple range download + this.autoUpdater.disableDifferentialDownload = true try { - const update = await this.autoUpdater.checkForUpdates() - if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) { + this.updateCheckResult = await this.autoUpdater.checkForUpdates() + if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) { // 如果 autoDownload 为 false,则需要再调用下面的函数触发下 // do not use await, because it will block the return of this function - this.autoUpdater.downloadUpdate() + logger.info('downloadUpdate manual by check for updates', this.cancellationToken) + this.autoUpdater.downloadUpdate(this.cancellationToken) } return { currentVersion: this.autoUpdater.currentVersion, - updateInfo: update?.updateInfo + updateInfo: this.updateCheckResult?.updateInfo } } catch (error) { logger.error('Failed to check for update:', error) @@ -178,7 +273,11 @@ export default class AppUpdater { return releaseNotes.map((note) => note.note).join('\n') } } - +interface GithubReleaseInfo { + draft: boolean + prerelease: boolean + tag_name: string +} interface ReleaseNoteInfo { readonly version: string readonly note: string | null diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index e994e90bed..6e0c813e6d 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,5 +1,6 @@ import { IpcChannel } from '@shared/IpcChannel' import { WebDavConfig } from '@types' +import { S3Config } from '@types' import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' @@ -10,6 +11,7 @@ import * as path from 'path' import { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' +import S3Storage from './RemoteStorage' import WebDav from './WebDav' import { windowService } from './WindowService' @@ -25,6 +27,11 @@ class BackupManager { this.restoreFromWebdav = this.restoreFromWebdav.bind(this) this.listWebdavFiles = this.listWebdavFiles.bind(this) this.deleteWebdavFile = this.deleteWebdavFile.bind(this) + this.backupToS3 = this.backupToS3.bind(this) + this.restoreFromS3 = this.restoreFromS3.bind(this) + this.listS3Files = this.listS3Files.bind(this) + this.deleteS3File = this.deleteS3File.bind(this) + this.checkS3Connection = this.checkS3Connection.bind(this) } private async setWritableRecursive(dirPath: string): Promise { @@ -85,7 +92,11 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.BackupProgress, processData) - Logger.log('[BackupManager] backup progress', processData) + // 只在关键阶段记录日志:开始、结束和主要阶段转换点 + const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed'] + if (logStages.includes(processData.stage) || processData.progress === 100) { + Logger.log('[BackupManager] backup progress', processData) + } } try { @@ -147,18 +158,23 @@ class BackupManager { let totalBytes = 0 let processedBytes = 0 - // 首先计算总文件数和总大小 + // 首先计算总文件数和总大小,但不记录详细日志 const calculateTotals = async (dirPath: string) => { - const items = await fs.readdir(dirPath, { withFileTypes: true }) - for (const item of items) { - const fullPath = path.join(dirPath, item.name) - if (item.isDirectory()) { - await calculateTotals(fullPath) - } else { - totalEntries++ - const stats = await fs.stat(fullPath) - totalBytes += stats.size + try { + const items = await fs.readdir(dirPath, { withFileTypes: true }) + for (const item of items) { + const fullPath = path.join(dirPath, item.name) + if (item.isDirectory()) { + await calculateTotals(fullPath) + } else { + totalEntries++ + const stats = await fs.stat(fullPath) + totalBytes += stats.size + } } + } catch (error) { + // 仅在出错时记录日志 + Logger.error('[BackupManager] Error calculating totals:', error) } } @@ -230,7 +246,11 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData) - Logger.log('[BackupManager] restore progress', processData) + // 只在关键阶段记录日志 + const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed'] + if (logStages.includes(processData.stage) || processData.progress === 100) { + Logger.log('[BackupManager] restore progress', processData) + } } try { @@ -382,21 +402,54 @@ class BackupManager { destination: string, onProgress: (size: number) => void ): Promise { - const items = await fs.readdir(source, { withFileTypes: true }) + // 先统计总文件数 + let totalFiles = 0 + let processedFiles = 0 + let lastProgressReported = 0 - for (const item of items) { - const sourcePath = path.join(source, item.name) - const destPath = path.join(destination, item.name) + // 计算总文件数 + const countFiles = async (dir: string): Promise => { + let count = 0 + const items = await fs.readdir(dir, { withFileTypes: true }) + for (const item of items) { + if (item.isDirectory()) { + count += await countFiles(path.join(dir, item.name)) + } else { + count++ + } + } + return count + } - if (item.isDirectory()) { - await fs.ensureDir(destPath) - await this.copyDirWithProgress(sourcePath, destPath, onProgress) - } else { - const stats = await fs.stat(sourcePath) - await fs.copy(sourcePath, destPath) - onProgress(stats.size) + totalFiles = await countFiles(source) + + // 复制文件并更新进度 + const copyDir = async (src: string, dest: string): Promise => { + const items = await fs.readdir(src, { withFileTypes: true }) + + for (const item of items) { + const sourcePath = path.join(src, item.name) + const destPath = path.join(dest, item.name) + + if (item.isDirectory()) { + await fs.ensureDir(destPath) + await copyDir(sourcePath, destPath) + } else { + const stats = await fs.stat(sourcePath) + await fs.copy(sourcePath, destPath) + processedFiles++ + + // 只在进度变化超过5%时报告进度 + const currentProgress = Math.floor((processedFiles / totalFiles) * 100) + if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) { + lastProgressReported = currentProgress + onProgress(stats.size) + } + } } } + + await copyDir(source, destination) } async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { @@ -423,6 +476,141 @@ class BackupManager { throw new Error(error.message || 'Failed to delete backup file') } } + + async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) { + // 获取设备名 + const os = require('os') + const deviceName = os.hostname ? os.hostname() : 'device' + const timestamp = new Date() + .toISOString() + .replace(/[-:T.Z]/g, '') + .slice(0, 14) + const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip` + + // 不记录详细日志,只记录开始和结束 + Logger.log(`[BackupManager] Starting S3 backup to ${filename}`) + + const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile) + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + try { + const fileBuffer = await fs.promises.readFile(backupedFilePath) + const result = await s3Client.putFileContents(filename, fileBuffer) + await fs.remove(backupedFilePath) + + Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`) + return result + } catch (error) { + Logger.error(`[BackupManager] S3 backup failed:`, error) + await fs.remove(backupedFilePath) + throw error + } + } + + async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { + const filename = s3Config.fileName || 'cherry-studio.backup.zip' + + // 只记录开始和结束或错误 + Logger.log(`[BackupManager] Starting restore from S3: ${filename}`) + + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + try { + const retrievedFile = await s3Client.getFileContents(filename) + const backupedFilePath = path.join(this.backupDir, filename) + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }) + } + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(backupedFilePath) + writeStream.write(retrievedFile as Buffer) + writeStream.end() + writeStream.on('finish', () => resolve()) + writeStream.on('error', (error) => reject(error)) + }) + + Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`) + return await this.restore(_, backupedFilePath) + } catch (error: any) { + Logger.error('[BackupManager] Failed to restore from S3:', error) + throw new Error(error.message || 'Failed to restore backup file') + } + } + + listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => { + try { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + const entries = await s3Client.instance?.list('/') + const files: Array<{ fileName: string; modifiedTime: string; size: number }> = [] + if (entries) { + for await (const entry of entries) { + const path = entry.path() + if (path.endsWith('.zip')) { + const meta = await s3Client.instance!.stat(path) + if (meta.isFile()) { + files.push({ + fileName: path.replace(/^\/+/, ''), + modifiedTime: meta.lastModified || '', + size: Number(meta.contentLength || 0n) + }) + } + } + } + } + return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime()) + } catch (error: any) { + Logger.error('Failed to list S3 files:', error) + throw new Error(error.message || 'Failed to list backup files') + } + } + + async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) { + try { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + return await s3Client.deleteFile(fileName) + } catch (error: any) { + Logger.error('Failed to delete S3 file:', error) + throw new Error(error.message || 'Failed to delete backup file') + } + } + + async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + return await s3Client.checkConnection() + } } export default BackupManager diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 573674bd70..8e4b5d2bf1 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -1,4 +1,4 @@ -import { defaultLanguage, FeedUrl, ZOOM_SHORTCUTS } from '@shared/config/constant' +import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/constant' import { LanguageVarious, Shortcut, ThemeMode } from '@types' import { app } from 'electron' import Store from 'electron-store' @@ -16,7 +16,8 @@ export enum ConfigKeys { ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant', AutoUpdate = 'autoUpdate', - FeedUrl = 'feedUrl', + TestPlan = 'testPlan', + TestChannel = 'testChannel', EnableDataCollection = 'enableDataCollection', SelectionAssistantEnabled = 'selectionAssistantEnabled', SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode', @@ -142,12 +143,20 @@ export class ConfigManager { this.set(ConfigKeys.AutoUpdate, value) } - getFeedUrl(): string { - return this.get(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION) + getTestPlan(): boolean { + return this.get(ConfigKeys.TestPlan, false) } - setFeedUrl(value: FeedUrl) { - this.set(ConfigKeys.FeedUrl, value) + setTestPlan(value: boolean) { + this.set(ConfigKeys.TestPlan, value) + } + + getTestChannel(): UpgradeChannel { + return this.get(ConfigKeys.TestChannel) + } + + setTestChannel(value: UpgradeChannel) { + this.set(ConfigKeys.TestChannel, value) } getEnableDataCollection(): boolean { diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts index 2f4f5aa20f..411d6e075d 100644 --- a/src/main/services/ContextMenu.ts +++ b/src/main/services/ContextMenu.ts @@ -4,18 +4,29 @@ import { locales } from '../utils/locales' import { configManager } from './ConfigManager' class ContextMenu { - public contextMenu(w: Electron.BrowserWindow) { - w.webContents.on('context-menu', (_event, properties) => { + public contextMenu(w: Electron.WebContents) { + w.on('context-menu', (_event, properties) => { const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties) const filtered = template.filter((item) => item.visible !== false) if (filtered.length > 0) { - const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)]) + let template = [...filtered, ...this.createInspectMenuItems(w)] + const dictionarySuggestions = this.createDictionarySuggestions(properties, w) + if (dictionarySuggestions.length > 0) { + template = [ + ...dictionarySuggestions, + { type: 'separator' }, + this.createSpellCheckMenuItem(properties, w), + { type: 'separator' }, + ...template + ] + } + const menu = Menu.buildFromTemplate(template) menu.popup() } }) } - private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] { + private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] { const locale = locales[configManager.getLanguage()] const { common } = locale.translation const template: MenuItemConstructorOptions[] = [ @@ -23,7 +34,7 @@ class ContextMenu { id: 'inspect', label: common.inspect, click: () => { - w.webContents.toggleDevTools() + w.toggleDevTools() }, enabled: true } @@ -72,6 +83,53 @@ class ContextMenu { return template } + + private createSpellCheckMenuItem( + properties: Electron.ContextMenuParams, + w: Electron.WebContents + ): MenuItemConstructorOptions { + const hasText = properties.selectionText.length > 0 + + return { + id: 'learnSpelling', + label: '&Learn Spelling', + visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), + click: () => { + w.session.addWordToSpellCheckerDictionary(properties.misspelledWord) + } + } + } + + private createDictionarySuggestions( + properties: Electron.ContextMenuParams, + w: Electron.WebContents + ): MenuItemConstructorOptions[] { + const hasText = properties.selectionText.length > 0 + + if (!hasText || !properties.misspelledWord) { + return [] + } + + if (properties.dictionarySuggestions.length === 0) { + return [ + { + id: 'dictionarySuggestions', + label: 'No Guesses Found', + visible: true, + enabled: false + } + ] + } + + return properties.dictionarySuggestions.map((suggestion) => ({ + id: 'dictionarySuggestions', + label: suggestion, + visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), + click: (menuItem: Electron.MenuItem) => { + w.replaceMisspelling(menuItem.label) + } + })) + } } export const contextMenu = new ContextMenu() diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 2ac689b8cc..0c81a454a7 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -19,6 +19,7 @@ import { getDocument } from 'officeparser/pdfjs-dist-build/pdf.js' import * as path from 'path' import { chdir } from 'process' import { v4 as uuidv4 } from 'uuid' +import WordExtractor from 'word-extractor' class FileStorage { private storageDir = getFilesDir() @@ -220,10 +221,20 @@ class FileStorage { public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { const filePath = path.join(this.storageDir, id) - if (documentExts.includes(path.extname(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 @@ -352,7 +363,7 @@ class FileStorage { public open = async ( _: Electron.IpcMainInvokeEvent, options: OpenDialogOptions - ): Promise<{ fileName: string; filePath: string; content: Buffer } | null> => { + ): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => { try { const result: OpenDialogReturnValue = await dialog.showOpenDialog({ title: '打开文件', @@ -364,8 +375,16 @@ class FileStorage { if (!result.canceled && result.filePaths.length > 0) { const filePath = result.filePaths[0] const fileName = filePath.split('/').pop() || '' - const content = await readFile(filePath) - return { fileName, filePath, content } + const stats = await fs.promises.stat(filePath) + + // If the file is less than 2GB, read the content + if (stats.size < 2 * 1024 * 1024 * 1024) { + const content = await readFile(filePath) + return { fileName, filePath, content, size: stats.size } + } + + // For large files, only return file information, do not read content + return { fileName, filePath, size: stats.size } } return null diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 62b2bba08f..d2d381c598 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -16,13 +16,14 @@ import * as fs from 'node:fs' import path from 'node:path' -import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@cherrystudio/embedjs' +import { RAGApplication, RAGApplicationBuilder } from '@cherrystudio/embedjs' import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { LibSqlDb } from '@cherrystudio/embedjs-libsql' import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap' import { WebLoader } from '@cherrystudio/embedjs-loader-web' import Embeddings from '@main/embeddings/Embeddings' import { addFileLoader } from '@main/loader' +import { NoteLoader } from '@main/loader/noteLoader' import Reranker from '@main/reranker/Reranker' import { windowService } from '@main/services/WindowService' import { getDataPath } from '@main/utils' @@ -143,7 +144,7 @@ class KnowledgeService { this.getRagApplication(base) } - public reset = async (_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise => { + public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise => { const ragApplication = await this.getRagApplication(base) await ragApplication.reset() } @@ -333,6 +334,7 @@ class KnowledgeService { ): LoaderTask { const { base, item, forceReload } = options const content = item.content as string + const sourceUrl = (item as any).sourceUrl const encoder = new TextEncoder() const contentBytes = encoder.encode(content) @@ -342,7 +344,12 @@ class KnowledgeService { state: LoaderTaskItemState.PENDING, task: () => { const loaderReturn = ragApplication.addLoader( - new TextLoader({ text: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }), + new NoteLoader({ + text: content, + sourceUrl, + chunkSize: base.chunkSize, + chunkOverlap: base.chunkOverlap + }), forceReload ) as Promise diff --git a/src/main/services/PythonService.ts b/src/main/services/PythonService.ts new file mode 100644 index 0000000000..13b4546e56 --- /dev/null +++ b/src/main/services/PythonService.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto' + +import { BrowserWindow, ipcMain } from 'electron' + +interface PythonExecutionRequest { + id: string + script: string + context: Record + timeout: number +} + +interface PythonExecutionResponse { + id: string + result?: string + error?: string +} + +/** + * Service for executing Python code by communicating with the PyodideService in the renderer process + */ +export class PythonService { + private static instance: PythonService | null = null + private mainWindow: BrowserWindow | null = null + private pendingRequests = new Map void; reject: (error: Error) => void }>() + + private constructor() { + // Private constructor for singleton pattern + this.setupIpcHandlers() + } + + public static getInstance(): PythonService { + if (!PythonService.instance) { + PythonService.instance = new PythonService() + } + return PythonService.instance + } + + private setupIpcHandlers() { + // Handle responses from renderer + ipcMain.on('python-execution-response', (_, response: PythonExecutionResponse) => { + const request = this.pendingRequests.get(response.id) + if (request) { + this.pendingRequests.delete(response.id) + if (response.error) { + request.reject(new Error(response.error)) + } else { + request.resolve(response.result || '') + } + } + }) + } + + public setMainWindow(mainWindow: BrowserWindow) { + this.mainWindow = mainWindow + } + + /** + * Execute Python code by sending request to renderer PyodideService + */ + public async executeScript( + script: string, + context: Record = {}, + timeout: number = 60000 + ): Promise { + if (!this.mainWindow) { + throw new Error('Main window not set in PythonService') + } + + return new Promise((resolve, reject) => { + const requestId = randomUUID() + + // Store the request + this.pendingRequests.set(requestId, { resolve, reject }) + + // Set up timeout + const timeoutId = setTimeout(() => { + this.pendingRequests.delete(requestId) + reject(new Error('Python execution timed out')) + }, timeout + 5000) // Add 5s buffer for IPC communication + + // Update resolve/reject to clear timeout + const originalResolve = resolve + const originalReject = reject + this.pendingRequests.set(requestId, { + resolve: (value: string) => { + clearTimeout(timeoutId) + originalResolve(value) + }, + reject: (error: Error) => { + clearTimeout(timeoutId) + originalReject(error) + } + }) + + // Send request to renderer + const request: PythonExecutionRequest = { id: requestId, script, context, timeout } + this.mainWindow?.webContents.send('python-execution-request', request) + }) + } +} + +export const pythonService = PythonService.getInstance() diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts index b62489bbbe..4efc57b6c6 100644 --- a/src/main/services/RemoteStorage.ts +++ b/src/main/services/RemoteStorage.ts @@ -1,57 +1,83 @@ -// import Logger from 'electron-log' -// import { Operator } from 'opendal' +import Logger from 'electron-log' +import type { Operator as OperatorType } from 'opendal' +const { Operator } = require('opendal') -// export default class RemoteStorage { -// public instance: Operator | undefined +export default class S3Storage { + public instance: OperatorType | undefined -// /** -// * -// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" -// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. -// * -// * For example, use minio as remote storage: -// * -// * ```typescript -// * const storage = new RemoteStorage('s3', { -// * endpoint: 'http://localhost:9000', -// * region: 'us-east-1', -// * bucket: 'testbucket', -// * access_key_id: 'user', -// * secret_access_key: 'password', -// * root: '/path/to/basepath', -// * }) -// * ``` -// */ -// constructor(scheme: string, options?: Record | undefined | null) { -// this.instance = new Operator(scheme, options) + /** + * + * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" + * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. + * + * For example, use minio as remote storage: + * + * ```typescript + * const storage = new S3Storage('s3', { + * endpoint: 'http://localhost:9000', + * region: 'us-east-1', + * bucket: 'testbucket', + * access_key_id: 'user', + * secret_access_key: 'password', + * root: '/path/to/basepath', + * }) + * ``` + */ + constructor(scheme: string, options?: Record | undefined | null) { + this.instance = new Operator(scheme, options) -// this.putFileContents = this.putFileContents.bind(this) -// this.getFileContents = this.getFileContents.bind(this) -// } + this.putFileContents = this.putFileContents.bind(this) + this.getFileContents = this.getFileContents.bind(this) + } -// public putFileContents = async (filename: string, data: string | Buffer) => { -// if (!this.instance) { -// return new Error('RemoteStorage client not initialized') -// } + public putFileContents = async (filename: string, data: string | Buffer) => { + if (!this.instance) { + return new Error('RemoteStorage client not initialized') + } -// try { -// return await this.instance.write(filename, data) -// } catch (error) { -// Logger.error('[RemoteStorage] Error putting file contents:', error) -// throw error -// } -// } + try { + return await this.instance.write(filename, data) + } catch (error) { + Logger.error('[RemoteStorage] Error putting file contents:', error) + throw error + } + } -// public getFileContents = async (filename: string) => { -// if (!this.instance) { -// throw new Error('RemoteStorage client not initialized') -// } + public getFileContents = async (filename: string) => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } -// try { -// return await this.instance.read(filename) -// } catch (error) { -// Logger.error('[RemoteStorage] Error getting file contents:', error) -// throw error -// } -// } -// } + try { + return await this.instance.read(filename) + } catch (error) { + Logger.error('[RemoteStorage] Error getting file contents:', error) + throw error + } + } + + public deleteFile = async (filename: string) => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } + try { + return await this.instance.delete(filename) + } catch (error) { + Logger.error('[RemoteStorage] Error deleting file:', error) + throw error + } + } + + public checkConnection = async () => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } + try { + // 检查根目录是否可访问 + return await this.instance.stat('/') + } catch (error) { + Logger.error('[RemoteStorage] Error checking connection:', error) + throw error + } + } +} diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index f6322e8939..ada014f0db 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -95,6 +95,7 @@ export class WindowService { this.setupMaximize(mainWindow, mainWindowState.isMaximized) this.setupContextMenu(mainWindow) + this.setupSpellCheck(mainWindow) this.setupWindowEvents(mainWindow) this.setupWebContentsHandlers(mainWindow) this.setupWindowLifecycleEvents(mainWindow) @@ -102,6 +103,18 @@ export class WindowService { this.loadMainWindowContent(mainWindow) } + private setupSpellCheck(mainWindow: BrowserWindow) { + const enableSpellCheck = configManager.get('enableSpellCheck', false) + if (enableSpellCheck) { + try { + const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[] + spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages) + } catch (error) { + Logger.error('Failed to set spell check languages:', error as Error) + } + } + } + private setupMainWindowMonitor(mainWindow: BrowserWindow) { mainWindow.webContents.on('render-process-gone', (_, details) => { Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`) @@ -130,9 +143,10 @@ export class WindowService { } private setupContextMenu(mainWindow: BrowserWindow) { - contextMenu.contextMenu(mainWindow) - app.on('browser-window-created', (_, win) => { - contextMenu.contextMenu(win) + contextMenu.contextMenu(mainWindow.webContents) + // setup context menu for all webviews like miniapp + app.on('web-contents-created', (_, webContents) => { + contextMenu.contextMenu(webContents) }) // Dangerous API diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts index aae00e85d4..14f4801524 100644 --- a/src/main/utils/__tests__/file.test.ts +++ b/src/main/utils/__tests__/file.test.ts @@ -92,6 +92,7 @@ describe('file', () => { it('should return DOCUMENT for document extensions', () => { expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT) expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.doc')).toBe(FileTypes.DOCUMENT) expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT) expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT) expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 737dcee0ba..177a28a90f 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -8,6 +8,20 @@ import { FileType, FileTypes } from '@types' import { app } from 'electron' import { v4 as uuidv4 } from 'uuid' +export function initAppDataDir() { + const appDataPath = getAppDataPathFromConfig() + if (appDataPath) { + app.setPath('userData', appDataPath) + return + } + + if (isPortable) { + const portableDir = process.env.PORTABLE_EXECUTABLE_DIR + app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data')) + return + } +} + // 创建文件类型映射表,提高查找效率 const fileTypeMap = new Map() @@ -35,46 +49,70 @@ export function hasWritePermission(path: string) { function getAppDataPathFromConfig() { try { const configPath = path.join(getConfigDir(), 'config.json') - if (fs.existsSync(configPath)) { - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - if (config.appDataPath && fs.existsSync(config.appDataPath) && hasWritePermission(config.appDataPath)) { - return config.appDataPath - } + if (!fs.existsSync(configPath)) { + return null } + + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + + if (!config.appDataPath) { + return null + } + + let appDataPath = null + // 兼容旧版本 + if (config.appDataPath && typeof config.appDataPath === 'string') { + appDataPath = config.appDataPath + // 将旧版本数据迁移到新版本 + appDataPath && updateAppDataConfig(appDataPath) + } else { + appDataPath = config.appDataPath.find( + (item: { executablePath: string }) => item.executablePath === app.getPath('exe') + )?.dataPath + } + + if (appDataPath && fs.existsSync(appDataPath) && hasWritePermission(appDataPath)) { + return appDataPath + } + + return null } catch (error) { return null } - return null } -export function initAppDataDir() { - const appDataPath = getAppDataPathFromConfig() - if (appDataPath) { - app.setPath('userData', appDataPath) - return - } - - if (isPortable) { - const portableDir = process.env.PORTABLE_EXECUTABLE_DIR - app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data')) - return - } -} - -export function updateConfig(appDataPath: string) { +export function updateAppDataConfig(appDataPath: string) { const configDir = getConfigDir() if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }) } + // config.json + // appDataPath: [{ executablePath: string, dataPath: string }] const configPath = path.join(getConfigDir(), 'config.json') if (!fs.existsSync(configPath)) { - fs.writeFileSync(configPath, JSON.stringify({ appDataPath }, null, 2)) + fs.writeFileSync( + configPath, + JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2) + ) return } const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - config.appDataPath = appDataPath + if (!config.appDataPath || (config.appDataPath && typeof config.appDataPath !== 'object')) { + config.appDataPath = [] + } + + const existingPath = config.appDataPath.find( + (item: { executablePath: string }) => item.executablePath === app.getPath('exe') + ) + + if (existingPath) { + existingPath.dataPath = appDataPath + } else { + config.appDataPath.push({ executablePath: app.getPath('exe'), dataPath: appDataPath }) + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a8ac3df89..f6e49ece10 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,8 +1,17 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { electronAPI } from '@electron-toolkit/preload' -import { FeedUrl } from '@shared/config/constant' +import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types' +import { + FileType, + KnowledgeBaseParams, + KnowledgeItem, + MCPServer, + S3Config, + Shortcut, + ThemeMode, + WebDavConfig +} from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' @@ -17,11 +26,14 @@ const api = { checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog), setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang), + setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable), + setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages), setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive), setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive), setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive), setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive), - setFeedUrl: (feedUrl: FeedUrl) => ipcRenderer.invoke(IpcChannel.App_SetFeedUrl, feedUrl), + setTestPlan: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTestPlan, isActive), + setTestChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetTestChannel, channel), setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme), handleZoomFactor: (delta: number, reset: boolean = false) => ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset), @@ -29,9 +41,13 @@ const api = { select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options), hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path), setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path), - copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath), + getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs), + copy: (oldPath: string, newPath: string, occupiedDirs: string[] = []) => + ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath, occupiedDirs), setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason), - relaunchApp: () => ipcRenderer.invoke(IpcChannel.App_RelaunchApp), + flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData), + isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path), + relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options), openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url), getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), @@ -64,7 +80,13 @@ const api = { createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options), deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => - ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig) + ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig), + backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config), + restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config), + listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config), + deleteS3File: (fileName: string, s3Config: S3Config) => + ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config), + checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), @@ -176,6 +198,10 @@ const api = { getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo), checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server) }, + python: { + execute: (script: string, context?: Record, timeout?: number) => + ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout) + }, shell: { openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options) }, @@ -218,7 +244,9 @@ const api = { }, webview: { setOpenLinkExternal: (webviewId: number, isExternal: boolean) => - ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal) + ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal), + setSpellCheckEnabled: (webviewId: number, isEnable: boolean) => + ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable) }, storeSync: { subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), diff --git a/src/renderer/selectionToolbar.html b/src/renderer/selectionToolbar.html index 1a219f6472..34efa7effc 100644 --- a/src/renderer/selectionToolbar.html +++ b/src/renderer/selectionToolbar.html @@ -2,42 +2,45 @@ - - - - Cherry Studio Selection Toolbar + + + + Cherry Studio Selection Toolbar -
- - + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + #root { + margin: 0 !important; + padding: 0 !important; + width: max-content !important; + height: fit-content !important; + } + \ No newline at end of file diff --git a/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts b/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts index b24eea2a2d..d940abdfe7 100644 --- a/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts +++ b/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts @@ -42,11 +42,19 @@ export class AihubmixAPIClient extends BaseApiClient { constructor(provider: Provider) { super(provider) + const providerExtraHeaders = { + ...provider, + extra_headers: { + ...provider.extra_headers, + 'APP-Code': 'MLTG2087' + } + } + // 初始化各个client - 现在有类型安全 - const claudeClient = new AnthropicAPIClient(provider) - const geminiClient = new GeminiAPIClient({ ...provider, apiHost: 'https://aihubmix.com/gemini' }) - const openaiClient = new OpenAIResponseAPIClient(provider) - const defaultClient = new OpenAIAPIClient(provider) + const claudeClient = new AnthropicAPIClient(providerExtraHeaders) + const geminiClient = new GeminiAPIClient({ ...providerExtraHeaders, apiHost: 'https://aihubmix.com/gemini' }) + const openaiClient = new OpenAIResponseAPIClient(providerExtraHeaders) + const defaultClient = new OpenAIAPIClient(providerExtraHeaders) this.clients.set('claude', claudeClient) this.clients.set('gemini', geminiClient) @@ -58,6 +66,13 @@ export class AihubmixAPIClient extends BaseApiClient { this.currentClient = this.defaultClient as BaseApiClient } + override getBaseURL(): string { + if (!this.currentClient) { + return this.provider.apiHost + } + return this.currentClient.getBaseURL() + } + /** * 类型守卫:确保client是BaseApiClient的实例 */ diff --git a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts index eb7d930c5d..e07b7508db 100644 --- a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts @@ -66,7 +66,7 @@ import { mcpToolCallResponseToAnthropicMessage, mcpToolsToAnthropicTools } from '@renderer/utils/mcp-tools' -import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' +import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find' import { buildSystemPrompt } from '@renderer/utils/prompt' import { BaseApiClient } from '../BaseApiClient' @@ -94,7 +94,8 @@ export class AnthropicAPIClient extends BaseApiClient< baseURL: this.getBaseURL(), dangerouslyAllowBrowser: true, defaultHeaders: { - 'anthropic-beta': 'output-128k-2025-02-19' + 'anthropic-beta': 'output-128k-2025-02-19', + ...this.provider.extra_headers } }) return this.sdkInstance @@ -191,7 +192,7 @@ export class AnthropicAPIClient extends BaseApiClient< const parts: MessageParam['content'] = [ { type: 'text', - text: getMainTextContent(message) + text: await this.getMessageContent(message) } ] diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index dd4fb1d516..549e931966 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -176,7 +176,10 @@ export class GeminiAPIClient extends BaseApiClient< apiVersion: this.getApiVersion(), httpOptions: { baseUrl: this.getBaseURL(), - apiVersion: this.getApiVersion() + apiVersion: this.getApiVersion(), + headers: { + ...this.provider.extra_headers + } } }) @@ -683,16 +686,19 @@ export class GeminiAPIClient extends BaseApiClient< toolCalls: FunctionCall[] ): Content[] { const parts: Part[] = [] + const modelParts: Part[] = [] if (output) { - parts.push({ + modelParts.push({ text: output }) } + toolCalls.forEach((toolCall) => { - parts.push({ + modelParts.push({ functionCall: toolCall }) }) + parts.push( ...toolResults .map((ts) => ts.parts) @@ -700,10 +706,22 @@ export class GeminiAPIClient extends BaseApiClient< .filter((p) => p !== undefined) ) - const lastMessage = currentReqMessages[currentReqMessages.length - 1] - if (lastMessage) { - lastMessage.parts?.push(...parts) + const userMessage: Content = { + role: 'user', + parts: [] } + + if (modelParts.length > 0) { + currentReqMessages.push({ + role: 'model', + parts: modelParts + }) + } + if (parts.length > 0) { + userMessage.parts?.push(...parts) + currentReqMessages.push(userMessage) + } + return currentReqMessages } @@ -744,7 +762,7 @@ export class GeminiAPIClient extends BaseApiClient< } }) } - return [messageParam, ...(sdkPayload.history || [])] + return [...(sdkPayload.history || []), messageParam] } private async uploadFile(file: FileType): Promise { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index a53247c1f7..499edfbb5c 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -113,6 +113,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } if (!reasoningEffort) { + if (model.provider === 'openrouter') { + return { reasoning: { enabled: false, exclude: true } } + } if (isSupportedThinkingTokenQwenModel(model)) { return { enable_thinking: false } } @@ -122,10 +125,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } if (isSupportedThinkingTokenGeminiModel(model)) { - // openrouter没有提供一个不推理的选项,先隐藏 - if (this.provider.id === 'openrouter') { - return { reasoning: { max_tokens: 0, exclude: true } } - } if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { return { reasoning_effort: 'none' } } diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts index 7730f228af..72fa1d7df8 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts @@ -159,6 +159,7 @@ export abstract class OpenAIBaseClient< baseURL: this.getBaseURL(), defaultHeaders: { ...this.defaultHeaders(), + ...this.provider.extra_headers, ...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}), ...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {}) } diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 5871a04cab..a6c49a5cf8 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -81,7 +81,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< apiKey: this.apiKey, baseURL: this.getBaseURL(), defaultHeaders: { - ...this.defaultHeaders() + ...this.defaultHeaders(), + ...this.provider.extra_headers } }) } @@ -425,6 +426,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< const toolCalls: OpenAIResponseSdkToolCall[] = [] const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] let hasBeenCollectedToolCalls = false + let hasReasoningSummary = false return () => ({ async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController) { // 处理chunk @@ -496,6 +498,16 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< outputItems.push(chunk.item) } break + case 'response.reasoning_summary_part.added': + if (hasReasoningSummary) { + const separator = '\n\n' + controller.enqueue({ + type: ChunkType.THINKING_DELTA, + text: separator + }) + } + hasReasoningSummary = true + break case 'response.reasoning_summary_text.delta': controller.enqueue({ type: ChunkType.THINKING_DELTA, diff --git a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts index fa0cc2cf61..893018d4c5 100644 --- a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts @@ -255,6 +255,10 @@ function buildParamsWithToolResults( // 从回复中构建助手消息 const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls) + if (output && ctx._internal.toolProcessingState) { + ctx._internal.toolProcessingState.output = undefined + } + // 估算新增消息的 token 消耗并累加到 usage 中 if (ctx._internal.observer?.usage && newReqMessages.length > currentReqMessages.length) { try { diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index ebe45ef5c6..225cbe8a9d 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -58,166 +58,80 @@ } } -.mention-models-dropdown { - &.ant-dropdown { - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - animation-duration: 0.15s !important; - } - - /* 移动其他样式到 mention-models-dropdown 类下 */ - .ant-slide-up-enter .ant-dropdown-menu, - .ant-slide-up-appear .ant-dropdown-menu, - .ant-slide-up-leave .ant-dropdown-menu, - .ant-slide-up-enter-active .ant-dropdown-menu, - .ant-slide-up-appear-active .ant-dropdown-menu, - .ant-slide-up-leave-active .ant-dropdown-menu { - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - } - - .ant-dropdown-menu { - /* 保持原有的下拉菜单样式,但限定在 mention-models-dropdown 类下 */ - max-height: 400px; - overflow-y: auto; - overflow-x: hidden; - padding: 4px 12px; - position: relative; - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - border: 0.5px solid rgba(var(--color-border-rgb), 0.3); - border-radius: 10px; - box-shadow: - 0 0 0 0.5px rgba(0, 0, 0, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.15), - 0 2px 8px rgba(0, 0, 0, 0.12), - inset 0 0 0 0.5px rgba(255, 255, 255, var(--inner-glow-opacity, 0.1)); - transform-origin: top; - will-change: transform, opacity; - transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); - margin-bottom: 0; - - &.no-scrollbar { - padding-right: 12px; - } - - &.has-scrollbar { - padding-right: 2px; - } - - // Scrollbar styles - &::-webkit-scrollbar { - width: 14px; - height: 6px; - } - - &::-webkit-scrollbar-thumb { - border: 4px solid transparent; - background-clip: padding-box; - border-radius: 7px; - background-color: var(--color-scrollbar-thumb); - min-height: 50px; - transition: all 0.2s; - } - - &:hover::-webkit-scrollbar-thumb { - background-color: var(--color-scrollbar-thumb); - } - - &::-webkit-scrollbar-thumb:hover { - background-color: var(--color-scrollbar-thumb-hover); - } - - &::-webkit-scrollbar-thumb:active { - background-color: var(--color-scrollbar-thumb-hover); - } - - &::-webkit-scrollbar-track { - background: transparent; - border-radius: 7px; - } - } - - .ant-dropdown-menu-item-group { - margin-bottom: 4px; - - &:not(:first-child) { - margin-top: 4px; - } - - .ant-dropdown-menu-item-group-title { - padding: 5px 12px; - color: var(--color-text-3); - font-size: 12px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; - opacity: 0.7; - } - } - - // Handle no-results case margin - .no-results { - padding: 8px 12px; - color: var(--color-text-3); - cursor: default; - font-size: 13px; - opacity: 0.8; - margin-bottom: 40px; - - &:hover { - background: none; - } - } - - .ant-dropdown-menu-item { - padding: 5px 12px; - margin: 0 -12px; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - align-items: center; - gap: 8px; - border-radius: 6px; - font-size: 13px; - - &:hover { - background: rgba(var(--color-hover-rgb), 0.5); - } - - &.ant-dropdown-menu-item-selected { - background-color: rgba(var(--color-primary-rgb), 0.12); - color: var(--color-primary); - } - - .ant-dropdown-menu-item-icon { - margin-right: 0; - opacity: 0.9; - } - } +.ant-dropdown-menu .ant-dropdown-menu-sub { + max-height: 50vh; + width: max-content; + overflow-y: auto; + overflow-x: hidden; + border: 0.5px solid var(--color-border); } - .ant-dropdown { + background-color: var(--ant-color-bg-elevated); + overflow: hidden; + border-radius: var(--ant-border-radius-lg); .ant-dropdown-menu { max-height: 50vh; overflow-y: auto; border: 0.5px solid var(--color-border); - .ant-dropdown-menu-sub { - max-height: 50vh; - width: max-content; - overflow-y: auto; - overflow-x: hidden; - border: 0.5px solid var(--color-border); - } } .ant-dropdown-arrow + .ant-dropdown-menu { border: none; } } - .ant-select-dropdown { border: 0.5px solid var(--color-border); } +.ant-dropdown-menu-submenu { + background-color: var(--ant-color-bg-elevated); + overflow: hidden; + border-radius: var(--ant-border-radius-lg); +} + +.ant-popover { + .ant-popover-inner { + border: 0.5px solid var(--color-border); + .ant-popover-inner-content { + max-height: 70vh; + overflow-y: auto; + } + } + .ant-popover-arrow + .ant-popover-content { + .ant-popover-inner { + border: none; + } + } +} + +.ant-modal:not(.ant-modal-confirm) { + .ant-modal-confirm-body-has-title { + padding: 16px 0 0 0; + } + .ant-modal-content { + border-radius: 10px; + border: 0.5px solid var(--color-border); + padding: 0 0 8px 0; + .ant-modal-header { + padding: 16px 16px 0 16px; + border-radius: 10px; + } + .ant-modal-body { + max-height: 80vh; + overflow-y: auto; + padding: 0 16px 0 16px; + } + .ant-modal-footer { + padding: 0 16px 8px 16px; + } + .ant-modal-confirm-btns { + margin-bottom: 8px; + } + } +} +.ant-modal.ant-modal-confirm.ant-modal-confirm-confirm { + .ant-modal-content { + padding: 16px; + } +} .ant-collapse { border: 1px solid var(--color-border); @@ -227,8 +141,14 @@ } .ant-collapse-content { - border-top: 1px solid var(--color-border) !important; + border-top: 0.5px solid var(--color-border) !important; .ant-color-picker & { border-top: none !important; } } + +.ant-slider { + .ant-slider-handle::after { + box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important; + } +} diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index 6100e1d0ee..ce7e9cefe9 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -47,7 +47,7 @@ --color-list-item: #222; --color-list-item-hover: #1e1e1e; - --modal-background: #1f1f1f; + --modal-background: #111111; --color-highlight: rgba(0, 0, 0, 1); --color-background-highlight: rgba(255, 255, 0, 0.9); @@ -66,9 +66,9 @@ --settings-width: 250px; --scrollbar-width: 5px; - --chat-background: #111111; - --chat-background-user: #28b561; - --chat-background-assistant: #2c2c2c; + --chat-background: transparent; + --chat-background-user: rgba(255, 255, 255, 0.08); + --chat-background-assistant: transparent; --chat-text-user: var(--color-black); --list-item-border-radius: 20px; @@ -132,8 +132,8 @@ --navbar-background-mac: rgba(255, 255, 255, 0.55); --navbar-background: rgba(244, 244, 244); - --chat-background: #f3f3f3; - --chat-background-user: #95ec69; - --chat-background-assistant: #ffffff; + --chat-background: transparent; + --chat-background-user: rgba(0, 0, 0, 0.045); + --chat-background-assistant: transparent; --chat-text-user: var(--color-text); } diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 9974f19596..c4bd23d1fc 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -111,27 +111,7 @@ ul { word-wrap: break-word; } -.bubble { - background-color: var(--chat-background); - #chat-main { - background-color: var(--chat-background); - } - #messages { - background-color: var(--chat-background); - } - #inputbar { - margin: -5px 15px 15px 15px; - background: var(--color-background); - } - .system-prompt { - background-color: var(--chat-background-assistant); - } - .message-content-container { - margin: 5px 0; - border-radius: 8px; - padding: 0.5rem 1rem; - } - +.bubble:not(.multi-select-mode) { .block-wrapper { display: flow-root; } @@ -149,30 +129,35 @@ ul { } .message-user { - color: var(--chat-text-user); - .message-content-container-user .anticon { - color: var(--chat-text-user) !important; + .message-header { + flex-direction: row-reverse; + text-align: right; + .message-header-info-wrap { + flex-direction: row-reverse; + text-align: right; + } } - - .markdown { - color: var(--chat-text-user); - } - } - .group-grid-container.horizontal, - .group-grid-container.grid { - .message-content-container-assistant { - padding: 0; - } - } - .group-message-wrapper { - background-color: var(--color-background); .message-content-container { - width: 100%; + border-radius: 10px 0 10px 10px; + padding: 10px 16px 10px 16px; + background-color: var(--chat-background-user); + align-self: self-end; + } + .MessageFooter { + margin-top: 2px; + align-self: self-end; } } - .group-menu-bar { - background-color: var(--color-background); + + .message-assistant { + .message-content-container { + padding-left: 0; + } + .MessageFooter { + margin-left: 0; + } } + code { color: var(--color-text); } @@ -188,11 +173,17 @@ ul { color: var(--color-icon); } -span.highlight { +::highlight(search-matches) { background-color: var(--color-background-highlight); color: var(--color-highlight); } -span.highlight.selected { +::highlight(current-match) { background-color: var(--color-background-highlight-accent); } + +textarea { + &::-webkit-resizer { + display: none; + } +} diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 0c80d9f68a..eea9070cae 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -98,7 +98,6 @@ border: none; border-top: 0.5px solid var(--color-border); margin: 20px 0; - background-color: var(--color-border); } span { @@ -119,7 +118,7 @@ } pre { - border-radius: 5px; + border-radius: 8px; overflow-x: auto; font-family: 'Fira Code', 'Courier New', Courier, monospace; background-color: var(--color-background-mute); @@ -157,15 +156,28 @@ } table { - border-collapse: collapse; + --table-border-radius: 8px; margin: 1em 0; width: 100%; + border-radius: var(--table-border-radius); + overflow: hidden; + border-collapse: separate; + border: 0.5px solid var(--color-border); + border-spacing: 0; } th, td { - border: 0.5px solid var(--color-border); + border-right: 0.5px solid var(--color-border); + border-bottom: 0.5px solid var(--color-border); padding: 0.5em; + &:last-child { + border-right: none; + } + } + + tr:last-child td { + border-bottom: none; } th { @@ -238,6 +250,10 @@ text-decoration: underline; } } + + > *:last-child { + margin-bottom: 0 !important; + } } .footnotes { @@ -309,7 +325,7 @@ mjx-container { /* CodeMirror 相关样式 */ .cm-editor { - border-radius: 5px; + border-radius: inherit; &.cm-focused { outline: none; @@ -317,7 +333,7 @@ mjx-container { .cm-scroller { font-family: var(--code-font-family); - border-radius: 5px; + border-radius: inherit; .cm-gutters { line-height: 1.6; diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss index dfbb6bbd59..bfe329c696 100644 --- a/src/renderer/src/assets/styles/selection-toolbar.scss +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -5,22 +5,57 @@ html { } :root { - --color-selection-toolbar-background: rgba(20, 20, 20, 0.95); - --color-selection-toolbar-border: rgba(55, 55, 55, 0.5); - --color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); - - --color-selection-toolbar-text: rgba(255, 255, 245, 0.9); - --color-selection-toolbar-hover-bg: #222222; - + // Basic Colors --color-primary: #00b96b; --color-error: #f44336; + + --selection-toolbar-color-primary: var(--color-primary); + --selection-toolbar-color-error: var(--color-error); + + // Toolbar + --selection-toolbar-height: 36px; // default: 36px max: 42px + --selection-toolbar-font-size: 14px; // default: 14px + + --selection-toolbar-logo-display: flex; // values: flex | none + --selection-toolbar-logo-size: 22px; // default: 22px + --selection-toolbar-logo-margin: 0 0 0 5px; // default: 0 0 05px + + // DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING + --selection-toolbar-padding: 2px 4px 2px 2px; // default: 2px 4px 2px 2px + --selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px + // ------------------------------------------------------------ + + --selection-toolbar-border-radius: 6px; + --selection-toolbar-border: 1px solid rgba(55, 55, 55, 0.5); + --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3); + --selection-toolbar-background: rgba(20, 20, 20, 0.95); + + // Buttons + + --selection-toolbar-button-icon-size: 16px; // default: 16px + --selection-toolbar-button-text-margin: 0 0 0 3px; // default: 0 0 0 3px + --selection-toolbar-button-margin: 0 2px; // default: 0 2px + --selection-toolbar-button-padding: 4px 6px; // default: 4px 6px + --selection-toolbar-button-border-radius: 4px; // default: 4px + --selection-toolbar-button-border: none; // default: none + --selection-toolbar-button-box-shadow: none; // default: none + + --selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9); + --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color); + --selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary); + --selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary); + --selection-toolbar-button-bgcolor: transparent; // default: transparent + --selection-toolbar-button-bgcolor-hover: #222222; } [theme-mode='light'] { - --color-selection-toolbar-background: rgba(245, 245, 245, 0.95); - --color-selection-toolbar-border: rgba(200, 200, 200, 0.5); - --color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); + --selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5); + --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3); + --selection-toolbar-background: rgba(245, 245, 245, 0.95); - --color-selection-toolbar-text: rgba(0, 0, 0, 1); - --color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04); + --selection-toolbar-button-text-color: rgba(0, 0, 0, 1); + --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color); + --selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary); + --selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary); + --selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04); } diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index d3c56f295b..55a10d5535 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { uuid } from '@renderer/utils' import { getReactStyleFromToken } from '@renderer/utils/shiki' import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ThemedToken } from 'shiki/core' import styled from 'styled-components' @@ -18,19 +18,20 @@ interface CodePreviewProps { /** * Shiki 流式代码高亮组件 * - * - 通过 shiki tokenizer 处理流式响应 - * - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过 + * - 通过 shiki tokenizer 处理流式响应,高性能 + * - 进入视口后触发高亮,改善页面内有大量长代码块时的响应 + * - 并发安全 */ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() - const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle() + const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle() const [isExpanded, setIsExpanded] = useState(!codeCollapsible) const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) const [tokenLines, setTokenLines] = useState([]) - const codeContentRef = useRef(null) - const prevCodeLengthRef = useRef(0) - const safeCodeStringRef = useRef(children) - const highlightQueueRef = useRef>(Promise.resolve()) + const [isInViewport, setIsInViewport] = useState(false) + const codeContainerRef = useRef(null) + const processingRef = useRef(false) + const latestRequestedContentRef = useRef(null) const callerId = useRef(`${Date.now()}-${uuid()}`).current const shikiThemeRef = useRef(activeShikiTheme) @@ -45,7 +46,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { icon: isExpanded ? : , tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'), visible: () => { - const scrollHeight = codeContentRef.current?.scrollHeight + const scrollHeight = codeContainerRef.current?.scrollHeight return codeCollapsible && (scrollHeight ?? 0) > 350 }, onClick: () => setIsExpanded((prev) => !prev) @@ -77,81 +78,63 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { setIsUnwrapped(!codeWrappable) }, [codeWrappable]) - // 处理尾部空白字符 - const safeCodeString = useMemo(() => { - return typeof children === 'string' ? children.trimEnd() : '' - }, [children]) - const highlightCode = useCallback(async () => { - if (!safeCodeString) return + const currentContent = typeof children === 'string' ? children.trimEnd() : '' - if (prevCodeLengthRef.current === safeCodeString.length) return + // 记录最新要处理的内容,为了保证最终状态正确 + latestRequestedContentRef.current = currentContent - // 捕获当前状态 - const startPos = prevCodeLengthRef.current - const endPos = safeCodeString.length + // 如果正在处理,先跳出,等到完成后会检查是否有新内容 + if (processingRef.current) return - // 添加到处理队列,确保按顺序处理 - highlightQueueRef.current = highlightQueueRef.current.then(async () => { - // FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮 - if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) { - cleanupTokenizers(callerId) - prevCodeLengthRef.current = 0 - safeCodeStringRef.current = '' + processingRef.current = true - const result = await highlightCodeChunk(safeCodeString, language, callerId) - setTokenLines(result.lines) + try { + // 循环处理,确保会处理最新内容 + while (latestRequestedContentRef.current !== null) { + const contentToProcess = latestRequestedContentRef.current + latestRequestedContentRef.current = null // 标记开始处理 - prevCodeLengthRef.current = safeCodeString.length - safeCodeStringRef.current = safeCodeString + // 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮 + const result = await highlightStreamingCode(contentToProcess, language, callerId) - return + // 如有结果,更新 tokenLines + if (result.lines.length > 0 || result.recall !== 0) { + setTokenLines((prev) => { + return result.recall === -1 + ? result.lines + : [...prev.slice(0, Math.max(0, prev.length - result.recall)), ...result.lines] + }) + } } - - // 跳过 race condition,延迟到后续任务 - if (prevCodeLengthRef.current !== startPos) { - return - } - - const incrementalCode = safeCodeString.slice(startPos, endPos) - const result = await highlightCodeChunk(incrementalCode, language, callerId) - setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines]) - prevCodeLengthRef.current = endPos - safeCodeStringRef.current = safeCodeString - }) - }, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString]) + } finally { + processingRef.current = false + } + }, [highlightStreamingCode, language, callerId, children]) // 主题变化时强制重新高亮 useEffect(() => { if (shikiThemeRef.current !== activeShikiTheme) { - prevCodeLengthRef.current++ shikiThemeRef.current = activeShikiTheme + cleanupTokenizers(callerId) + setTokenLines([]) } - }, [activeShikiTheme]) + }, [activeShikiTheme, callerId, cleanupTokenizers]) // 组件卸载时清理资源 useEffect(() => { return () => cleanupTokenizers(callerId) }, [callerId, cleanupTokenizers]) - // 触发代码高亮 - // - 进入视口后触发第一次高亮 - // - 内容变化后触发之后的高亮 + // 视口检测逻辑,进入视口后触发第一次代码高亮 useEffect(() => { - let isMounted = true - - if (prevCodeLengthRef.current > 0) { - setTimeout(highlightCode, 0) - return - } - - const codeElement = codeContentRef.current + const codeElement = codeContainerRef.current if (!codeElement) return const observer = new IntersectionObserver( (entries) => { - if (entries[0].intersectionRatio > 0 && isMounted) { - setTimeout(highlightCode, 0) + if (entries[0].intersectionRatio > 0) { + setIsInViewport(true) observer.disconnect() } }, @@ -161,21 +144,35 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { ) observer.observe(codeElement) + return () => observer.disconnect() + }, []) // 只执行一次 - return () => { - isMounted = false - observer.disconnect() - } - }, [highlightCode]) + // 触发代码高亮 + useEffect(() => { + if (!isInViewport) return - const hasHighlightedCode = useMemo(() => { - return tokenLines.length > 0 - }, [tokenLines.length]) + setTimeout(highlightCode, 0) + }, [isInViewport, highlightCode]) + + const lastDigitsRef = useRef(1) + + useLayoutEffect(() => { + const container = codeContainerRef.current + if (!container || !codeShowLineNumbers) return + + const digits = Math.max(tokenLines.length.toString().length, 1) + if (digits === lastDigitsRef.current) return + + const gutterWidth = digits * 0.6 + container.style.setProperty('--gutter-width', `${gutterWidth}rem`) + lastDigitsRef.current = digits + }, [codeShowLineNumbers, tokenLines.length]) + + const hasHighlightedCode = tokenLines.length > 0 return ( { maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none' }}> {hasHighlightedCode ? ( - + ) : ( {children} )} @@ -191,97 +188,103 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { ) } +interface ShikiTokensRendererProps { + language: string + tokenLines: ThemedToken[][] + showLineNumbers?: boolean +} + /** * 渲染 Shiki 高亮后的 tokens * * 独立出来,方便将来做 virtual list */ -const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo( - ({ language, tokenLines }) => { - const { getShikiPreProperties } = useCodeStyle() - const rendererRef = useRef(null) +const ShikiTokensRenderer: React.FC = memo(({ language, tokenLines, showLineNumbers }) => { + const { getShikiPreProperties } = useCodeStyle() + const rendererRef = useRef(null) - // 设置 pre 标签属性 - useEffect(() => { - getShikiPreProperties(language).then((properties) => { - const pre = rendererRef.current - if (pre) { - pre.className = properties.class - pre.style.cssText = properties.style - pre.tabIndex = properties.tabindex - } - }) - }, [language, getShikiPreProperties]) + // 设置 pre 标签属性 + useLayoutEffect(() => { + getShikiPreProperties(language).then((properties) => { + const pre = rendererRef.current + if (pre) { + pre.className = properties.class + pre.style.cssText = properties.style + pre.tabIndex = properties.tabindex + } + }) + }, [language, getShikiPreProperties]) - return ( -
-        
-          {tokenLines.map((lineTokens, lineIndex) => (
-            
+  return (
+    
+      
+        {tokenLines.map((lineTokens, lineIndex) => (
+          
+            {showLineNumbers && {lineIndex + 1}}
+            
               {lineTokens.map((token, tokenIndex) => (
                 
                   {token.content}
                 
               ))}
             
-          ))}
-        
-      
- ) - } -) +
+ ))} +
+
+ ) +}) const ContentContainer = styled.div<{ - $lineNumbers: boolean $wrap: boolean $fadeIn: boolean }>` position: relative; overflow: auto; - border: 0.5px solid transparent; - border-radius: 5px; + border-radius: inherit; margin-top: 0; + /* gutter 宽度默认值 */ + --gutter-width: 0.6rem; + .shiki { padding: 1em; + border-radius: inherit; code { display: flex; flex-direction: column; .line { - display: block; + display: flex; + align-items: flex-start; min-height: 1.3rem; - padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')}; - * { - overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; - white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; + .line-number { + width: var(--gutter-width); + text-align: right; + opacity: 0.35; + margin-right: 1rem; + user-select: none; + flex-shrink: 0; + overflow: hidden; + line-height: inherit; + font-family: inherit; + font-variant-numeric: tabular-nums; + } + + .line-content { + flex: 1; + + * { + overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; + white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; + } } } } } - ${(props) => - props.$lineNumbers && - ` - code { - counter-reset: step; - counter-increment: step 0; - position: relative; - } - - code .line::before { - content: counter(step); - counter-increment: step; - width: 1rem; - position: absolute; - left: 0; - text-align: right; - opacity: 0.35; - } - `} - @keyframes contentFadeIn { from { opacity: 0; @@ -291,7 +294,7 @@ const ContentContainer = styled.div<{ } } - animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')}; + animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.1s ease-in forwards' : 'none')}; ` const CodePlaceholder = styled.div` diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx index 811b8665cc..c25ab3079d 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -273,6 +273,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` align-items: center; color: var(--color-text); font-size: 14px; + line-height: 1; font-weight: bold; padding: 0 10px; border-top-left-radius: 8px; @@ -288,6 +289,10 @@ const SplitViewWrapper = styled.div` flex: 1 1 auto; width: 100%; } + + &:not(:has(+ [class*='Container'])) { + border-radius: 0 0 8px 8px; + } ` export default memo(CodeBlockView) diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index d92fd91e8e..db699fa030 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -227,10 +227,10 @@ const CodeEditor = ({ ...customBasicSetup // override basicSetup }} style={{ - ...style, fontSize: `${fontSize - 1}px`, - border: '0.5px solid transparent', - marginTop: 0 + marginTop: 0, + borderRadius: 'inherit', + ...style }} /> ) diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 08a1fd415a..1f895e348b 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -3,13 +3,10 @@ import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout' import { Tooltip } from 'antd' import { debounce } from 'lodash' import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react' -import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -const HIGHLIGHT_CLASS = 'highlight' -const HIGHLIGHT_SELECT_CLASS = 'selected' - interface Props { children?: React.ReactNode searchTarget: React.RefObject | React.RefObject | HTMLElement @@ -18,19 +15,14 @@ interface Props { * * 返回`true`表示该`node`会被搜索 */ - filter: (node: Node) => boolean + filter: NodeFilter includeUser?: boolean onIncludeUserChange?: (value: boolean) => void } enum SearchCompletedState { NotSearched, - FirstSearched -} - -enum SearchTargetIndex { - Next, - Prev + Searched } export interface ContentSearchRef { @@ -47,60 +39,20 @@ export interface ContentSearchRef { focus(): void } -interface MatchInfo { - index: number - length: number - text: string -} - const escapeRegExp = (string: string): string => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string } -const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => { - if (!elementList || elementList.length === 0) { - return null - } - let closestElementIndex: number | null = null - let minVerticalDistance = Infinity - const windowCenterY = window.innerHeight / 2 - for (let i = 0; i < elementList.length; i++) { - const element = elementList[i] - if (!(element instanceof HTMLElement)) { - continue - } - const rect = element.getBoundingClientRect() - if (rect.bottom < 0 || rect.top > window.innerHeight) { - continue - } - const elementCenterY = rect.top + rect.height / 2 - const verticalDistance = Math.abs(elementCenterY - windowCenterY) - if (verticalDistance < minVerticalDistance) { - minVerticalDistance = verticalDistance - closestElementIndex = i - } - } - return closestElementIndex -} - -const highlightText = ( - textNode: Node, +const findRangesInTarget = ( + target: HTMLElement, + filter: NodeFilter, searchText: string, - highlightClass: string, isCaseSensitive: boolean, isWholeWord: boolean -): HTMLSpanElement[] | null => { - const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement - if (textNodeParentNode) { - if (textNodeParentNode.classList.contains(highlightClass)) { - return null - } - } - if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) { - return null - } +): Range[] => { + CSS.highlights.clear() + const ranges: Range[] = [] - const textContent = textNode.textContent const escapedSearchText = escapeRegExp(searchText) // 检查搜索文本是否仅包含拉丁字母 @@ -109,89 +61,66 @@ const highlightText = ( // 只有当搜索文本仅包含拉丁字母时才应用大小写敏感 const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi' const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText - const regex = new RegExp(regexPattern, regexFlags) + const searchRegex = new RegExp(regexPattern, regexFlags) + const treeWalker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, filter) + const allTextNodes: { node: Node; startOffset: number }[] = [] + let fullText = '' - let match - const matches: MatchInfo[] = [] - while ((match = regex.exec(textContent)) !== null) { - if (typeof match.index === 'number' && typeof match[0] === 'string') { - matches.push({ index: match.index, length: match[0].length, text: match[0] }) - } else { - console.error('Unexpected match format:', match) - } + // 1. 拼接所有文本节点内容 + while (treeWalker.nextNode()) { + allTextNodes.push({ + node: treeWalker.currentNode, + startOffset: fullText.length + }) + fullText += treeWalker.currentNode.nodeValue } - if (matches.length === 0) { - return null - } + // 2.在完整文本中查找匹配项 + let match: RegExpExecArray | null = null + while ((match = searchRegex.exec(fullText))) { + const matchStart = match.index + const matchEnd = matchStart + match[0].length - const parentNode = textNode.parentNode - if (!parentNode) { - return null - } + // 3. 将匹配项的索引映射回DOM Range + let startNode: Node | null = null + let endNode: Node | null = null + let startOffset = 0 + let endOffset = 0 - const fragment = document.createDocumentFragment() - let currentIndex = 0 - const highlightTextSet = new Set() - - matches.forEach(({ index, length, text }) => { - if (index > currentIndex) { - fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index))) - } - const highlightSpan = document.createElement('span') - highlightSpan.className = highlightClass - highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive - fragment.appendChild(highlightSpan) - highlightTextSet.add(highlightSpan) - currentIndex = index + length - }) - - if (currentIndex < textContent.length) { - fragment.appendChild(document.createTextNode(textContent.substring(currentIndex))) - } - - parentNode.replaceChild(fragment, textNode) - return [...highlightTextSet] -} - -const mergeAdjacentTextNodes = (node: HTMLElement) => { - const children = Array.from(node.childNodes) - const groups: Array = [] - let currentTextGroup: { text: string; nodes: Node[] } | null = null - - for (const child of children) { - if (child.nodeType === Node.TEXT_NODE) { - if (currentTextGroup === null) { - currentTextGroup = { - text: child.textContent ?? '', - nodes: [child] - } - } else { - currentTextGroup.text += child.textContent - currentTextGroup.nodes.push(child) + // 找到起始节点和偏移 + for (const nodeInfo of allTextNodes) { + if ( + matchStart >= nodeInfo.startOffset && + matchStart < nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0) + ) { + startNode = nodeInfo.node + startOffset = matchStart - nodeInfo.startOffset + break } - } else { - if (currentTextGroup !== null) { - groups.push(currentTextGroup!) - currentTextGroup = null + } + + // 找到结束节点和偏移 + for (const nodeInfo of allTextNodes) { + if ( + matchEnd > nodeInfo.startOffset && + matchEnd <= nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0) + ) { + endNode = nodeInfo.node + endOffset = matchEnd - nodeInfo.startOffset + break } - groups.push(child) + } + + // 如果起始和结束节点都找到了,则创建一个 Range + if (startNode && endNode) { + const range = new Range() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + ranges.push(range) } } - if (currentTextGroup !== null) { - groups.push(currentTextGroup) - } - - const newChildren = groups.map((group) => { - if (group instanceof Node) { - return group - } else { - return document.createTextNode(group.text) - } - }) - - node.replaceChildren(...newChildren) + return ranges } // eslint-disable-next-line @eslint-react/no-forward-ref @@ -206,328 +135,178 @@ export const ContentSearch = React.forwardRef( })() const containerRef = React.useRef(null) const searchInputRef = React.useRef(null) - const [searchResultIndex, setSearchResultIndex] = useState(0) - const [totalCount, setTotalCount] = useState(0) const [enableContentSearch, setEnableContentSearch] = useState(false) const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched) const [isCaseSensitive, setIsCaseSensitive] = useState(false) const [isWholeWord, setIsWholeWord] = useState(false) - const [shouldScroll, setShouldScroll] = useState(false) - const highlightTextSet = useState(new Set())[0] + const [allRanges, setAllRanges] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) const prevSearchText = useRef('') const { t } = useTranslation() - const locateByIndex = (index: number, shouldScroll = true) => { - if (target) { - const highlightTextNodes = [...highlightTextSet] as HTMLElement[] - highlightTextNodes.sort((a, b) => { - const { top: aTop } = a.getBoundingClientRect() - const { top: bTop } = b.getBoundingClientRect() - return aTop - bTop - }) - for (const node of highlightTextNodes) { - node.classList.remove(HIGHLIGHT_SELECT_CLASS) - } - setSearchResultIndex(index) - if (highlightTextNodes.length > 0) { - const highlightTextNode = highlightTextNodes[index] ?? null - if (highlightTextNode) { - highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS) + const resetSearch = useCallback(() => { + CSS.highlights.clear() + setAllRanges([]) + setSearchCompleted(SearchCompletedState.NotSearched) + }, []) + + const locateByIndex = useCallback( + (shouldScroll = true) => { + // 清理旧的高亮 + CSS.highlights.clear() + + if (allRanges.length > 0) { + // 1. 创建并注册所有匹配项的高亮 + const allMatchesHighlight = new Highlight(...allRanges) + CSS.highlights.set('search-matches', allMatchesHighlight) + + // 2. 如果有当前项,为其创建并注册一个特殊的高亮 + if (currentIndex !== -1 && allRanges[currentIndex]) { + const currentMatchRange = allRanges[currentIndex] + const currentMatchHighlight = new Highlight(currentMatchRange) + CSS.highlights.set('current-match', currentMatchHighlight) + + // 3. 将当前项滚动到视图中 + // 获取第一个文本节点的父元素来进行滚动 + const parentElement = currentMatchRange.startContainer.parentElement if (shouldScroll) { - highlightTextNode.scrollIntoView({ + parentElement?.scrollIntoView({ behavior: 'smooth', - block: 'center' - // inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码 + block: 'center', + inline: 'nearest' }) } } } - } - } + }, + [allRanges, currentIndex] + ) - const restoreHighlight = () => { - const highlightTextParentNodeSet = new Set() - // Make a copy because the set might be modified during iteration indirectly - const nodesToRestore = [...highlightTextSet] - for (const highlightTextNode of nodesToRestore) { - if (highlightTextNode.textContent) { - const textNode = document.createTextNode(highlightTextNode.textContent) - const node = highlightTextNode as HTMLElement - if (node.parentNode) { - highlightTextParentNodeSet.add(node.parentNode as HTMLElement) - node.replaceWith(textNode) // This removes the node from the DOM - } - } - } - highlightTextSet.clear() // Clear the original set after processing - for (const parentNode of highlightTextParentNodeSet) { - mergeAdjacentTextNodes(parentNode) - } - // highlightTextSet.clear() // Already cleared - } - - const search = (searchTargetIndex?: SearchTargetIndex): number | null => { + const search = useCallback(() => { const searchText = searchInputRef.current?.value.trim() ?? null + setSearchCompleted(SearchCompletedState.Searched) if (target && searchText !== null && searchText !== '') { - restoreHighlight() - const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT) - let textNode: Node | null - const textNodeSet: Set = new Set() - while ((textNode = iter.nextNode())) { - if (filter(textNode)) { - textNodeSet.add(textNode) - } - } - - const highlightTextSetTemp = new Set() - for (const node of textNodeSet) { - const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord) - if (list) { - list.forEach((node) => highlightTextSetTemp.add(node)) - } - } - const highlightTextList = [...highlightTextSetTemp] - setTotalCount(highlightTextList.length) - highlightTextSetTemp.forEach((node) => highlightTextSet.add(node)) - const changeIndex = () => { - let index: number - switch (searchTargetIndex) { - case SearchTargetIndex.Next: - { - index = (searchResultIndex + 1) % highlightTextList.length - } - break - case SearchTargetIndex.Prev: - { - index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length - } - break - default: { - index = searchResultIndex - } - } - return Math.max(index, 0) - } - - const targetIndex = (() => { - switch (searchCompleted) { - case SearchCompletedState.NotSearched: { - setSearchCompleted(SearchCompletedState.FirstSearched) - const index = findWindowVerticalCenterElementIndex(highlightTextList) - if (index !== null) { - setSearchResultIndex(index) - return index - } else { - setSearchResultIndex(0) - return 0 - } - } - case SearchCompletedState.FirstSearched: { - return changeIndex() - } - default: { - return null - } - } - })() - - if (targetIndex === null) { - return null - } else { - const totalCount = highlightTextSet.size - if (targetIndex >= totalCount) { - return totalCount - 1 - } else { - return targetIndex - } - } - } else { - return null + const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord) + setAllRanges(ranges) + setCurrentIndex(0) } - } + }, [target, filter, isCaseSensitive, isWholeWord]) - const _searchHandlerDebounce = debounce(() => { - implementation.search() - }, 300) - const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce]) - const userInputHandler = (event: React.ChangeEvent) => { - const value = event.target.value.trim() - if (value.length === 0) { - restoreHighlight() - setTotalCount(0) - setSearchResultIndex(0) - setSearchCompleted(SearchCompletedState.NotSearched) - } else { - // 用户输入时允许滚动 - setShouldScroll(true) - searchHandler() - } - prevSearchText.current = value - } - - const keyDownHandler = (event: React.KeyboardEvent) => { - const { code, key, shiftKey } = event - if (key === 'Process') { - return - } - - switch (code) { - case 'Enter': - { - if (shiftKey) { - implementation.searchPrev() - } else { - implementation.searchNext() - } - event.preventDefault() - } - break - case 'Escape': - { - implementation.disable() - } - break - } - } - - const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus()) - - const userOutlinedButtonOnClick = () => { - if (onIncludeUserChange) { - onIncludeUserChange(!includeUser) - } - searchInputFocus() - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - const implementation = { - disable() { - setEnableContentSearch(false) - restoreHighlight() - setShouldScroll(false) - }, - enable(initialText?: string) { - setEnableContentSearch(true) - setShouldScroll(false) // Default to false, search itself might set it to true - if (searchInputRef.current) { - const inputEl = searchInputRef.current - if (initialText && initialText.trim().length > 0) { - inputEl.value = initialText - // Trigger search after setting initial text - // Need to make sure search() uses the new value - // and also to focus and select - requestAnimationFrame(() => { - inputEl.focus() - inputEl.select() - setShouldScroll(true) - const targetIndex = search() - if (targetIndex !== null) { - locateByIndex(targetIndex, true) // Ensure scrolling - } else { - // If search returns null (e.g., empty input or no matches with initial text), clear state - restoreHighlight() - setTotalCount(0) - setSearchResultIndex(0) + const implementation = useMemo( + () => ({ + disable: () => { + setEnableContentSearch(false) + CSS.highlights.clear() + }, + enable: (initialText?: string) => { + setEnableContentSearch(true) + if (searchInputRef.current) { + const inputEl = searchInputRef.current + if (initialText && initialText.trim().length > 0) { + inputEl.value = initialText + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + search() + CSS.highlights.clear() setSearchCompleted(SearchCompletedState.NotSearched) - } - }) - } else { - requestAnimationFrame(() => { - inputEl.focus() - inputEl.select() - }) - // Only search if there's existing text and no new initialText - if (inputEl.value.trim()) { - const targetIndex = search() - if (targetIndex !== null) { - setSearchResultIndex(targetIndex) - // locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text - } + }) + } else { + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + }) } } - } - }, - searchNext() { - if (enableContentSearch) { - const targetIndex = search(SearchTargetIndex.Next) - if (targetIndex !== null) { - locateByIndex(targetIndex) + }, + searchNext: () => { + if (allRanges.length > 0) { + setCurrentIndex((prev) => (prev < allRanges.length - 1 ? prev + 1 : 0)) } - } - }, - searchPrev() { - if (enableContentSearch) { - const targetIndex = search(SearchTargetIndex.Prev) - if (targetIndex !== null) { - locateByIndex(targetIndex) + }, + searchPrev: () => { + if (allRanges.length > 0) { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allRanges.length - 1)) } - } - }, - resetSearchState() { - if (enableContentSearch) { + }, + resetSearchState: () => { setSearchCompleted(SearchCompletedState.NotSearched) - // Maybe also reset index? Depends on desired behavior - // setSearchResultIndex(0); + }, + search: () => { + search() + locateByIndex(true) + }, + silentSearch: () => { + search() + locateByIndex(false) + }, + focus: () => { + searchInputRef.current?.focus() } + }), + [allRanges.length, locateByIndex, search] + ) + + const _searchHandlerDebounce = useMemo(() => debounce(implementation.search, 300), [implementation.search]) + + const searchHandler = useCallback(() => { + _searchHandlerDebounce() + }, [_searchHandlerDebounce]) + + const userInputHandler = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value.trim() + if (value.length === 0) { + resetSearch() + } else { + searchHandler() + } + prevSearchText.current = value }, - search() { - if (enableContentSearch) { - const targetIndex = search() - if (targetIndex !== null) { - locateByIndex(targetIndex, shouldScroll) + [searchHandler, resetSearch] + ) + + const keyDownHandler = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + const value = (event.target as HTMLInputElement).value.trim() + if (value.length === 0) { + resetSearch() + return + } + if (event.shiftKey) { + implementation.searchPrev() } else { - // If search returns null (e.g., empty input), clear state - restoreHighlight() - setTotalCount(0) - setSearchResultIndex(0) - setSearchCompleted(SearchCompletedState.NotSearched) + implementation.searchNext() } + } else if (event.key === 'Escape') { + implementation.disable() } }, - silentSearch() { - if (enableContentSearch) { - const targetIndex = search() - if (targetIndex !== null) { - // 只更新索引,不触发滚动 - locateByIndex(targetIndex, false) - } - } - }, - focus() { - searchInputFocus() - } - } + [implementation, resetSearch] + ) - useImperativeHandle(ref, () => ({ - disable() { - implementation.disable() - }, - enable(initialText?: string) { - implementation.enable(initialText) - }, - searchNext() { - implementation.searchNext() - }, - searchPrev() { - implementation.searchPrev() - }, - search() { - implementation.search() - }, - silentSearch() { - implementation.silentSearch() - }, - focus() { - implementation.focus() - } - })) + const searchInputFocus = useCallback(() => { + requestAnimationFrame(() => searchInputRef.current?.focus()) + }, []) + + const userOutlinedButtonOnClick = useCallback(() => { + onIncludeUserChange?.(!includeUser) + searchInputFocus() + }, [includeUser, onIncludeUserChange, searchInputFocus]) + + useImperativeHandle(ref, () => implementation, [implementation]) + + useEffect(() => { + locateByIndex() + }, [currentIndex, locateByIndex]) - // Re-run search when options change and search is active useEffect(() => { if (enableContentSearch && searchInputRef.current?.value.trim()) { - implementation.search() + search() } - }, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency + }, [isCaseSensitive, isWholeWord, enableContentSearch, search]) const prevButtonOnClick = () => { implementation.searchPrev() @@ -589,11 +368,11 @@ export const ContentSearch = React.forwardRef( {searchCompleted !== SearchCompletedState.NotSearched ? ( - totalCount > 0 ? ( + allRanges.length > 0 ? ( <> - {searchResultIndex + 1} + {currentIndex + 1} / - {totalCount} + {allRanges.length} ) : ( {t('common.no_results')} @@ -603,10 +382,10 @@ export const ContentSearch = React.forwardRef( )} - + - + diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx index 195fcb2a38..610afa695f 100644 --- a/src/renderer/src/components/ContextMenu/index.tsx +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -1,87 +1,59 @@ import { Dropdown } from 'antd' -import { useCallback, useEffect, useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' interface ContextMenuProps { children: React.ReactNode - onContextMenu?: (e: React.MouseEvent) => void - style?: React.CSSProperties } -const ContextMenu: React.FC = ({ children, onContextMenu, style }) => { +const ContextMenu: React.FC = ({ children }) => { const { t } = useTranslation() - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) - const [selectedText, setSelectedText] = useState('') + const [selectedText, setSelectedText] = useState(undefined) - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - const _selectedText = window.getSelection()?.toString() - if (_selectedText) { - setContextMenuPosition({ x: e.clientX, y: e.clientY }) - setSelectedText(_selectedText) - } - onContextMenu?.(e) - }, - [onContextMenu] - ) + const contextMenuItems = useMemo(() => { + if (!selectedText) return [] - useEffect(() => { - const handleClick = () => { - setContextMenuPosition(null) - } - document.addEventListener('click', handleClick) - return () => { - document.removeEventListener('click', handleClick) - } - }, []) - - // 获取右键菜单项 - const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [ - { - key: 'copy', - label: t('common.copy'), - onClick: () => { - if (selectedText) { - navigator.clipboard - .writeText(selectedText) - .then(() => { - window.message.success({ content: t('message.copied'), key: 'copy-message' }) - }) - .catch(() => { - window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) - }) - } - } - }, - { - key: 'quote', - label: t('chat.message.quote'), - onClick: () => { - if (selectedText) { - window.api?.quoteToMainWindow(selectedText) + return [ + { + key: 'copy', + label: t('common.copy'), + onClick: () => { + if (selectedText) { + navigator.clipboard + .writeText(selectedText) + .then(() => { + window.message.success({ content: t('message.copied'), key: 'copy-message' }) + }) + .catch(() => { + window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) + }) + } + } + }, + { + key: 'quote', + label: t('chat.message.quote'), + onClick: () => { + if (selectedText) { + window.api?.quoteToMainWindow(selectedText) + } } } + ] + }, [selectedText, t]) + + const onOpenChange = (open: boolean) => { + if (open) { + const selectedText = window.getSelection()?.toString() + setSelectedText(selectedText) } - ] + } return ( - - {contextMenuPosition && ( - -
- - )} + {children} - + ) } -const ContextContainer = styled.div`` - export default ContextMenu diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index 9c94084d70..c6f4f79a78 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -1,5 +1,6 @@ import { Collapse } from 'antd' import { merge } from 'lodash' +import { ChevronRight } from 'lucide-react' import { FC, memo, useMemo, useState } from 'react' interface CustomCollapseProps { @@ -78,6 +79,14 @@ const CustomCollapse: FC = ({ destroyInactivePanel={destroyInactivePanel} collapsible={collapsible} onChange={setActiveKeys} + expandIcon={({ isActive }) => ( + + )} items={[ { styles: collapseItemStyles, diff --git a/src/renderer/src/components/EditableNumber/index.tsx b/src/renderer/src/components/EditableNumber/index.tsx new file mode 100644 index 0000000000..3cc0f09507 --- /dev/null +++ b/src/renderer/src/components/EditableNumber/index.tsx @@ -0,0 +1,114 @@ +import { InputNumber } from 'antd' +import { FC, useEffect, useRef, useState } from 'react' +import styled from 'styled-components' + +export interface EditableNumberProps { + value?: number | null + min?: number + max?: number + step?: number + precision?: number + placeholder?: string + disabled?: boolean + changeOnBlur?: boolean + onChange?: (value: number | null) => void + onBlur?: () => void + style?: React.CSSProperties + className?: string + size?: 'small' | 'middle' | 'large' + suffix?: string + prefix?: string + align?: 'start' | 'center' | 'end' +} + +const EditableNumber: FC = ({ + value, + min, + max, + step = 0.01, + precision, + placeholder, + disabled = false, + onChange, + onBlur, + changeOnBlur = false, + style, + className, + size = 'middle', + align = 'end' +}) => { + const [isEditing, setIsEditing] = useState(false) + const [inputValue, setInputValue] = useState(value) + const inputRef = useRef(null) + + useEffect(() => { + setInputValue(value) + }, [value]) + + const handleFocus = () => { + if (disabled) return + setIsEditing(true) + } + + const handleInputChange = (newValue: number | null) => { + onChange?.(newValue ?? null) + } + + const handleBlur = () => { + setIsEditing(false) + onBlur?.() + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleBlur() + } else if (e.key === 'Escape') { + setInputValue(value) + setIsEditing(false) + } + } + + return ( + + + + {value ?? placeholder} + + + ) +} + +const Container = styled.div` + display: inline-block; + position: relative; +` + +const DisplayText = styled.div<{ + $align: 'start' | 'center' | 'end' + $isEditing: boolean +}>` + position: absolute; + inset: 0; + display: ${({ $isEditing }) => ($isEditing ? 'none' : 'flex')}; + align-items: center; + justify-content: ${({ $align }) => $align}; + pointer-events: none; +` + +export default EditableNumber diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index bd360c8a30..f9a4ee7e1e 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -10,7 +10,7 @@ import { PushpinOutlined, ReloadOutlined } from '@ant-design/icons' -import { isLinux, isMac, isWindows } from '@renderer/config/constant' +import { isLinux, isMac, isWin } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useBridge } from '@renderer/hooks/useBridge' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' @@ -303,7 +303,7 @@ const MinappPopupContainer: React.FC = () => { )} - + + + + ) + } + ] + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys) + } + } + + return ( + } onClick={fetchBackupFiles} disabled={loading}> + {t('settings.data.s3.manager.refresh')} + , + , + + ]}> + + + ) +} diff --git a/src/renderer/src/components/S3Modals.tsx b/src/renderer/src/components/S3Modals.tsx new file mode 100644 index 0000000000..a74ad2e9ca --- /dev/null +++ b/src/renderer/src/components/S3Modals.tsx @@ -0,0 +1,258 @@ +import { backupToS3, handleData } from '@renderer/services/BackupService' +import { formatFileSize } from '@renderer/utils' +import { Input, Modal, Select, Spin } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + +export function useS3BackupModal() { + const [customFileName, setCustomFileName] = useState('') + const [isModalVisible, setIsModalVisible] = useState(false) + const [backuping, setBackuping] = useState(false) + + const handleBackup = async () => { + setBackuping(true) + try { + await backupToS3({ customFileName, showMessage: true }) + } finally { + setBackuping(false) + setIsModalVisible(false) + } + } + + const handleCancel = () => { + setIsModalVisible(false) + } + + const showBackupModal = useCallback(async () => { + // 获取默认文件名 + const deviceType = await window.api.system.getDeviceType() + const hostname = await window.api.system.getHostname() + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + setCustomFileName(defaultFileName) + setIsModalVisible(true) + }, []) + + return { + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName, + showBackupModal + } +} + +type S3BackupModalProps = { + isModalVisible: boolean + handleBackup: () => Promise + handleCancel: () => void + backuping: boolean + customFileName: string + setCustomFileName: (value: string) => void +} + +export function S3BackupModal({ + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName +}: S3BackupModalProps) { + const { t } = useTranslation() + + return ( + + setCustomFileName(e.target.value)} + placeholder={t('settings.data.s3.backup.modal.filename.placeholder')} + /> + + ) +} + +interface UseS3RestoreModalProps { + endpoint: string | undefined + region: string | undefined + bucket: string | undefined + access_key_id: string | undefined + secret_access_key: string | undefined + root?: string | undefined +} + +export function useS3RestoreModal({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root +}: UseS3RestoreModalProps) { + const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false) + const [restoring, setRestoring] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + const [loadingFiles, setLoadingFiles] = useState(false) + const [backupFiles, setBackupFiles] = useState([]) + const { t } = useTranslation() + + const showRestoreModal = useCallback(async () => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' }) + return + } + + setIsRestoreModalVisible(true) + setLoadingFiles(true) + try { + const files = await window.api.backup.listS3Files({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + }) + setBackupFiles(files) + } catch (error: any) { + window.message.error({ + content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }), + key: 'list-files-error' + }) + } finally { + setLoadingFiles(false) + } + }, [endpoint, region, bucket, access_key_id, secret_access_key, root, t]) + + const handleRestore = useCallback(async () => { + if (!selectedFile || !endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error({ + content: !selectedFile + ? t('settings.data.s3.restore.file.required') + : t('settings.data.s3.restore.config.incomplete'), + key: 'restore-error' + }) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.restore.confirm.title'), + content: t('settings.data.s3.restore.confirm.content'), + okText: t('settings.data.s3.restore.confirm.ok'), + cancelText: t('settings.data.s3.restore.confirm.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + const data = await window.api.backup.restoreFromS3({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root, + fileName: selectedFile + }) + await handleData(JSON.parse(data)) + window.message.success(t('settings.data.s3.restore.success')) + setIsRestoreModalVisible(false) + } catch (error: any) { + window.message.error({ + content: t('settings.data.s3.restore.error', { message: error.message }), + key: 'restore-error' + }) + } finally { + setRestoring(false) + } + } + }) + }, [selectedFile, endpoint, region, bucket, access_key_id, secret_access_key, root, t]) + + const handleCancel = () => { + setIsRestoreModalVisible(false) + } + + return { + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles, + showRestoreModal + } +} + +type S3RestoreModalProps = ReturnType + +export function S3RestoreModal({ + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles +}: S3RestoreModalProps) { + const { t } = useTranslation() + + return ( + +
+ 1 ? ( + + + + ) : ( + + + + ) + } + suffix={search.length >= 2 ? : null} ref={inputRef} + placeholder={t('history.search.placeholder')} + value={search} onChange={(e) => setSearch(e.target.value.trimStart())} - suffix={search.length >= 2 ? : } + allowClear + autoFocus + spellCheck={false} + style={{ paddingLeft: 0 }} + variant="borderless" + size="middle" onPressEnter={onSearch} /> - + + + { const SearchMessage: FC = ({ message, ...props }) => { const navigate = NavigationService.navigate! - const { messageStyle } = useSettings() const { t } = useTranslation() const [topic, setTopic] = useState(null) @@ -43,18 +41,18 @@ const SearchMessage: FC = ({ message, ...props }) => { return ( - - + + @@ -74,12 +72,11 @@ const MessagesContainer = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; display: flex; flex-direction: column; - .message { - padding: 0; - } + padding: 16px; + position: relative; ` export default SearchMessage diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index 5882f4945c..2fd299a388 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -151,7 +151,8 @@ const Container = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 0 16px; display: flex; flex-direction: column; ` diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 27372db4f3..1b4be00029 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -1,9 +1,8 @@ -import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons' +import { MessageOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' -import { useSettings } from '@renderer/hooks/useSettings' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' @@ -13,6 +12,7 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' +import { Forward } from 'lucide-react' import { FC, useEffect } from 'react' import styled from 'styled-components' @@ -25,7 +25,6 @@ interface Props extends React.HTMLAttributes { const TopicMessages: FC = ({ topic, ...props }) => { const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') - const { messageStyle } = useSettings() const dispatch = useAppDispatch() useEffect(() => { @@ -48,8 +47,8 @@ const TopicMessages: FC = ({ topic, ...props }) => { return ( - - + + {topic?.messages.map((message) => (
@@ -58,7 +57,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { size="middle" style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }} onClick={() => locateToMessage(navigate, message)} - icon={} + icon={} />
@@ -86,12 +85,10 @@ const MessagesContainer = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 16px; display: flex; flex-direction: column; - .message { - padding: 0; - } ` export default TopicMessages diff --git a/src/renderer/src/pages/history/components/TopicsHistory.tsx b/src/renderer/src/pages/history/components/TopicsHistory.tsx index 85d8ef5a26..d95a3f7ae6 100644 --- a/src/renderer/src/pages/history/components/TopicsHistory.tsx +++ b/src/renderer/src/pages/history/components/TopicsHistory.tsx @@ -78,7 +78,8 @@ const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props } const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 0 16px; display: flex; flex-direction: column; ` diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index e2fdbb740c..2639a06387 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -7,6 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' import { Assistant, Topic } from '@renderer/types' +import { classNames } from '@renderer/utils' import { Flex } from 'antd' import { debounce } from 'lodash' import React, { FC, useMemo, useState } from 'react' @@ -54,28 +55,30 @@ const Chat: FC = (props) => { } }) - const contentSearchFilter = (node: Node): boolean => { - if (node.parentNode) { - let parentNode: HTMLElement | null = node.parentNode as HTMLElement - while (parentNode?.parentNode) { - if (parentNode.classList.contains('MessageFooter')) { - return false - } + const contentSearchFilter: NodeFilter = { + acceptNode(node) { + if (node.parentNode) { + let parentNode: HTMLElement | null = node.parentNode as HTMLElement + while (parentNode?.parentNode) { + if (parentNode.classList.contains('MessageFooter')) { + return NodeFilter.FILTER_REJECT + } - if (filterIncludeUser) { - if (parentNode?.classList.contains('message-content-container')) { - return true - } - } else { - if (parentNode?.classList.contains('message-content-container-assistant')) { - return true + if (filterIncludeUser) { + if (parentNode?.classList.contains('message-content-container')) { + return NodeFilter.FILTER_ACCEPT + } + } else { + if (parentNode?.classList.contains('message-content-container-assistant')) { + return NodeFilter.FILTER_ACCEPT + } } + parentNode = parentNode.parentNode as HTMLElement } - parentNode = parentNode.parentNode as HTMLElement + return NodeFilter.FILTER_REJECT + } else { + return NodeFilter.FILTER_REJECT } - return false - } else { - return false } } @@ -106,15 +109,8 @@ const Chat: FC = (props) => { } return ( - +
- } - filter={contentSearchFilter} - includeUser={filterIncludeUser} - onIncludeUserChange={userOutlinedItemClickHandler} - /> = (props) => { onComponentUpdate={messagesComponentUpdateHandler} onFirstUpdate={messagesComponentFirstUpdateHandler} /> + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} + /> {isMultiSelectMode && } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 958b779030..9d3da9646e 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -77,7 +77,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = showInputEstimatedTokens, autoTranslateWithSpace, enableQuickPanelTriggers, - enableBackspaceDeleteModel + enableBackspaceDeleteModel, + enableSpellCheck } = useSettings() const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) @@ -138,17 +139,21 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = _text = text _files = files - const resizeTextArea = useCallback(() => { - const textArea = textareaRef.current?.resizableTextArea?.textArea - if (textArea) { - // 如果已经手动设置了高度,则不自动调整 - if (textareaHeight) { - return + const resizeTextArea = useCallback( + (force: boolean = false) => { + const textArea = textareaRef.current?.resizableTextArea?.textArea + if (textArea) { + // 如果已经手动设置了高度,则不自动调整 + if (textareaHeight && !force) { + return + } + if (textArea?.scrollHeight) { + textArea.style.height = Math.min(textArea.scrollHeight, 400) + 'px' + } } - textArea.style.height = 'auto' - textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px` - } - }, [textareaHeight]) + }, + [textareaHeight] + ) const sendMessage = useCallback(async () => { if (inputEmpty || loading) { @@ -748,13 +753,13 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } return ( - - + + = ({ assistant: _assistant, setActiveTopic, topic }) = : t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) }) } autoFocus - contextMenu="true" variant="borderless" - spellCheck={false} + spellCheck={enableSpellCheck} rows={2} ref={textareaRef} style={{ fontSize, - minHeight: textareaHeight ? `${textareaHeight}px` : undefined + minHeight: textareaHeight ? `${textareaHeight}px` : '30px' }} styles={{ textarea: TextareaStyle }} onFocus={(e: React.FocusEvent) => { @@ -851,8 +855,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = - - + + ) } @@ -887,16 +891,15 @@ const Container = styled.div` flex-direction: column; position: relative; z-index: 2; + padding: 0 16px 16px 16px; ` const InputBarContainer = styled.div` border: 0.5px solid var(--color-border); transition: all 0.2s ease; position: relative; - margin: 14px 20px; - margin-top: 0; border-radius: 15px; - padding-top: 6px; // 为拖动手柄留出空间 + padding-top: 8px; // 为拖动手柄留出空间 background-color: var(--color-background-opacity); &.file-dragging { @@ -919,7 +922,7 @@ const InputBarContainer = styled.div` const TextareaStyle: CSSProperties = { paddingLeft: 0, - padding: '6px 15px 8px' // 减小顶部padding + padding: '6px 15px 0px' // 减小顶部padding } const Textarea = styled(TextArea)` @@ -934,16 +937,17 @@ const Textarea = styled(TextArea)` &.ant-input { line-height: 1.4; } + &::-webkit-scrollbar { + width: 3px; + } ` const Toolbar = styled.div` display: flex; flex-direction: row; justify-content: space-between; - padding: 0 8px; - padding-bottom: 0; - margin-bottom: 4px; - height: 30px; + padding: 5px 8px; + height: 40px; gap: 16px; position: relative; z-index: 2; diff --git a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx index 0f556a1d15..bad0729b8e 100644 --- a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx +++ b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx @@ -45,7 +45,7 @@ const TokenCount: FC = ({ estimateTokenCount, inputTokenCount, contextCou return ( - + {contextCount.current} / {formatMaxCount(contextCount.max)} diff --git a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx index 45b804c851..6041b562af 100644 --- a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx +++ b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx @@ -54,9 +54,10 @@ const CitationTooltip: React.FC = ({ children, citation }) return ( = ({ children, className, id, onSave }) => { {children} ) : ( - + {children} ) diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 2a6446fec7..454550c5c8 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -8,8 +8,8 @@ import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage' import { parseJSON } from '@renderer/utils' -import { escapeBrackets, removeSvgEmptyLines } from '@renderer/utils/formats' -import { findCitationInChildren, getCodeBlockId } from '@renderer/utils/markdown' +import { removeSvgEmptyLines } from '@renderer/utils/formats' +import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown' import { isEmpty } from 'lodash' import { type FC, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -52,7 +52,7 @@ const Markdown: FC = ({ block }) => { const empty = isEmpty(block.content) const paused = block.status === 'paused' const content = empty && paused ? t('message.chat.completion.paused') : block.content - return removeSvgEmptyLines(escapeBrackets(content)) + return removeSvgEmptyLines(processLatexBrackets(content)) }, [block, t]) const rehypePlugins = useMemo(() => { diff --git a/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx index 06a390c06a..072bf3047e 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx @@ -93,7 +93,7 @@ describe('CitationTooltip', () => { const tooltip = screen.getByTestId('tooltip-wrapper') expect(tooltip).toHaveAttribute('data-placement', 'top') - expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)') + expect(tooltip).toHaveAttribute('data-color', 'var(--color-background)') const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}') expect(styles.body).toEqual({ diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx index abd7067ab0..be9b18c13b 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -42,13 +42,13 @@ vi.mock('@renderer/utils', () => ({ })) vi.mock('@renderer/utils/formats', () => ({ - escapeBrackets: vi.fn((str) => str), removeSvgEmptyLines: vi.fn((str) => str) })) vi.mock('@renderer/utils/markdown', () => ({ findCitationInChildren: vi.fn(() => '{"id": 1, "url": "https://example.com"}'), - getCodeBlockId: vi.fn(() => 'code-block-1') + getCodeBlockId: vi.fn(() => 'code-block-1'), + processLatexBrackets: vi.fn((str) => str) })) // Mock components with more realistic behavior @@ -212,16 +212,6 @@ describe('Markdown', () => { expect(markdown).not.toHaveTextContent('Paused') }) - it('should process content through format utilities', async () => { - const { escapeBrackets, removeSvgEmptyLines } = await import('@renderer/utils/formats') - const content = 'Content with [brackets] and SVG' - - render() - - expect(escapeBrackets).toHaveBeenCalledWith(content) - expect(removeSvgEmptyLines).toHaveBeenCalledWith(content) - }) - it('should match snapshot', () => { const { container } = render() expect(container.firstChild).toMatchSnapshot() diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap index ff5c69767e..e9c6def351 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap @@ -47,7 +47,7 @@ exports[`CitationTooltip > basic rendering > should match snapshot 1`] = ` }
selectFormattedCitationsByBlockId(state, block.id)) + const { websearch } = useSelector((state: RootState) => state.runtime) + const message = useSelector((state: RootState) => state.messages.entities[block.messageId]) + const userMessageId = message?.askId || block.messageId // 如果没有 askId 则回退到 messageId + const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI const hasCitations = useMemo(() => { return ( @@ -21,8 +27,32 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) { ) }, [formattedCitations, block.knowledge, hasGeminiBlock]) + const getWebSearchStatusText = (requestId: string) => { + const status = websearch.activeSearches[requestId] ?? { phase: 'default' } + + switch (status.phase) { + case 'fetch_complete': + return t('message.websearch.fetch_complete', { + count: status.countAfter ?? 0 + }) + case 'rag': + return t('message.websearch.rag') + case 'rag_complete': + return t('message.websearch.rag_complete', { + countBefore: status.countBefore ?? 0, + countAfter: status.countAfter ?? 0 + }) + case 'rag_failed': + return t('message.websearch.rag_failed') + case 'cutoff': + return t('message.websearch.cutoff') + default: + return t('message.searching') + } + } + if (block.status === MessageBlockStatus.PROCESSING) { - return + return } if (!hasCitations) { diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index 2cdc9a684c..b20b62bbc9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -31,7 +31,7 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock }> = ({ block }) => } const Alert = styled(AntdAlert)` - margin: 0.5rem 0; + margin: 0.5rem 0 !important; padding: 10px; font-size: 12px; ` diff --git a/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx index 8cecea1ad8..db4b35efa8 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx @@ -18,12 +18,12 @@ const ImageBlock: React.FC = ({ block }) => { ? [`file://${block?.file?.path}`] : [] return ( - + {images.map((src, index) => ( ))} @@ -34,6 +34,5 @@ const Container = styled.div` display: flex; flex-direction: row; gap: 10px; - margin-top: 8px; ` export default React.memo(ImageBlock) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 72f2871586..336287a130 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -3,7 +3,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Collapse, message as antdMessage, Tooltip } from 'antd' -import { Lightbulb } from 'lucide-react' +import { ChevronRight, Lightbulb } from 'lucide-react' import { motion } from 'motion/react' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -57,6 +57,14 @@ const ThinkingBlock: React.FC = ({ block }) => { size="small" onChange={() => setActiveKey((key) => (key ? '' : 'thought'))} className="message-thought-container" + expandIcon={({ isActive }) => ( + + )} expandIconPosition="end" items={[ { @@ -142,7 +150,7 @@ const ThinkingTimeSeconds = memo( ) const CollapseContainer = styled(Collapse)` - margin-bottom: 15px; + margin: 15px 0; ` const MessageTitleLabel = styled.div` diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap b/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap index 7f1f866b8b..805e7d2a90 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = ` .c0 { - margin-bottom: 15px; + margin: 15px 0; } .c1 { diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index b469f03264..9f4d6e838a 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -164,17 +164,7 @@ export default React.memo(MessageBlockRenderer) const ImageBlockGroup = styled.div` display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(3, minmax(200px, 1fr)); gap: 8px; max-width: 960px; - /* > * { - min-width: 200px; - } */ - @media (min-width: 1536px) { - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - max-width: 1280px; - > * { - min-width: 250px; - } - } ` diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 672587fff5..f147619adc 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,10 +1,9 @@ import ContextMenu from '@renderer/components/ContextMenu' import Favicon from '@renderer/components/Icons/FallbackFavicon' -import { HStack } from '@renderer/components/Layout' import { fetchWebContent } from '@renderer/utils/fetch' import { cleanMarkdownContent } from '@renderer/utils/formats' import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' -import { Button, Drawer, message, Skeleton } from 'antd' +import { Button, message, Popover, Skeleton } from 'antd' import { Check, Copy, FileSearch } from 'lucide-react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -48,16 +47,53 @@ const truncateText = (text: string, maxLength = 100) => { const CitationsList: React.FC = ({ citations }) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) const previewItems = citations.slice(0, 3) const count = citations.length if (!count) return null + const popoverContent = ( +
+ {citations.map((citation) => ( + + {citation.type === 'websearch' ? ( + + + + ) : ( + + + + )} + + ))} +
+ ) + return ( - <> - setOpen(true)}> + + {t('message.citations')} +
+ } + placement="right" + trigger="hover" + styles={{ + body: { + padding: '0 0 8px 0' + } + }}> + {previewItems.map((c, i) => ( @@ -71,27 +107,7 @@ const CitationsList: React.FC = ({ citations }) => { {t('message.citation', { count })} - - setOpen(false)} - open={open} - width={680} - styles={{ header: { border: 'none' }, body: { paddingTop: 0 } }} - destroyOnClose={false}> - {open && - citations.map((citation) => ( - - {citation.type === 'websearch' ? ( - - ) : ( - - )} - - ))} - - +
) } @@ -136,16 +152,17 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { }) return ( - - + + - {citation.number} {citation.showFavicon && citation.url && ( )} handleLinkClick(citation.url, e)}> {citation.title || {citation.hostname}} + + {citation.number} {fetchedContent && } {isLoading ? ( @@ -153,28 +170,27 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { ) : ( {fetchedContent} )} - - + + ) } const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => { return ( - - + + - {citation.number} {citation.showFavicon && } handleLinkClick(citation.url, e)}> {citation.title} + + {citation.number} {citation.content && } - - {citation.content && truncateText(citation.content, 100)} - - - + {citation.content && citation.content} + + ) } @@ -182,7 +198,7 @@ const OpenButton = styled(Button)` display: flex; align-items: center; padding: 3px 8px; - margin-bottom: 8px; + margin: 8px 0; align-self: flex-start; font-size: 12px; background-color: var(--color-background-soft); @@ -213,10 +229,19 @@ const PreviewIcon = styled.div` ` const CitationIndex = styled.div` - font-size: 14px; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--color-reference); + font-size: 10px; line-height: 1.6; - color: var(--color-text-2); - margin-right: 8px; + color: var(--color-reference-text); + flex-shrink: 0; + opacity: 1; + transition: opacity 0.3s ease; ` const CitationLink = styled.a` @@ -224,7 +249,7 @@ const CitationLink = styled.a` line-height: 1.6; color: var(--color-text-1); text-decoration: none; - + flex: 1; .hostname { color: var(--color-link); } @@ -236,10 +261,14 @@ const CopyIconWrapper = styled.div` align-items: center; justify-content: center; color: var(--color-text-2); - opacity: 0.6; - margin-left: auto; + opacity: 0; padding: 4px; border-radius: 4px; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + transition: opacity 0.3s ease; &:hover { opacity: 1; @@ -251,11 +280,17 @@ const WebSearchCard = styled.div` display: flex; flex-direction: column; width: 100%; - padding: 12px; - border-radius: var(--list-item-border-radius); - background-color: var(--color-background); + padding: 12px 0; transition: all 0.3s ease; position: relative; + &:hover { + ${CopyIconWrapper} { + opacity: 1; + } + ${CitationIndex} { + opacity: 0; + } + } ` const WebSearchCardHeader = styled.div` @@ -265,6 +300,7 @@ const WebSearchCardHeader = styled.div` gap: 8px; margin-bottom: 6px; width: 100%; + position: relative; ` const WebSearchCardContent = styled.div` @@ -273,6 +309,7 @@ const WebSearchCardContent = styled.div` color: var(--color-text-2); user-select: text; cursor: text; + word-break: break-all; &.selectable-text { -webkit-user-select: text; @@ -282,4 +319,21 @@ const WebSearchCardContent = styled.div` } ` +const PopoverContent = styled.div` + max-width: min(400px, 60vw); + max-height: 60vh; + padding: 0 12px; +` + +const KnowledgePopoverContent = styled(PopoverContent)` + max-width: 600px; +` + +const PopoverContentItem = styled.div` + border-bottom: 0.5px solid var(--color-border); + &:last-child { + border-bottom: none; + } +` + export default CitationsList diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 19fcbdaded..d013e34e0e 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -1,9 +1,9 @@ -import ContextMenu from '@renderer/components/ContextMenu' +import Scrollbar from '@renderer/components/Scrollbar' import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useAssistant } from '@renderer/hooks/useAssistant' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useModel } from '@renderer/hooks/useModel' -import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' +import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelUniqId } from '@renderer/services/ModelService' @@ -42,14 +42,12 @@ const MessageItem: FC = ({ index, hideMenuBar = false, isGrouped, - isStreaming = false, - style + isStreaming = false }) => { const { t } = useTranslation() const { assistant, setModel } = useAssistant(message.assistantId) const model = useModel(getMessageModelId(message), message.model?.provider) || message.model - const { isBubbleStyle } = useMessageStyle() - const { showMessageDivider, messageFont, fontSize, narrowMode, messageStyle } = useSettings() + const { messageFont, fontSize } = useSettings() const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic) const messageContainerRef = useRef(null) const { editingMessageId, stopEditing } = useMessageEditing() @@ -101,9 +99,6 @@ const MessageItem: FC = ({ const isAssistantMessage = message.role === 'assistant' const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing - const messageBorder = !isBubbleStyle && showMessageDivider ? '1px dotted var(--color-border)' : 'none' - const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) - const messageHighlightHandler = useCallback((highlight: boolean = true) => { if (messageContainerRef.current) { messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) @@ -140,101 +135,38 @@ const MessageItem: FC = ({ 'message-assistant': isAssistantMessage, 'message-user': !isAssistantMessage })} - ref={messageContainerRef} - style={{ - ...style, - justifyContent: isBubbleStyle ? (isAssistantMessage ? 'flex-start' : 'flex-end') : undefined, - flex: isBubbleStyle ? undefined : 1 - }}> + ref={messageContainerRef}> + {isEditing && ( - - -
- -
-
+ )} {!isEditing && ( - - + <> - {showMenubar && !isBubbleStyle && ( - - } - setModel={setModel} - /> - - )} - {showMenubar && isBubbleStyle && ( - + {showMenubar && ( + = ({ /> )} - + )} ) } -const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => { - return isBubbleStyle - ? isAssistantMessage - ? 'var(--chat-background-assistant)' - : 'var(--chat-background-user)' - : undefined -} - const MessageContainer = styled.div` display: flex; + flex-direction: column; width: 100%; position: relative; transition: background-color 0.3s ease; - padding: 0 20px; transform: translateZ(0); will-change: transform; + padding: 10px 10px 0 10px; + border-radius: 10px; &.message-highlight { background-color: var(--color-primary-mute); } @@ -290,13 +216,9 @@ const MessageContainer = styled.div` } ` -const MessageContentContainer = styled.div` +const MessageContentContainer = styled(Scrollbar)` max-width: 100%; - display: flex; - flex: 1; - flex-direction: column; - justify-content: space-between; - margin-left: 46px; + padding-left: 46px; margin-top: 5px; overflow-y: auto; ` @@ -306,9 +228,9 @@ const MessageFooter = styled.div` flex-direction: row; justify-content: space-between; align-items: center; - padding: 2px 0; - margin-top: 2px; gap: 20px; + margin-left: 46px; + margin-top: 2px; ` const NewContextMessage = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx index 3e4773dcd1..7b116f8495 100644 --- a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -184,7 +184,7 @@ const MessageAnchorLine: FC = ({ messages }) => { else messageItemsRef.current.delete('bottom-anchor') }} style={{ - opacity: mouseY ? 0.5 + calculateValueByDistance('bottom-anchor', 1) : 0.6 + opacity: mouseY ? 0.5 : Math.max(0, 0.6 - (0.3 * Math.abs(0 - messages.length / 2)) / 5) }} onClick={scrollToBottom}> = ({ messages }) => { {messages.map((message, index) => { const opacity = 0.5 + calculateValueByDistance(message.id, 1) - const scale = 1 + calculateValueByDistance(message.id, 1) + const scale = 1 + calculateValueByDistance(message.id, 1.2) const size = 10 + calculateValueByDistance(message.id, 20) const avatarSource = getAvatarSource(isLocalAi, getMessageModelId(message)) const username = removeLeadingEmoji(getUserName(message)) @@ -219,15 +219,14 @@ const MessageAnchorLine: FC = ({ messages }) => { {message.role === 'assistant' ? ( - - A - + }} + /> ) : ( <> {isEmoji(avatar) ? ( @@ -241,7 +240,7 @@ const MessageAnchorLine: FC = ({ messages }) => { {avatar} ) : ( - + )} )} @@ -260,17 +259,28 @@ const MessageItemContainer = styled.div` align-items: flex-end; justify-content: space-between; text-align: right; - gap: 4px; + gap: 3px; opacity: 0; transform-origin: right center; + transition: transform cubic-bezier(0.25, 1, 0.5, 1) 150ms; + will-change: transform; +` + +const MessageItemAvatar = styled(Avatar)` + transition: + width, + height, + cubic-bezier(0.25, 1, 0.5, 1) 150ms; + will-change: width, height; ` const MessageLineContainer = styled.div<{ $height: number | null }>` width: 14px; position: fixed; - top: ${(props) => (props.$height ? `calc(${props.$height / 2}px + var(--status-bar-height))` : '50%')}; + top: calc(50% - var(--status-bar-height) - 10px); right: 13px; - max-height: ${(props) => (props.$height ? `${props.$height}px` : 'calc(100% - var(--status-bar-height) * 2)')}; + max-height: ${(props) => + props.$height ? `${props.$height - 20}px` : 'calc(100% - var(--status-bar-height) * 2 - 20px)'}; transform: translateY(-50%); z-index: 0; user-select: none; @@ -280,7 +290,7 @@ const MessageLineContainer = styled.div<{ $height: number | null }>` font-size: 5px; overflow: hidden; &:hover { - width: 440px; + width: 500px; overflow-x: visible; overflow-y: hidden; ${MessageItemContainer} { diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 895eb787d2..36449c8efd 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -40,7 +40,7 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) const model = assistant.model || assistant.defaultModel const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) - const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut } = useSettings() + const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings() const { t } = useTranslation() const textareaRef = useRef(null) const attachmentButtonRef = useRef(null) @@ -222,13 +222,16 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) }} onKeyDown={(e) => handleKeyDown(e, block.id)} autoFocus - contextMenu="true" - spellCheck={false} + spellCheck={enableSpellCheck} onPaste={(e) => onPaste(e.nativeEvent)} onFocus={() => { // 记录当前聚焦的组件 PasteService.setLastFocusedComponent('messageEditor') }} + onContextMenu={(e) => { + // 阻止事件冒泡,避免触发全局的 Electron contextMenu + e.stopPropagation() + }} style={{ fontSize, padding: '0px 15px 8px 15px' @@ -305,10 +308,11 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) const EditorContainer = styled.div` padding: 8px 0; - border: 1px solid var(--color-border); + border: 0.5px solid var(--color-border); transition: all 0.2s ease; border-radius: 15px; margin-top: 5px; + margin-bottom: 10px; background-color: var(--color-background-opacity); width: 100%; diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index efc9cd8f5d..9d0fb1c6f8 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -10,11 +10,10 @@ import type { Message } from '@renderer/types/newMessage' import { classNames } from '@renderer/utils' import { Popover } from 'antd' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import styled, { css } from 'styled-components' +import styled from 'styled-components' import MessageItem from './Message' import MessageGroupMenuBar from './MessageGroupMenuBar' -import SelectableMessage from './MessageSelect' interface Props { messages: (Message & { index: number })[] @@ -62,7 +61,6 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { ) const isGrouped = isMultiSelectMode ? false : messageLength > 1 && messages.every((m) => m.role === 'assistant') - const isHorizontal = multiModelMessageStyle === 'horizontal' const isGrid = multiModelMessageStyle === 'grid' useEffect(() => { @@ -166,25 +164,19 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { isGrouped, message, topic, - index: message.index, - style: { - paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15 - } + index: message.index } const messageContent = ( + className={classNames([ + { + [multiModelMessageStyle]: message.role === 'assistant' && messages.length > 1, + selected: message.id === selectedMessageId + } + ])}> ) @@ -193,47 +185,43 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { return ( + className={classNames([ + 'in-popover', + { + [multiModelMessageStyle]: message.role === 'assistant' && messages.length > 1, + selected: message.id === selectedMessageId + } + ])}> } trigger={gridPopoverTrigger} - styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}> -
{messageContent}
+ styles={{ + root: { maxWidth: '60vw', overflowY: 'auto', zIndex: 1000 }, + body: { padding: 2 } + }}> + {messageContent}
) } - return ( - - {messageContent} - - ) + return messageContent }, - [isGrid, isGrouped, topic, multiModelMessageStyle, isHorizontal, selectedMessageId, gridPopoverTrigger] + [isGrid, isGrouped, topic, multiModelMessageStyle, messages.length, selectedMessageId, gridPopoverTrigger] ) return ( + id={messages[0].askId ? `message-group-${messages[0].askId}` : undefined} + className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])}> + className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])}> {messages.map(renderMessage)} {isGrouped && ( @@ -256,73 +244,122 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { ) } -const GroupContainer = styled.div<{ $isGrouped: boolean; $layout: MultiModelMessageStyle }>` - padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && 'horizontal' === $layout ? '15px' : '0')}; - &.group-container.horizontal, - &.group-container.grid { - padding: 0 20px; - .message { - padding: 0; - } +const GroupContainer = styled.div` + &.horizontal, + &.grid { + padding: 4px 10px; .group-menu-bar { margin-left: 0; margin-right: 0; } } + &.multi-select-mode { + padding: 5px 10px; + } ` -const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle; $gridColumns: number }>` +const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>` width: 100%; display: grid; - gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')}; - grid-template-columns: repeat( - ${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)}, - minmax(480px, 1fr) - ); - @media (max-width: 800px) { - grid-template-columns: repeat( - ${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)}, - minmax(400px, 1fr) - ); + overflow-y: visible; + gap: 16px; + &.horizontal { + padding-bottom: 4px; + grid-template-columns: repeat(${({ $count }) => $count}, minmax(480px, 1fr)); + overflow-x: auto; + } + &.fold, + &.vertical { + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 8px; + } + &.grid { + grid-template-columns: repeat( + ${({ $count, $gridColumns }) => ($count > 1 ? $gridColumns || 2 : 1)}, + minmax(0, 1fr) + ); + grid-template-rows: auto; + } + + &.multi-select-mode { + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 10px; + .grid { + height: auto; + } + .message { + border: 0.5px solid var(--color-border); + border-radius: 10px; + padding: 10px; + .message-content-container { + max-height: 200px; + overflow-y: hidden !important; + } + .MessageFooter { + display: none; + } + } } - ${({ $layout }) => - $layout === 'horizontal' && - css` - margin-top: 15px; - `} - ${({ $gridColumns, $layout, $count }) => - $layout === 'grid' && - css` - margin-top: 15px; - grid-template-columns: repeat(${$count > 1 ? $gridColumns || 2 : 1}, minmax(0, 1fr)); - grid-template-rows: auto; - gap: 16px; - `} - ${({ $layout }) => { - return $layout === 'horizontal' - ? css` - overflow-y: auto; - ` - : 'overflow-y: visible;' - }} ` interface MessageWrapperProps { - $layout: 'fold' | 'horizontal' | 'vertical' | 'grid' - // $selected: boolean - $isGrouped: boolean $isInPopover?: boolean } -const MessageWrapper = styled(Scrollbar)` - width: 100%; - display: flex; - +const MessageWrapper = styled.div` &.horizontal { - display: inline-block; + overflow-y: auto; + .message { + height: 100%; + border: 0.5px solid var(--color-border); + border-radius: 10px; + } + .message-content-container { + flex: 1; + padding-left: 0; + max-height: calc(100vh - 350px); + overflow-y: auto !important; + margin-right: -10px; + } + .MessageFooter { + margin-left: 0; + margin-top: 2px; + margin-bottom: 2px; + } } &.grid { - display: inline-block; + height: 300px; + overflow-y: hidden; + border: 0.5px solid var(--color-border); + border-radius: 10px; + cursor: pointer; + .message { + height: 100%; + } + .message-content-container { + overflow: hidden; + padding-left: 0; + flex: 1; + pointer-events: none; + } + .MessageFooter { + margin-left: 0; + margin-top: 2px; + margin-bottom: 2px; + } + } + &.in-popover { + height: auto; + border: none; + max-height: 50vh; + overflow-y: auto; + cursor: default; + .message-content-container { + padding-left: 0; + } + .MessageFooter { + margin-left: 0; + } } &.fold { display: none; @@ -330,38 +367,6 @@ const MessageWrapper = styled(Scrollbar)` display: inline-block; } } - - ${({ $layout, $isGrouped }) => { - if ($layout === 'horizontal' && $isGrouped) { - return css` - border: 0.5px solid var(--color-border); - padding: 10px; - border-radius: 6px; - max-height: 600px; - margin-bottom: 10px; - ` - } - return '' - }} - - ${({ $layout, $isInPopover, $isGrouped }) => { - // 如果布局是grid,并且是组消息,则设置最大高度和溢出行为(卡片不可滚动,点击展开后可滚动) - // 如果布局是horizontal,则设置溢出行为(卡片可滚动) - // 如果布局是fold、vertical,高度不限制,与正常消息流布局一致,则设置卡片不可滚动(visible) - return $layout === 'grid' && $isGrouped - ? css` - max-height: ${$isInPopover ? '50vh' : '300px'}; - overflow-y: ${$isInPopover ? 'auto' : 'hidden'}; - border: 0.5px solid ${$isInPopover ? 'transparent' : 'var(--color-border)'}; - padding: 10px; - border-radius: 6px; - background-color: var(--color-background); - ` - : css` - overflow-y: ${$layout === 'horizontal' ? 'auto' : 'visible'}; - border-radius: 6px; - ` - }} ` export default memo(MessageGroup) diff --git a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx index bd639eb472..6c2e7766d7 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx @@ -59,6 +59,7 @@ const MessageGroupMenuBar: FC = ({ {['fold', 'vertical', 'horizontal', 'grid'].map((layout) => ( ` flex-direction: row; align-items: center; gap: 10px; - margin: 0 20px; - padding: 6px 10px; - border-radius: 6px; - margin-top: 10px; + padding: 8px; + border-radius: 10px; + margin: 8px 10px 16px; justify-content: space-between; overflow: hidden; border: 0.5px solid var(--color-border); height: 40px; - background-color: var(--color-background); ` const LayoutContainer = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx b/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx index 8fe085aa2f..c185d509f1 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx @@ -6,8 +6,10 @@ import { useSettings } from '@renderer/hooks/useSettings' import { useAppDispatch } from '@renderer/store' import { setFoldDisplayMode } from '@renderer/store/settings' import type { Model } from '@renderer/types' -import type { Message } from '@renderer/types/newMessage' +import { AssistantMessageStatus, type Message } from '@renderer/types/newMessage' +import { lightbulbSoftVariants } from '@renderer/utils/motionVariants' import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd' +import { motion } from 'motion/react' import { FC, memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -26,50 +28,62 @@ const MessageGroupModelList: FC = ({ messages, selec const { foldDisplayMode } = useSettings() const isCompact = foldDisplayMode === 'compact' + const isMessageProcessing = useCallback((message: Message) => { + return [ + AssistantMessageStatus.PENDING, + AssistantMessageStatus.PROCESSING, + AssistantMessageStatus.SEARCHING + ].includes(message.status as AssistantMessageStatus) + }, []) + const renderLabel = useCallback( (message: Message) => { const modelTip = message.model?.name + const isProcessing = isMessageProcessing(message) if (isCompact) { return ( - + { setSelectedMessage(message) }}> - + + + ) } return ( - + {message.model?.name} ) }, - [isCompact, selectMessageId, setSelectedMessage] + [isCompact, isMessageProcessing, selectMessageId, setSelectedMessage] ) return ( - dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))}> - + + dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))}> {isCompact ? : } - - - + + {isCompact ? ( /* Compact style display */ diff --git a/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx b/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx index 0b52c4168e..208a75f952 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx @@ -1,10 +1,11 @@ import { SettingOutlined } from '@ant-design/icons' +import Selector from '@renderer/components/Selector' import { useSettings } from '@renderer/hooks/useSettings' import { SettingDivider } from '@renderer/pages/settings' import { SettingRow } from '@renderer/pages/settings' import { useAppDispatch } from '@renderer/store' import { setGridColumns, setGridPopoverTrigger } from '@renderer/store/settings' -import { Col, Row, Select, Slider } from 'antd' +import { Col, Row, Slider } from 'antd' import { Popover } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,19 +19,21 @@ const MessageGroupSettings: FC = () => { return ( +
{t('settings.messages.grid_popover_trigger')}
- + options={[ + { label: t('settings.messages.grid_popover_trigger.hover'), value: 'hover' }, + { label: t('settings.messages.grid_popover_trigger.click'), value: 'click' } + ]} + />
diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index 285cfd516f..465ca923b8 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -4,16 +4,17 @@ import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env' import { getModelLogo } from '@renderer/config/models' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelName } from '@renderer/services/ModelService' -import type { Assistant, Model } from '@renderer/types' +import type { Assistant, Model, Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { firstLetter, isEmoji, removeLeadingEmoji } from '@renderer/utils' -import { Avatar } from 'antd' +import { Avatar, Checkbox } from 'antd' import dayjs from 'dayjs' -import { CSSProperties, FC, memo, useCallback, useMemo } from 'react' +import { FC, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -24,6 +25,7 @@ interface Props { assistant: Assistant model?: Model index: number | undefined + topic: Topic } const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => { @@ -31,7 +33,7 @@ const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => { return modelId ? getModelLogo(modelId) : undefined } -const MessageHeader: FC = memo(({ assistant, model, message, index }) => { +const MessageHeader: FC = memo(({ assistant, model, message, index, topic }) => { const avatar = useAvatar() const { theme } = useTheme() const { userName, sidebarIcons } = useSettings() @@ -39,6 +41,10 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => const { isBubbleStyle } = useMessageStyle() const { openMinappById } = useMinappPopup() + const { isMultiSelectMode, selectedMessageIds, handleSelectMessage } = useChatContext(topic) + + const isSelected = selectedMessageIds?.includes(message.id) + const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message]) const getUserName = useCallback(() => { @@ -67,65 +73,54 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [model?.provider, showMinappIcon]) - const avatarStyle: CSSProperties | undefined = isBubbleStyle - ? { - flexDirection: isAssistantMessage ? 'row' : 'row-reverse', - textAlign: isAssistantMessage ? 'left' : 'right' - } - : undefined - - const containerStyle = isBubbleStyle - ? { - justifyContent: isAssistantMessage ? 'flex-start' : 'flex-end' - } - : undefined - return ( - - - {isAssistantMessage ? ( - - {avatarName} - - ) : ( - <> - {isEmoji(avatar) ? ( - UserPopup.show()} size={35} fontSize={20}> - {avatar} - - ) : ( - UserPopup.show()} - /> - )} - - )} - - - {username} - - - {dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')} - {showTokens && | } - - - - + + {isAssistantMessage ? ( + + {avatarName} + + ) : ( + <> + {isEmoji(avatar) ? ( + UserPopup.show()} size={35} fontSize={20}> + {avatar} + + ) : ( + UserPopup.show()} + /> + )} + + )} + + + {username} + + + {dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')} + {showTokens && | } + + + + {isMultiSelectMode && ( + handleSelectMessage(message.id, e.target.checked)} + style={{ position: 'absolute', right: 0, top: 0 }} + /> + )} ) }) @@ -133,23 +128,18 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => MessageHeader.displayName = 'MessageHeader' const Container = styled.div` - display: flex; - flex-direction: row; - align-items: center; - padding-bottom: 4px; -` - -const AvatarWrapper = styled.div` display: flex; flex-direction: row; align-items: center; gap: 10px; + position: relative; ` const UserWrap = styled.div` display: flex; flex-direction: column; justify-content: space-between; + flex: 1; ` const InfoWrap = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index c7e39adf2d..fda0f40a4b 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -1,4 +1,4 @@ -import { CheckOutlined, EditOutlined, MenuOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' +import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import { TranslateLanguageOptions } from '@renderer/config/translate' @@ -29,7 +29,7 @@ import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown' import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' -import { AtSign, Copy, Languages, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react' +import { AtSign, Copy, Languages, ListChecks, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react' import { FilePenLine } from 'lucide-react' import { FC, memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -195,7 +195,7 @@ const MessageMenubar: FC = (props) => { { label: t('chat.multiple.select'), key: 'multi-select', - icon: , + icon: , onClick: () => { toggleMultiSelectMode(true) } @@ -507,8 +507,7 @@ const MessageMenubar: FC = (props) => { e.domEvent.stopPropagation() }} trigger={['click']} - placement="topRight" - arrow> + placement="topRight"> e.stopPropagation()} diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index cae4237ffd..999ddc05ec 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -1,3 +1,4 @@ +import ContextMenu from '@renderer/components/ContextMenu' import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' import Scrollbar from '@renderer/components/Scrollbar' import { LOAD_MORE_COUNT } from '@renderer/config/constant' @@ -16,9 +17,9 @@ import { estimateHistoryTokens } from '@renderer/services/TokenService' import store, { useAppDispatch } from '@renderer/store' import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock' import { newMessagesActions } from '@renderer/store/newMessage' -import { saveMessageAndBlocksToDB } from '@renderer/store/thunk/messageThunk' +import { saveMessageAndBlocksToDB, updateMessageAndBlocksThunk } from '@renderer/store/thunk/messageThunk' import type { Assistant, Topic } from '@renderer/types' -import { type Message, MessageBlockType } from '@renderer/types/newMessage' +import { type Message, MessageBlock, MessageBlockType } from '@renderer/types/newMessage' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, @@ -210,7 +211,15 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o if (msgBlock && isTextLikeBlock(msgBlock) && msgBlock.type !== MessageBlockType.ERROR) { try { const updatedRaw = updateCodeBlock(msgBlock.content, codeBlockId, newContent) + const updatedBlock: MessageBlock = { + ...msgBlock, + content: updatedRaw, + updatedAt: new Date().toISOString() + } + dispatch(updateOneBlock({ id: msgBlockId, changes: { content: updatedRaw } })) + await dispatch(updateMessageAndBlocksThunk(topic.id, null, [updatedBlock])) + window.message.success({ content: t('code_block.edit.save.success'), key: 'save-code' }) } catch (error) { console.error(`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}:`, error) @@ -271,7 +280,6 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o id="messages" className="messages-container" ref={scrollContainerRef} - style={{ position: 'relative', paddingTop: showPrompt ? 10 : 0 }} key={assistant.id} onScroll={handleScrollPosition}> @@ -283,22 +291,25 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o scrollableTarget="messages" inverse style={{ overflow: 'visible' }}> - - {groupedMessages.map(([key, groupMessages]) => ( - - ))} - {isLoadingMore && ( - - - - )} - + + + {groupedMessages.map(([key, groupMessages]) => ( + + ))} + {isLoadingMore && ( + + + + )} + + + {showPrompt && } {messageNavigation === 'anchor' && } @@ -361,6 +372,10 @@ const LoaderContainer = styled.div` const ScrollContainer = styled.div` display: flex; flex-direction: column-reverse; + padding: 20px 10px 20px 16px; + .multi-select-mode & { + padding-bottom: 60px; + } ` interface ContainerProps { @@ -370,11 +385,9 @@ interface ContainerProps { const MessagesContainer = styled(Scrollbar)` display: flex; flex-direction: column-reverse; - padding: 10px 0 20px; overflow-x: hidden; - background-color: var(--color-background); z-index: 1; - margin-right: 2px; + position: relative; ` export default Messages diff --git a/src/renderer/src/pages/home/Messages/NarrowLayout.tsx b/src/renderer/src/pages/home/Messages/NarrowLayout.tsx index b1579b4dc5..6431bb151c 100644 --- a/src/renderer/src/pages/home/Messages/NarrowLayout.tsx +++ b/src/renderer/src/pages/home/Messages/NarrowLayout.tsx @@ -10,7 +10,11 @@ const NarrowLayout: FC = ({ children, ...props }) => { const { narrowMode } = useSettings() if (narrowMode) { - return {children} + return ( + + {children} + + ) } return children diff --git a/src/renderer/src/pages/home/Messages/Prompt.tsx b/src/renderer/src/pages/home/Messages/Prompt.tsx index 1fe67eca43..f0df2a460f 100644 --- a/src/renderer/src/pages/home/Messages/Prompt.tsx +++ b/src/renderer/src/pages/home/Messages/Prompt.tsx @@ -30,11 +30,11 @@ const Prompt: FC = ({ assistant, topic }) => { } const Container = styled.div<{ $isDark: boolean }>` - padding: 10px 20px; - margin: 5px 20px 0 20px; + padding: 10px 16px; border-radius: 10px; cursor: pointer; border: 0.5px solid var(--color-border); + margin: 10px 10px 0 10px; ` const Text = styled.div` diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index cbcd272791..b164e7b492 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -27,10 +27,9 @@ const Assistants: FC = ({ }) => { const { assistants, removeAssistant, addAssistant, updateAssistants } = useAssistants() const [dragging, setDragging] = useState(false) - const [collapsedTags, setCollapsedTags] = useState>({}) const { addAgent } = useAgents() const { t } = useTranslation() - const { getGroupedAssistants } = useTags() + const { getGroupedAssistants, collapsedTags, toggleTagCollapse } = useTags() const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType() const containerRef = useRef(null) @@ -46,13 +45,6 @@ const Assistants: FC = ({ [activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant] ) - const toggleTagCollapse = useCallback((tag: string) => { - setCollapsedTags((prev) => ({ - ...prev, - [tag]: !prev[tag] - })) - }, []) - const handleSortByChange = useCallback( (sortType: AssistantsSortType) => { setAssistantsTabSortType(sortType) @@ -103,7 +95,6 @@ const Assistants: FC = ({ handleGroupReorder(group.tag, newList)} - style={{ paddingBottom: dragging ? '34px' : 0 }} onDragStart={() => setDragging(true)} onDragEnd={() => setDragging(false)}> {(assistant) => ( @@ -141,7 +132,6 @@ const Assistants: FC = ({ setDragging(true)} onDragEnd={() => setDragging(false)}> {(assistant) => ( diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index b3b7a844e7..67c3ba7b97 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -1,6 +1,7 @@ -import { CheckOutlined } from '@ant-design/icons' +import EditableNumber from '@renderer/components/EditableNumber' import { HStack } from '@renderer/components/Layout' import Scrollbar from '@renderer/components/Scrollbar' +import Selector from '@renderer/components/Selector' import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { isOpenAIModel, @@ -38,7 +39,6 @@ import { setPasteLongTextThreshold, setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, - setShowMessageDivider, setShowPrompt, setShowTokens, setShowTranslateConfirm, @@ -54,7 +54,7 @@ import { } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { getSendMessageShortcutLabel } from '@renderer/utils/input' -import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' +import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd' import { CircleHelp, Settings2 } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -86,7 +86,6 @@ const SettingsTab: FC = (props) => { const { showPrompt, - showMessageDivider, messageFont, showInputEstimatedTokens, sendMessageShortcut, @@ -312,20 +311,6 @@ const SettingsTab: FC = (props) => { dispatch(setShowTokens(checked))} /> - - - {t('settings.messages.divider')} - - - - - dispatch(setShowMessageDivider(checked))} - /> - - {t('settings.messages.use_serif_font')} = (props) => { {t('message.message.style')} - dispatch(setMessageStyle(value as 'plain' | 'bubble'))} - style={{ width: 135 }} - size="small"> - {t('message.message.style.plain')} - {t('message.message.style.bubble')} - + options={[ + { value: 'plain', label: t('message.message.style.plain') }, + { value: 'bubble', label: t('message.message.style.bubble') } + ]} + /> {t('message.message.multi_model_style')} - dispatch(setMultiModelMessageStyle(value as 'fold' | 'vertical' | 'horizontal' | 'grid')) } - style={{ width: 135 }}> - {t('message.message.multi_model_style.fold')} - {t('message.message.multi_model_style.vertical')} - {t('message.message.multi_model_style.horizontal')} - {t('message.message.multi_model_style.grid')} - + options={[ + { value: 'fold', label: t('message.message.multi_model_style.fold') }, + { value: 'vertical', label: t('message.message.multi_model_style.vertical') }, + { value: 'horizontal', label: t('message.message.multi_model_style.horizontal') }, + { value: 'grid', label: t('message.message.multi_model_style.grid') } + ]} + /> {t('settings.messages.navigation')} - dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))} - style={{ width: 135 }}> - {t('settings.messages.navigation.none')} - {t('settings.messages.navigation.buttons')} - {t('settings.messages.navigation.anchor')} - + options={[ + { value: 'none', label: t('settings.messages.navigation.none') }, + { value: 'buttons', label: t('settings.messages.navigation.buttons') }, + { value: 'anchor', label: t('settings.messages.navigation.anchor') } + ]} + /> {t('settings.messages.math_engine')} - dispatch(setMathEngine(value as MathEngine))} - style={{ width: 135 }} - size="small"> - KaTeX - MathJax - {t('settings.messages.math_engine.none')} - + options={[ + { value: 'KaTeX', label: 'KaTeX' }, + { value: 'MathJax', label: 'MathJax' }, + { value: 'none', label: t('settings.messages.math_engine.none') } + ]} + /> @@ -430,17 +415,14 @@ const SettingsTab: FC = (props) => { {t('message.message.code_style')} - onCodeStyleChange(value as CodeStyleVarious)} - style={{ width: 135 }} - size="small"> - {themeNames.map((theme) => ( - - {theme} - - ))} - + options={themeNames.map((theme) => ({ + value: theme, + label: theme + }))} + /> @@ -466,7 +448,7 @@ const SettingsTab: FC = (props) => { - = (props) => { {t('settings.messages.input.paste_long_text_threshold')} - = (props) => { {t('settings.input.target_language')} - } + onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)} options={[ { value: 'chinese', label: t('settings.input.target_language.chinese') }, { value: 'chinese-traditional', label: t('settings.input.target_language.chinese-traditional') }, @@ -653,17 +633,14 @@ const SettingsTab: FC = (props) => { { value: 'japanese', label: t('settings.input.target_language.japanese') }, { value: 'russian', label: t('settings.input.target_language.russian') } ]} - onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)} - style={{ width: 135 }} /> {t('settings.messages.input.send_shortcuts')} - } + onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)} options={[ { value: 'Enter', label: getSendMessageShortcutLabel('Enter') }, { value: 'Ctrl+Enter', label: getSendMessageShortcutLabel('Ctrl+Enter') }, @@ -671,8 +648,6 @@ const SettingsTab: FC = (props) => { { value: 'Command+Enter', label: getSendMessageShortcutLabel('Command+Enter') }, { value: 'Shift+Enter', label: getSendMessageShortcutLabel('Shift+Enter') } ]} - onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)} - style={{ width: 135 }} /> @@ -704,12 +679,4 @@ const SettingGroup = styled.div<{ theme?: ThemeMode }>` margin-bottom: 10px; ` -const StyledSelect = styled(Select)` - .ant-select-selector { - border-radius: 15px !important; - padding: 4px 10px !important; - height: 26px !important; - } -` - export default SettingsTab diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 6d370484fb..01a548b8c1 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -127,11 +127,13 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic } await modelGenerating() const index = findIndex(assistant.topics, (t) => t.id === topic.id) - setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1]) + if (topic.id === activeTopic.id) { + setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1]) + } removeTopic(topic) setDeletingTopicId(null) }, - [assistant.topics, onClearMessages, removeTopic, setActiveTopic] + [activeTopic.id, assistant.topics, onClearMessages, removeTopic, setActiveTopic] ) const onPinTopic = useCallback( @@ -471,7 +473,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic {topicName} - {isActive && !topic.pinned && ( + {!topic.pinned && ( = ({ - { setServiceTierMode(value as OpenAIServiceTier) }} - size="small" options={serviceTierOptions} /> @@ -135,6 +134,7 @@ const OpenAISettingsGroup: FC = ({ )} + ) } diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index 37362bd1d6..7d5d251660 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -35,6 +35,11 @@ interface KnowledgeContentProps { const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts] +const getDisplayTime = (item: KnowledgeItem) => { + const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at + return dayjs(timestamp).format('MM-DD HH:mm') +} + const KnowledgeContent: FC = ({ selectedBase }) => { const { t } = useTranslation() const [expandAll, setExpandAll] = useState(false) @@ -335,7 +340,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { ), ext: file.ext, - extra: `${dayjs(file.created_at).format('MM-DD HH:mm')} · ${formatFileSize(file.size)}`, + extra: `${getDisplayTime(item)} · ${formatFileSize(file.size)}`, actions: ( {item.uniqueId && ( @@ -392,7 +397,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { ), ext: '.folder', - extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`, + extra: getDisplayTime(item), actions: ( {item.uniqueId && -
+
= ({ base: _base, resolve }) => { const TopViewKey = 'KnowledgeSettingsPopup' -const AdvancedSettingsButton = styled.div` - cursor: pointer; - margin-bottom: 16px; - margin-top: -10px; - color: var(--color-primary); - display: flex; - align-items: center; -` - export default class KnowledgeSettingsPopup { static hide() { TopView.hide(TopViewKey) diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 82f11ec7d4..50832da2e6 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -10,7 +10,8 @@ import { useAppDispatch } from '@renderer/store' import { setUpdateState } from '@renderer/store/runtime' import { ThemeMode } from '@renderer/types' import { compareVersions, runAsyncFunction } from '@renderer/utils' -import { Avatar, Button, Progress, Row, Switch, Tag, Tooltip } from 'antd' +import { UpgradeChannel } from '@shared/config/constant' +import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd' import { debounce } from 'lodash' import { Bug, FileCheck, Github, Globe, Mail, Rss } from 'lucide-react' import { FC, useEffect, useState } from 'react' @@ -25,7 +26,7 @@ const AboutSettings: FC = () => { const [version, setVersion] = useState('') const [isPortable, setIsPortable] = useState(false) const { t } = useTranslation() - const { autoCheckUpdate, setAutoCheckUpdate, earlyAccess, setEarlyAccess } = useSettings() + const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings() const { theme } = useTheme() const dispatch = useAppDispatch() const { update } = useRuntime() @@ -95,15 +96,82 @@ const AboutSettings: FC = () => { const hasNewVersion = update?.info?.version && version ? compareVersions(update.info.version, version) > 0 : false + const currentChannelByVersion = + [ + { pattern: `-${UpgradeChannel.BETA}.`, channel: UpgradeChannel.BETA }, + { pattern: `-${UpgradeChannel.RC}.`, channel: UpgradeChannel.RC } + ].find(({ pattern }) => version.includes(pattern))?.channel || UpgradeChannel.LATEST + + useEffect(() => { + if (testPlan && currentChannelByVersion !== UpgradeChannel.LATEST && testChannel !== currentChannelByVersion) { + window.message.warning(t('settings.general.test_plan.version_channel_not_match')) + } + }, [testPlan, testChannel, currentChannelByVersion, t]) + + const handleTestChannelChange = async (value: UpgradeChannel) => { + setTestChannel(value) + // Clear update info when switching upgrade channel + dispatch( + setUpdateState({ + available: false, + info: null, + downloaded: false, + checking: false, + downloading: false, + downloadProgress: 0 + }) + ) + } + + // Get available test version options based on current version + const getAvailableTestChannels = () => { + return [ + { + tooltip: t('settings.general.test_plan.rc_version_tooltip'), + label: t('settings.general.test_plan.rc_version'), + value: UpgradeChannel.RC + }, + { + tooltip: t('settings.general.test_plan.beta_version_tooltip'), + label: t('settings.general.test_plan.beta_version'), + value: UpgradeChannel.BETA + } + ] + } + + const handleSetTestPlan = (value: boolean) => { + setTestPlan(value) + dispatch( + setUpdateState({ + available: false, + info: null, + downloaded: false, + checking: false, + downloading: false, + downloadProgress: 0 + }) + ) + + if (value === true) { + setTestChannel(getTestChannel()) + } + } + + const getTestChannel = () => { + if (testChannel === UpgradeChannel.LATEST) { + return UpgradeChannel.RC + } + return testChannel + } + useEffect(() => { runAsyncFunction(async () => { const appInfo = await window.api.getAppInfo() setVersion(appInfo.version) setIsPortable(appInfo.isPortable) }) - setEarlyAccess(earlyAccess) setAutoCheckUpdate(autoCheckUpdate) - }, [autoCheckUpdate, earlyAccess, setAutoCheckUpdate, setEarlyAccess]) + }, [autoCheckUpdate, setAutoCheckUpdate]) return ( @@ -165,11 +233,30 @@ const AboutSettings: FC = () => { - {t('settings.general.early_access.title')} - - setEarlyAccess(v)} /> + {t('settings.general.test_plan.title')} + + handleSetTestPlan(v)} /> + {testPlan && ( + <> + + + {t('settings.general.test_plan.version_options')} + handleTestChannelChange(e.target.value)}> + {getAvailableTestChannels().map((option) => ( + + {option.label} + + ))} + + + + )} )} diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx index 169ed3ffd5..a593f41cbe 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx @@ -3,7 +3,7 @@ import { Box } from '@renderer/components/Layout' import { useAppSelector } from '@renderer/store' import { Assistant, AssistantSettings } from '@renderer/types' import { Row, Segmented, Select, SelectProps, Tooltip } from 'antd' -import { CircleHelp } from 'lucide-react' +import { ChevronDown, CircleHelp } from 'lucide-react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -46,6 +46,7 @@ const AssistantKnowledgeBaseSettings: React.FC = ({ assistant, updateAssi .toLowerCase() .includes(input.toLowerCase()) } + suffixIcon={} /> diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 1a22848ce5..31e44abfbb 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -1,13 +1,16 @@ import { DeleteOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import EditableNumber from '@renderer/components/EditableNumber' import { HStack } from '@renderer/components/Layout' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' +import Selector from '@renderer/components/Selector' import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { SettingRow } from '@renderer/pages/settings' import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' import { isNull } from 'lodash' +import { ChevronDown } from 'lucide-react' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -107,9 +110,15 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA ) case 'boolean': return ( - onUpdateCustomParameter(index, 'value', checked)} + { setToolUseMode(value) updateAssistantSettings({ toolUseMode: value }) - }}> - {t('assistants.settings.tool_use_mode.prompt')} - {t('assistants.settings.tool_use_mode.function')} - + }} + size={14} + /> @@ -409,20 +433,26 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(e) => onUpdateCustomParameter(index, 'name', e.target.value)} /> -
+ - {renderParameterValueInput(param, index)} + {renderParameterValueInput(param, index)} - + {emoji && ( = ({ resolve, tab, ...prop styles={{ content: { padding: 0, - overflow: 'hidden', - background: 'var(--color-background)' + overflow: 'hidden' }, - header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0 } + header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0, borderRadius: 0 }, + body: { + padding: 0 + } }} - width="70vw" + width="min(800px, 70vw)" height="80vh" centered> @@ -145,15 +147,14 @@ const AssistantSettingPopupContainer: React.FC = ({ resolve, tab, ...prop } const LeftMenu = styled.div` - background-color: var(--color-background); height: calc(80vh - 20px); border-right: 0.5px solid var(--color-border); ` const Settings = styled.div` flex: 1; - padding: 10px 20px; - height: calc(80vh - 20px); + padding: 16px 16px; + height: calc(80vh - 16px); overflow-y: scroll; ` @@ -163,6 +164,7 @@ const StyledModal = styled(Modal)` } .ant-modal-close { top: 4px; + right: 4px; } .ant-menu-item { height: 36px; diff --git a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx index eb37f41737..f4e76fadd9 100755 --- a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx @@ -39,7 +39,6 @@ const AgentsSubscribeUrlSettings: FC = () => { /> - ) } diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 5a80545345..1ef101157c 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -1,4 +1,5 @@ import { + CloudServerOutlined, CloudSyncOutlined, FileSearchOutlined, FolderOpenOutlined, @@ -19,6 +20,7 @@ import store, { useAppDispatch } from '@renderer/store' import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings' import { AppInfo } from '@renderer/types' import { formatFileSize } from '@renderer/utils' +import { occupiedDirs } from '@shared/config/constant' import { Button, Progress, Switch, Typography } from 'antd' import { FileText, FolderCog, FolderInput, Sparkle } from 'lucide-react' import { FC, useEffect, useState } from 'react' @@ -41,6 +43,7 @@ import MarkdownExportSettings from './MarkdownExportSettings' import NotionSettings from './NotionSettings' import NutstoreSettings from './NutstoreSettings' import ObsidianSettings from './ObsidianSettings' +import S3Settings from './S3Settings' import SiyuanSettings from './SiyuanSettings' import WebDavSettings from './WebDavSettings' import YuqueSettings from './YuqueSettings' @@ -87,6 +90,7 @@ const DataSettings: FC = () => { { key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') }, { key: 'webdav', title: 'settings.data.webdav.title', icon: }, { key: 'nutstore', title: 'settings.data.nutstore.title', icon: }, + { key: 's3', title: 'settings.data.s3.title', icon: }, { key: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') }, { key: 'export_menu', @@ -202,6 +206,18 @@ const DataSettings: FC = () => { return } + // check new app data path is not in old app data path + if (newAppDataPath.startsWith(appInfo.appDataPath)) { + window.message.error(t('settings.data.app_data.select_error_same_path')) + return + } + + // check new app data path is not in app install path + if (newAppDataPath.startsWith(appInfo.installPath)) { + window.message.error(t('settings.data.app_data.select_error_in_app_path')) + return + } + // check new app data path has write permission const hasWritePermission = await window.api.hasWritePermission(newAppDataPath) if (!hasWritePermission) { @@ -213,22 +229,39 @@ const DataSettings: FC = () => {
{t('settings.data.app_data.migration_title')}
) const migrationClassName = 'migration-modal' - const messageKey = 'data-migration' + showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName) + } - // 显示确认对话框 - showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName, messageKey) + const doubleConfirmModalBeforeCopyData = (newPath: string) => { + window.modal.confirm({ + title: t('settings.data.app_data.select_not_empty_dir'), + content: t('settings.data.app_data.select_not_empty_dir_content'), + centered: true, + okText: t('common.confirm'), + cancelText: t('common.cancel'), + onOk: () => { + window.message.info({ + content: t('settings.data.app_data.restart_notice'), + duration: 2 + }) + setTimeout(() => { + window.api.relaunchApp({ + args: ['--new-data-path=' + newPath] + }) + }, 500) + } + }) } // 显示确认迁移的对话框 - const showMigrationConfirmModal = ( + const showMigrationConfirmModal = async ( originalPath: string, newPath: string, title: React.ReactNode, - className: string, - messageKey: string + className: string ) => { // 复制数据选项状态 - let shouldCopyData = true + let shouldCopyData = !(await window.api.isNotEmptyDir(newPath)) // 创建路径内容组件 const PathsContent = () => ( @@ -248,7 +281,7 @@ const DataSettings: FC = () => {
{ shouldCopyData = checked }} @@ -262,7 +295,7 @@ const DataSettings: FC = () => { ) // 显示确认模态框 - const modal = window.modal.confirm({ + window.modal.confirm({ title, className, width: 'min(600px, 90vw)', @@ -287,30 +320,26 @@ const DataSettings: FC = () => { cancelText: t('common.cancel'), onOk: async () => { try { - // 立即关闭确认对话框 - modal.destroy() - - // 设置停止退出应用 - window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason')) - if (shouldCopyData) { - // 如果选择复制数据,显示进度模态框并执行迁移 - const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent) - - try { - await startMigration(originalPath, newPath, progressInterval, updateProgress, loadingModal, messageKey) - } catch (error) { - if (progressInterval) { - clearInterval(progressInterval) - } - loadingModal.destroy() - throw error + if (await window.api.isNotEmptyDir(newPath)) { + doubleConfirmModalBeforeCopyData(newPath) + return } - } else { - // 如果不复制数据,直接设置新的应用数据路径 - await window.api.setAppDataPath(newPath) - window.message.success(t('settings.data.app_data.path_changed_without_copy')) + + window.message.info({ + content: t('settings.data.app_data.restart_notice'), + duration: 3 + }) + setTimeout(() => { + window.api.relaunchApp({ + args: ['--new-data-path=' + newPath] + }) + }, 500) + return } + // 如果不复制数据,直接设置新的应用数据路径 + await window.api.setAppDataPath(newPath) + window.message.success(t('settings.data.app_data.path_changed_without_copy')) // 更新应用数据路径 setAppInfo(await window.api.getAppInfo()) @@ -320,16 +349,11 @@ const DataSettings: FC = () => { window.message.success(t('settings.data.app_data.select_success')) window.api.setStopQuitApp(false, '') window.api.relaunchApp() - }, 1000) + }, 500) } catch (error) { window.api.setStopQuitApp(false, '') window.message.error({ - content: - (shouldCopyData - ? t('settings.data.app_data.copy_failed') - : t('settings.data.app_data.path_change_failed')) + - ': ' + - error, + content: t('settings.data.app_data.path_change_failed') + ': ' + error, duration: 5 }) } @@ -337,123 +361,195 @@ const DataSettings: FC = () => { }) } - // 显示进度模态框 - const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => { - let currentProgress = 0 - let progressInterval: NodeJS.Timeout | null = null + useEffect(() => { + const handleDataMigration = async () => { + const newDataPath = await window.api.getDataPathFromArgs() + if (!newDataPath) return - // 创建进度更新模态框 - const loadingModal = window.modal.info({ - title, - className, - width: 'min(600px, 90vw)', - style: { minHeight: '400px' }, - icon: , - content: ( - - - -

{t('settings.data.app_data.copying')}

-
- -
-

- {t('settings.data.app_data.copying_warning')} -

-
-
- ), - centered: true, - closable: false, - maskClosable: false, - okButtonProps: { style: { display: 'none' } } - }) + const originalPath = (await window.api.getAppInfo())?.appDataPath + if (!originalPath) return - // 更新进度的函数 - const updateProgress = (progress: number, status: 'active' | 'success' = 'active') => { - loadingModal.update({ - title, - content: ( - - - -

{t('settings.data.app_data.copying')}

-
- -
-

- {t('settings.data.app_data.copying_warning')} -

-
-
- ) - }) - } + const title = ( +
{t('settings.data.app_data.migration_title')}
+ ) + const className = 'migration-modal' + const messageKey = 'data-migration' - // 开始模拟进度更新 - progressInterval = setInterval(() => { - if (currentProgress < 95) { - currentProgress += Math.random() * 5 + 1 - if (currentProgress > 95) currentProgress = 95 - updateProgress(currentProgress) - } - }, 500) + // 显示进度模态框 + const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => { + let currentProgress = 0 + let progressInterval: NodeJS.Timeout | null = null - return { loadingModal, progressInterval, updateProgress } - } + // 创建进度更新模态框 + const loadingModal = window.modal.info({ + title, + className, + width: 'min(600px, 90vw)', + style: { minHeight: '400px' }, + icon: , + content: ( + + + +

{t('settings.data.app_data.copying')}

+
+ +
+

+ {t('settings.data.app_data.copying_warning')} +

+
+
+ ), + centered: true, + closable: false, + maskClosable: false, + okButtonProps: { style: { display: 'none' } } + }) - // 开始迁移数据 - const startMigration = async ( - originalPath: string, - newPath: string, - progressInterval: NodeJS.Timeout | null, - updateProgress: (progress: number, status?: 'active' | 'success') => void, - loadingModal: { destroy: () => void }, - messageKey: string - ): Promise => { - // 开始复制过程 - const copyResult = await window.api.copy(originalPath, newPath) - - // 停止进度更新 - if (progressInterval) { - clearInterval(progressInterval) - } - - // 显示100%完成 - updateProgress(100, 'success') - - if (!copyResult.success) { - // 延迟关闭加载模态框 - await new Promise((resolve) => { - setTimeout(() => { - loadingModal.destroy() - window.message.error({ - content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error, - key: messageKey, - duration: 5 + // 更新进度的函数 + const updateProgress = (progress: number, status: 'active' | 'success' = 'active') => { + loadingModal.update({ + title, + content: ( + + + +

{t('settings.data.app_data.copying')}

+
+ +
+

+ {t('settings.data.app_data.copying_warning')} +

+
+
+ ) }) - resolve() - }, 500) - }) + } - throw new Error(copyResult.error || 'Unknown error during copy') + // 开始模拟进度更新 + progressInterval = setInterval(() => { + if (currentProgress < 95) { + currentProgress += Math.random() * 5 + 1 + if (currentProgress > 95) currentProgress = 95 + updateProgress(currentProgress) + } + }, 500) + + return { loadingModal, progressInterval, updateProgress } + } + + // 开始迁移数据 + const startMigration = async ( + originalPath: string, + newPath: string, + progressInterval: NodeJS.Timeout | null, + updateProgress: (progress: number, status?: 'active' | 'success') => void, + loadingModal: { destroy: () => void }, + messageKey: string + ): Promise => { + // flush app data + await window.api.flushAppData() + + // wait 2 seconds to flush app data + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // 开始复制过程 + const copyResult = await window.api.copy( + originalPath, + newPath, + occupiedDirs.map((dir) => originalPath + '/' + dir) + ) + + // 停止进度更新 + if (progressInterval) { + clearInterval(progressInterval) + } + + // 显示100%完成 + updateProgress(100, 'success') + + if (!copyResult.success) { + // 延迟关闭加载模态框 + await new Promise((resolve) => { + setTimeout(() => { + loadingModal.destroy() + window.message.error({ + content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error, + key: messageKey, + duration: 5 + }) + resolve() + }, 500) + }) + + throw new Error(copyResult.error || 'Unknown error during copy') + } + + // 在复制成功后设置新的AppDataPath + await window.api.setAppDataPath(newPath) + + // 短暂延迟以显示100%完成 + await new Promise((resolve) => setTimeout(resolve, 500)) + + // 关闭加载模态框 + loadingModal.destroy() + + window.message.success({ + content: t('settings.data.app_data.copy_success'), + key: messageKey, + duration: 2 + }) + } + + // Create PathsContent component for this specific migration + const PathsContent = () => ( +
+ + {t('settings.data.app_data.original_path')}: + {originalPath} + + + {t('settings.data.app_data.new_path')}: + {newDataPath} + +
+ ) + + const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent) + try { + window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason')) + await startMigration(originalPath, newDataPath, progressInterval, updateProgress, loadingModal, messageKey) + + // 更新应用数据路径 + setAppInfo(await window.api.getAppInfo()) + + // 通知用户并重启应用 + setTimeout(() => { + window.message.success(t('settings.data.app_data.select_success')) + window.api.setStopQuitApp(false, '') + window.api.relaunchApp({ + args: ['--user-data-dir=' + newDataPath] + }) + }, 1000) + } catch (error) { + window.api.setStopQuitApp(false, '') + window.message.error({ + content: t('settings.data.app_data.copy_failed') + ': ' + error, + key: messageKey, + duration: 5 + }) + } finally { + if (progressInterval) { + clearInterval(progressInterval) + } + loadingModal.destroy() + } } - // 在复制成功后设置新的AppDataPath - await window.api.setAppDataPath(newPath) - - // 短暂延迟以显示100%完成 - await new Promise((resolve) => setTimeout(resolve, 500)) - - // 关闭加载模态框 - loadingModal.destroy() - - window.message.success({ - content: t('settings.data.app_data.copy_success'), - key: messageKey, - duration: 2 - }) - } + handleDataMigration() + }, [t]) const onSkipBackupFilesChange = (value: boolean) => { setSkipBackupFile(value) @@ -560,6 +656,7 @@ const DataSettings: FC = () => { )} {menu === 'webdav' && } {menu === 'nutstore' && } + {menu === 's3' && } {menu === 'export_menu' && } {menu === 'markdown_export' && } {menu === 'notion' && } @@ -593,8 +690,12 @@ const MenuList = styled.div` gap: 5px; width: var(--settings-width); padding: 12px; + padding-bottom: 48px; border-right: 0.5px solid var(--color-border); - height: 100%; + height: 100vh; + overflow: auto; + box-sizing: border-box; + min-height: 0; .iconfont { color: var(--color-text-2); line-height: 16px; diff --git a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx index 3574c808d1..32b3148541 100644 --- a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setJoplinExportReasoning, setJoplinToken, setJoplinUrl } from '@renderer/store/settings' -import { Button, Switch, Tooltip } from 'antd' +import { Button, Space, Switch, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -106,14 +106,15 @@ const JoplinSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx index 719a2363d7..26e8d0872a 100644 --- a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx @@ -10,7 +10,7 @@ import { setNotionExportReasoning, setNotionPageNameKey } from '@renderer/store/settings' -import { Button, Switch, Tooltip } from 'antd' +import { Button, Space, Switch, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -121,15 +121,16 @@ const NotionSettings: FC = () => { {t('settings.data.notion.api_key')} - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx index 15207200b4..108452c133 100644 --- a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx @@ -1,6 +1,7 @@ import { CheckOutlined, FolderOutlined, LoadingOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import NutstorePathPopup from '@renderer/components/Popups/NutsorePathPopup' +import Selector from '@renderer/components/Selector' import { WebdavBackupManager } from '@renderer/components/WebdavBackupManager' import { useWebdavBackupModal, WebdavBackupModal } from '@renderer/components/WebdavModals' import { useTheme } from '@renderer/context/ThemeProvider' @@ -23,7 +24,7 @@ import { } from '@renderer/store/nutstore' import { modalConfirm } from '@renderer/utils' import { NUTSTORE_HOST } from '@shared/config/nutstore' -import { Button, Input, Select, Switch, Tooltip, Typography } from 'antd' +import { Button, Input, Switch, Tooltip, Typography } from 'antd' import dayjs from 'dayjs' import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -279,18 +280,23 @@ const NutstoreSettings: FC = () => { {t('settings.data.webdav.autoSync')} - + {nutstoreAutoSync && syncInterval > 0 && ( <> diff --git a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx new file mode 100644 index 0000000000..d3c582a431 --- /dev/null +++ b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx @@ -0,0 +1,276 @@ +import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import { S3BackupManager } from '@renderer/components/S3BackupManager' +import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals' +import { useTheme } from '@renderer/context/ThemeProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { S3Config, setS3 } from '@renderer/store/settings' +import { Button, Input, Select, Switch, Tooltip } from 'antd' +import dayjs from 'dayjs' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..' + +const S3Settings: FC = () => { + const { s3 = {} as S3Config } = useSettings() + + const { + endpoint: s3EndpointInit = '', + region: s3RegionInit = '', + bucket: s3BucketInit = '', + accessKeyId: s3AccessKeyIdInit = '', + secretAccessKey: s3SecretAccessKeyInit = '', + root: s3RootInit = '', + syncInterval: s3SyncIntervalInit = 0, + maxBackups: s3MaxBackupsInit = 5, + skipBackupFile: s3SkipBackupFileInit = false + } = s3 + + const [endpoint, setEndpoint] = useState(s3EndpointInit) + const [region, setRegion] = useState(s3RegionInit) + const [bucket, setBucket] = useState(s3BucketInit) + const [accessKeyId, setAccessKeyId] = useState(s3AccessKeyIdInit) + const [secretAccessKey, setSecretAccessKey] = useState(s3SecretAccessKeyInit) + const [root, setRoot] = useState(s3RootInit) + const [skipBackupFile, setSkipBackupFile] = useState(s3SkipBackupFileInit) + const [backupManagerVisible, setBackupManagerVisible] = useState(false) + + const [syncInterval, setSyncInterval] = useState(s3SyncIntervalInit) + const [maxBackups, setMaxBackups] = useState(s3MaxBackupsInit) + + const dispatch = useAppDispatch() + const { theme } = useTheme() + const { t } = useTranslation() + + const { s3Sync } = useAppSelector((state) => state.backup) + + const onSyncIntervalChange = (value: number) => { + setSyncInterval(value) + dispatch(setS3({ ...s3, syncInterval: value, autoSync: value !== 0 })) + if (value === 0) { + stopAutoSync() + } else { + startAutoSync() + } + } + + const onMaxBackupsChange = (value: number) => { + setMaxBackups(value) + dispatch(setS3({ ...s3, maxBackups: value })) + } + + const onSkipBackupFilesChange = (value: boolean) => { + setSkipBackupFile(value) + dispatch(setS3({ ...s3, skipBackupFile: value })) + } + + const renderSyncStatus = () => { + if (!endpoint) return null + + if (!s3Sync?.lastSyncTime && !s3Sync?.syncing && !s3Sync?.lastSyncError) { + return {t('settings.data.s3.syncStatus.noSync')} + } + + return ( + + {s3Sync?.syncing && } + {!s3Sync?.syncing && s3Sync?.lastSyncError && ( + + + + )} + {s3Sync?.lastSyncTime && ( + + {t('settings.data.s3.syncStatus.lastSync', { time: dayjs(s3Sync.lastSyncTime).format('HH:mm:ss') })} + + )} + + ) + } + + const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } = + useS3BackupModal() + + const showBackupManager = () => { + setBackupManagerVisible(true) + } + + const closeBackupManager = () => { + setBackupManagerVisible(false) + } + + return ( + + {t('settings.data.s3.title')} + {t('settings.data.s3.title.help')} + + + {t('settings.data.s3.endpoint')} + setEndpoint(e.target.value)} + style={{ width: 250 }} + type="url" + onBlur={() => dispatch(setS3({ ...s3, endpoint: endpoint || '' }))} + /> + + + + {t('settings.data.s3.region')} + setRegion(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, region: region || '' }))} + /> + + + + {t('settings.data.s3.bucket')} + setBucket(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, bucket: bucket || '' }))} + /> + + + + {t('settings.data.s3.accessKeyId')} + setAccessKeyId(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, accessKeyId: accessKeyId || '' }))} + /> + + + + {t('settings.data.s3.secretAccessKey')} + setSecretAccessKey(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, secretAccessKey: secretAccessKey || '' }))} + /> + + + + {t('settings.data.s3.root')} + setRoot(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, root: root || '' }))} + /> + + + + {t('settings.data.s3.backup.operation')} + + + + + + + + {t('settings.data.s3.autoSync')} + + + + + {t('settings.data.s3.maxBackups')} + + + + + {t('settings.data.s3.skipBackupFile')} + + + + {t('settings.data.s3.skipBackupFile.help')} + + {syncInterval > 0 && ( + <> + + + {t('settings.data.s3.syncStatus')} + {renderSyncStatus()} + + + )} + <> + + + + + + ) +} + +export default S3Settings diff --git a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx index 3ba6673eea..2681f13053 100644 --- a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings' -import { Button, Tooltip } from 'antd' +import { Button, Space, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -108,14 +108,15 @@ const SiyuanSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 8e2a7e5aa2..54db33f024 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -1,5 +1,6 @@ import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' +import Selector from '@renderer/components/Selector' import { WebdavBackupManager } from '@renderer/components/WebdavBackupManager' import { useWebdavBackupModal, WebdavBackupModal } from '@renderer/components/WebdavModals' import { useTheme } from '@renderer/context/ThemeProvider' @@ -16,7 +17,7 @@ import { setWebdavSyncInterval as _setWebdavSyncInterval, setWebdavUser as _setWebdavUser } from '@renderer/store/settings' -import { Button, Input, Select, Switch, Tooltip } from 'antd' +import { Button, Input, Switch, Tooltip } from 'antd' import dayjs from 'dayjs' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -173,31 +174,43 @@ const WebDavSettings: FC = () => { {t('settings.data.webdav.autoSync')} - + {t('settings.data.webdav.maxBackups')} - + diff --git a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx index 72a629e55b..60a8d6ef7c 100644 --- a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings' -import { Button, Tooltip } from 'antd' +import { Button, Space, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -100,14 +100,15 @@ const YuqueSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 48c8dcbe92..27453ef1cd 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -196,7 +196,7 @@ const DisplaySettings: FC = () => { value={userTheme.colorPrimary} onChange={(color) => handleColorPrimaryChange(color.toHexString())} showText - style={{ width: '110px' }} + size="small" presets={[ { label: 'Presets', @@ -222,13 +222,15 @@ const DisplaySettings: FC = () => { {t('settings.zoom.title')} - + {emoji && ( { justifyContent: 'space-between' }}> {t('settings.assistant.model_params')} - @@ -156,7 +156,7 @@ const AssistantSettings: FC = () => { -
+ { step={0.01} /> - + { - + { step={0.01} /> - + @@ -207,7 +207,7 @@ const AssistantSettings: FC = () => { - + { step={1} /> - + { /> - + @@ -255,7 +255,7 @@ const AssistantSettings: FC = () => { onUpdateAssistantSettings({ enableMaxTokens: enabled }) }} /> - + {enableMaxTokens && ( @@ -307,7 +307,7 @@ const PopupContainer: React.FC = ({ resolve }) => { afterClose={onClose} transitionName="animation-move-down" centered - width={800} + width={500} footer={null}> diff --git a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx index fbfc8bb05b..cf6dd7c41f 100644 --- a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx @@ -16,7 +16,7 @@ import { setTranslateModelPrompt } from '@renderer/store/settings' import { Model } from '@renderer/types' import { Button, Select, Tooltip } from 'antd' import { find, sortBy } from 'lodash' -import { CircleHelp, FolderPen, Languages, MessageSquareMore, Rocket, Settings2 } from 'lucide-react' +import { ChevronDown, CircleHelp, FolderPen, Languages, MessageSquareMore, Rocket, Settings2 } from 'lucide-react' import { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -104,6 +104,7 @@ const ModelSettings: FC = () => { options={selectOptions} showSearch placeholder={t('settings.models.empty')} + suffixIcon={} /> {showMoreSettings && ( -
- - {t('models.type.select')} +
+ + {t('models.type.select')}: {(() => { const defaultTypes = [ ...(isVisionModel(model) ? ['vision'] : []), @@ -235,6 +238,7 @@ const ModelEditContent: FC = ({ model, onUpdateModel, ope } }} dropdownMatchSelectWidth={false} + suffixIcon={} /> @@ -281,32 +285,9 @@ const ModelEditContent: FC = ({ model, onUpdateModel, ope } const TypeTitle = styled.div` - margin-top: 16px; - margin-bottom: 12px; + margin: 12px 0; font-size: 14px; font-weight: 600; ` -const ExpandIcon = styled.div` - font-size: 12px; - color: var(--color-text-3); -` - -const MoreSettingsRow = styled.div` - display: flex; - align-items: center; - gap: 8px; - color: var(--color-text-3); - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - - &:hover { - background-color: var(--color-background-soft); - } -` - export default ModelEditContent diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx index 4947b00336..0bbebf57e9 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx @@ -1,4 +1,4 @@ -import { isWindows } from '@renderer/config/constant' +import { isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant' import { FilterMode, TriggerMode } from '@renderer/types/selectionTypes' @@ -50,10 +50,11 @@ const SelectionAssistantSettings: FC = () => { setFilterList } = useSelectionAssistant() const [isFilterListModalOpen, setIsFilterListModalOpen] = useState(false) + const [opacityValue, setOpacityValue] = useState(actionWindowOpacity) // force disable selection assistant on non-windows systems useEffect(() => { - if (!isWindows && selectionEnabled) { + if (!isWin && selectionEnabled) { setSelectionEnabled(false) } }, [selectionEnabled, setSelectionEnabled]) @@ -76,12 +77,12 @@ const SelectionAssistantSettings: FC = () => { {t('selection.settings.enable.title')} - {!isWindows && {t('selection.settings.enable.description')}} + {!isWin && {t('selection.settings.enable.description')}} setSelectionEnabled(checked)} - disabled={!isWindows} + disabled={!isWin} /> @@ -195,14 +196,15 @@ const SelectionAssistantSettings: FC = () => { {t('selection.settings.window.opacity.title')} {t('selection.settings.window.opacity.description')} -
{actionWindowOpacity}%
+
{opacityValue}%
diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index a414dd2212..9c8eec0653 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -1,6 +1,6 @@ import { ClearOutlined, UndoOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' -import { isMac, isWindows } from '@renderer/config/constant' +import { isMac, isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useShortcuts } from '@renderer/hooks/useShortcuts' import { useAppDispatch } from '@renderer/store' @@ -24,7 +24,7 @@ const ShortcutSettings: FC = () => { //if shortcut is not available on all the platforms, block the shortcut here let shortcuts = originalShortcuts - if (!isWindows) { + if (!isWin) { //Selection Assistant only available on Windows now const excludedShortcuts = ['selection_assistant_toggle', 'selection_assistant_select_text'] shortcuts = shortcuts.filter((s) => !excludedShortcuts.includes(s.key)) @@ -86,7 +86,7 @@ const ShortcutSettings: FC = () => { case 'Ctrl': return isMac ? '⌃' : 'Ctrl' case 'Command': - return isMac ? '⌘' : isWindows ? 'Win' : 'Super' + return isMac ? '⌘' : isWin ? 'Win' : 'Super' case 'Alt': return isMac ? '⌥' : 'Alt' case 'Shift': diff --git a/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx b/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx index 12f5e29925..7577ed8c07 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx @@ -1,5 +1,5 @@ import { TopView } from '@renderer/components/TopView' -import { Button, Form, FormProps, Input, Modal } from 'antd' +import { Button, Flex, Form, FormProps, Input, Modal } from 'antd' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -66,7 +66,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { centered>
= ({ title, resolve }) => { - + - + ) diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx index 098c007ca8..f891e2aee1 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx @@ -1,18 +1,16 @@ import { useTheme } from '@renderer/context/ThemeProvider' -import { useAppDispatch, useAppSelector } from '@renderer/store' -import { setContentLimit, setMaxResult, setSearchWithTime } from '@renderer/store/websearch' -import { Input, Slider, Switch, Tooltip } from 'antd' +import { useWebSearchSettings } from '@renderer/hooks/useWebSearchProviders' +import { useAppDispatch } from '@renderer/store' +import { setMaxResult, setSearchWithTime } from '@renderer/store/websearch' +import { Slider, Switch } from 'antd' import { t } from 'i18next' -import { Info } from 'lucide-react' import { FC } from 'react' import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' const BasicSettings: FC = () => { const { theme } = useTheme() - const searchWithTime = useAppSelector((state) => state.websearch.searchWithTime) - const maxResults = useAppSelector((state) => state.websearch.maxResults) - const contentLimit = useAppSelector((state) => state.websearch.contentLimit) + const { searchWithTime, maxResults } = useWebSearchSettings() const dispatch = useAppDispatch() @@ -30,36 +28,14 @@ const BasicSettings: FC = () => { {t('settings.websearch.search_max_result')} dispatch(setMaxResult(value))} /> - - - - {t('settings.websearch.content_limit')} - - - - - { - const value = e.target.value - if (value === '') { - dispatch(setContentLimit(undefined)) - } else if (!isNaN(Number(value)) && Number(value) > 0) { - dispatch(setContentLimit(Number(value))) - } - }} - /> - ) diff --git a/src/renderer/src/pages/settings/WebSearchSettings/CompressionSettings/CutoffSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/CompressionSettings/CutoffSettings.tsx new file mode 100644 index 0000000000..add4024598 --- /dev/null +++ b/src/renderer/src/pages/settings/WebSearchSettings/CompressionSettings/CutoffSettings.tsx @@ -0,0 +1,60 @@ +import { useWebSearchSettings } from '@renderer/hooks/useWebSearchProviders' +import { SettingRow, SettingRowTitle } from '@renderer/pages/settings' +import { Input, Select, Space, Tooltip } from 'antd' +import { ChevronDown, Info } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +const INPUT_BOX_WIDTH = '200px' + +const CutoffSettings = () => { + const { t } = useTranslation() + const { compressionConfig, updateCompressionConfig } = useWebSearchSettings() + + const handleCutoffLimitChange = (value: number | null) => { + updateCompressionConfig({ cutoffLimit: value || undefined }) + } + + const handleCutoffUnitChange = (unit: 'char' | 'token') => { + updateCompressionConfig({ cutoffUnit: unit }) + } + + const unitOptions = [ + { value: 'char', label: t('settings.websearch.compression.cutoff.unit.char') }, + { value: 'token', label: t('settings.websearch.compression.cutoff.unit.token') } + ] + + return ( + + + {t('settings.websearch.compression.cutoff.limit')} + + + + + + { + const value = e.target.value + if (value === '') { + handleCutoffLimitChange(null) + } else if (!isNaN(Number(value)) && Number(value) > 0) { + handleCutoffLimitChange(Number(value)) + } + }} + /> + } + /> + + + + + + {t('models.embedding_dimensions')} + + + + +
+ + +
+
+ + + + {t('models.rerank_model')} + } + /> + + + + {compressionConfig?.method === 'cutoff' && } + {compressionConfig?.method === 'rag' && } + + ) +} + +export default CompressionSettings diff --git a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx index e00eb785e7..ecf2b8375b 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx @@ -1,14 +1,15 @@ +import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import { WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' -import { Select } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' import BasicSettings from './BasicSettings' import BlacklistSettings from './BlacklistSettings' +import CompressionSettings from './CompressionSettings' import WebSearchProviderSetting from './WebSearchProviderSetting' const WebSearchSettings: FC = () => { @@ -37,9 +38,9 @@ const WebSearchSettings: FC = () => { {t('settings.websearch.search_provider')}
- ) }))} + suffixIcon={} /> )} @@ -229,7 +232,7 @@ const TranslatePage: FC = () => { const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese']) const [settingsVisible, setSettingsVisible] = useState(false) const [detectedLanguage, setDetectedLanguage] = useState(null) - const [sourceLanguage, setSourceLanguage] = useState('auto') // 添加用户选择的源语言状态 + const [sourceLanguage, setSourceLanguage] = useState('auto') const contentContainerRef = useRef(null) const textAreaRef = useRef(null) const outputTextRef = useRef(null) @@ -307,8 +310,7 @@ const TranslatePage: FC = () => { let actualSourceLanguage: string if (sourceLanguage === 'auto') { actualSourceLanguage = await detectLanguage(text) - console.log('检测到的语言:', actualSourceLanguage) - setDetectedLanguage(actualSourceLanguage) // 更新检测到的语言 + setDetectedLanguage(actualSourceLanguage) } else { actualSourceLanguage = sourceLanguage } @@ -385,6 +387,9 @@ const TranslatePage: FC = () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) targetLang && setTargetLanguage(targetLang.value) + const sourceLang = await db.settings.get({ id: 'translate:source:language' }) + sourceLang && setSourceLanguage(sourceLang.value) + const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' }) if (bidirectionalPairSetting) { const langPair = bidirectionalPairSetting.value @@ -450,6 +455,7 @@ const TranslatePage: FC = () => { ) }))} + suffixIcon={} /> ) } @@ -526,12 +532,15 @@ const TranslatePage: FC = () => { value={sourceLanguage} style={{ width: 180 }} optionFilterProp="label" - onChange={(value) => setSourceLanguage(value)} + onChange={(value) => { + setSourceLanguage(value) + db.settings.put({ id: 'translate:source:language', value }) + }} options={[ { value: 'auto', label: detectedLanguage - ? `${t('translate.detected.language')}(${t(`languages.${detectedLanguage.toLowerCase()}`)})` + ? `${t('translate.detected.language')} (${t(`languages.${detectedLanguage.toLowerCase()}`)})` : t('translate.detected.language') }, ...translateLanguageOptions().map((lang) => ({ @@ -546,6 +555,7 @@ const TranslatePage: FC = () => { ) })) ]} + suffixIcon={} />