Merge branch 'main' into feat-knowlege-ocr

This commit is contained in:
eeee0717
2025-06-23 17:37:18 +08:00
54 changed files with 1264 additions and 426 deletions
+6 -8
View File
@@ -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
View File
@@ -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": {
+3
View File
@@ -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 -1
View File
@@ -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([
+24 -27
View File
@@ -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) {
+36 -52
View File
@@ -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) {
+5
View File
@@ -0,0 +1,5 @@
import { app } from 'electron'
import { initAppDataDir } from './utils/file'
app.isPackaged && initAppDataDir()
+5 -2
View File
@@ -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
View File
@@ -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)
})
+1
View File
@@ -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,
+12 -1
View File
@@ -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
+1 -1
View File
@@ -56,7 +56,7 @@ export class WindowService {
minHeight: 600,
show: false,
autoHideMenuBar: true,
transparent: isMac,
transparent: false,
vibrancy: 'sidebar',
visualEffectState: 'active',
titleBarStyle: 'hidden',
+1
View File
@@ -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
View File
@@ -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))
}
+4 -1
View File
@@ -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,
+20 -5
View File
@@ -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>
+2 -8
View File
@@ -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> {
+8
View File
@@ -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()
+9 -5
View File
@@ -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",
+10 -6
View File
@@ -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}} 分",
+9 -5
View File
@@ -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}} минута",
+9 -5
View File
@@ -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": "使用话题命名模型为导出的消息创建标题",
+9 -5
View File
@@ -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}} 分鐘",
+2 -2
View File
@@ -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()
@@ -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')
})
})
})
@@ -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);
+1 -1
View File
@@ -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) => ({
+17 -4
View File
@@ -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()) {
+22 -2
View File
@@ -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))
+27 -37
View File
@@ -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
}
]
-1
View File
@@ -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
}
+1
View File
@@ -368,6 +368,7 @@ export type AppInfo = {
logsPath: string
arch: string
isPortable: boolean
installPath: string
}
export interface Shortcut {
+1 -1
View File
@@ -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)
})
})
})
})
-18
View File
@@ -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
+80
View File
@@ -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 '\\[' '\\]' '$$$$'
+70 -24
View File
@@ -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"