diff --git a/electron-builder.yml b/electron-builder.yml index 077dede2d..1f726d148 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -109,11 +109,9 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - 划词助手:支持文本选择快捷键、开关快捷键、思考块支持和引用功能 - 复制功能:新增纯文本复制(去除Markdown格式符号) - 知识库:支持设置向量维度,修复Ollama分数错误和维度编辑问题 - 多语言:增加模型名称多语言提示和翻译源语言手动选择 - 文件管理:修复主题/消息删除时文件未清理问题,优化文件选择流程 - 模型:修复Gemini模型推理预算、Voyage AI嵌入问题和DeepSeek翻译模型更新 - 图像功能:统一图片查看器,支持Base64图片渲染,修复图片预览相关问题 - UI:实现标签折叠/拖拽排序,修复气泡溢出,增加引文索引显示 + - 新功能:可选数据保存目录 + - 快捷助手:支持单独选择助手,支持暂停、上下文、思考过程、流式 + - 划词助手:系统托盘菜单开关 + - 翻译:新增 Markdown 预览选项 + - 新供应商:新增 Vertex AI 服务商 + - 错误修复和界面优化 diff --git a/package.json b/package.json index 9efc2cbfc..6c613920f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.5.0-rc.1", + "version": "1.5.0-rc.2", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -63,6 +63,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", "pdfjs-dist": "4.10.38", @@ -114,6 +115,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@tryfabric/martian": "^1.2.4", + "@types/balanced-match": "^3", "@types/diff": "^7", "@types/fs-extra": "^11", "@types/lodash": "^4.17.5", @@ -126,6 +128,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", @@ -139,6 +142,7 @@ "archiver": "^7.0.1", "async-mutex": "^0.5.0", "axios": "^1.7.3", + "balanced-match": "^3.0.1", "browser-image-compression": "^2.0.2", "color": "^5.0.0", "dayjs": "^1.11.11", @@ -179,7 +183,6 @@ "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", @@ -193,7 +196,7 @@ "react-hotkeys-hook": "^4.6.1", "react-i18next": "^14.1.2", "react-infinite-scroll-component": "^6.1.0", - "react-markdown": "^9.0.1", + "react-markdown": "^10.1.0", "react-redux": "^9.1.2", "react-router": "6", "react-router-dom": "6", @@ -202,10 +205,10 @@ "redux": "^5.0.1", "redux-persist": "^6.0.0", "rehype-katex": "^7.0.1", - "rehype-mathjax": "^7.0.0", + "rehype-mathjax": "^7.1.0", "rehype-raw": "^7.0.0", - "remark-cjk-friendly": "^1.1.0", - "remark-gfm": "^4.0.0", + "remark-cjk-friendly": "^1.2.0", + "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remove-markdown": "^0.6.2", "rollup-plugin-visualizer": "^5.12.0", @@ -221,6 +224,7 @@ "vite": "6.2.6", "vitest": "^3.1.4", "webdav": "^5.8.0", + "word-extractor": "^1.0.4", "zipread": "^1.3.3" }, "optionalDependencies": { diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 600ed6ce5..86342370f 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -20,6 +20,9 @@ export enum IpcChannel { 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', diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 5a3465f64..719600650 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([ diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js index 4b77bdb07..8e232dfa9 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 StreamZip = require('node-stream-zip') const { downloadWithRedirects } = require('./download') -const { StreamZipAsync } = require('node-stream-zip') // 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 = { @@ -64,41 +64,38 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION, // Use the new download function await downloadWithRedirects(downloadUrl, tempFilename) - // Extract the zip file using node-stream-zip + // Extract the zip file using adm-zip console.log(`Extracting ${packageName} to ${binDir}...`) - const zip = new StreamZipAsync(tempFilename) - zip.extract(null, tempdir, (err) => { - if (err) { - throw new Error(`Failed to extract file: ${err}`) - } - }) + 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 8762323c7..2c882d07d 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 StreamZip = require('node-stream-zip') const { downloadWithRedirects } = require('./download') -const { StreamZipAsync } = require('node-stream-zip') // 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,50 +65,35 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is console.log(`Extracting ${packageName} to ${binDir}...`) - // 根据文件扩展名选择解压方法 - if (packageName.endsWith('.zip')) { - // 使用 node-stream-zip 处理 zip 文件 - const zip = new StreamZipAsync(tempFilename) - zip.extract(null, binDir, (err) => { - if (err) { - throw new Error(`Failed to extract file: ${err}`) - } - }) - 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 000000000..f682c06fc --- /dev/null +++ b/src/main/bootstrap.ts @@ -0,0 +1,5 @@ +import { app } from 'electron' + +import { initAppDataDir } from './utils/file' + +app.isPackaged && initAppDataDir() diff --git a/src/main/index.ts b/src/main/index.ts index 102264317..3699335a9 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 df3444b7b..5eb77afdd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,6 @@ 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' @@ -35,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() @@ -58,7 +59,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) => { @@ -219,10 +221,34 @@ 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) => { try { @@ -235,8 +261,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) }) diff --git a/src/main/loader/index.ts b/src/main/loader/index.ts index e4cec12a8..783e62881 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/mcpServers/sequentialthinking.ts b/src/main/mcpServers/sequentialthinking.ts index 4589c0bf3..bcda96e19 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/services/FileStorage.ts b/src/main/services/FileStorage.ts index 4729732c2..52adcbbe3 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -231,10 +231,21 @@ 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 WordExtractor = require('word-extractor') + 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 diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 3f37d7c40..f6322e893 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -56,7 +56,7 @@ export class WindowService { minHeight: 600, show: false, autoHideMenuBar: true, - transparent: isMac, + transparent: false, vibrancy: 'sidebar', visualEffectState: 'active', titleBarStyle: 'hidden', diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts index aae00e85d..14f480152 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 a9571a785..e48ac6a29 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -8,6 +8,20 @@ import { FileMetadata, 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 25f017d8e..2f1585a09 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,9 +40,12 @@ 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), + getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs), copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath), 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), diff --git a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts index 864f2fff3..eb7d930c5 100644 --- a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts @@ -90,7 +90,7 @@ export class AnthropicAPIClient extends BaseApiClient< return this.sdkInstance } this.sdkInstance = new Anthropic({ - apiKey: this.getApiKey(), + apiKey: this.apiKey, baseURL: this.getBaseURL(), dangerouslyAllowBrowser: true, defaultHeaders: { diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index 393a5d802..09fe05db3 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -85,7 +85,7 @@ export class GeminiAPIClient extends BaseApiClient< ...rest, config: { ...rest.config, - abortSignal: options?.abortSignal, + abortSignal: options?.signal, httpOptions: { ...rest.config?.httpOptions, timeout: options?.timeout @@ -479,6 +479,7 @@ export class GeminiAPIClient extends BaseApiClient< for (const message of messages) { history.push(await this.convertMessageToSdkParam(message)) } + messages.push(userLastMessage) } } diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts index cd03607c2..7730f228a 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts @@ -135,7 +135,7 @@ export abstract class OpenAIBaseClient< return this.sdkInstance } - let apiKeyForSdkInstance = this.provider.apiKey + let apiKeyForSdkInstance = this.apiKey if (this.provider.id === 'copilot') { const defaultHeaders = store.getState().copilot.defaultHeaders diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index d75b809a1..bb76186f1 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -78,7 +78,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< return new OpenAI({ dangerouslyAllowBrowser: true, - apiKey: this.provider.apiKey, + apiKey: this.apiKey, baseURL: this.getBaseURL(), defaultHeaders: { ...this.defaultHeaders() @@ -425,6 +425,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 +497,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/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index d4788439e..ebe45ef5c 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -197,11 +197,26 @@ } } -.ant-dropdown-menu .ant-dropdown-menu-sub { - max-height: 350px; - width: max-content; - overflow-y: auto; - overflow-x: hidden; +.ant-dropdown { + .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-collapse { diff --git a/src/renderer/src/components/MarkdownEditor/index.tsx b/src/renderer/src/components/MarkdownEditor/index.tsx index 5a355a07c..427ff1ccc 100644 --- a/src/renderer/src/components/MarkdownEditor/index.tsx +++ b/src/renderer/src/components/MarkdownEditor/index.tsx @@ -41,11 +41,10 @@ const MarkdownEditor: FC = ({ return ( - + + rehypePlugins={[rehypeRaw, rehypeKatex]}> {inputValue || t('settings.provider.notes.markdown_editor_default_value')} diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 4a842c59c..a847f6d49 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -1352,12 +1352,6 @@ export const SYSTEM_MODELS: Record = { name: 'DeepSeek-V3', group: 'DeepSeek' }, - { - id: 'deepseek-v3-250324', - provider: 'doubao', - name: 'DeepSeek-V3', - group: 'DeepSeek' - }, { id: 'doubao-pro-32k-241215', provider: 'doubao', @@ -2688,7 +2682,7 @@ export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean { return false } - return isOpenAIWebSearchModel(model) || model.id.includes('sonar') + return isOpenAIWebSearchChatCompletionOnlyModel(model) || model.id.includes('sonar') } export function isGenerateImageModel(model: Model): boolean { @@ -2720,7 +2714,7 @@ export function isSupportedDisableGenerationModel(model: Model): boolean { return false } - return SUPPORTED_DISABLE_GENERATION_MODELS.includes(model.id) + return SUPPORTED_DISABLE_GENERATION_MODELS.includes(getBaseModelName(model.id)) } export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record { diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 531582708..8b5fe0ade 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -30,6 +30,14 @@ export function useAppInit() { console.timeEnd('init') }, []) + useEffect(() => { + window.api.getDataPathFromArgs().then((dataPath) => { + if (dataPath) { + window.navigate('/settings/data', { replace: true }) + } + }) + }, []) + useUpdateHandler() useFullScreenNotice() diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e3c325033..5215b39b2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1097,11 +1097,11 @@ "app_data": "App Data", "app_data.select": "Modify Directory", "app_data.select_title": "Change App Data Directory", - "app_data.restart_notice": "The app will need to restart to apply the changes", - "app_data.copy_data_option": "Copy data from original directory to new directory", + "app_data.restart_notice": "The app may need to restart multiple times to apply the changes", + "app_data.copy_data_option": "Copy data, will automatically restart after copying the original directory data to the new directory", "app_data.copy_time_notice": "Copying data may take a while, do not force quit app", - "app_data.path_changed_without_copy": "Path changed successfully, but data not copied", - "app_data.copying_warning": "Data copying, do not force quit app", + "app_data.path_changed_without_copy": "Path changed successfully", + "app_data.copying_warning": "Data copying, do not force quit app, the app will restart after copied", "app_data.copying": "Copying data to new location...", "app_data.copy_success": "Successfully copied data to new location", "app_data.copy_failed": "Failed to copy data", @@ -1113,6 +1113,10 @@ "app_data.select_error_root_path": "New path cannot be the root path", "app_data.select_error_write_permission": "New path does not have write permission", "app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited", + "app_data.select_not_empty_dir": "New path is not empty", + "app_data.select_not_empty_dir_content": "New path is not empty, it will overwrite the data in the new path, there is a risk of data loss and copy failure, continue?", + "app_data.select_error_same_path": "New path is the same as the old path, please select another path", + "app_data.select_error_in_app_path": "New path is the same as the application installation path, please select another path", "app_knowledge": "Knowledge Base Files", "app_knowledge.button.delete": "Delete File", "app_knowledge.remove_all": "Remove Knowledge Base Files", @@ -1173,7 +1177,7 @@ "markdown_export.select": "Select", "markdown_export.title": "Markdown Export", "markdown_export.show_model_name.title": "Use Model Name on Export", - "markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.", + "markdown_export.show_model_name.help": "When enabled, the model name will be displayed when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.", "markdown_export.show_model_provider.title": "Show Model Provider", "markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown", "minute_interval_one": "{{count}} minute", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 523ec635c..9db96bf56 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1095,11 +1095,11 @@ "app_data": "アプリデータ", "app_data.select": "ディレクトリを変更", "app_data.select_title": "アプリデータディレクトリの変更", - "app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります", - "app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます", - "app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください", - "app_data.path_changed_without_copy": "パスが変更されましたが、データがコピーされていません", - "app_data.copying_warning": "データコピー中、アプリを強制終了しないでください", + "app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります。", + "app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます。", + "app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください。", + "app_data.path_changed_without_copy": "パスが変更されました。", + "app_data.copying_warning": "データコピー中、アプリを強制終了しないでください。コピーが完了すると、アプリが自動的に再起動します。", "app_data.copying": "新しい場所にデータをコピーしています...", "app_data.copy_success": "データを新しい場所に正常にコピーしました", "app_data.copy_failed": "データのコピーに失敗しました", @@ -1111,6 +1111,10 @@ "app_data.select_error_root_path": "新しいパスはルートパスにできません", "app_data.select_error_write_permission": "新しいパスに書き込み権限がありません", "app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません", + "app_data.select_not_empty_dir": "新しいパスは空ではありません", + "app_data.select_not_empty_dir_content": "新しいパスは空ではありません。新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?", + "app_data.select_error_same_path": "新しいパスは元のパスと同じです。別のパスを選択してください", + "app_data.select_error_in_app_path": "新しいパスはアプリのインストールパスと同じです。別のパスを選択してください", "app_knowledge": "知識ベースファイル", "app_knowledge.button.delete": "ファイルを削除", "app_knowledge.remove_all": "ナレッジベースファイルを削除", @@ -1171,7 +1175,7 @@ "markdown_export.select": "選択", "markdown_export.title": "Markdown エクスポート", "markdown_export.show_model_name.title": "エクスポート時にモデル名を使用", - "markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。", + "markdown_export.show_model_name.help": "有効にすると、Markdownエクスポート時にモデル名を表示します。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。", "markdown_export.show_model_provider.title": "モデルプロバイダーを表示", "markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダー(例:OpenAI、Geminiなど)を表示します。", "minute_interval_one": "{{count}} 分", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 6f6d68e0f..09842d104 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1095,11 +1095,11 @@ "app_data": "Данные приложения", "app_data.select": "Изменить директорию", "app_data.select_title": "Изменить директорию данных приложения", - "app_data.restart_notice": "Для применения изменений потребуется перезапуск приложения", - "app_data.copy_data_option": "Копировать данные из исходной директории в новую директорию", + "app_data.restart_notice": "Для применения изменений может потребоваться несколько перезапусков приложения", + "app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию", "app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы", - "app_data.path_changed_without_copy": "Путь изменен успешно, но данные не скопированы", - "app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение", + "app_data.path_changed_without_copy": "Путь изменен успешно", + "app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение, приложение будет перезапущено после копирования", "app_data.copying": "Копирование данных в новое место...", "app_data.copy_success": "Данные успешно скопированы в новое место", "app_data.copy_failed": "Не удалось скопировать данные", @@ -1111,6 +1111,10 @@ "app_data.select_error_root_path": "Новый путь не может быть корневым", "app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись", "app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто", + "app_data.select_not_empty_dir": "Новый путь не пуст", + "app_data.select_not_empty_dir_content": "Новый путь не пуст, он перезапишет данные в новом пути, есть риск потери данных и ошибки копирования, продолжить?", + "app_data.select_error_in_app_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь", + "app_data.select_error_same_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь", "app_knowledge": "Файлы базы знаний", "app_knowledge.button.delete": "Удалить файл", "app_knowledge.remove_all": "Удалить файлы базы знаний", @@ -1171,7 +1175,7 @@ "markdown_export.select": "Выбрать", "markdown_export.title": "Экспорт в Markdown", "markdown_export.show_model_name.title": "Использовать имя модели при экспорте", - "markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.", + "markdown_export.show_model_name.help": "Если включено, при экспорте в Markdown будет отображаться имя модели. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.", "markdown_export.show_model_provider.title": "Показать поставщика модели", "markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown", "minute_interval_one": "{{count}} минута", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index aa9799f9c..44f912819 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1097,11 +1097,11 @@ "app_data": "应用数据", "app_data.select": "修改目录", "app_data.select_title": "更改应用数据目录", - "app_data.restart_notice": "应用需要重启以应用更改", - "app_data.copy_data_option": "复制数据,开启后会将原始目录数据复制到新目录", + "app_data.restart_notice": "应用可能会重启多次以应用更改", + "app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录", "app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用", - "app_data.path_changed_without_copy": "路径已更改成功,但数据未复制", - "app_data.copying_warning": "数据复制中,不要强制退出app", + "app_data.path_changed_without_copy": "路径已更改成功", + "app_data.copying_warning": "数据复制中,不要强制退出app, 复制完成后会自动重启应用", "app_data.copying": "正在将数据复制到新位置...", "app_data.copy_success": "已成功复制数据到新位置", "app_data.copy_failed": "复制数据失败", @@ -1113,6 +1113,10 @@ "app_data.select_error_root_path": "新路径不能是根路径", "app_data.select_error_write_permission": "新路径没有写入权限", "app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出", + "app_data.select_not_empty_dir": "新路径不为空", + "app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据, 有数据丢失和复制失败的风险,是否继续?", + "app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径", + "app_data.select_error_in_app_path": "新路径与应用安装路径相同,请选择其他路径", "app_knowledge": "知识库文件", "app_knowledge.button.delete": "删除文件", "app_knowledge.remove_all": "删除知识库文件", @@ -1173,7 +1177,7 @@ "markdown_export.select": "选择", "markdown_export.title": "Markdown 导出", "markdown_export.show_model_name.title": "导出时使用模型名称", - "markdown_export.show_model_name.help": "开启后,使用话题命名模型为导出的消息创建标题。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。", + "markdown_export.show_model_name.help": "开启后,导出Markdown时会显示模型名称。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。", "markdown_export.show_model_provider.title": "显示模型供应商", "markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商,如OpenAI、Gemini等", "message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 560b4fa7b..65669a90f 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1097,11 +1097,11 @@ "app_data": "應用數據", "app_data.select": "修改目錄", "app_data.select_title": "變更應用數據目錄", - "app_data.restart_notice": "變更數據目錄後需要重啟應用才能生效", - "app_data.copy_data_option": "複製數據, 開啟後會將原始目錄數據複製到新目錄", + "app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效", + "app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄", "app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用", - "app_data.path_changed_without_copy": "路徑已變更成功,但數據未複製", - "app_data.copying_warning": "數據複製中,不要強制退出應用", + "app_data.path_changed_without_copy": "路徑已變更成功", + "app_data.copying_warning": "數據複製中,不要強制退出應用, 複製完成後會自動重啟應用", "app_data.copying": "正在複製數據到新位置...", "app_data.copy_success": "成功複製數據到新位置", "app_data.copy_failed": "複製數據失敗", @@ -1113,6 +1113,10 @@ "app_data.select_error_root_path": "新路徑不能是根路徑", "app_data.select_error_write_permission": "新路徑沒有寫入權限", "app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出", + "app_data.select_not_empty_dir": "新路徑不為空", + "app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失和複製失敗的風險,是否繼續?", + "app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑", + "app_data.select_error_in_app_path": "新路徑與應用安裝路徑相同,請選擇其他路徑", "app_knowledge": "知識庫文件", "app_knowledge.button.delete": "刪除檔案", "app_knowledge.remove_all": "刪除知識庫檔案", @@ -1173,7 +1177,7 @@ "markdown_export.select": "選擇", "markdown_export.title": "Markdown 匯出", "markdown_export.show_model_name.title": "匯出時使用模型名稱", - "markdown_export.show_model_name.help": "啟用後,將以主題命名模型為匯出的訊息建立標題。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。", + "markdown_export.show_model_name.help": "啟用後,匯出Markdown時會顯示模型名稱。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。", "markdown_export.show_model_provider.title": "顯示模型供應商", "markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商,如OpenAI、Gemini等", "minute_interval_one": "{{count}} 分鐘", diff --git a/src/renderer/src/pages/agents/AgentsPage.tsx b/src/renderer/src/pages/agents/AgentsPage.tsx index b9873c310..1a8e3ce99 100644 --- a/src/renderer/src/pages/agents/AgentsPage.tsx +++ b/src/renderer/src/pages/agents/AgentsPage.tsx @@ -75,8 +75,8 @@ const AgentsPage: FC = () => { {agent.description && {agent.description}} {agent.prompt && ( - - {agent.prompt}{' '} + + {agent.prompt} )} diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 08adb07d4..d1f129bd0 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -15,7 +15,7 @@ import { useAssistant } from '@renderer/hooks/useAssistant' import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' -import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' +import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' import { getDefaultTopic } from '@renderer/services/AssistantService' @@ -87,7 +87,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const { t } = useTranslation() const containerRef = useRef(null) const { searching } = useRuntime() - const { isBubbleStyle } = useMessageStyle() const { pauseMessages } = useMessageOperations(topic) const loading = useTopicLoading(topic) const dispatch = useAppDispatch() @@ -673,8 +672,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : []) }, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon]) - const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1 - const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => { updateAssistant({ ...assistant, knowledge_bases: bases }) setSelectedKnowledgeBases(bases ?? []) @@ -786,7 +783,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = contextMenu="true" variant="borderless" spellCheck={false} - rows={textareaRows} + rows={2} ref={textareaRef} style={{ fontSize, @@ -933,7 +930,7 @@ const Textarea = styled(TextArea)` overflow: auto; width: 100%; box-sizing: border-box; - transition: height 0.2s ease; + transition: none !important; &.ant-input { line-height: 1.4; } diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 8ee90a859..454550c5c 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' @@ -24,6 +24,7 @@ import remarkMath from 'remark-math' import CodeBlock from './CodeBlock' import Link from './Link' +import remarkDisableConstructs from './plugins/remarkDisableConstructs' import Table from './Table' const ALLOWED_ELEMENTS = @@ -40,7 +41,7 @@ const Markdown: FC = ({ block }) => { const { mathEngine } = useSettings() const remarkPlugins = useMemo(() => { - const plugins = [remarkGfm, remarkCjkFriendly] + const plugins = [remarkGfm, remarkCjkFriendly, remarkDisableConstructs(['codeIndented'])] if (mathEngine !== 'none') { plugins.push(remarkMath) } @@ -51,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(() => { @@ -105,20 +106,21 @@ const Markdown: FC = ({ block }) => { }, []) return ( - - {messageContent} - +
+ + {messageContent} + +
) } 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 c72f30de9..be9b18c13 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 @@ -103,6 +103,12 @@ vi.mock('rehype-katex', () => ({ __esModule: true, default: vi.fn() })) vi.mock('rehype-mathjax', () => ({ __esModule: true, default: vi.fn() })) vi.mock('rehype-raw', () => ({ __esModule: true, default: vi.fn() })) +// Mock custom plugins +vi.mock('../plugins/remarkDisableConstructs', () => ({ + __esModule: true, + default: vi.fn() +})) + // Mock ReactMarkdown with realistic rendering vi.mock('react-markdown', () => ({ __esModule: true, @@ -162,12 +168,16 @@ describe('Markdown', () => { describe('rendering', () => { it('should render markdown content with correct structure', () => { const block = createMainTextBlock({ content: 'Test content' }) - render() + const { container } = render() - const markdown = screen.getByTestId('markdown-content') - expect(markdown).toBeInTheDocument() - expect(markdown).toHaveClass('markdown') - expect(markdown).toHaveTextContent('Test content') + // Check that the outer container has the markdown class + const markdownContainer = container.querySelector('.markdown') + expect(markdownContainer).toBeInTheDocument() + + // Check that the markdown content is rendered inside + const markdownContent = screen.getByTestId('markdown-content') + expect(markdownContent).toBeInTheDocument() + expect(markdownContent).toHaveTextContent('Test content') }) it('should handle empty content gracefully', () => { @@ -202,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__/Markdown.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap index 29aae68dc..975aa2e09 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap @@ -3,55 +3,58 @@ exports[`Markdown > rendering > should match snapshot 1`] = `
- # Test Markdown +
+ # Test Markdown This is **bold** text. - - link - -
-
- - test code - - -
-
-
+ link +
- - test table -
- + + test code + + +
+
+
+ + test table +
+ +
+
+ + img +
- - img -
`; diff --git a/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx b/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx new file mode 100644 index 000000000..0a20e79b8 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx @@ -0,0 +1,155 @@ +import { render } from '@testing-library/react' +import ReactMarkdown from 'react-markdown' +import { describe, expect, it } from 'vitest' + +import remarkDisableConstructs from '../remarkDisableConstructs' + +describe('disableIndentedCode', () => { + const renderMarkdown = (markdown: string, constructs: string[] = ['codeIndented']) => { + return render({markdown}) + } + + describe('normal path', () => { + it('should disable indented code blocks while preserving other code types', () => { + const markdown = ` +# Test Document + +Regular paragraph. + + This should be treated as a regular paragraph, not code + +\`inline code\` should work + +\`\`\`javascript +// This fenced code should work +console.log('hello') +\`\`\` + +Another paragraph. +` + + const { container } = renderMarkdown(markdown) + + // Verify only fenced code (pre element) + expect(container.querySelectorAll('pre')).toHaveLength(1) + + // Verify inline code + const inlineCode = container.querySelector('code:not(pre code)') + expect(inlineCode?.textContent).toBe('inline code') + + // Verify fenced code + const fencedCode = container.querySelector('pre code') + expect(fencedCode?.textContent).toContain('console.log') + + // Verify indented content becomes paragraph + const paragraphs = container.querySelectorAll('p') + const indentedParagraph = Array.from(paragraphs).find((p) => + p.textContent?.includes('This should be treated as a regular paragraph') + ) + expect(indentedParagraph).toBeTruthy() + }) + + it('should handle indented code in nested structures', () => { + const markdown = ` +> Blockquote with \`inline code\` +> +> This indented code in blockquote should become text + +1. List item + + This indented code in list should become text + +* Bullet list + * Nested item + + More indented code to convert +` + + const { container } = renderMarkdown(markdown) + + // Verify no indented code blocks + expect(container.querySelectorAll('pre')).toHaveLength(0) + + // Verify blockquote exists and contains converted text + const blockquote = container.querySelector('blockquote') + expect(blockquote?.textContent).toContain('This indented code in blockquote should become text') + + // Verify lists exist + const lists = container.querySelectorAll('ul, ol') + expect(lists.length).toBeGreaterThan(0) + }) + + it('should preserve other markdown elements when disabling constructs', () => { + const markdown = ` +# Heading + +Paragraph text. + + Indented code to disable + +[Link text](https://example.com) + +\`\`\` +Fenced code to keep +\`\`\` +` + + const { container } = renderMarkdown(markdown) + + // Verify heading + expect(container.querySelector('h1')?.textContent).toBe('Heading') + + // Verify link + const link = container.querySelector('a') + expect(link?.textContent).toBe('Link text') + expect(link?.getAttribute('href')).toBe('https://example.com') + + // Verify only fenced code + expect(container.querySelectorAll('pre')).toHaveLength(1) + }) + }) + + describe('edge cases', () => { + it('should not affect markdown when no constructs are disabled', () => { + const markdown = ` + This is indented code + +\`inline code\` + +\`\`\`javascript +console.log('fenced') +\`\`\` +` + + const { container } = renderMarkdown(markdown, []) + + // Should have indented code and fenced code + expect(container.querySelectorAll('pre')).toHaveLength(2) + }) + + it('should handle markdown with only inline and fenced code', () => { + const markdown = ` +Regular paragraph with \`inline code\`. + +\`\`\`typescript +function test(): string { + return "hello"; +} +\`\`\` +` + + const { container } = renderMarkdown(markdown) + + // Should have only fenced code + expect(container.querySelectorAll('pre')).toHaveLength(1) + + // Verify fenced code content + const fencedCode = container.querySelector('pre code') + expect(fencedCode?.textContent).toContain('function test()') + + // Verify inline code + const inlineCode = container.querySelector('code:not(pre code)') + expect(inlineCode?.textContent).toBe('inline code') + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts b/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts new file mode 100644 index 000000000..dcf0b32b2 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import remarkDisableConstructs from '../remarkDisableConstructs' + +describe('remarkDisableConstructs', () => { + let mockData: any + let mockThis: any + + beforeEach(() => { + mockData = {} + mockThis = { + data: vi.fn().mockReturnValue(mockData) + } + }) + + describe('plugin creation', () => { + it('should return a function when called', () => { + const plugin = remarkDisableConstructs(['codeIndented']) + + expect(typeof plugin).toBe('function') + }) + }) + + describe('normal path', () => { + it('should add micromarkExtensions for single construct', () => { + const plugin = remarkDisableConstructs(['codeIndented']) + plugin.call(mockThis as any) + + expect(mockData).toHaveProperty('micromarkExtensions') + expect(Array.isArray(mockData.micromarkExtensions)).toBe(true) + expect(mockData.micromarkExtensions).toHaveLength(1) + expect(mockData.micromarkExtensions[0]).toEqual({ + disable: { + null: ['codeIndented'] + } + }) + }) + + it('should handle multiple constructs', () => { + const constructs = ['codeIndented', 'autolink', 'htmlFlow'] + const plugin = remarkDisableConstructs(constructs) + plugin.call(mockThis as any) + + expect(mockData.micromarkExtensions[0]).toEqual({ + disable: { + null: constructs + } + }) + }) + }) + + describe('edge cases', () => { + it('should not add extensions when empty array is provided', () => { + const plugin = remarkDisableConstructs([]) + plugin.call(mockThis as any) + + expect(mockData).not.toHaveProperty('micromarkExtensions') + }) + + it('should not add extensions when undefined is passed', () => { + const plugin = remarkDisableConstructs() + plugin.call(mockThis as any) + + expect(mockData).not.toHaveProperty('micromarkExtensions') + }) + + it('should handle empty construct names', () => { + const plugin = remarkDisableConstructs(['', ' ']) + plugin.call(mockThis as any) + + expect(mockData.micromarkExtensions[0]).toEqual({ + disable: { + null: ['', ' '] + } + }) + }) + + it('should handle mixed valid and empty construct names', () => { + const plugin = remarkDisableConstructs(['codeIndented', '', 'autolink']) + plugin.call(mockThis as any) + + expect(mockData.micromarkExtensions[0]).toEqual({ + disable: { + null: ['codeIndented', '', 'autolink'] + } + }) + }) + }) + + describe('interaction with existing data', () => { + it('should append to existing micromarkExtensions', () => { + const existingExtension = { some: 'extension' } + mockData.micromarkExtensions = [existingExtension] + + const plugin = remarkDisableConstructs(['codeIndented']) + plugin.call(mockThis as any) + + expect(mockData.micromarkExtensions).toHaveLength(2) + expect(mockData.micromarkExtensions[0]).toBe(existingExtension) + expect(mockData.micromarkExtensions[1]).toEqual({ + disable: { + null: ['codeIndented'] + } + }) + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts b/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts new file mode 100644 index 000000000..967e67ef5 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts @@ -0,0 +1,53 @@ +import type { Plugin } from 'unified' + +/** + * Custom remark plugin to disable specific markdown constructs + * + * This plugin allows you to disable specific markdown constructs by passing + * them as micromark extensions to the underlying parser. + * + * @see https://github.com/micromark/micromark + * + * @example + * ```typescript + * // Disable indented code blocks + * remarkDisableConstructs(['codeIndented']) + * + * // Disable multiple constructs + * remarkDisableConstructs(['codeIndented', 'autolink', 'htmlFlow']) + * ``` + */ + +/** + * Helper function to add values to plugin data + * @param data - The plugin data object + * @param field - The field name to add to + * @param value - The value to add + */ +function add(data: any, field: string, value: unknown): void { + const list = data[field] ? data[field] : (data[field] = []) + list.push(value) +} + +/** + * Remark plugin to disable specific markdown constructs + * @param constructs - Array of construct names to disable (e.g., ['codeIndented', 'autolink']) + * @returns A remark plugin function + */ +function remarkDisableConstructs(constructs: string[] = []): Plugin<[], any, any> { + return function () { + const data = this.data() + + if (constructs.length > 0) { + const disableExtension = { + disable: { + null: constructs + } + } + + add(data, 'micromarkExtensions', disableExtension) + } + } +} + +export default remarkDisableConstructs diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 6d370484f..01a548b8c 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 && ( = ({ assistant }) => { - {model ? model.name : t('button.select_model')} {providerName ? '| ' + providerName : ''} + {model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''} + ) } @@ -55,21 +57,23 @@ const SelectModelButton: FC = ({ assistant }) => { const DropdownButton = styled(Button)` font-size: 11px; border-radius: 15px; - padding: 12px 8px 12px 3px; + padding: 13px 5px; -webkit-app-region: none; box-shadow: none; background-color: transparent; border: 1px solid transparent; + margin-top: 1px; ` const ButtonContent = styled.div` display: flex; align-items: center; - gap: 5px; + gap: 6px; ` const ModelName = styled.span` font-weight: 500; + margin-right: -2px; ` export default SelectModelButton diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx index 02b818504..d5b288298 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx @@ -103,8 +103,8 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant } {showMarkdown ? ( - setShowMarkdown(false)}> - {prompt} + setShowMarkdown(false)}> + {prompt}
) : ( diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 5a8054534..0038f1947 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -202,6 +202,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 +225,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 +277,7 @@ const DataSettings: FC = () => {
{ shouldCopyData = checked }} @@ -262,7 +291,7 @@ const DataSettings: FC = () => { ) // 显示确认模态框 - const modal = window.modal.confirm({ + window.modal.confirm({ title, className, width: 'min(600px, 90vw)', @@ -287,30 +316,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 +345,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,6 +357,68 @@ const DataSettings: FC = () => { }) } + useEffect(() => { + const handleDataMigration = async () => { + const newDataPath = await window.api.getDataPathFromArgs() + if (!newDataPath) return + + const originalPath = (await window.api.getAppInfo())?.appDataPath + if (!originalPath) return + + const title = ( +
{t('settings.data.app_data.migration_title')}
+ ) + const className = 'migration-modal' + const messageKey = 'data-migration' + + // 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() + } + } + + handleDataMigration() + }, []) + // 显示进度模态框 const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => { let currentProgress = 0 @@ -411,6 +493,12 @@ const DataSettings: FC = () => { 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) diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 1d8edd93d..11db557bf 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -229,7 +229,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 +307,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 +384,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 @@ -526,12 +528,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) => ({ diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 46c5fd849..fb2c07310 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -462,12 +462,23 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages: }) const conversation = JSON.stringify(structredMessages) + // 复制 assistant 对象,并强制关闭思考预算 + const summaryAssistant = { + ...assistant, + settings: { + ...assistant.settings, + reasoning_effort: undefined, + qwenThinkMode: false + } + } + const params: CompletionsParams = { callType: 'summary', messages: conversation, - assistant: { ...assistant, prompt, model }, + assistant: { ...summaryAssistant, prompt, model }, maxTokens: 1000, - streamOutput: false + streamOutput: false, + enableReasoning: false } try { @@ -590,7 +601,8 @@ export async function checkApi(provider: Provider, model: Model): Promise callType: 'check', messages: 'hi', assistant, - streamOutput: true + streamOutput: true, + shouldThrow: true } // Try streaming check first @@ -605,7 +617,8 @@ export async function checkApi(provider: Provider, model: Model): Promise callType: 'check', messages: 'hi', assistant, - streamOutput: false + streamOutput: false, + shouldThrow: true } const result = await ai.completions(params) if (!result.getText()) { diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 7619e0e3b..e8ec416b1 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -14,7 +14,17 @@ export function getDefaultAssistant(): Assistant { topics: [getDefaultTopic('default')], messages: [], type: 'assistant', - regularPhrases: [] // Added regularPhrases + regularPhrases: [], // Added regularPhrases + settings: { + temperature: DEFAULT_TEMPERATURE, + contextCount: DEFAULT_CONTEXTCOUNT, + enableMaxTokens: false, + maxTokens: 0, + streamOutput: true, + topP: 1, + toolUseMode: 'prompt', + customParameters: [] + } } } @@ -127,7 +137,17 @@ export async function createAssistantFromAgent(agent: Agent) { topics: [topic], model: agent.defaultModel, type: 'assistant', - regularPhrases: agent.regularPhrases || [] // Ensured regularPhrases + regularPhrases: agent.regularPhrases || [], // Ensured regularPhrases + settings: agent.settings || { + temperature: DEFAULT_TEMPERATURE, + contextCount: DEFAULT_CONTEXTCOUNT, + enableMaxTokens: false, + maxTokens: 0, + streamOutput: true, + topP: 1, + toolUseMode: 'prompt', + customParameters: [] + } } store.dispatch(addAssistant(assistant)) diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index a281bc977..f70c4ef2b 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -237,14 +237,15 @@ export const INITIAL_PROVIDERS: Provider[] = [ isVertex: false }, { - id: 'zhipu', - name: 'ZhiPu', - type: 'openai', + id: 'vertexai', + name: 'VertexAI', + type: 'vertexai', apiKey: '', - apiHost: 'https://open.bigmodel.cn/api/paas/v4/', - models: SYSTEM_MODELS.zhipu, + apiHost: 'https://aiplatform.googleapis.com', + models: [], isSystem: true, - enabled: false + enabled: false, + isVertex: true }, { id: 'github', @@ -267,6 +268,16 @@ export const INITIAL_PROVIDERS: Provider[] = [ enabled: false, isAuthed: false }, + { + id: 'zhipu', + name: 'ZhiPu', + type: 'openai', + apiKey: '', + apiHost: 'https://open.bigmodel.cn/api/paas/v4/', + models: SYSTEM_MODELS.zhipu, + isSystem: true, + enabled: false + }, { id: 'yi', name: 'Yi', @@ -377,26 +388,6 @@ export const INITIAL_PROVIDERS: Provider[] = [ isSystem: true, enabled: false }, - { - id: 'zhinao', - name: 'zhinao', - type: 'openai', - apiKey: '', - apiHost: 'https://api.360.cn', - models: SYSTEM_MODELS.zhinao, - isSystem: true, - enabled: false - }, - { - id: 'hunyuan', - name: 'hunyuan', - type: 'openai', - apiKey: '', - apiHost: 'https://api.hunyuan.cloud.tencent.com', - models: SYSTEM_MODELS.hunyuan, - isSystem: true, - enabled: false - }, { id: 'nvidia', name: 'nvidia', @@ -477,6 +468,16 @@ export const INITIAL_PROVIDERS: Provider[] = [ isSystem: true, enabled: false }, + { + id: 'hunyuan', + name: 'hunyuan', + type: 'openai', + apiKey: '', + apiHost: 'https://api.hunyuan.cloud.tencent.com', + models: SYSTEM_MODELS.hunyuan, + isSystem: true, + enabled: false + }, { id: 'tencent-cloud-ti', name: 'Tencent Cloud TI', @@ -516,17 +517,6 @@ export const INITIAL_PROVIDERS: Provider[] = [ models: SYSTEM_MODELS.voyageai, isSystem: true, enabled: false - }, - { - id: 'vertexai', - name: 'VertexAI', - type: 'vertexai', - apiKey: '', - apiHost: 'https://aiplatform.googleapis.com', - models: [], - isSystem: true, - enabled: false, - isVertex: true } ] diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index a51f4f44e..df12ff35f 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1586,7 +1586,6 @@ const migrateConfig = { '113': (state: RootState) => { try { addProvider(state, 'vertexai') - state.llm.providers = moveProvider(state.llm.providers, 'vertexai', 10) if (!state.llm.settings.vertexai) { state.llm.settings.vertexai = llmInitialState.settings.vertexai } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 2890ffa85..ef7b680a4 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -368,6 +368,7 @@ export type AppInfo = { logsPath: string arch: string isPortable: boolean + installPath: string } export interface Shortcut { diff --git a/src/renderer/src/types/sdk.ts b/src/renderer/src/types/sdk.ts index c066952ec..559e02eca 100644 --- a/src/renderer/src/types/sdk.ts +++ b/src/renderer/src/types/sdk.ts @@ -102,6 +102,6 @@ export type GeminiSdkToolCall = FunctionCall export type GeminiOptions = { streamOutput: boolean - abortSignal?: AbortSignal + signal?: AbortSignal timeout?: number } diff --git a/src/renderer/src/utils/__tests__/formats.test.ts b/src/renderer/src/utils/__tests__/formats.test.ts index 5a817f45d..09189b452 100644 --- a/src/renderer/src/utils/__tests__/formats.test.ts +++ b/src/renderer/src/utils/__tests__/formats.test.ts @@ -6,7 +6,6 @@ import { describe, expect, it, vi } from 'vitest' import { addImageFileToContents, encodeHTML, - escapeBrackets, escapeDollarNumber, extractTitle, removeSvgEmptyLines, @@ -180,36 +179,6 @@ describe('formats', () => { }) }) - describe('escapeBrackets', () => { - it('should convert \\[...\\] to display math format', () => { - expect(escapeBrackets('The formula is \\[a+b=c\\]')).toBe('The formula is \n$$\na+b=c\n$$\n') - }) - - it('should convert \\(...\\) to inline math format', () => { - expect(escapeBrackets('The formula is \\(a+b=c\\)')).toBe('The formula is $a+b=c$') - }) - - it('should not affect code blocks', () => { - const codeBlock = 'This is text with a code block ```const x = \\[1, 2, 3\\]```' - expect(escapeBrackets(codeBlock)).toBe(codeBlock) - }) - - it('should not affect inline code', () => { - const inlineCode = 'This is text with `const x = \\[1, 2, 3\\]` inline code' - expect(escapeBrackets(inlineCode)).toBe(inlineCode) - }) - - it('should handle multiple occurrences', () => { - const input = 'Formula 1: \\[a+b=c\\] and formula 2: \\(x+y=z\\)' - const expected = 'Formula 1: \n$$\na+b=c\n$$\n and formula 2: $x+y=z$' - expect(escapeBrackets(input)).toBe(expected) - }) - - it('should handle empty string', () => { - expect(escapeBrackets('')).toBe('') - }) - }) - describe('extractTitle', () => { it('should extract title from HTML string', () => { const html = 'Page TitleContent' diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index e35550bf4..4f48deba0 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -9,6 +9,7 @@ import { getCodeBlockId, getExtensionByLanguage, markdownToPlainText, + processLatexBrackets, removeTrailingDoubleSpaces, updateCodeBlock } from '../markdown' @@ -461,4 +462,198 @@ describe('markdown', () => { expect(markdownToPlainText('This is plain text.')).toBe('This is plain text.') }) }) + + describe('processLatexBrackets', () => { + describe('basic LaTeX conversion', () => { + it('should convert display math \\[...\\] to $$...$$', () => { + expect(processLatexBrackets('The formula is \\[a+b=c\\]')).toBe('The formula is $$a+b=c$$') + }) + + it('should convert inline math \\(...\\) to $...$', () => { + expect(processLatexBrackets('The formula is \\(a+b=c\\)')).toBe('The formula is $a+b=c$') + }) + }) + + describe('code block protection', () => { + it('should not affect multi-line code blocks', () => { + const input = 'Text ```const arr = \\[1, 2, 3\\]\\nconst func = \\(x\\) => x``` more text' + expect(processLatexBrackets(input)).toBe(input) + }) + + it('should not affect inline code', () => { + const input = 'This is text with `const x = \\[1, 2, 3\\]` inline code' + expect(processLatexBrackets(input)).toBe(input) + }) + + it('should handle mixed code and LaTeX', () => { + const input = 'Math: \\[x + y\\] and code: `arr = \\[1, 2\\]` and more math: \\(z\\)' + const expected = 'Math: $$x + y$$ and code: `arr = \\[1, 2\\]` and more math: $z$' + expect(processLatexBrackets(input)).toBe(expected) + }) + + it('should protect complex code blocks', () => { + for (const [input, expected] of new Map([ + [ + '```javascript\\nconst latex = "\\\\[formula\\\\]"\\n```', + '```javascript\\nconst latex = "\\\\[formula\\\\]"\\n```' + ], + ['`\\[escaped brackets\\]`', '`\\[escaped brackets\\]`'], + [ + '```\\narray = \\[\\n \\(item1\\),\\n \\(item2\\)\\n\\]\\n```', + '```\\narray = \\[\\n \\(item1\\),\\n \\(item2\\)\\n\\]\\n```' + ] + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + }) + + describe('link protection', () => { + it('should not affect LaTeX in link text', () => { + const input = '[\\[pdf\\] Document](https://example.com/doc.pdf)' + expect(processLatexBrackets(input)).toBe(input) + }) + + it('should not affect LaTeX in link URLs', () => { + const input = '[Click here](https://example.com/path\\[with\\]brackets)' + expect(processLatexBrackets(input)).toBe(input) + }) + + it('should handle mixed links and LaTeX', () => { + const input = 'See [\\[pdf\\] file](url) for formula \\[x + y = z\\]' + const expected = 'See [\\[pdf\\] file](url) for formula $$x + y = z$$' + expect(processLatexBrackets(input)).toBe(expected) + }) + + it('should protect complex link patterns', () => { + for (const [input, expected] of new Map([ + ['[Title with \\(math\\)](https://example.com)', '[Title with \\(math\\)](https://example.com)'], + ['[Link](https://example.com/\\[path\\]/file)', '[Link](https://example.com/\\[path\\]/file)'], + [ + '[\\[Section 1\\] Overview](url) and \\[math formula\\]', + '[\\[Section 1\\] Overview](url) and $$math formula$$' + ] + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + }) + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(processLatexBrackets('')).toBe('') + }) + + it('should handle content without LaTeX', () => { + for (const [input, expected] of new Map([ + ['Regular text without math', 'Regular text without math'], + ['Text with [regular] brackets', 'Text with [regular] brackets'], + ['Text with (parentheses)', 'Text with (parentheses)'], + ['No special characters here', 'No special characters here'] + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + + it('should handle malformed LaTeX patterns', () => { + for (const [input, expected] of new Map([ + ['\\[unclosed bracket', '\\[unclosed bracket'], + ['unopened bracket\\]', 'unopened bracket\\]'], + ['\\(unclosed paren', '\\(unclosed paren'], + ['unopened paren\\)', 'unopened paren\\)'], + ['\\[\\]', '$$$$'], // Empty LaTeX block + ['\\(\\)', '$$'] // Empty LaTeX inline + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + + it('should handle nested brackets', () => { + for (const [input, expected] of new Map([ + ['\\[outer \\[inner\\] formula\\]', '$$outer \\[inner\\] formula$$'], + ['\\(a + \\(b + c\\)\\)', '$a + \\(b + c\\)$'] + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + }) + + describe('complex cases', () => { + it('should handle complex mixed content', () => { + const complexInput = ` +# Mathematical Document + +Here's a simple formula \\(E = mc^2\\) in text. + +## Section 1: Equations + +The quadratic formula is \\[x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}\\]. + +- Item 1: See formula \\(\\alpha + \\beta = \\gamma\\) in this list +- Item 2: Check [\\[PDF\\] Complex Analysis](https://example.com/math.pdf) + - Subitem 2.1: Basic concepts and definitions + - Subitem 2.2: The Cauchy-Riemann equations \\[\\frac{\\partial u}{\\partial x} = \\frac{\\partial v}{\\partial y}, \\quad \\frac{\\partial u}{\\partial y} = -\\frac{\\partial v}{\\partial x}\\] + - Subitem 2.3: Green's theorem connects line integrals and double integrals + \\[ + \\oint_C (P dx + Q dy) = \\iint_D \\left(\\frac{\\partial Q}{\\partial x} - \\frac{\\partial P}{\\partial y}\\right) dx dy + \\] + - Subitem 2.4: Applications in engineering and physics +- Item 3: The sum \\[\\sum_{i=1}^{n} \\frac{1}{i^2} = \\frac{\\pi^2}{6}\\] is famous + +\`\`\`javascript +// Code should not be affected +const matrix = \\[ + \\[1, 2\\], + \\[3, 4\\] +\\]; +const func = \\(x\\) => x * 2; +\`\`\` + +Read more in [Section \\[3.2\\]: Advanced Topics](url) and see inline code \`\\[array\\]\`. + +Final thoughts on \\(\\nabla \\cdot \\vec{F} = \\rho\\) and display math: + +\\[\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}\\] +` + + const expectedOutput = ` +# Mathematical Document + +Here's a simple formula $E = mc^2$ in text. + +## Section 1: Equations + +The quadratic formula is $$x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$$. + +- Item 1: See formula $\\alpha + \\beta = \\gamma$ in this list +- Item 2: Check [\\[PDF\\] Complex Analysis](https://example.com/math.pdf) + - Subitem 2.1: Basic concepts and definitions + - Subitem 2.2: The Cauchy-Riemann equations $$\\frac{\\partial u}{\\partial x} = \\frac{\\partial v}{\\partial y}, \\quad \\frac{\\partial u}{\\partial y} = -\\frac{\\partial v}{\\partial x}$$ + - Subitem 2.3: Green's theorem connects line integrals and double integrals + $$ + \\oint_C (P dx + Q dy) = \\iint_D \\left(\\frac{\\partial Q}{\\partial x} - \\frac{\\partial P}{\\partial y}\\right) dx dy + $$ + - Subitem 2.4: Applications in engineering and physics +- Item 3: The sum $$\\sum_{i=1}^{n} \\frac{1}{i^2} = \\frac{\\pi^2}{6}$$ is famous + +\`\`\`javascript +// Code should not be affected +const matrix = \\[ + \\[1, 2\\], + \\[3, 4\\] +\\]; +const func = \\(x\\) => x * 2; +\`\`\` + +Read more in [Section \\[3.2\\]: Advanced Topics](url) and see inline code \`\\[array\\]\`. + +Final thoughts on $\\nabla \\cdot \\vec{F} = \\rho$ and display math: + +$$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ +` + + expect(processLatexBrackets(complexInput)).toBe(expectedOutput) + }) + }) + }) }) diff --git a/src/renderer/src/utils/formats.ts b/src/renderer/src/utils/formats.ts index 559b4e7a5..ee64efd44 100644 --- a/src/renderer/src/utils/formats.ts +++ b/src/renderer/src/utils/formats.ts @@ -53,24 +53,6 @@ export function escapeDollarNumber(text: string) { return escapedText } -export function escapeBrackets(text: string) { - const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g - return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => { - if (codeBlock) { - return codeBlock - } else if (squareBracket) { - return ` -$$ -${squareBracket} -$$ -` - } else if (roundBracket) { - return `$${roundBracket}$` - } - return match - }) -} - export function extractTitle(html: string): string | null { if (!html) return null diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index a54e3d69d..57025ca63 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -1,4 +1,5 @@ import { languages } from '@shared/config/languages' +import balanced from 'balanced-match' import remarkParse from 'remark-parse' import remarkStringify from 'remark-stringify' import removeMarkdown from 'remove-markdown' @@ -29,6 +30,85 @@ export const findCitationInChildren = (children: any): string => { return '' } +// 检查是否包含潜在的 LaTeX 模式 +const containsLatexRegex = /\\\(.*?\\\)|\\\[.*?\\\]|\$.*?\$|\\begin\{equation\}.*?\\end\{equation\}/ + +/** + * 转换 LaTeX 公式括号 `\[\]` 和 `\(\)` 为 Markdown 格式 `$$...$$` 和 `$...$` + * + * remark-math 本身不支持 LaTeX 原生语法,作为替代的一些插件效果也不理想。 + * + * 目前的实现: + * - 保护代码块和链接,避免被 remark-math 处理 + * - 支持嵌套括号的平衡匹配 + * - 转义 `\\(x\\)` 会被处理为 `\$x\$`,`\\[x\\]` 会被处理为 `\$$x\$$` + * + * @see https://github.com/remarkjs/remark-math/issues/39 + * @param text 输入的 Markdown 文本 + * @returns 处理后的字符串 + */ +export const processLatexBrackets = (text: string) => { + // 没有 LaTeX 模式直接返回 + if (!containsLatexRegex.test(text)) { + return text + } + + // 保护代码块和链接 + const protectedItems: string[] = [] + let processedContent = text + + processedContent = processedContent + // 保护代码块(包括多行代码块和行内代码) + .replace(/(```[\s\S]*?```|`[^`]*`)/g, (match) => { + const index = protectedItems.length + protectedItems.push(match) + return `__CHERRY_STUDIO_PROTECTED_${index}__` + }) + // 保护链接 [text](url) + .replace(/\[([^[\]]*(?:\[[^\]]*\][^[\]]*)*)\]\([^)]*?\)/g, (match) => { + const index = protectedItems.length + protectedItems.push(match) + return `__CHERRY_STUDIO_PROTECTED_${index}__` + }) + + // LaTeX 括号转换函数 + const processMath = (content: string, openDelim: string, closeDelim: string, wrapper: string): string => { + let result = '' + let remaining = content + + while (remaining.length > 0) { + const match = balanced(openDelim, closeDelim, remaining) + if (!match) { + result += remaining + break + } + + result += match.pre + result += `${wrapper}${match.body}${wrapper}` + remaining = match.post + } + + return result + } + + // 先处理块级公式,再处理内联公式 + let result = processMath(processedContent, '\\[', '\\]', '$$') + result = processMath(result, '\\(', '\\)', '$') + + // 还原被保护的内容 + result = result.replace(/__CHERRY_STUDIO_PROTECTED_(\d+)__/g, (match, indexStr) => { + const index = parseInt(indexStr, 10) + // 添加边界检查,防止数组越界 + if (index >= 0 && index < protectedItems.length) { + return protectedItems[index] + } + // 如果索引无效,保持原始匹配 + return match + }) + + return result +} + /** * 转换数学公式格式: * - 将 LaTeX 格式的 '\\[' 和 '\\]' 转换为 '$$$$'。 diff --git a/yarn.lock b/yarn.lock index d17be5b78..099605c38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4258,6 +4258,13 @@ __metadata: languageName: node linkType: hard +"@types/balanced-match@npm:^3": + version: 3.0.2 + resolution: "@types/balanced-match@npm:3.0.2" + checksum: 10c0/833f6499609363537026c4ec2770af5c5a36e71b80f7b5b23884b15296301bfcf974cd40bc75fda940dea4994acd96c9222b284c248383a1ade59bf8835940b0 + languageName: node + linkType: hard + "@types/cacheable-request@npm:^6.0.1": version: 6.0.3 resolution: "@types/cacheable-request@npm:6.0.3" @@ -4897,6 +4904,15 @@ __metadata: languageName: node linkType: hard +"@types/word-extractor@npm:^1": + version: 1.0.6 + resolution: "@types/word-extractor@npm:1.0.6" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/84f89c458213db5aec4d6badad14e0f2c07ac4b92f16165d19a95548f2b98fd5fff00419d49547464cb75c9432b5e9cb3b452d75eb5f07d808e31b44be390453 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.4": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" @@ -5776,6 +5792,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.3.0" "@tryfabric/martian": "npm:^1.2.4" + "@types/balanced-match": "npm:^3" "@types/diff": "npm:^7" "@types/fs-extra": "npm:^11" "@types/lodash": "npm:^4.17.5" @@ -5788,6 +5805,7 @@ __metadata: "@types/react-infinite-scroll-component": "npm:^5.0.0" "@types/react-window": "npm:^1" "@types/tinycolor2": "npm:^1" + "@types/word-extractor": "npm:^1" "@uiw/codemirror-extensions-langs": "npm:^4.23.12" "@uiw/codemirror-themes-all": "npm:^4.23.12" "@uiw/react-codemirror": "npm:^4.23.12" @@ -5801,6 +5819,7 @@ __metadata: archiver: "npm:^7.0.1" async-mutex: "npm:^0.5.0" axios: "npm:^1.7.3" + balanced-match: "npm:^3.0.1" browser-image-compression: "npm:^2.0.2" color: "npm:^5.0.0" dayjs: "npm:^1.11.11" @@ -5859,7 +5878,7 @@ __metadata: react-hotkeys-hook: "npm:^4.6.1" react-i18next: "npm:^14.1.2" react-infinite-scroll-component: "npm:^6.1.0" - react-markdown: "npm:^9.0.1" + react-markdown: "npm:^10.1.0" react-redux: "npm:^9.1.2" react-router: "npm:6" react-router-dom: "npm:6" @@ -5868,10 +5887,10 @@ __metadata: redux: "npm:^5.0.1" redux-persist: "npm:^6.0.0" rehype-katex: "npm:^7.0.1" - rehype-mathjax: "npm:^7.0.0" + rehype-mathjax: "npm:^7.1.0" rehype-raw: "npm:^7.0.0" - remark-cjk-friendly: "npm:^1.1.0" - remark-gfm: "npm:^4.0.0" + remark-cjk-friendly: "npm:^1.2.0" + remark-gfm: "npm:^4.0.1" remark-math: "npm:^6.0.0" remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" @@ -5889,6 +5908,7 @@ __metadata: vite: "npm:6.2.6" vitest: "npm:^3.1.4" webdav: "npm:^5.8.0" + word-extractor: "npm:^1.0.4" zipread: "npm:^1.3.3" dependenciesMeta: "@cherrystudio/mac-system-ocr": @@ -6436,6 +6456,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^3.0.1": + version: 3.0.1 + resolution: "balanced-match@npm:3.0.1" + checksum: 10c0/ac8dd63a5b260610c2cbda982f436e964c1b9ae8764d368a523769da40a31710abd6e19f0fdf1773c4ad7b2ea7ba7b285d547375dc723f6e754369835afc8e9f + languageName: node + linkType: hard + "bare-events@npm:^2.2.0": version: 2.5.4 resolution: "bare-events@npm:2.5.4" @@ -12903,9 +12930,9 @@ __metadata: languageName: node linkType: hard -"micromark-extension-cjk-friendly-util@npm:^1.1.0": - version: 1.1.0 - resolution: "micromark-extension-cjk-friendly-util@npm:1.1.0" +"micromark-extension-cjk-friendly-util@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-extension-cjk-friendly-util@npm:2.0.0" dependencies: get-east-asian-width: "npm:^1.3.0" micromark-util-character: "npm:^2.0.0" @@ -12913,16 +12940,16 @@ __metadata: peerDependenciesMeta: micromark-util-types: optional: true - checksum: 10c0/3ae1d4fd92f03a6c8e34e314c14a42b35cdd1bcbe043fceb1d2d45cd1a7b364e77643a3ca181910666cb11cc3606a1595fae9a15e87b0a4988fc57d5e4f65f67 + checksum: 10c0/194c799d88982ebf785e65a1c29cbded17d5dd3510a1769123ec30ddb7e256502b97753f63e8994d91ebafa1e9b96aa2dc2a90aa4e2f2072269b05652a412886 languageName: node linkType: hard -"micromark-extension-cjk-friendly@npm:^1.1.0": - version: 1.1.0 - resolution: "micromark-extension-cjk-friendly@npm:1.1.0" +"micromark-extension-cjk-friendly@npm:^1.2.0": + version: 1.2.0 + resolution: "micromark-extension-cjk-friendly@npm:1.2.0" dependencies: devlop: "npm:^1.0.0" - micromark-extension-cjk-friendly-util: "npm:^1.1.0" + micromark-extension-cjk-friendly-util: "npm:^2.0.0" micromark-util-chunked: "npm:^2.0.0" micromark-util-resolve-all: "npm:^2.0.0" micromark-util-symbol: "npm:^2.0.0" @@ -12932,7 +12959,7 @@ __metadata: peerDependenciesMeta: micromark-util-types: optional: true - checksum: 10c0/95be6d8b4164b9b3b5281d77ed4f9337d95b2041ad4f7a775baa0d7f8ec495818101881eea2c7cc0ee4ee11738716899f20b3fbfbc2e6b80106544065d2ec04d + checksum: 10c0/5be1841629310e21c803b64feb00453fb8ac939be80c2ff473d8b4486d8eca973347520912a6e4abeda5bea4ed8ef39d3db48c4bad8285dd380d9ed34417dd0d languageName: node linkType: hard @@ -15796,9 +15823,9 @@ __metadata: languageName: node linkType: hard -"react-markdown@npm:^9.0.1": - version: 9.1.0 - resolution: "react-markdown@npm:9.1.0" +"react-markdown@npm:^10.1.0": + version: 10.1.0 + resolution: "react-markdown@npm:10.1.0" dependencies: "@types/hast": "npm:^3.0.0" "@types/mdast": "npm:^4.0.0" @@ -15814,7 +15841,7 @@ __metadata: peerDependencies: "@types/react": ">=18" react: ">=18" - checksum: 10c0/5bd645d39379f776d64588105f4060c390c3c8e4ff048552c9fa0ad31b756bb3ff7c11081542dc58d840ccf183a6dd4fd4d4edab44d8c24dee8b66551a5fd30d + checksum: 10c0/4a5dc7d15ca6d05e9ee95318c1904f83b111a76f7588c44f50f1d54d4c97193b84e4f64c4b592057c989228238a2590306cedd0c4d398e75da49262b2b5ae1bf languageName: node linkType: hard @@ -16100,7 +16127,7 @@ __metadata: languageName: node linkType: hard -"rehype-mathjax@npm:^7.0.0": +"rehype-mathjax@npm:^7.1.0": version: 7.1.0 resolution: "rehype-mathjax@npm:7.1.0" dependencies: @@ -16127,18 +16154,18 @@ __metadata: languageName: node linkType: hard -"remark-cjk-friendly@npm:^1.1.0": - version: 1.1.0 - resolution: "remark-cjk-friendly@npm:1.1.0" +"remark-cjk-friendly@npm:^1.2.0": + version: 1.2.0 + resolution: "remark-cjk-friendly@npm:1.2.0" dependencies: - micromark-extension-cjk-friendly: "npm:^1.1.0" + micromark-extension-cjk-friendly: "npm:^1.2.0" peerDependencies: "@types/mdast": ^4.0.0 unified: ^11.0.0 peerDependenciesMeta: "@types/mdast": optional: true - checksum: 10c0/ef43a4c404baaaa3e3d888ea68db8ffa101746faadb96d19d6b7ee8d00f0a025613c2e508527236961b226e41d8fb34f6cc6ac217ae8770fcbf47b9f496ab32a + checksum: 10c0/ca7dc4fd50491693c4a84164650b30c3ae027cc7aa11b7a2e3811ab07ad0bf0c73484e37f9aed710bb68f95ca03cc540effe64cbe94bbc055b40e1aa951e2013 languageName: node linkType: hard @@ -16152,7 +16179,7 @@ __metadata: languageName: node linkType: hard -"remark-gfm@npm:^4.0.0": +"remark-gfm@npm:^4.0.1": version: 4.0.1 resolution: "remark-gfm@npm:4.0.1" dependencies: @@ -16613,6 +16640,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^5.0.1": + version: 5.0.1 + resolution: "saxes@npm:5.0.1" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/b7476c41dbe1c3a89907d2546fecfba234de5e66743ef914cde2603f47b19bed09732ab51b528ad0f98b958369d8be72b6f5af5c9cfad69972a73d061f0b3952 + languageName: node + linkType: hard + "saxes@npm:^6.0.0": version: 6.0.0 resolution: "saxes@npm:6.0.0" @@ -18817,6 +18853,16 @@ __metadata: languageName: node linkType: hard +"word-extractor@npm:^1.0.4": + version: 1.0.4 + resolution: "word-extractor@npm:1.0.4" + dependencies: + saxes: "npm:^5.0.1" + yauzl: "npm:^2.10.0" + checksum: 10c0/f8c6b4f9278802d0c803479c1441713e351e67f7b0d2f85bd8cbe94b76298d4adb058b5f23ee0a01faa02f3b1f01c507a4a2f44fa39cfcbd498a51769dd9e8e7 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5"