Merge branch 'main' into feat-knowlege-ocr
This commit is contained in:
@@ -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 服务商
|
||||
- 错误修复和界面优化
|
||||
|
||||
+10
-6
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
import { initAppDataDir } from './utils/file'
|
||||
|
||||
app.isPackaged && initAppDataDir()
|
||||
+5
-2
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
+31
-5
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const FILE_LOADER_MAP: Record<string, string> = {
|
||||
// 内置类型
|
||||
'.pdf': 'common',
|
||||
'.csv': 'common',
|
||||
'.doc': 'common',
|
||||
'.docx': 'common',
|
||||
'.pptx': 'common',
|
||||
'.xlsx': 'common',
|
||||
|
||||
@@ -106,6 +106,7 @@ class SequentialThinkingServer {
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
thought: validatedInput.thought,
|
||||
thoughtNumber: validatedInput.thoughtNumber,
|
||||
totalThoughts: validatedInput.totalThoughts,
|
||||
nextThoughtNeeded: validatedInput.nextThoughtNeeded,
|
||||
|
||||
@@ -231,10 +231,21 @@ class FileStorage {
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||
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
|
||||
|
||||
@@ -56,7 +56,7 @@ export class WindowService {
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
transparent: false,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
|
||||
@@ -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)
|
||||
|
||||
+61
-23
@@ -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<string, FileTypes>()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<GenericChunk>) {
|
||||
// 处理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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,11 +41,10 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
||||
return (
|
||||
<EditorContainer style={{ height }}>
|
||||
<InputArea value={inputValue} onChange={handleChange} placeholder={placeholder} autoFocus={autoFocus} />
|
||||
<PreviewArea>
|
||||
<PreviewArea className="markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkCjkFriendly, remarkMath]}
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex]}
|
||||
className="markdown">
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex]}>
|
||||
{inputValue || t('settings.provider.notes.markdown_editor_default_value')}
|
||||
</ReactMarkdown>
|
||||
</PreviewArea>
|
||||
|
||||
@@ -1352,12 +1352,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
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<string, any> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}} 分",
|
||||
|
||||
@@ -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}} минута",
|
||||
|
||||
@@ -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": "使用话题命名模型为导出的消息创建标题",
|
||||
|
||||
@@ -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}} 分鐘",
|
||||
|
||||
@@ -75,8 +75,8 @@ const AgentsPage: FC = () => {
|
||||
{agent.description && <AgentDescription>{agent.description}</AgentDescription>}
|
||||
|
||||
{agent.prompt && (
|
||||
<AgentPrompt>
|
||||
<ReactMarkdown className="markdown">{agent.prompt}</ReactMarkdown>{' '}
|
||||
<AgentPrompt className="markdown">
|
||||
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
|
||||
</AgentPrompt>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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;
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ block }) => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
className="markdown"
|
||||
components={components}
|
||||
disallowedElements={DISALLOWED_ELEMENTS}
|
||||
urlTransform={urlTransform}
|
||||
remarkRehypeOptions={{
|
||||
footnoteLabel: t('common.footnotes'),
|
||||
footnoteLabelTagName: 'h4',
|
||||
footnoteBackContent: ' '
|
||||
}}>
|
||||
{messageContent}
|
||||
</ReactMarkdown>
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={components}
|
||||
disallowedElements={DISALLOWED_ELEMENTS}
|
||||
urlTransform={urlTransform}
|
||||
remarkRehypeOptions={{
|
||||
footnoteLabel: t('common.footnotes'),
|
||||
footnoteLabelTagName: 'h4',
|
||||
footnoteBackContent: ' '
|
||||
}}>
|
||||
{messageContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(<Markdown block={block} />)
|
||||
const { container } = render(<Markdown block={block} />)
|
||||
|
||||
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(<Markdown block={createMainTextBlock({ content })} />)
|
||||
|
||||
expect(escapeBrackets).toHaveBeenCalledWith(content)
|
||||
expect(removeSvgEmptyLines).toHaveBeenCalledWith(content)
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const { container } = render(<Markdown block={createMainTextBlock()} />)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
|
||||
+44
-41
@@ -3,55 +3,58 @@
|
||||
exports[`Markdown > rendering > should match snapshot 1`] = `
|
||||
<div
|
||||
class="markdown"
|
||||
data-testid="markdown-content"
|
||||
>
|
||||
# Test Markdown
|
||||
<div
|
||||
data-testid="markdown-content"
|
||||
>
|
||||
# Test Markdown
|
||||
|
||||
This is **bold** text.
|
||||
<span
|
||||
data-testid="has-link-component"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<div
|
||||
data-testid="has-code-component"
|
||||
>
|
||||
<div
|
||||
data-id="code-block-1"
|
||||
data-testid="code-block"
|
||||
<span
|
||||
data-testid="has-link-component"
|
||||
>
|
||||
<code>
|
||||
test code
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="has-table-component"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<div
|
||||
data-block-id="test-block-1"
|
||||
data-testid="table-component"
|
||||
data-testid="has-code-component"
|
||||
>
|
||||
<table>
|
||||
test table
|
||||
</table>
|
||||
<button
|
||||
data-testid="copy-table-button"
|
||||
type="button"
|
||||
<div
|
||||
data-id="code-block-1"
|
||||
data-testid="code-block"
|
||||
>
|
||||
Copy Table
|
||||
</button>
|
||||
<code>
|
||||
test code
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="has-table-component"
|
||||
>
|
||||
<div
|
||||
data-block-id="test-block-1"
|
||||
data-testid="table-component"
|
||||
>
|
||||
<table>
|
||||
test table
|
||||
</table>
|
||||
<button
|
||||
data-testid="copy-table-button"
|
||||
type="button"
|
||||
>
|
||||
Copy Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-testid="has-img-component"
|
||||
>
|
||||
img
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
data-testid="has-img-component"
|
||||
>
|
||||
img
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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(<ReactMarkdown remarkPlugins={[remarkDisableConstructs(constructs)]}>{markdown}</ReactMarkdown>)
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
+107
@@ -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']
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -127,11 +127,13 @@ const Topics: FC<Props> = ({ 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<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
<TopicName className={getTopicNameClassName()} title={topicName}>
|
||||
{topicName}
|
||||
</TopicName>
|
||||
{isActive && !topic.pinned && (
|
||||
{!topic.pinned && (
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
mouseEnterDelay={0.7}
|
||||
@@ -548,6 +550,10 @@ const TopicListItem = styled.div`
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-list-item-hover);
|
||||
transition: background-color 0.1s;
|
||||
.menu {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-list-item);
|
||||
|
||||
@@ -157,7 +157,7 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
max-width: var(--assistants-width);
|
||||
min-width: var(--assistants-width);
|
||||
background-color: transparent;
|
||||
background-color: var(--color-background);
|
||||
overflow: hidden;
|
||||
.collapsed {
|
||||
width: 0;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import { ChevronsUpDown } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -45,9 +46,10 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
<ButtonContent>
|
||||
<ModelAvatar model={model} size={20} />
|
||||
<ModelName>
|
||||
{model ? model.name : t('button.select_model')} {providerName ? '| ' + providerName : ''}
|
||||
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
|
||||
</ModelName>
|
||||
</ButtonContent>
|
||||
<ChevronsUpDown size={14} color="var(--color-icon)" />
|
||||
</DropdownButton>
|
||||
)
|
||||
}
|
||||
@@ -55,21 +57,23 @@ const SelectModelButton: FC<Props> = ({ 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
|
||||
|
||||
@@ -103,8 +103,8 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
</HStack>
|
||||
<TextAreaContainer>
|
||||
{showMarkdown ? (
|
||||
<MarkdownContainer onClick={() => setShowMarkdown(false)}>
|
||||
<ReactMarkdown className="markdown">{prompt}</ReactMarkdown>
|
||||
<MarkdownContainer className="markdown" onClick={() => setShowMarkdown(false)}>
|
||||
<ReactMarkdown>{prompt}</ReactMarkdown>
|
||||
<div style={{ height: '30px' }} />
|
||||
</MarkdownContainer>
|
||||
) : (
|
||||
|
||||
@@ -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 = () => {
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('settings.data.app_data.migration_title')}</div>
|
||||
)
|
||||
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 = () => {
|
||||
<div>
|
||||
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
defaultChecked={shouldCopyData}
|
||||
onChange={(checked) => {
|
||||
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 = (
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('settings.data.app_data.migration_title')}</div>
|
||||
)
|
||||
const className = 'migration-modal'
|
||||
const messageKey = 'data-migration'
|
||||
|
||||
// Create PathsContent component for this specific migration
|
||||
const PathsContent = () => (
|
||||
<div>
|
||||
<MigrationPathRow>
|
||||
<MigrationPathLabel>{t('settings.data.app_data.original_path')}:</MigrationPathLabel>
|
||||
<MigrationPathValue>{originalPath}</MigrationPathValue>
|
||||
</MigrationPathRow>
|
||||
<MigrationPathRow style={{ marginTop: '16px' }}>
|
||||
<MigrationPathLabel>{t('settings.data.app_data.new_path')}:</MigrationPathLabel>
|
||||
<MigrationPathValue>{newDataPath}</MigrationPathValue>
|
||||
</MigrationPathRow>
|
||||
</div>
|
||||
)
|
||||
|
||||
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<void> => {
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ const TranslatePage: FC = () => {
|
||||
const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese'])
|
||||
const [settingsVisible, setSettingsVisible] = useState(false)
|
||||
const [detectedLanguage, setDetectedLanguage] = useState<string | null>(null)
|
||||
const [sourceLanguage, setSourceLanguage] = useState<string>('auto') // 添加用户选择的源语言状态
|
||||
const [sourceLanguage, setSourceLanguage] = useState<string>('auto')
|
||||
const contentContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textAreaRef = useRef<TextAreaRef>(null)
|
||||
const outputTextRef = useRef<HTMLDivElement>(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) => ({
|
||||
|
||||
@@ -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<void>
|
||||
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<void>
|
||||
callType: 'check',
|
||||
messages: 'hi',
|
||||
assistant,
|
||||
streamOutput: false
|
||||
streamOutput: false,
|
||||
shouldThrow: true
|
||||
}
|
||||
const result = await ai.completions(params)
|
||||
if (!result.getText()) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -368,6 +368,7 @@ export type AppInfo = {
|
||||
logsPath: string
|
||||
arch: string
|
||||
isPortable: boolean
|
||||
installPath: string
|
||||
}
|
||||
|
||||
export interface Shortcut {
|
||||
|
||||
@@ -102,6 +102,6 @@ export type GeminiSdkToolCall = FunctionCall
|
||||
|
||||
export type GeminiOptions = {
|
||||
streamOutput: boolean
|
||||
abortSignal?: AbortSignal
|
||||
signal?: AbortSignal
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
@@ -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 = '<html><head><title>Page Title</title></head><body>Content</body></html>'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 格式的 '\\[' 和 '\\]' 转换为 '$$$$'。
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user