mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 05:11:24 +08:00
Merge branch 'main' into feat/aisdk-package
This commit is contained in:
commit
592a7ddc3f
30
README.md
30
README.md
@ -1,3 +1,33 @@
|
||||
<div align="right" >
|
||||
<details>
|
||||
<summary >🌐 Language</summary>
|
||||
<div>
|
||||
<div align="right">
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=en">English</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-CN">简体中文</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-TW">繁體中文</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ja">日本語</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ko">한국어</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=hi">हिन्दी</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=th">ไทย</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fr">Français</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=de">Deutsch</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=es">Español</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=it">Itapano</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ru">Русский</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pt">Português</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=nl">Nederlands</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pl">Polski</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ar">العربية</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fa">فارسی</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=tr">Türkçe</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=vi">Tiếng Việt</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=id">Bahasa Indonesia</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h1 align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/releases">
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
|
||||
|
||||
@ -107,9 +107,10 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
- 新功能:可选数据保存目录
|
||||
- 快捷助手:支持单独选择助手,支持暂停、上下文、思考过程、流式
|
||||
- 划词助手:系统托盘菜单开关
|
||||
- 翻译:新增 Markdown 预览选项
|
||||
- 新供应商:新增 Vertex AI 服务商
|
||||
- 错误修复和界面优化
|
||||
界面优化:优化多处界面样式,气泡样式改版,自动调整代码预览边栏宽度
|
||||
知识库:修复知识库引用不显示问题,修复部分嵌入模型适配问题
|
||||
备份与恢复:修复超过 2GB 大文件无法恢复问题
|
||||
文件处理:添加 .doc 文件支持
|
||||
划词助手:支持自定义 CSS 样式
|
||||
MCP:基于 Pyodide 实现 Python MCP 服务
|
||||
其他错误修复和优化
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.7",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@ -62,6 +62,7 @@
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"notion-helper": "^1.3.22",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"selection-hook": "^0.9.23",
|
||||
@ -124,6 +125,7 @@
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/react-window": "^1",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/word-extractor": "^1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.12",
|
||||
"@uiw/codemirror-themes-all": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
@ -177,10 +179,10 @@
|
||||
"mermaid": "^11.6.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"opendal": "0.47.11",
|
||||
"p-queue": "^8.1.0",
|
||||
"playwright": "^1.52.0",
|
||||
"prettier": "^3.5.3",
|
||||
@ -213,12 +215,13 @@
|
||||
"styled-components": "^6.1.11",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^0.4.1",
|
||||
"tokenx": "^1.1.0",
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "6.2.6",
|
||||
"vitest": "^3.1.4",
|
||||
"webdav": "^5.8.0",
|
||||
"word-extractor": "^1.0.4",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@ -3,6 +3,8 @@ export enum IpcChannel {
|
||||
App_ClearCache = 'app:clear-cache',
|
||||
App_SetLaunchOnBoot = 'app:set-launch-on-boot',
|
||||
App_SetLanguage = 'app:set-language',
|
||||
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
||||
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
||||
App_CheckForUpdate = 'app:check-for-update',
|
||||
App_Reload = 'app:reload',
|
||||
@ -13,13 +15,17 @@ export enum IpcChannel {
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
App_SetTheme = 'app:set-theme',
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_SetFeedUrl = 'app:set-feed-url',
|
||||
App_SetTestPlan = 'app:set-test-plan',
|
||||
App_SetTestChannel = 'app:set-test-channel',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
App_Select = 'app:select',
|
||||
App_HasWritePermission = 'app:has-write-permission',
|
||||
App_Copy = 'app:copy',
|
||||
App_SetStopQuitApp = 'app:set-stop-quit-app',
|
||||
App_SetAppDataPath = 'app:set-app-data-path',
|
||||
App_GetDataPathFromArgs = 'app:get-data-path-from-args',
|
||||
App_FlushAppData = 'app:flush-app-data',
|
||||
App_IsNotEmptyDir = 'app:is-not-empty-dir',
|
||||
App_RelaunchApp = 'app:relaunch-app',
|
||||
App_IsBinaryExist = 'app:is-binary-exist',
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
@ -32,6 +38,7 @@ export enum IpcChannel {
|
||||
Notification_OnClick = 'notification:on-click',
|
||||
|
||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
||||
|
||||
// Open
|
||||
Open_Path = 'open:path',
|
||||
@ -64,6 +71,9 @@ export enum IpcChannel {
|
||||
Mcp_ServersUpdated = 'mcp:servers-updated',
|
||||
Mcp_CheckConnectivity = 'mcp:check-connectivity',
|
||||
|
||||
// Python
|
||||
Python_Execute = 'python:execute',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||
@ -143,6 +153,11 @@ export enum IpcChannel {
|
||||
Backup_CheckConnection = 'backup:checkConnection',
|
||||
Backup_CreateDirectory = 'backup:createDirectory',
|
||||
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
|
||||
Backup_BackupToS3 = 'backup:backupToS3',
|
||||
Backup_RestoreFromS3 = 'backup:restoreFromS3',
|
||||
Backup_ListS3Files = 'backup:listS3Files',
|
||||
Backup_DeleteS3File = 'backup:deleteS3File',
|
||||
Backup_CheckS3Connection = 'backup:checkS3Connection',
|
||||
|
||||
// zip
|
||||
Zip_Compress = 'zip:compress',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const documentExts = ['.pdf', '.doc', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const thirdPartyApplicationExts = ['.draftsExport']
|
||||
export const bookExts = ['.epub']
|
||||
const textExtsByCategory = new Map([
|
||||
@ -406,6 +406,16 @@ export const defaultLanguage = 'en-US'
|
||||
|
||||
export enum FeedUrl {
|
||||
PRODUCTION = 'https://releases.cherry-ai.com',
|
||||
EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
||||
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download',
|
||||
PRERELEASE_LOWEST = 'https://github.com/CherryHQ/cherry-studio/releases/download/v1.4.0'
|
||||
}
|
||||
|
||||
export enum UpgradeChannel {
|
||||
LATEST = 'latest', // 最新稳定版本
|
||||
RC = 'rc', // 公测版本
|
||||
BETA = 'beta' // 预览版本
|
||||
}
|
||||
|
||||
export const defaultTimeout = 5 * 1000 * 60
|
||||
|
||||
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']
|
||||
|
||||
@ -2,12 +2,12 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const AdmZip = require('adm-zip')
|
||||
const StreamZip = require('node-stream-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading bun binaries
|
||||
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
|
||||
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
|
||||
const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const BUN_PACKAGES = {
|
||||
@ -66,35 +66,36 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
|
||||
|
||||
// Extract the zip file using adm-zip
|
||||
console.log(`Extracting ${packageName} to ${binDir}...`)
|
||||
const zip = new AdmZip(tempFilename)
|
||||
zip.extractAllTo(tempdir, true)
|
||||
const zip = new StreamZip.async({ file: tempFilename })
|
||||
|
||||
// Move files using Node.js fs
|
||||
const sourceDir = path.join(tempdir, packageName.split('.')[0])
|
||||
const files = fs.readdirSync(sourceDir)
|
||||
// Get all entries in the zip file
|
||||
const entries = await zip.entries()
|
||||
|
||||
for (const file of files) {
|
||||
const sourcePath = path.join(sourceDir, file)
|
||||
const destPath = path.join(binDir, file)
|
||||
// Extract files directly to binDir, flattening the directory structure
|
||||
for (const entry of Object.values(entries)) {
|
||||
if (!entry.isDirectory) {
|
||||
// Get just the filename without path
|
||||
const filename = path.basename(entry.name)
|
||||
const outputPath = path.join(binDir, filename)
|
||||
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
fs.unlinkSync(sourcePath)
|
||||
|
||||
// Set executable permissions for non-Windows platforms
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
// 755 permission: rwxr-xr-x
|
||||
fs.chmodSync(destPath, '755')
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
|
||||
console.log(`Extracting ${entry.name} -> ${filename}`)
|
||||
await zip.extract(entry.name, outputPath)
|
||||
// Make executable files executable on Unix-like systems
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(outputPath, 0o755)
|
||||
} catch (chmodError) {
|
||||
console.error(`Warning: Failed to set executable permissions on ${filename}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
console.log(`Extracted ${entry.name} -> ${outputPath}`)
|
||||
}
|
||||
}
|
||||
await zip.close()
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(tempFilename)
|
||||
fs.rmSync(sourceDir, { recursive: true })
|
||||
|
||||
console.log(`Successfully installed bun ${version} for ${platformKey}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
@ -2,34 +2,33 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const tar = require('tar')
|
||||
const AdmZip = require('adm-zip')
|
||||
const StreamZip = require('node-stream-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading uv binaries
|
||||
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
|
||||
const DEFAULT_UV_VERSION = '0.6.14'
|
||||
const DEFAULT_UV_VERSION = '0.7.13'
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const UV_PACKAGES = {
|
||||
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
|
||||
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
|
||||
'darwin-arm64': 'uv-aarch64-apple-darwin.zip',
|
||||
'darwin-x64': 'uv-x86_64-apple-darwin.zip',
|
||||
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
|
||||
'win32-ia32': 'uv-i686-pc-windows-msvc.zip',
|
||||
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
|
||||
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz',
|
||||
'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz',
|
||||
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz',
|
||||
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz',
|
||||
'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz',
|
||||
'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz',
|
||||
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz',
|
||||
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip',
|
||||
'linux-ia32': 'uv-i686-unknown-linux-gnu.zip',
|
||||
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip',
|
||||
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip',
|
||||
'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip',
|
||||
'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip',
|
||||
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip',
|
||||
// MUSL variants
|
||||
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz',
|
||||
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
|
||||
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip',
|
||||
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip',
|
||||
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip',
|
||||
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip',
|
||||
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip'
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,46 +65,35 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
|
||||
|
||||
console.log(`Extracting ${packageName} to ${binDir}...`)
|
||||
|
||||
// 根据文件扩展名选择解压方法
|
||||
if (packageName.endsWith('.zip')) {
|
||||
// 使用 adm-zip 处理 zip 文件
|
||||
const zip = new AdmZip(tempFilename)
|
||||
zip.extractAllTo(binDir, true)
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
|
||||
return true
|
||||
} else {
|
||||
// tar.gz 文件的处理保持不变
|
||||
await tar.x({
|
||||
file: tempFilename,
|
||||
cwd: tempdir,
|
||||
z: true
|
||||
})
|
||||
const zip = new StreamZip.async({ file: tempFilename })
|
||||
|
||||
// Move files using Node.js fs
|
||||
const sourceDir = path.join(tempdir, packageName.split('.')[0])
|
||||
const files = fs.readdirSync(sourceDir)
|
||||
for (const file of files) {
|
||||
const sourcePath = path.join(sourceDir, file)
|
||||
const destPath = path.join(binDir, file)
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
fs.unlinkSync(sourcePath)
|
||||
// Get all entries in the zip file
|
||||
const entries = await zip.entries()
|
||||
|
||||
// Set executable permissions for non-Windows platforms
|
||||
// Extract files directly to binDir, flattening the directory structure
|
||||
for (const entry of Object.values(entries)) {
|
||||
if (!entry.isDirectory) {
|
||||
// Get just the filename without path
|
||||
const filename = path.basename(entry.name)
|
||||
const outputPath = path.join(binDir, filename)
|
||||
|
||||
console.log(`Extracting ${entry.name} -> ${filename}`)
|
||||
await zip.extract(entry.name, outputPath)
|
||||
// Make executable files executable on Unix-like systems
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(destPath, '755')
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
|
||||
fs.chmodSync(outputPath, 0o755)
|
||||
} catch (chmodError) {
|
||||
console.error(`Warning: Failed to set executable permissions on ${filename}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
console.log(`Extracted ${entry.name} -> ${outputPath}`)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(tempFilename)
|
||||
fs.rmSync(sourceDir, { recursive: true })
|
||||
}
|
||||
|
||||
await zip.close()
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
33
src/main/bootstrap.ts
Normal file
33
src/main/bootstrap.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { occupiedDirs } from '@shared/config/constant'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { initAppDataDir } from './utils/file'
|
||||
|
||||
app.isPackaged && initAppDataDir()
|
||||
|
||||
// 在主进程中复制 appData 中某些一直被占用的文件
|
||||
// 在renderer进程还没有启动时,主进程可以复制这些文件到新的appData中
|
||||
function copyOccupiedDirsInMainProcess() {
|
||||
const newAppDataPath = process.argv
|
||||
.slice(1)
|
||||
.find((arg) => arg.startsWith('--new-data-path='))
|
||||
?.split('--new-data-path=')[1]
|
||||
if (!newAppDataPath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const appDataPath = app.getPath('userData')
|
||||
occupiedDirs.forEach((dir) => {
|
||||
const dirPath = path.join(appDataPath, dir)
|
||||
const newDirPath = path.join(newAppDataPath, dir)
|
||||
if (fs.existsSync(dirPath)) {
|
||||
fs.cpSync(dirPath, newDirPath, { recursive: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
copyOccupiedDirsInMainProcess()
|
||||
@ -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()
|
||||
|
||||
/**
|
||||
|
||||
112
src/main/ipc.ts
112
src/main/ipc.ts
@ -1,13 +1,14 @@
|
||||
import fs from 'node:fs'
|
||||
import { arch } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell } from 'electron'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
@ -24,6 +25,7 @@ import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
@ -34,7 +36,7 @@ import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateConfig } from './utils/file'
|
||||
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
@ -47,6 +49,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
|
||||
// Initialize Python service with main window
|
||||
pythonService.setMainWindow(mainWindow)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Info, () => ({
|
||||
version: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
@ -57,7 +62,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
resourcesPath: getResourcePath(),
|
||||
logsPath: log.transports.file.getFile().path,
|
||||
arch: arch(),
|
||||
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
|
||||
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env,
|
||||
installPath: path.dirname(app.getPath('exe'))
|
||||
}))
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
|
||||
@ -85,6 +91,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setLanguage(language)
|
||||
})
|
||||
|
||||
// spell check
|
||||
ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => {
|
||||
// disable spell check for all webviews
|
||||
const webviews = webContents.getAllWebContents()
|
||||
webviews.forEach((webview) => {
|
||||
webview.session.setSpellCheckerEnabled(isEnable)
|
||||
})
|
||||
})
|
||||
|
||||
// spell check languages
|
||||
ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => {
|
||||
if (languages.length === 0) {
|
||||
return
|
||||
}
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
windows.forEach((window) => {
|
||||
window.webContents.session.setSpellCheckerLanguages(languages)
|
||||
})
|
||||
configManager.set('spellCheckLanguages', languages)
|
||||
})
|
||||
|
||||
// launch on boot
|
||||
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
|
||||
// Set login item settings for windows and mac
|
||||
@ -115,8 +142,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => {
|
||||
appUpdater.setFeedUrl(feedUrl)
|
||||
ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => {
|
||||
log.info('set test plan', isActive)
|
||||
if (isActive !== configManager.getTestPlan()) {
|
||||
appUpdater.cancelDownload()
|
||||
configManager.setTestPlan(isActive)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => {
|
||||
log.info('set test channel', channel)
|
||||
if (channel !== configManager.getTestChannel()) {
|
||||
appUpdater.cancelDownload()
|
||||
configManager.setTestChannel(channel)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
@ -218,14 +257,46 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// Set app data path
|
||||
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
|
||||
updateConfig(filePath)
|
||||
updateAppDataConfig(filePath)
|
||||
app.setPath('userData', filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => {
|
||||
return process.argv
|
||||
.slice(1)
|
||||
.find((arg) => arg.startsWith('--new-data-path='))
|
||||
?.split('--new-data-path=')[1]
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_FlushAppData, () => {
|
||||
BrowserWindow.getAllWindows().forEach((w) => {
|
||||
w.webContents.session.flushStorageData()
|
||||
w.webContents.session.cookies.flushStore()
|
||||
|
||||
w.webContents.session.closeAllConnections()
|
||||
})
|
||||
|
||||
session.defaultSession.flushStorageData()
|
||||
session.defaultSession.cookies.flushStore()
|
||||
session.defaultSession.closeAllConnections()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => {
|
||||
return fs.readdirSync(path).length > 0
|
||||
})
|
||||
|
||||
// Copy user data to new location
|
||||
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => {
|
||||
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string, occupiedDirs: string[] = []) => {
|
||||
try {
|
||||
await fs.promises.cp(oldPath, newPath, { recursive: true })
|
||||
await fs.promises.cp(oldPath, newPath, {
|
||||
recursive: true,
|
||||
filter: (src) => {
|
||||
if (occupiedDirs.some((dir) => src.startsWith(path.resolve(dir)))) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
log.error('Failed to copy user data:', error)
|
||||
@ -234,8 +305,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// Relaunch app
|
||||
ipcMain.handle(IpcChannel.App_RelaunchApp, () => {
|
||||
app.relaunch()
|
||||
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
|
||||
app.relaunch(options)
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
@ -273,6 +344,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
|
||||
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
|
||||
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
|
||||
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
|
||||
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File)
|
||||
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection)
|
||||
|
||||
// file
|
||||
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
|
||||
@ -377,6 +453,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
|
||||
|
||||
// Register Python execution handler
|
||||
ipcMain.handle(
|
||||
IpcChannel.Python_Execute,
|
||||
async (_, script: string, context?: Record<string, any>, timeout?: number) => {
|
||||
return await pythonService.executeScript(script, context, timeout)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
|
||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
||||
@ -422,6 +506,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
setOpenLinkExternal(webviewId, isExternal)
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
|
||||
const webview = webContents.fromId(webviewId)
|
||||
if (!webview) return
|
||||
webview.session.setSpellCheckerEnabled(isEnable)
|
||||
})
|
||||
|
||||
// store sync
|
||||
storeSyncService.registerIpcHandler()
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ const FILE_LOADER_MAP: Record<string, string> = {
|
||||
// 内置类型
|
||||
'.pdf': 'common',
|
||||
'.csv': 'common',
|
||||
'.doc': 'common',
|
||||
'.docx': 'common',
|
||||
'.pptx': 'common',
|
||||
'.xlsx': 'common',
|
||||
|
||||
44
src/main/loader/noteLoader.ts
Normal file
44
src/main/loader/noteLoader.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
|
||||
import { cleanString } from '@cherrystudio/embedjs-utils'
|
||||
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
|
||||
import md5 from 'md5'
|
||||
|
||||
export class NoteLoader extends BaseLoader<{ type: 'NoteLoader' }> {
|
||||
private readonly text: string
|
||||
private readonly sourceUrl?: string
|
||||
|
||||
constructor({
|
||||
text,
|
||||
sourceUrl,
|
||||
chunkSize,
|
||||
chunkOverlap
|
||||
}: {
|
||||
text: string
|
||||
sourceUrl?: string
|
||||
chunkSize?: number
|
||||
chunkOverlap?: number
|
||||
}) {
|
||||
super(`NoteLoader_${md5(text + (sourceUrl || ''))}`, { text, sourceUrl }, chunkSize ?? 2000, chunkOverlap ?? 0)
|
||||
this.text = text
|
||||
this.sourceUrl = sourceUrl
|
||||
}
|
||||
|
||||
override async *getUnfilteredChunks() {
|
||||
const chunker = new RecursiveCharacterTextSplitter({
|
||||
chunkSize: this.chunkSize,
|
||||
chunkOverlap: this.chunkOverlap
|
||||
})
|
||||
|
||||
const chunks = await chunker.splitText(cleanString(this.text))
|
||||
|
||||
for (const chunk of chunks) {
|
||||
yield {
|
||||
pageContent: chunk,
|
||||
metadata: {
|
||||
type: 'NoteLoader' as const,
|
||||
source: this.sourceUrl || 'note'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import DifyKnowledgeServer from './dify-knowledge'
|
||||
import FetchServer from './fetch'
|
||||
import FileSystemServer from './filesystem'
|
||||
import MemoryServer from './memory'
|
||||
import PythonServer from './python'
|
||||
import ThinkingServer from './sequentialthinking'
|
||||
|
||||
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
|
||||
@ -31,6 +32,9 @@ export function createInMemoryMCPServer(name: string, args: string[] = [], envs:
|
||||
const difyKey = envs.DIFY_KEY
|
||||
return new DifyKnowledgeServer(difyKey, args).server
|
||||
}
|
||||
case '@cherry/python': {
|
||||
return new PythonServer().server
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||
}
|
||||
|
||||
113
src/main/mcpServers/python.ts
Normal file
113
src/main/mcpServers/python.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { pythonService } from '@main/services/PythonService'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
/**
|
||||
* Python MCP Server for executing Python code using Pyodide
|
||||
*/
|
||||
class PythonServer {
|
||||
public server: Server
|
||||
|
||||
constructor() {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'python-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
this.setupRequestHandlers()
|
||||
}
|
||||
|
||||
private setupRequestHandlers() {
|
||||
// List available tools
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'python_execute',
|
||||
description: `Execute Python code using Pyodide in a sandboxed environment. Supports most Python standard library and scientific packages.
|
||||
The code will be executed with Python 3.12.
|
||||
Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start
|
||||
with a comment of the form:
|
||||
# /// script
|
||||
# dependencies = ['pydantic']
|
||||
# ///
|
||||
print('python code here')`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description: 'The Python code to execute'
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
description: 'Optional context variables to pass to the Python execution environment',
|
||||
additionalProperties: true
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds (default: 60000)',
|
||||
default: 60000
|
||||
}
|
||||
},
|
||||
required: ['code']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Handle tool calls
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
if (name !== 'python_execute') {
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Tool ${name} not found`)
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
code,
|
||||
context = {},
|
||||
timeout = 60000
|
||||
} = args as {
|
||||
code: string
|
||||
context?: Record<string, any>
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'Code parameter is required and must be a string')
|
||||
}
|
||||
|
||||
Logger.info('Executing Python code via Pyodide')
|
||||
|
||||
const result = await pythonService.executeScript(code, context, timeout)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error('Python execution error:', errorMessage)
|
||||
|
||||
throw new McpError(ErrorCode.InternalError, `Python execution failed: ${errorMessage}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default PythonServer
|
||||
@ -106,6 +106,7 @@ class SequentialThinkingServer {
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
thought: validatedInput.thought,
|
||||
thoughtNumber: validatedInput.thoughtNumber,
|
||||
totalThoughts: validatedInput.totalThoughts,
|
||||
nextThoughtNeeded: validatedInput.nextThoughtNeeded,
|
||||
|
||||
@ -17,7 +17,7 @@ export default abstract class BaseReranker {
|
||||
* Get Rerank Request Url
|
||||
*/
|
||||
protected getRerankUrl() {
|
||||
if (this.base.rerankModelProvider === 'dashscope') {
|
||||
if (this.base.rerankModelProvider === 'bailian') {
|
||||
return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ export default abstract class BaseReranker {
|
||||
documents,
|
||||
top_k: topN
|
||||
}
|
||||
} else if (provider === 'dashscope') {
|
||||
} else if (provider === 'bailian') {
|
||||
return {
|
||||
model: this.base.rerankModel,
|
||||
input: {
|
||||
@ -82,11 +82,11 @@ export default abstract class BaseReranker {
|
||||
*/
|
||||
protected extractRerankResult(data: any) {
|
||||
const provider = this.base.rerankModelProvider
|
||||
if (provider === 'dashscope') {
|
||||
if (provider === 'bailian') {
|
||||
return data.output.results
|
||||
} else if (provider === 'voyageai') {
|
||||
return data.data
|
||||
} else if (provider === 'mis-tei') {
|
||||
} else if (provider?.includes('tei')) {
|
||||
return data.map((item: any) => {
|
||||
return {
|
||||
index: item.index,
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater } from 'electron-updater'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||
import path from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
@ -14,6 +14,8 @@ import { configManager } from './ConfigManager'
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
private cancellationToken: CancellationToken = new CancellationToken()
|
||||
private updateCheckResult: UpdateCheckResult | null = null
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
logger.transports.file.level = 'info'
|
||||
@ -22,9 +24,7 @@ export default class AppUpdater {
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||
autoUpdater.setFeedURL(configManager.getFeedUrl())
|
||||
|
||||
// 检测下载错误
|
||||
autoUpdater.on('error', (error) => {
|
||||
// 简单记录错误信息和时间戳
|
||||
logger.error('更新异常', {
|
||||
@ -64,6 +64,35 @@ export default class AppUpdater {
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) {
|
||||
try {
|
||||
logger.info('get pre release version from github', channel)
|
||||
const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
})
|
||||
const data = (await responses.json()) as GithubReleaseInfo[]
|
||||
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
|
||||
return item.prerelease && item.tag_name.includes(`-${channel}.`)
|
||||
})
|
||||
|
||||
logger.info('release info', release)
|
||||
|
||||
if (!release) {
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info('release info', release.tag_name)
|
||||
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
|
||||
} catch (error) {
|
||||
logger.error('Failed to get latest not draft version from github:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async _getIpCountry() {
|
||||
try {
|
||||
// add timeout using AbortController
|
||||
@ -93,9 +122,72 @@ export default class AppUpdater {
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
}
|
||||
|
||||
public setFeedUrl(feedUrl: FeedUrl) {
|
||||
autoUpdater.setFeedURL(feedUrl)
|
||||
configManager.setFeedUrl(feedUrl)
|
||||
private _getChannelByVersion(version: string) {
|
||||
if (version.includes(`-${UpgradeChannel.BETA}.`)) {
|
||||
return UpgradeChannel.BETA
|
||||
}
|
||||
if (version.includes(`-${UpgradeChannel.RC}.`)) {
|
||||
return UpgradeChannel.RC
|
||||
}
|
||||
return UpgradeChannel.LATEST
|
||||
}
|
||||
|
||||
private _getTestChannel() {
|
||||
const currentChannel = this._getChannelByVersion(app.getVersion())
|
||||
const savedChannel = configManager.getTestChannel()
|
||||
|
||||
if (currentChannel === UpgradeChannel.LATEST) {
|
||||
return savedChannel || UpgradeChannel.RC
|
||||
}
|
||||
|
||||
if (savedChannel === currentChannel) {
|
||||
return savedChannel
|
||||
}
|
||||
|
||||
// if the upgrade channel is not equal to the current channel, use the latest channel
|
||||
return UpgradeChannel.LATEST
|
||||
}
|
||||
|
||||
private async _setFeedUrl() {
|
||||
const testPlan = configManager.getTestPlan()
|
||||
if (testPlan) {
|
||||
const channel = this._getTestChannel()
|
||||
|
||||
if (channel === UpgradeChannel.LATEST) {
|
||||
this.autoUpdater.channel = UpgradeChannel.LATEST
|
||||
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
|
||||
return
|
||||
}
|
||||
|
||||
const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel)
|
||||
if (preReleaseUrl) {
|
||||
this.autoUpdater.setFeedURL(preReleaseUrl)
|
||||
this.autoUpdater.channel = channel
|
||||
return
|
||||
}
|
||||
|
||||
// if no prerelease url, use lowest prerelease version to avoid error
|
||||
this.autoUpdater.setFeedURL(FeedUrl.PRERELEASE_LOWEST)
|
||||
this.autoUpdater.channel = UpgradeChannel.LATEST
|
||||
return
|
||||
}
|
||||
|
||||
this.autoUpdater.channel = UpgradeChannel.LATEST
|
||||
this.autoUpdater.setFeedURL(FeedUrl.PRODUCTION)
|
||||
|
||||
const ipCountry = await this._getIpCountry()
|
||||
logger.info('ipCountry', ipCountry)
|
||||
if (ipCountry.toLowerCase() !== 'cn') {
|
||||
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
|
||||
}
|
||||
}
|
||||
|
||||
public cancelDownload() {
|
||||
this.cancellationToken.cancel()
|
||||
this.cancellationToken = new CancellationToken()
|
||||
if (this.autoUpdater.autoDownload) {
|
||||
this.updateCheckResult?.cancellationToken?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
public async checkForUpdates() {
|
||||
@ -106,23 +198,26 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
const ipCountry = await this._getIpCountry()
|
||||
logger.info('ipCountry', ipCountry)
|
||||
if (ipCountry !== 'CN') {
|
||||
this.autoUpdater.setFeedURL(FeedUrl.EARLY_ACCESS)
|
||||
}
|
||||
await this._setFeedUrl()
|
||||
|
||||
// disable downgrade after change the channel
|
||||
this.autoUpdater.allowDowngrade = false
|
||||
|
||||
// github and gitcode don't support multiple range download
|
||||
this.autoUpdater.disableDifferentialDownload = true
|
||||
|
||||
try {
|
||||
const update = await this.autoUpdater.checkForUpdates()
|
||||
if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
|
||||
this.updateCheckResult = await this.autoUpdater.checkForUpdates()
|
||||
if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
|
||||
// 如果 autoDownload 为 false,则需要再调用下面的函数触发下
|
||||
// do not use await, because it will block the return of this function
|
||||
this.autoUpdater.downloadUpdate()
|
||||
logger.info('downloadUpdate manual by check for updates', this.cancellationToken)
|
||||
this.autoUpdater.downloadUpdate(this.cancellationToken)
|
||||
}
|
||||
|
||||
return {
|
||||
currentVersion: this.autoUpdater.currentVersion,
|
||||
updateInfo: update?.updateInfo
|
||||
updateInfo: this.updateCheckResult?.updateInfo
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check for update:', error)
|
||||
@ -178,7 +273,11 @@ export default class AppUpdater {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
interface GithubReleaseInfo {
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
}
|
||||
interface ReleaseNoteInfo {
|
||||
readonly version: string
|
||||
readonly note: string | null
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { WebDavConfig } from '@types'
|
||||
import { S3Config } from '@types'
|
||||
import archiver from 'archiver'
|
||||
import { exec } from 'child_process'
|
||||
import { app } from 'electron'
|
||||
@ -10,6 +11,7 @@ import * as path from 'path'
|
||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import S3Storage from './RemoteStorage'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@ -25,6 +27,11 @@ class BackupManager {
|
||||
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
||||
this.listWebdavFiles = this.listWebdavFiles.bind(this)
|
||||
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
|
||||
this.backupToS3 = this.backupToS3.bind(this)
|
||||
this.restoreFromS3 = this.restoreFromS3.bind(this)
|
||||
this.listS3Files = this.listS3Files.bind(this)
|
||||
this.deleteS3File = this.deleteS3File.bind(this)
|
||||
this.checkS3Connection = this.checkS3Connection.bind(this)
|
||||
}
|
||||
|
||||
private async setWritableRecursive(dirPath: string): Promise<void> {
|
||||
@ -85,7 +92,11 @@ class BackupManager {
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
|
||||
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@ -147,18 +158,23 @@ class BackupManager {
|
||||
let totalBytes = 0
|
||||
let processedBytes = 0
|
||||
|
||||
// 首先计算总文件数和总大小
|
||||
// 首先计算总文件数和总大小,但不记录详细日志
|
||||
const calculateTotals = async (dirPath: string) => {
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
if (item.isDirectory()) {
|
||||
await calculateTotals(fullPath)
|
||||
} else {
|
||||
totalEntries++
|
||||
const stats = await fs.stat(fullPath)
|
||||
totalBytes += stats.size
|
||||
try {
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
if (item.isDirectory()) {
|
||||
await calculateTotals(fullPath)
|
||||
} else {
|
||||
totalEntries++
|
||||
const stats = await fs.stat(fullPath)
|
||||
totalBytes += stats.size
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 仅在出错时记录日志
|
||||
Logger.error('[BackupManager] Error calculating totals:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,7 +246,11 @@ class BackupManager {
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
// 只在关键阶段记录日志
|
||||
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@ -382,21 +402,54 @@ class BackupManager {
|
||||
destination: string,
|
||||
onProgress: (size: number) => void
|
||||
): Promise<void> {
|
||||
const items = await fs.readdir(source, { withFileTypes: true })
|
||||
// 先统计总文件数
|
||||
let totalFiles = 0
|
||||
let processedFiles = 0
|
||||
let lastProgressReported = 0
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(source, item.name)
|
||||
const destPath = path.join(destination, item.name)
|
||||
// 计算总文件数
|
||||
const countFiles = async (dir: string): Promise<number> => {
|
||||
let count = 0
|
||||
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
if (item.isDirectory()) {
|
||||
count += await countFiles(path.join(dir, item.name))
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await fs.ensureDir(destPath)
|
||||
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
|
||||
} else {
|
||||
const stats = await fs.stat(sourcePath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
onProgress(stats.size)
|
||||
totalFiles = await countFiles(source)
|
||||
|
||||
// 复制文件并更新进度
|
||||
const copyDir = async (src: string, dest: string): Promise<void> => {
|
||||
const items = await fs.readdir(src, { withFileTypes: true })
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(src, item.name)
|
||||
const destPath = path.join(dest, item.name)
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await fs.ensureDir(destPath)
|
||||
await copyDir(sourcePath, destPath)
|
||||
} else {
|
||||
const stats = await fs.stat(sourcePath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
processedFiles++
|
||||
|
||||
// 只在进度变化超过5%时报告进度
|
||||
const currentProgress = Math.floor((processedFiles / totalFiles) * 100)
|
||||
if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) {
|
||||
lastProgressReported = currentProgress
|
||||
onProgress(stats.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await copyDir(source, destination)
|
||||
}
|
||||
|
||||
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
@ -423,6 +476,141 @@ class BackupManager {
|
||||
throw new Error(error.message || 'Failed to delete backup file')
|
||||
}
|
||||
}
|
||||
|
||||
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
||||
// 获取设备名
|
||||
const os = require('os')
|
||||
const deviceName = os.hostname ? os.hostname() : 'device'
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:T.Z]/g, '')
|
||||
.slice(0, 14)
|
||||
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
|
||||
|
||||
// 不记录详细日志,只记录开始和结束
|
||||
Logger.log(`[BackupManager] Starting S3 backup to ${filename}`)
|
||||
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
|
||||
const s3Client = new S3Storage('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
try {
|
||||
const fileBuffer = await fs.promises.readFile(backupedFilePath)
|
||||
const result = await s3Client.putFileContents(filename, fileBuffer)
|
||||
await fs.remove(backupedFilePath)
|
||||
|
||||
Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`)
|
||||
return result
|
||||
} catch (error) {
|
||||
Logger.error(`[BackupManager] S3 backup failed:`, error)
|
||||
await fs.remove(backupedFilePath)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
||||
|
||||
// 只记录开始和结束或错误
|
||||
Logger.log(`[BackupManager] Starting restore from S3: ${filename}`)
|
||||
|
||||
const s3Client = new S3Storage('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
try {
|
||||
const retrievedFile = await s3Client.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
if (!fs.existsSync(this.backupDir)) {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(backupedFilePath)
|
||||
writeStream.write(retrievedFile as Buffer)
|
||||
writeStream.end()
|
||||
writeStream.on('finish', () => resolve())
|
||||
writeStream.on('error', (error) => reject(error))
|
||||
})
|
||||
|
||||
Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`)
|
||||
return await this.restore(_, backupedFilePath)
|
||||
} catch (error: any) {
|
||||
Logger.error('[BackupManager] Failed to restore from S3:', error)
|
||||
throw new Error(error.message || 'Failed to restore backup file')
|
||||
}
|
||||
}
|
||||
|
||||
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
|
||||
try {
|
||||
const s3Client = new S3Storage('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
const entries = await s3Client.instance?.list('/')
|
||||
const files: Array<{ fileName: string; modifiedTime: string; size: number }> = []
|
||||
if (entries) {
|
||||
for await (const entry of entries) {
|
||||
const path = entry.path()
|
||||
if (path.endsWith('.zip')) {
|
||||
const meta = await s3Client.instance!.stat(path)
|
||||
if (meta.isFile()) {
|
||||
files.push({
|
||||
fileName: path.replace(/^\/+/, ''),
|
||||
modifiedTime: meta.lastModified || '',
|
||||
size: Number(meta.contentLength || 0n)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
|
||||
} catch (error: any) {
|
||||
Logger.error('Failed to list S3 files:', error)
|
||||
throw new Error(error.message || 'Failed to list backup files')
|
||||
}
|
||||
}
|
||||
|
||||
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
|
||||
try {
|
||||
const s3Client = new S3Storage('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
return await s3Client.deleteFile(fileName)
|
||||
} catch (error: any) {
|
||||
Logger.error('Failed to delete S3 file:', error)
|
||||
throw new Error(error.message || 'Failed to delete backup file')
|
||||
}
|
||||
}
|
||||
|
||||
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||
const s3Client = new S3Storage('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
return await s3Client.checkConnection()
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { defaultLanguage, FeedUrl, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
@ -16,7 +16,8 @@ export enum ConfigKeys {
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
FeedUrl = 'feedUrl',
|
||||
TestPlan = 'testPlan',
|
||||
TestChannel = 'testChannel',
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
@ -142,12 +143,20 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.AutoUpdate, value)
|
||||
}
|
||||
|
||||
getFeedUrl(): string {
|
||||
return this.get<string>(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION)
|
||||
getTestPlan(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.TestPlan, false)
|
||||
}
|
||||
|
||||
setFeedUrl(value: FeedUrl) {
|
||||
this.set(ConfigKeys.FeedUrl, value)
|
||||
setTestPlan(value: boolean) {
|
||||
this.set(ConfigKeys.TestPlan, value)
|
||||
}
|
||||
|
||||
getTestChannel(): UpgradeChannel {
|
||||
return this.get<UpgradeChannel>(ConfigKeys.TestChannel)
|
||||
}
|
||||
|
||||
setTestChannel(value: UpgradeChannel) {
|
||||
this.set(ConfigKeys.TestChannel, value)
|
||||
}
|
||||
|
||||
getEnableDataCollection(): boolean {
|
||||
|
||||
@ -4,18 +4,29 @@ import { locales } from '../utils/locales'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ContextMenu {
|
||||
public contextMenu(w: Electron.BrowserWindow) {
|
||||
w.webContents.on('context-menu', (_event, properties) => {
|
||||
public contextMenu(w: Electron.WebContents) {
|
||||
w.on('context-menu', (_event, properties) => {
|
||||
const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties)
|
||||
const filtered = template.filter((item) => item.visible !== false)
|
||||
if (filtered.length > 0) {
|
||||
const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)])
|
||||
let template = [...filtered, ...this.createInspectMenuItems(w)]
|
||||
const dictionarySuggestions = this.createDictionarySuggestions(properties, w)
|
||||
if (dictionarySuggestions.length > 0) {
|
||||
template = [
|
||||
...dictionarySuggestions,
|
||||
{ type: 'separator' },
|
||||
this.createSpellCheckMenuItem(properties, w),
|
||||
{ type: 'separator' },
|
||||
...template
|
||||
]
|
||||
}
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
menu.popup()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] {
|
||||
private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { common } = locale.translation
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
@ -23,7 +34,7 @@ class ContextMenu {
|
||||
id: 'inspect',
|
||||
label: common.inspect,
|
||||
click: () => {
|
||||
w.webContents.toggleDevTools()
|
||||
w.toggleDevTools()
|
||||
},
|
||||
enabled: true
|
||||
}
|
||||
@ -72,6 +83,53 @@ class ContextMenu {
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
private createSpellCheckMenuItem(
|
||||
properties: Electron.ContextMenuParams,
|
||||
w: Electron.WebContents
|
||||
): MenuItemConstructorOptions {
|
||||
const hasText = properties.selectionText.length > 0
|
||||
|
||||
return {
|
||||
id: 'learnSpelling',
|
||||
label: '&Learn Spelling',
|
||||
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
|
||||
click: () => {
|
||||
w.session.addWordToSpellCheckerDictionary(properties.misspelledWord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createDictionarySuggestions(
|
||||
properties: Electron.ContextMenuParams,
|
||||
w: Electron.WebContents
|
||||
): MenuItemConstructorOptions[] {
|
||||
const hasText = properties.selectionText.length > 0
|
||||
|
||||
if (!hasText || !properties.misspelledWord) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (properties.dictionarySuggestions.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: 'dictionarySuggestions',
|
||||
label: 'No Guesses Found',
|
||||
visible: true,
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return properties.dictionarySuggestions.map((suggestion) => ({
|
||||
id: 'dictionarySuggestions',
|
||||
label: suggestion,
|
||||
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
|
||||
click: (menuItem: Electron.MenuItem) => {
|
||||
w.replaceMisspelling(menuItem.label)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export const contextMenu = new ContextMenu()
|
||||
|
||||
@ -19,6 +19,7 @@ import { getDocument } from 'officeparser/pdfjs-dist-build/pdf.js'
|
||||
import * as path from 'path'
|
||||
import { chdir } from 'process'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import WordExtractor from 'word-extractor'
|
||||
|
||||
class FileStorage {
|
||||
private storageDir = getFilesDir()
|
||||
@ -220,10 +221,20 @@ class FileStorage {
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<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 extractor = new WordExtractor()
|
||||
const extracted = await extractor.extract(filePath)
|
||||
chdir(originalCwd)
|
||||
return extracted.getBody()
|
||||
}
|
||||
|
||||
const data = await officeParser.parseOfficeAsync(filePath)
|
||||
chdir(originalCwd)
|
||||
return data
|
||||
@ -352,7 +363,7 @@ class FileStorage {
|
||||
public open = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options: OpenDialogOptions
|
||||
): Promise<{ fileName: string; filePath: string; content: Buffer } | null> => {
|
||||
): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '打开文件',
|
||||
@ -364,8 +375,16 @@ class FileStorage {
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, filePath, content }
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
|
||||
// If the file is less than 2GB, read the content
|
||||
if (stats.size < 2 * 1024 * 1024 * 1024) {
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, filePath, content, size: stats.size }
|
||||
}
|
||||
|
||||
// For large files, only return file information, do not read content
|
||||
return { fileName, filePath, size: stats.size }
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@ -16,13 +16,14 @@
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@cherrystudio/embedjs'
|
||||
import { RAGApplication, RAGApplicationBuilder } from '@cherrystudio/embedjs'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
|
||||
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import Embeddings from '@main/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/loader'
|
||||
import { NoteLoader } from '@main/loader/noteLoader'
|
||||
import Reranker from '@main/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
@ -143,7 +144,7 @@ class KnowledgeService {
|
||||
this.getRagApplication(base)
|
||||
}
|
||||
|
||||
public reset = async (_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise<void> => {
|
||||
public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
await ragApplication.reset()
|
||||
}
|
||||
@ -333,6 +334,7 @@ class KnowledgeService {
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const content = item.content as string
|
||||
const sourceUrl = (item as any).sourceUrl
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const contentBytes = encoder.encode(content)
|
||||
@ -342,7 +344,12 @@ class KnowledgeService {
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () => {
|
||||
const loaderReturn = ragApplication.addLoader(
|
||||
new TextLoader({ text: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }),
|
||||
new NoteLoader({
|
||||
text: content,
|
||||
sourceUrl,
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}),
|
||||
forceReload
|
||||
) as Promise<LoaderReturn>
|
||||
|
||||
|
||||
102
src/main/services/PythonService.ts
Normal file
102
src/main/services/PythonService.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
interface PythonExecutionRequest {
|
||||
id: string
|
||||
script: string
|
||||
context: Record<string, any>
|
||||
timeout: number
|
||||
}
|
||||
|
||||
interface PythonExecutionResponse {
|
||||
id: string
|
||||
result?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for executing Python code by communicating with the PyodideService in the renderer process
|
||||
*/
|
||||
export class PythonService {
|
||||
private static instance: PythonService | null = null
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
private pendingRequests = new Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>()
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
this.setupIpcHandlers()
|
||||
}
|
||||
|
||||
public static getInstance(): PythonService {
|
||||
if (!PythonService.instance) {
|
||||
PythonService.instance = new PythonService()
|
||||
}
|
||||
return PythonService.instance
|
||||
}
|
||||
|
||||
private setupIpcHandlers() {
|
||||
// Handle responses from renderer
|
||||
ipcMain.on('python-execution-response', (_, response: PythonExecutionResponse) => {
|
||||
const request = this.pendingRequests.get(response.id)
|
||||
if (request) {
|
||||
this.pendingRequests.delete(response.id)
|
||||
if (response.error) {
|
||||
request.reject(new Error(response.error))
|
||||
} else {
|
||||
request.resolve(response.result || '')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public setMainWindow(mainWindow: BrowserWindow) {
|
||||
this.mainWindow = mainWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Python code by sending request to renderer PyodideService
|
||||
*/
|
||||
public async executeScript(
|
||||
script: string,
|
||||
context: Record<string, any> = {},
|
||||
timeout: number = 60000
|
||||
): Promise<string> {
|
||||
if (!this.mainWindow) {
|
||||
throw new Error('Main window not set in PythonService')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = randomUUID()
|
||||
|
||||
// Store the request
|
||||
this.pendingRequests.set(requestId, { resolve, reject })
|
||||
|
||||
// Set up timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId)
|
||||
reject(new Error('Python execution timed out'))
|
||||
}, timeout + 5000) // Add 5s buffer for IPC communication
|
||||
|
||||
// Update resolve/reject to clear timeout
|
||||
const originalResolve = resolve
|
||||
const originalReject = reject
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve: (value: string) => {
|
||||
clearTimeout(timeoutId)
|
||||
originalResolve(value)
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timeoutId)
|
||||
originalReject(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Send request to renderer
|
||||
const request: PythonExecutionRequest = { id: requestId, script, context, timeout }
|
||||
this.mainWindow?.webContents.send('python-execution-request', request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const pythonService = PythonService.getInstance()
|
||||
@ -1,57 +1,83 @@
|
||||
// import Logger from 'electron-log'
|
||||
// import { Operator } from 'opendal'
|
||||
import Logger from 'electron-log'
|
||||
import type { Operator as OperatorType } from 'opendal'
|
||||
const { Operator } = require('opendal')
|
||||
|
||||
// export default class RemoteStorage {
|
||||
// public instance: Operator | undefined
|
||||
export default class S3Storage {
|
||||
public instance: OperatorType | undefined
|
||||
|
||||
// /**
|
||||
// *
|
||||
// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
|
||||
// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
|
||||
// *
|
||||
// * For example, use minio as remote storage:
|
||||
// *
|
||||
// * ```typescript
|
||||
// * const storage = new RemoteStorage('s3', {
|
||||
// * endpoint: 'http://localhost:9000',
|
||||
// * region: 'us-east-1',
|
||||
// * bucket: 'testbucket',
|
||||
// * access_key_id: 'user',
|
||||
// * secret_access_key: 'password',
|
||||
// * root: '/path/to/basepath',
|
||||
// * })
|
||||
// * ```
|
||||
// */
|
||||
// constructor(scheme: string, options?: Record<string, string> | undefined | null) {
|
||||
// this.instance = new Operator(scheme, options)
|
||||
/**
|
||||
*
|
||||
* @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
|
||||
* @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
|
||||
*
|
||||
* For example, use minio as remote storage:
|
||||
*
|
||||
* ```typescript
|
||||
* const storage = new S3Storage('s3', {
|
||||
* endpoint: 'http://localhost:9000',
|
||||
* region: 'us-east-1',
|
||||
* bucket: 'testbucket',
|
||||
* access_key_id: 'user',
|
||||
* secret_access_key: 'password',
|
||||
* root: '/path/to/basepath',
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
constructor(scheme: string, options?: Record<string, string> | undefined | null) {
|
||||
this.instance = new Operator(scheme, options)
|
||||
|
||||
// this.putFileContents = this.putFileContents.bind(this)
|
||||
// this.getFileContents = this.getFileContents.bind(this)
|
||||
// }
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
this.getFileContents = this.getFileContents.bind(this)
|
||||
}
|
||||
|
||||
// public putFileContents = async (filename: string, data: string | Buffer) => {
|
||||
// if (!this.instance) {
|
||||
// return new Error('RemoteStorage client not initialized')
|
||||
// }
|
||||
public putFileContents = async (filename: string, data: string | Buffer) => {
|
||||
if (!this.instance) {
|
||||
return new Error('RemoteStorage client not initialized')
|
||||
}
|
||||
|
||||
// try {
|
||||
// return await this.instance.write(filename, data)
|
||||
// } catch (error) {
|
||||
// Logger.error('[RemoteStorage] Error putting file contents:', error)
|
||||
// throw error
|
||||
// }
|
||||
// }
|
||||
try {
|
||||
return await this.instance.write(filename, data)
|
||||
} catch (error) {
|
||||
Logger.error('[RemoteStorage] Error putting file contents:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// public getFileContents = async (filename: string) => {
|
||||
// if (!this.instance) {
|
||||
// throw new Error('RemoteStorage client not initialized')
|
||||
// }
|
||||
public getFileContents = async (filename: string) => {
|
||||
if (!this.instance) {
|
||||
throw new Error('RemoteStorage client not initialized')
|
||||
}
|
||||
|
||||
// try {
|
||||
// return await this.instance.read(filename)
|
||||
// } catch (error) {
|
||||
// Logger.error('[RemoteStorage] Error getting file contents:', error)
|
||||
// throw error
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
try {
|
||||
return await this.instance.read(filename)
|
||||
} catch (error) {
|
||||
Logger.error('[RemoteStorage] Error getting file contents:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public deleteFile = async (filename: string) => {
|
||||
if (!this.instance) {
|
||||
throw new Error('RemoteStorage client not initialized')
|
||||
}
|
||||
try {
|
||||
return await this.instance.delete(filename)
|
||||
} catch (error) {
|
||||
Logger.error('[RemoteStorage] Error deleting file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public checkConnection = async () => {
|
||||
if (!this.instance) {
|
||||
throw new Error('RemoteStorage client not initialized')
|
||||
}
|
||||
try {
|
||||
// 检查根目录是否可访问
|
||||
return await this.instance.stat('/')
|
||||
} catch (error) {
|
||||
Logger.error('[RemoteStorage] Error checking connection:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,6 +95,7 @@ export class WindowService {
|
||||
|
||||
this.setupMaximize(mainWindow, mainWindowState.isMaximized)
|
||||
this.setupContextMenu(mainWindow)
|
||||
this.setupSpellCheck(mainWindow)
|
||||
this.setupWindowEvents(mainWindow)
|
||||
this.setupWebContentsHandlers(mainWindow)
|
||||
this.setupWindowLifecycleEvents(mainWindow)
|
||||
@ -102,6 +103,18 @@ export class WindowService {
|
||||
this.loadMainWindowContent(mainWindow)
|
||||
}
|
||||
|
||||
private setupSpellCheck(mainWindow: BrowserWindow) {
|
||||
const enableSpellCheck = configManager.get('enableSpellCheck', false)
|
||||
if (enableSpellCheck) {
|
||||
try {
|
||||
const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[]
|
||||
spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages)
|
||||
} catch (error) {
|
||||
Logger.error('Failed to set spell check languages:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupMainWindowMonitor(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.on('render-process-gone', (_, details) => {
|
||||
Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`)
|
||||
@ -130,9 +143,10 @@ export class WindowService {
|
||||
}
|
||||
|
||||
private setupContextMenu(mainWindow: BrowserWindow) {
|
||||
contextMenu.contextMenu(mainWindow)
|
||||
app.on('browser-window-created', (_, win) => {
|
||||
contextMenu.contextMenu(win)
|
||||
contextMenu.contextMenu(mainWindow.webContents)
|
||||
// setup context menu for all webviews like miniapp
|
||||
app.on('web-contents-created', (_, webContents) => {
|
||||
contextMenu.contextMenu(webContents)
|
||||
})
|
||||
|
||||
// Dangerous API
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -8,6 +8,20 @@ import { FileType, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function initAppDataDir() {
|
||||
const appDataPath = getAppDataPathFromConfig()
|
||||
if (appDataPath) {
|
||||
app.setPath('userData', appDataPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPortable) {
|
||||
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
|
||||
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 创建文件类型映射表,提高查找效率
|
||||
const fileTypeMap = new Map<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))
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import {
|
||||
FileType,
|
||||
KnowledgeBaseParams,
|
||||
KnowledgeItem,
|
||||
MCPServer,
|
||||
S3Config,
|
||||
Shortcut,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
@ -17,11 +26,14 @@ const api = {
|
||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
||||
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
||||
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
||||
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive),
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
setFeedUrl: (feedUrl: FeedUrl) => ipcRenderer.invoke(IpcChannel.App_SetFeedUrl, feedUrl),
|
||||
setTestPlan: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTestPlan, isActive),
|
||||
setTestChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetTestChannel, channel),
|
||||
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
@ -29,9 +41,13 @@ const api = {
|
||||
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
|
||||
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
|
||||
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
|
||||
copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath),
|
||||
getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs),
|
||||
copy: (oldPath: string, newPath: string, occupiedDirs: string[] = []) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath, occupiedDirs),
|
||||
setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason),
|
||||
relaunchApp: () => ipcRenderer.invoke(IpcChannel.App_RelaunchApp),
|
||||
flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData),
|
||||
isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path),
|
||||
relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
@ -64,7 +80,13 @@ const api = {
|
||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
|
||||
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
|
||||
backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config),
|
||||
restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config),
|
||||
listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config),
|
||||
deleteS3File: (fileName: string, s3Config: S3Config) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config),
|
||||
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
@ -176,6 +198,10 @@ const api = {
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
|
||||
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
|
||||
},
|
||||
python: {
|
||||
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
|
||||
},
|
||||
shell: {
|
||||
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
|
||||
},
|
||||
@ -218,7 +244,9 @@ const api = {
|
||||
},
|
||||
webview: {
|
||||
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal)
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
||||
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
|
||||
},
|
||||
storeSync: {
|
||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||
|
||||
@ -2,42 +2,45 @@
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
}
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -42,11 +42,19 @@ export class AihubmixAPIClient extends BaseApiClient {
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
|
||||
const providerExtraHeaders = {
|
||||
...provider,
|
||||
extra_headers: {
|
||||
...provider.extra_headers,
|
||||
'APP-Code': 'MLTG2087'
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化各个client - 现在有类型安全
|
||||
const claudeClient = new AnthropicAPIClient(provider)
|
||||
const geminiClient = new GeminiAPIClient({ ...provider, apiHost: 'https://aihubmix.com/gemini' })
|
||||
const openaiClient = new OpenAIResponseAPIClient(provider)
|
||||
const defaultClient = new OpenAIAPIClient(provider)
|
||||
const claudeClient = new AnthropicAPIClient(providerExtraHeaders)
|
||||
const geminiClient = new GeminiAPIClient({ ...providerExtraHeaders, apiHost: 'https://aihubmix.com/gemini' })
|
||||
const openaiClient = new OpenAIResponseAPIClient(providerExtraHeaders)
|
||||
const defaultClient = new OpenAIAPIClient(providerExtraHeaders)
|
||||
|
||||
this.clients.set('claude', claudeClient)
|
||||
this.clients.set('gemini', geminiClient)
|
||||
@ -58,6 +66,13 @@ export class AihubmixAPIClient extends BaseApiClient {
|
||||
this.currentClient = this.defaultClient as BaseApiClient
|
||||
}
|
||||
|
||||
override getBaseURL(): string {
|
||||
if (!this.currentClient) {
|
||||
return this.provider.apiHost
|
||||
}
|
||||
return this.currentClient.getBaseURL()
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:确保client是BaseApiClient的实例
|
||||
*/
|
||||
|
||||
@ -66,7 +66,7 @@ import {
|
||||
mcpToolCallResponseToAnthropicMessage,
|
||||
mcpToolsToAnthropicTools
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
|
||||
import { BaseApiClient } from '../BaseApiClient'
|
||||
@ -94,7 +94,8 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
baseURL: this.getBaseURL(),
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: {
|
||||
'anthropic-beta': 'output-128k-2025-02-19'
|
||||
'anthropic-beta': 'output-128k-2025-02-19',
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
return this.sdkInstance
|
||||
@ -191,7 +192,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
const parts: MessageParam['content'] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: getMainTextContent(message)
|
||||
text: await this.getMessageContent(message)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -176,7 +176,10 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
apiVersion: this.getApiVersion(),
|
||||
httpOptions: {
|
||||
baseUrl: this.getBaseURL(),
|
||||
apiVersion: this.getApiVersion()
|
||||
apiVersion: this.getApiVersion(),
|
||||
headers: {
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -683,16 +686,19 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
toolCalls: FunctionCall[]
|
||||
): Content[] {
|
||||
const parts: Part[] = []
|
||||
const modelParts: Part[] = []
|
||||
if (output) {
|
||||
parts.push({
|
||||
modelParts.push({
|
||||
text: output
|
||||
})
|
||||
}
|
||||
|
||||
toolCalls.forEach((toolCall) => {
|
||||
parts.push({
|
||||
modelParts.push({
|
||||
functionCall: toolCall
|
||||
})
|
||||
})
|
||||
|
||||
parts.push(
|
||||
...toolResults
|
||||
.map((ts) => ts.parts)
|
||||
@ -700,10 +706,22 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
.filter((p) => p !== undefined)
|
||||
)
|
||||
|
||||
const lastMessage = currentReqMessages[currentReqMessages.length - 1]
|
||||
if (lastMessage) {
|
||||
lastMessage.parts?.push(...parts)
|
||||
const userMessage: Content = {
|
||||
role: 'user',
|
||||
parts: []
|
||||
}
|
||||
|
||||
if (modelParts.length > 0) {
|
||||
currentReqMessages.push({
|
||||
role: 'model',
|
||||
parts: modelParts
|
||||
})
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
userMessage.parts?.push(...parts)
|
||||
currentReqMessages.push(userMessage)
|
||||
}
|
||||
|
||||
return currentReqMessages
|
||||
}
|
||||
|
||||
@ -744,7 +762,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
})
|
||||
}
|
||||
return [messageParam, ...(sdkPayload.history || [])]
|
||||
return [...(sdkPayload.history || []), messageParam]
|
||||
}
|
||||
|
||||
private async uploadFile(file: FileType): Promise<File> {
|
||||
|
||||
@ -113,6 +113,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if (!reasoningEffort) {
|
||||
if (model.provider === 'openrouter') {
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
}
|
||||
if (isSupportedThinkingTokenQwenModel(model)) {
|
||||
return { enable_thinking: false }
|
||||
}
|
||||
@ -122,10 +125,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
// openrouter没有提供一个不推理的选项,先隐藏
|
||||
if (this.provider.id === 'openrouter') {
|
||||
return { reasoning: { max_tokens: 0, exclude: true } }
|
||||
}
|
||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
return { reasoning_effort: 'none' }
|
||||
}
|
||||
|
||||
@ -159,6 +159,7 @@ export abstract class OpenAIBaseClient<
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers,
|
||||
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
|
||||
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
|
||||
}
|
||||
|
||||
@ -81,7 +81,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders()
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -425,6 +426,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
const toolCalls: OpenAIResponseSdkToolCall[] = []
|
||||
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
|
||||
let hasBeenCollectedToolCalls = false
|
||||
let hasReasoningSummary = false
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 处理chunk
|
||||
@ -496,6 +498,16 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
outputItems.push(chunk.item)
|
||||
}
|
||||
break
|
||||
case 'response.reasoning_summary_part.added':
|
||||
if (hasReasoningSummary) {
|
||||
const separator = '\n\n'
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: separator
|
||||
})
|
||||
}
|
||||
hasReasoningSummary = true
|
||||
break
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
|
||||
@ -255,6 +255,10 @@ function buildParamsWithToolResults(
|
||||
// 从回复中构建助手消息
|
||||
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
|
||||
|
||||
if (output && ctx._internal.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = undefined
|
||||
}
|
||||
|
||||
// 估算新增消息的 token 消耗并累加到 usage 中
|
||||
if (ctx._internal.observer?.usage && newReqMessages.length > currentReqMessages.length) {
|
||||
try {
|
||||
|
||||
@ -58,166 +58,80 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mention-models-dropdown {
|
||||
&.ant-dropdown {
|
||||
background: rgba(var(--color-base-rgb), 0.65) !important;
|
||||
backdrop-filter: blur(35px) saturate(150%) !important;
|
||||
animation-duration: 0.15s !important;
|
||||
}
|
||||
|
||||
/* 移动其他样式到 mention-models-dropdown 类下 */
|
||||
.ant-slide-up-enter .ant-dropdown-menu,
|
||||
.ant-slide-up-appear .ant-dropdown-menu,
|
||||
.ant-slide-up-leave .ant-dropdown-menu,
|
||||
.ant-slide-up-enter-active .ant-dropdown-menu,
|
||||
.ant-slide-up-appear-active .ant-dropdown-menu,
|
||||
.ant-slide-up-leave-active .ant-dropdown-menu {
|
||||
background: rgba(var(--color-base-rgb), 0.65) !important;
|
||||
backdrop-filter: blur(35px) saturate(150%) !important;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu {
|
||||
/* 保持原有的下拉菜单样式,但限定在 mention-models-dropdown 类下 */
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 12px;
|
||||
position: relative;
|
||||
background: rgba(var(--color-base-rgb), 0.65) !important;
|
||||
backdrop-filter: blur(35px) saturate(150%) !important;
|
||||
border: 0.5px solid rgba(var(--color-border-rgb), 0.3);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.15),
|
||||
0 4px 16px rgba(0, 0, 0, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.12),
|
||||
inset 0 0 0 0.5px rgba(255, 255, 255, var(--inner-glow-opacity, 0.1));
|
||||
transform-origin: top;
|
||||
will-change: transform, opacity;
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-bottom: 0;
|
||||
|
||||
&.no-scrollbar {
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
&.has-scrollbar {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
// Scrollbar styles
|
||||
&::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border: 4px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 7px;
|
||||
background-color: var(--color-scrollbar-thumb);
|
||||
min-height: 50px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar-thumb);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:active {
|
||||
background-color: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-group {
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-group-title {
|
||||
padding: 5px 12px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle no-results case margin
|
||||
.no-results {
|
||||
padding: 8px 12px;
|
||||
color: var(--color-text-3);
|
||||
cursor: default;
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 40px;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
padding: 5px 12px;
|
||||
margin: 0 -12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--color-hover-rgb), 0.5);
|
||||
}
|
||||
|
||||
&.ant-dropdown-menu-item-selected {
|
||||
background-color: rgba(var(--color-primary-rgb), 0.12);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-icon {
|
||||
margin-right: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
.ant-dropdown-menu .ant-dropdown-menu-sub {
|
||||
max-height: 50vh;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ant-dropdown {
|
||||
background-color: var(--ant-color-bg-elevated);
|
||||
overflow: hidden;
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
.ant-dropdown-menu {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border: 0.5px solid var(--color-border);
|
||||
.ant-dropdown-menu-sub {
|
||||
max-height: 50vh;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border: 0.5px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
.ant-dropdown-arrow + .ant-dropdown-menu {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
border: 0.5px solid var(--color-border);
|
||||
}
|
||||
.ant-dropdown-menu-submenu {
|
||||
background-color: var(--ant-color-bg-elevated);
|
||||
overflow: hidden;
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
}
|
||||
|
||||
.ant-popover {
|
||||
.ant-popover-inner {
|
||||
border: 0.5px solid var(--color-border);
|
||||
.ant-popover-inner-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
.ant-popover-arrow + .ant-popover-content {
|
||||
.ant-popover-inner {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal:not(.ant-modal-confirm) {
|
||||
.ant-modal-confirm-body-has-title {
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
.ant-modal-content {
|
||||
border-radius: 10px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
padding: 0 0 8px 0;
|
||||
.ant-modal-header {
|
||||
padding: 16px 16px 0 16px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ant-modal-body {
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 0 16px;
|
||||
}
|
||||
.ant-modal-footer {
|
||||
padding: 0 16px 8px 16px;
|
||||
}
|
||||
.ant-modal-confirm-btns {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-modal.ant-modal-confirm.ant-modal-confirm-confirm {
|
||||
.ant-modal-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse {
|
||||
border: 1px solid var(--color-border);
|
||||
@ -227,8 +141,14 @@
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--color-border) !important;
|
||||
border-top: 0.5px solid var(--color-border) !important;
|
||||
.ant-color-picker & {
|
||||
border-top: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-slider {
|
||||
.ant-slider-handle::after {
|
||||
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
--color-list-item: #222;
|
||||
--color-list-item-hover: #1e1e1e;
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
--modal-background: #111111;
|
||||
|
||||
--color-highlight: rgba(0, 0, 0, 1);
|
||||
--color-background-highlight: rgba(255, 255, 0, 0.9);
|
||||
@ -66,9 +66,9 @@
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--chat-background: #111111;
|
||||
--chat-background-user: #28b561;
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: rgba(255, 255, 255, 0.08);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 20px;
|
||||
@ -132,8 +132,8 @@
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: rgba(0, 0, 0, 0.045);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-text);
|
||||
}
|
||||
|
||||
@ -111,27 +111,7 @@ ul {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background-color: var(--chat-background);
|
||||
#chat-main {
|
||||
background-color: var(--chat-background);
|
||||
}
|
||||
#messages {
|
||||
background-color: var(--chat-background);
|
||||
}
|
||||
#inputbar {
|
||||
margin: -5px 15px 15px 15px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
.system-prompt {
|
||||
background-color: var(--chat-background-assistant);
|
||||
}
|
||||
.message-content-container {
|
||||
margin: 5px 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.bubble:not(.multi-select-mode) {
|
||||
.block-wrapper {
|
||||
display: flow-root;
|
||||
}
|
||||
@ -149,30 +129,35 @@ ul {
|
||||
}
|
||||
|
||||
.message-user {
|
||||
color: var(--chat-text-user);
|
||||
.message-content-container-user .anticon {
|
||||
color: var(--chat-text-user) !important;
|
||||
.message-header {
|
||||
flex-direction: row-reverse;
|
||||
text-align: right;
|
||||
.message-header-info-wrap {
|
||||
flex-direction: row-reverse;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown {
|
||||
color: var(--chat-text-user);
|
||||
}
|
||||
}
|
||||
.group-grid-container.horizontal,
|
||||
.group-grid-container.grid {
|
||||
.message-content-container-assistant {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.group-message-wrapper {
|
||||
background-color: var(--color-background);
|
||||
.message-content-container {
|
||||
width: 100%;
|
||||
border-radius: 10px 0 10px 10px;
|
||||
padding: 10px 16px 10px 16px;
|
||||
background-color: var(--chat-background-user);
|
||||
align-self: self-end;
|
||||
}
|
||||
.MessageFooter {
|
||||
margin-top: 2px;
|
||||
align-self: self-end;
|
||||
}
|
||||
}
|
||||
.group-menu-bar {
|
||||
background-color: var(--color-background);
|
||||
|
||||
.message-assistant {
|
||||
.message-content-container {
|
||||
padding-left: 0;
|
||||
}
|
||||
.MessageFooter {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--color-text);
|
||||
}
|
||||
@ -188,11 +173,17 @@ ul {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
|
||||
span.highlight {
|
||||
::highlight(search-matches) {
|
||||
background-color: var(--color-background-highlight);
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
span.highlight.selected {
|
||||
::highlight(current-match) {
|
||||
background-color: var(--color-background-highlight-accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
&::-webkit-resizer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +98,6 @@
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
margin: 20px 0;
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
span {
|
||||
@ -119,7 +118,7 @@
|
||||
}
|
||||
|
||||
pre {
|
||||
border-radius: 5px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
@ -157,15 +156,28 @@
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
--table-border-radius: 8px;
|
||||
margin: 1em 0;
|
||||
width: 100%;
|
||||
border-radius: var(--table-border-radius);
|
||||
overflow: hidden;
|
||||
border-collapse: separate;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
padding: 0.5em;
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
th {
|
||||
@ -238,6 +250,10 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
@ -309,7 +325,7 @@ mjx-container {
|
||||
|
||||
/* CodeMirror 相关样式 */
|
||||
.cm-editor {
|
||||
border-radius: 5px;
|
||||
border-radius: inherit;
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
@ -317,7 +333,7 @@ mjx-container {
|
||||
|
||||
.cm-scroller {
|
||||
font-family: var(--code-font-family);
|
||||
border-radius: 5px;
|
||||
border-radius: inherit;
|
||||
|
||||
.cm-gutters {
|
||||
line-height: 1.6;
|
||||
|
||||
@ -5,22 +5,57 @@ html {
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
|
||||
--color-selection-toolbar-hover-bg: #222222;
|
||||
|
||||
// Basic Colors
|
||||
--color-primary: #00b96b;
|
||||
--color-error: #f44336;
|
||||
|
||||
--selection-toolbar-color-primary: var(--color-primary);
|
||||
--selection-toolbar-color-error: var(--color-error);
|
||||
|
||||
// Toolbar
|
||||
--selection-toolbar-height: 36px; // default: 36px max: 42px
|
||||
--selection-toolbar-font-size: 14px; // default: 14px
|
||||
|
||||
--selection-toolbar-logo-display: flex; // values: flex | none
|
||||
--selection-toolbar-logo-size: 22px; // default: 22px
|
||||
--selection-toolbar-logo-margin: 0 0 0 5px; // default: 0 0 05px
|
||||
|
||||
// DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING
|
||||
--selection-toolbar-padding: 2px 4px 2px 2px; // default: 2px 4px 2px 2px
|
||||
--selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px
|
||||
// ------------------------------------------------------------
|
||||
|
||||
--selection-toolbar-border-radius: 6px;
|
||||
--selection-toolbar-border: 1px solid rgba(55, 55, 55, 0.5);
|
||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
||||
--selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
|
||||
// Buttons
|
||||
|
||||
--selection-toolbar-button-icon-size: 16px; // default: 16px
|
||||
--selection-toolbar-button-text-margin: 0 0 0 3px; // default: 0 0 0 3px
|
||||
--selection-toolbar-button-margin: 0 2px; // default: 0 2px
|
||||
--selection-toolbar-button-padding: 4px 6px; // default: 4px 6px
|
||||
--selection-toolbar-button-border-radius: 4px; // default: 4px
|
||||
--selection-toolbar-button-border: none; // default: none
|
||||
--selection-toolbar-button-box-shadow: none; // default: none
|
||||
|
||||
--selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9);
|
||||
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-bgcolor: transparent; // default: transparent
|
||||
--selection-toolbar-button-bgcolor-hover: #222222;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
--selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5);
|
||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
||||
--selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
|
||||
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
|
||||
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
|
||||
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { getReactStyleFromToken } from '@renderer/utils/shiki'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
@ -18,19 +18,20 @@ interface CodePreviewProps {
|
||||
/**
|
||||
* Shiki 流式代码高亮组件
|
||||
*
|
||||
* - 通过 shiki tokenizer 处理流式响应
|
||||
* - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过
|
||||
* - 通过 shiki tokenizer 处理流式响应,高性能
|
||||
* - 进入视口后触发高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
*/
|
||||
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle()
|
||||
const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
||||
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
|
||||
const codeContentRef = useRef<HTMLDivElement>(null)
|
||||
const prevCodeLengthRef = useRef(0)
|
||||
const safeCodeStringRef = useRef(children)
|
||||
const highlightQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [isInViewport, setIsInViewport] = useState(false)
|
||||
const codeContainerRef = useRef<HTMLDivElement>(null)
|
||||
const processingRef = useRef(false)
|
||||
const latestRequestedContentRef = useRef<string | null>(null)
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
const shikiThemeRef = useRef(activeShikiTheme)
|
||||
|
||||
@ -45,7 +46,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = codeContentRef.current?.scrollHeight
|
||||
const scrollHeight = codeContainerRef.current?.scrollHeight
|
||||
return codeCollapsible && (scrollHeight ?? 0) > 350
|
||||
},
|
||||
onClick: () => setIsExpanded((prev) => !prev)
|
||||
@ -77,81 +78,63 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
setIsUnwrapped(!codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
// 处理尾部空白字符
|
||||
const safeCodeString = useMemo(() => {
|
||||
return typeof children === 'string' ? children.trimEnd() : ''
|
||||
}, [children])
|
||||
|
||||
const highlightCode = useCallback(async () => {
|
||||
if (!safeCodeString) return
|
||||
const currentContent = typeof children === 'string' ? children.trimEnd() : ''
|
||||
|
||||
if (prevCodeLengthRef.current === safeCodeString.length) return
|
||||
// 记录最新要处理的内容,为了保证最终状态正确
|
||||
latestRequestedContentRef.current = currentContent
|
||||
|
||||
// 捕获当前状态
|
||||
const startPos = prevCodeLengthRef.current
|
||||
const endPos = safeCodeString.length
|
||||
// 如果正在处理,先跳出,等到完成后会检查是否有新内容
|
||||
if (processingRef.current) return
|
||||
|
||||
// 添加到处理队列,确保按顺序处理
|
||||
highlightQueueRef.current = highlightQueueRef.current.then(async () => {
|
||||
// FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮
|
||||
if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) {
|
||||
cleanupTokenizers(callerId)
|
||||
prevCodeLengthRef.current = 0
|
||||
safeCodeStringRef.current = ''
|
||||
processingRef.current = true
|
||||
|
||||
const result = await highlightCodeChunk(safeCodeString, language, callerId)
|
||||
setTokenLines(result.lines)
|
||||
try {
|
||||
// 循环处理,确保会处理最新内容
|
||||
while (latestRequestedContentRef.current !== null) {
|
||||
const contentToProcess = latestRequestedContentRef.current
|
||||
latestRequestedContentRef.current = null // 标记开始处理
|
||||
|
||||
prevCodeLengthRef.current = safeCodeString.length
|
||||
safeCodeStringRef.current = safeCodeString
|
||||
// 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮
|
||||
const result = await highlightStreamingCode(contentToProcess, language, callerId)
|
||||
|
||||
return
|
||||
// 如有结果,更新 tokenLines
|
||||
if (result.lines.length > 0 || result.recall !== 0) {
|
||||
setTokenLines((prev) => {
|
||||
return result.recall === -1
|
||||
? result.lines
|
||||
: [...prev.slice(0, Math.max(0, prev.length - result.recall)), ...result.lines]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过 race condition,延迟到后续任务
|
||||
if (prevCodeLengthRef.current !== startPos) {
|
||||
return
|
||||
}
|
||||
|
||||
const incrementalCode = safeCodeString.slice(startPos, endPos)
|
||||
const result = await highlightCodeChunk(incrementalCode, language, callerId)
|
||||
setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines])
|
||||
prevCodeLengthRef.current = endPos
|
||||
safeCodeStringRef.current = safeCodeString
|
||||
})
|
||||
}, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString])
|
||||
} finally {
|
||||
processingRef.current = false
|
||||
}
|
||||
}, [highlightStreamingCode, language, callerId, children])
|
||||
|
||||
// 主题变化时强制重新高亮
|
||||
useEffect(() => {
|
||||
if (shikiThemeRef.current !== activeShikiTheme) {
|
||||
prevCodeLengthRef.current++
|
||||
shikiThemeRef.current = activeShikiTheme
|
||||
cleanupTokenizers(callerId)
|
||||
setTokenLines([])
|
||||
}
|
||||
}, [activeShikiTheme])
|
||||
}, [activeShikiTheme, callerId, cleanupTokenizers])
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
return () => cleanupTokenizers(callerId)
|
||||
}, [callerId, cleanupTokenizers])
|
||||
|
||||
// 触发代码高亮
|
||||
// - 进入视口后触发第一次高亮
|
||||
// - 内容变化后触发之后的高亮
|
||||
// 视口检测逻辑,进入视口后触发第一次代码高亮
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
if (prevCodeLengthRef.current > 0) {
|
||||
setTimeout(highlightCode, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const codeElement = codeContentRef.current
|
||||
const codeElement = codeContainerRef.current
|
||||
if (!codeElement) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].intersectionRatio > 0 && isMounted) {
|
||||
setTimeout(highlightCode, 0)
|
||||
if (entries[0].intersectionRatio > 0) {
|
||||
setIsInViewport(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
@ -161,21 +144,35 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
)
|
||||
|
||||
observer.observe(codeElement)
|
||||
return () => observer.disconnect()
|
||||
}, []) // 只执行一次
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [highlightCode])
|
||||
// 触发代码高亮
|
||||
useEffect(() => {
|
||||
if (!isInViewport) return
|
||||
|
||||
const hasHighlightedCode = useMemo(() => {
|
||||
return tokenLines.length > 0
|
||||
}, [tokenLines.length])
|
||||
setTimeout(highlightCode, 0)
|
||||
}, [isInViewport, highlightCode])
|
||||
|
||||
const lastDigitsRef = useRef(1)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = codeContainerRef.current
|
||||
if (!container || !codeShowLineNumbers) return
|
||||
|
||||
const digits = Math.max(tokenLines.length.toString().length, 1)
|
||||
if (digits === lastDigitsRef.current) return
|
||||
|
||||
const gutterWidth = digits * 0.6
|
||||
container.style.setProperty('--gutter-width', `${gutterWidth}rem`)
|
||||
lastDigitsRef.current = digits
|
||||
}, [codeShowLineNumbers, tokenLines.length])
|
||||
|
||||
const hasHighlightedCode = tokenLines.length > 0
|
||||
|
||||
return (
|
||||
<ContentContainer
|
||||
ref={codeContentRef}
|
||||
$lineNumbers={codeShowLineNumbers}
|
||||
ref={codeContainerRef}
|
||||
$wrap={codeWrappable && !isUnwrapped}
|
||||
$fadeIn={hasHighlightedCode}
|
||||
style={{
|
||||
@ -183,7 +180,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
|
||||
}}>
|
||||
{hasHighlightedCode ? (
|
||||
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
|
||||
<ShikiTokensRenderer language={language} tokenLines={tokenLines} showLineNumbers={codeShowLineNumbers} />
|
||||
) : (
|
||||
<CodePlaceholder>{children}</CodePlaceholder>
|
||||
)}
|
||||
@ -191,97 +188,103 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
interface ShikiTokensRendererProps {
|
||||
language: string
|
||||
tokenLines: ThemedToken[][]
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Shiki 高亮后的 tokens
|
||||
*
|
||||
* 独立出来,方便将来做 virtual list
|
||||
*/
|
||||
const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo(
|
||||
({ language, tokenLines }) => {
|
||||
const { getShikiPreProperties } = useCodeStyle()
|
||||
const rendererRef = useRef<HTMLPreElement>(null)
|
||||
const ShikiTokensRenderer: React.FC<ShikiTokensRendererProps> = memo(({ language, tokenLines, showLineNumbers }) => {
|
||||
const { getShikiPreProperties } = useCodeStyle()
|
||||
const rendererRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
// 设置 pre 标签属性
|
||||
useEffect(() => {
|
||||
getShikiPreProperties(language).then((properties) => {
|
||||
const pre = rendererRef.current
|
||||
if (pre) {
|
||||
pre.className = properties.class
|
||||
pre.style.cssText = properties.style
|
||||
pre.tabIndex = properties.tabindex
|
||||
}
|
||||
})
|
||||
}, [language, getShikiPreProperties])
|
||||
// 设置 pre 标签属性
|
||||
useLayoutEffect(() => {
|
||||
getShikiPreProperties(language).then((properties) => {
|
||||
const pre = rendererRef.current
|
||||
if (pre) {
|
||||
pre.className = properties.class
|
||||
pre.style.cssText = properties.style
|
||||
pre.tabIndex = properties.tabindex
|
||||
}
|
||||
})
|
||||
}, [language, getShikiPreProperties])
|
||||
|
||||
return (
|
||||
<pre className="shiki" ref={rendererRef}>
|
||||
<code>
|
||||
{tokenLines.map((lineTokens, lineIndex) => (
|
||||
<span key={`line-${lineIndex}`} className="line">
|
||||
return (
|
||||
<pre className="shiki" ref={rendererRef}>
|
||||
<code>
|
||||
{tokenLines.map((lineTokens, lineIndex) => (
|
||||
<span key={`line-${lineIndex}`} className="line">
|
||||
{showLineNumbers && <span className="line-number">{lineIndex + 1}</span>}
|
||||
<span className="line-content">
|
||||
{lineTokens.map((token, tokenIndex) => (
|
||||
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
)
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
})
|
||||
|
||||
const ContentContainer = styled.div<{
|
||||
$lineNumbers: boolean
|
||||
$wrap: boolean
|
||||
$fadeIn: boolean
|
||||
}>`
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: 5px;
|
||||
border-radius: inherit;
|
||||
margin-top: 0;
|
||||
|
||||
/* gutter 宽度默认值 */
|
||||
--gutter-width: 0.6rem;
|
||||
|
||||
.shiki {
|
||||
padding: 1em;
|
||||
border-radius: inherit;
|
||||
|
||||
code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.line {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 1.3rem;
|
||||
padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')};
|
||||
|
||||
* {
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
.line-number {
|
||||
width: var(--gutter-width);
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
margin-right: 1rem;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
|
||||
* {
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$lineNumbers &&
|
||||
`
|
||||
code {
|
||||
counter-reset: step;
|
||||
counter-increment: step 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
code .line::before {
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
width: 1rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
}
|
||||
`}
|
||||
|
||||
@keyframes contentFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@ -291,7 +294,7 @@ const ContentContainer = styled.div<{
|
||||
}
|
||||
}
|
||||
|
||||
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')};
|
||||
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.1s ease-in forwards' : 'none')};
|
||||
`
|
||||
|
||||
const CodePlaceholder = styled.div`
|
||||
|
||||
@ -273,6 +273,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
font-weight: bold;
|
||||
padding: 0 10px;
|
||||
border-top-left-radius: 8px;
|
||||
@ -288,6 +289,10 @@ const SplitViewWrapper = styled.div`
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodeBlockView)
|
||||
|
||||
@ -227,10 +227,10 @@ const CodeEditor = ({
|
||||
...customBasicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
border: '0.5px solid transparent',
|
||||
marginTop: 0
|
||||
marginTop: 0,
|
||||
borderRadius: 'inherit',
|
||||
...style
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -3,13 +3,10 @@ import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||
import { Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const HIGHLIGHT_CLASS = 'highlight'
|
||||
const HIGHLIGHT_SELECT_CLASS = 'selected'
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode
|
||||
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
|
||||
@ -18,19 +15,14 @@ interface Props {
|
||||
*
|
||||
* 返回`true`表示该`node`会被搜索
|
||||
*/
|
||||
filter: (node: Node) => boolean
|
||||
filter: NodeFilter
|
||||
includeUser?: boolean
|
||||
onIncludeUserChange?: (value: boolean) => void
|
||||
}
|
||||
|
||||
enum SearchCompletedState {
|
||||
NotSearched,
|
||||
FirstSearched
|
||||
}
|
||||
|
||||
enum SearchTargetIndex {
|
||||
Next,
|
||||
Prev
|
||||
Searched
|
||||
}
|
||||
|
||||
export interface ContentSearchRef {
|
||||
@ -47,60 +39,20 @@ export interface ContentSearchRef {
|
||||
focus(): void
|
||||
}
|
||||
|
||||
interface MatchInfo {
|
||||
index: number
|
||||
length: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const escapeRegExp = (string: string): string => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
||||
}
|
||||
|
||||
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
|
||||
if (!elementList || elementList.length === 0) {
|
||||
return null
|
||||
}
|
||||
let closestElementIndex: number | null = null
|
||||
let minVerticalDistance = Infinity
|
||||
const windowCenterY = window.innerHeight / 2
|
||||
for (let i = 0; i < elementList.length; i++) {
|
||||
const element = elementList[i]
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue
|
||||
}
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||
continue
|
||||
}
|
||||
const elementCenterY = rect.top + rect.height / 2
|
||||
const verticalDistance = Math.abs(elementCenterY - windowCenterY)
|
||||
if (verticalDistance < minVerticalDistance) {
|
||||
minVerticalDistance = verticalDistance
|
||||
closestElementIndex = i
|
||||
}
|
||||
}
|
||||
return closestElementIndex
|
||||
}
|
||||
|
||||
const highlightText = (
|
||||
textNode: Node,
|
||||
const findRangesInTarget = (
|
||||
target: HTMLElement,
|
||||
filter: NodeFilter,
|
||||
searchText: string,
|
||||
highlightClass: string,
|
||||
isCaseSensitive: boolean,
|
||||
isWholeWord: boolean
|
||||
): HTMLSpanElement[] | null => {
|
||||
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
|
||||
if (textNodeParentNode) {
|
||||
if (textNodeParentNode.classList.contains(highlightClass)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
|
||||
return null
|
||||
}
|
||||
): Range[] => {
|
||||
CSS.highlights.clear()
|
||||
const ranges: Range[] = []
|
||||
|
||||
const textContent = textNode.textContent
|
||||
const escapedSearchText = escapeRegExp(searchText)
|
||||
|
||||
// 检查搜索文本是否仅包含拉丁字母
|
||||
@ -109,89 +61,66 @@ const highlightText = (
|
||||
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
|
||||
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
|
||||
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
|
||||
const regex = new RegExp(regexPattern, regexFlags)
|
||||
const searchRegex = new RegExp(regexPattern, regexFlags)
|
||||
const treeWalker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, filter)
|
||||
const allTextNodes: { node: Node; startOffset: number }[] = []
|
||||
let fullText = ''
|
||||
|
||||
let match
|
||||
const matches: MatchInfo[] = []
|
||||
while ((match = regex.exec(textContent)) !== null) {
|
||||
if (typeof match.index === 'number' && typeof match[0] === 'string') {
|
||||
matches.push({ index: match.index, length: match[0].length, text: match[0] })
|
||||
} else {
|
||||
console.error('Unexpected match format:', match)
|
||||
}
|
||||
// 1. 拼接所有文本节点内容
|
||||
while (treeWalker.nextNode()) {
|
||||
allTextNodes.push({
|
||||
node: treeWalker.currentNode,
|
||||
startOffset: fullText.length
|
||||
})
|
||||
fullText += treeWalker.currentNode.nodeValue
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
// 2.在完整文本中查找匹配项
|
||||
let match: RegExpExecArray | null = null
|
||||
while ((match = searchRegex.exec(fullText))) {
|
||||
const matchStart = match.index
|
||||
const matchEnd = matchStart + match[0].length
|
||||
|
||||
const parentNode = textNode.parentNode
|
||||
if (!parentNode) {
|
||||
return null
|
||||
}
|
||||
// 3. 将匹配项的索引映射回DOM Range
|
||||
let startNode: Node | null = null
|
||||
let endNode: Node | null = null
|
||||
let startOffset = 0
|
||||
let endOffset = 0
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
let currentIndex = 0
|
||||
const highlightTextSet = new Set<HTMLSpanElement>()
|
||||
|
||||
matches.forEach(({ index, length, text }) => {
|
||||
if (index > currentIndex) {
|
||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
|
||||
}
|
||||
const highlightSpan = document.createElement('span')
|
||||
highlightSpan.className = highlightClass
|
||||
highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
|
||||
fragment.appendChild(highlightSpan)
|
||||
highlightTextSet.add(highlightSpan)
|
||||
currentIndex = index + length
|
||||
})
|
||||
|
||||
if (currentIndex < textContent.length) {
|
||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
|
||||
}
|
||||
|
||||
parentNode.replaceChild(fragment, textNode)
|
||||
return [...highlightTextSet]
|
||||
}
|
||||
|
||||
const mergeAdjacentTextNodes = (node: HTMLElement) => {
|
||||
const children = Array.from(node.childNodes)
|
||||
const groups: Array<Node | { text: string; nodes: Node[] }> = []
|
||||
let currentTextGroup: { text: string; nodes: Node[] } | null = null
|
||||
|
||||
for (const child of children) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (currentTextGroup === null) {
|
||||
currentTextGroup = {
|
||||
text: child.textContent ?? '',
|
||||
nodes: [child]
|
||||
}
|
||||
} else {
|
||||
currentTextGroup.text += child.textContent
|
||||
currentTextGroup.nodes.push(child)
|
||||
// 找到起始节点和偏移
|
||||
for (const nodeInfo of allTextNodes) {
|
||||
if (
|
||||
matchStart >= nodeInfo.startOffset &&
|
||||
matchStart < nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
|
||||
) {
|
||||
startNode = nodeInfo.node
|
||||
startOffset = matchStart - nodeInfo.startOffset
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (currentTextGroup !== null) {
|
||||
groups.push(currentTextGroup!)
|
||||
currentTextGroup = null
|
||||
}
|
||||
|
||||
// 找到结束节点和偏移
|
||||
for (const nodeInfo of allTextNodes) {
|
||||
if (
|
||||
matchEnd > nodeInfo.startOffset &&
|
||||
matchEnd <= nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
|
||||
) {
|
||||
endNode = nodeInfo.node
|
||||
endOffset = matchEnd - nodeInfo.startOffset
|
||||
break
|
||||
}
|
||||
groups.push(child)
|
||||
}
|
||||
|
||||
// 如果起始和结束节点都找到了,则创建一个 Range
|
||||
if (startNode && endNode) {
|
||||
const range = new Range()
|
||||
range.setStart(startNode, startOffset)
|
||||
range.setEnd(endNode, endOffset)
|
||||
ranges.push(range)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTextGroup !== null) {
|
||||
groups.push(currentTextGroup)
|
||||
}
|
||||
|
||||
const newChildren = groups.map((group) => {
|
||||
if (group instanceof Node) {
|
||||
return group
|
||||
} else {
|
||||
return document.createTextNode(group.text)
|
||||
}
|
||||
})
|
||||
|
||||
node.replaceChildren(...newChildren)
|
||||
return ranges
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @eslint-react/no-forward-ref
|
||||
@ -206,328 +135,178 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
})()
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [searchResultIndex, setSearchResultIndex] = useState(0)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [enableContentSearch, setEnableContentSearch] = useState(false)
|
||||
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
|
||||
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||
const [isWholeWord, setIsWholeWord] = useState(false)
|
||||
const [shouldScroll, setShouldScroll] = useState(false)
|
||||
const highlightTextSet = useState(new Set<Node>())[0]
|
||||
const [allRanges, setAllRanges] = useState<Range[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const prevSearchText = useRef('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
const locateByIndex = (index: number, shouldScroll = true) => {
|
||||
if (target) {
|
||||
const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
|
||||
highlightTextNodes.sort((a, b) => {
|
||||
const { top: aTop } = a.getBoundingClientRect()
|
||||
const { top: bTop } = b.getBoundingClientRect()
|
||||
return aTop - bTop
|
||||
})
|
||||
for (const node of highlightTextNodes) {
|
||||
node.classList.remove(HIGHLIGHT_SELECT_CLASS)
|
||||
}
|
||||
setSearchResultIndex(index)
|
||||
if (highlightTextNodes.length > 0) {
|
||||
const highlightTextNode = highlightTextNodes[index] ?? null
|
||||
if (highlightTextNode) {
|
||||
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
|
||||
const resetSearch = useCallback(() => {
|
||||
CSS.highlights.clear()
|
||||
setAllRanges([])
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}, [])
|
||||
|
||||
const locateByIndex = useCallback(
|
||||
(shouldScroll = true) => {
|
||||
// 清理旧的高亮
|
||||
CSS.highlights.clear()
|
||||
|
||||
if (allRanges.length > 0) {
|
||||
// 1. 创建并注册所有匹配项的高亮
|
||||
const allMatchesHighlight = new Highlight(...allRanges)
|
||||
CSS.highlights.set('search-matches', allMatchesHighlight)
|
||||
|
||||
// 2. 如果有当前项,为其创建并注册一个特殊的高亮
|
||||
if (currentIndex !== -1 && allRanges[currentIndex]) {
|
||||
const currentMatchRange = allRanges[currentIndex]
|
||||
const currentMatchHighlight = new Highlight(currentMatchRange)
|
||||
CSS.highlights.set('current-match', currentMatchHighlight)
|
||||
|
||||
// 3. 将当前项滚动到视图中
|
||||
// 获取第一个文本节点的父元素来进行滚动
|
||||
const parentElement = currentMatchRange.startContainer.parentElement
|
||||
if (shouldScroll) {
|
||||
highlightTextNode.scrollIntoView({
|
||||
parentElement?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
// inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[allRanges, currentIndex]
|
||||
)
|
||||
|
||||
const restoreHighlight = () => {
|
||||
const highlightTextParentNodeSet = new Set<HTMLElement>()
|
||||
// Make a copy because the set might be modified during iteration indirectly
|
||||
const nodesToRestore = [...highlightTextSet]
|
||||
for (const highlightTextNode of nodesToRestore) {
|
||||
if (highlightTextNode.textContent) {
|
||||
const textNode = document.createTextNode(highlightTextNode.textContent)
|
||||
const node = highlightTextNode as HTMLElement
|
||||
if (node.parentNode) {
|
||||
highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
|
||||
node.replaceWith(textNode) // This removes the node from the DOM
|
||||
}
|
||||
}
|
||||
}
|
||||
highlightTextSet.clear() // Clear the original set after processing
|
||||
for (const parentNode of highlightTextParentNodeSet) {
|
||||
mergeAdjacentTextNodes(parentNode)
|
||||
}
|
||||
// highlightTextSet.clear() // Already cleared
|
||||
}
|
||||
|
||||
const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
|
||||
const search = useCallback(() => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
setSearchCompleted(SearchCompletedState.Searched)
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
restoreHighlight()
|
||||
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
|
||||
let textNode: Node | null
|
||||
const textNodeSet: Set<Node> = new Set()
|
||||
while ((textNode = iter.nextNode())) {
|
||||
if (filter(textNode)) {
|
||||
textNodeSet.add(textNode)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightTextSetTemp = new Set<HTMLSpanElement>()
|
||||
for (const node of textNodeSet) {
|
||||
const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
|
||||
if (list) {
|
||||
list.forEach((node) => highlightTextSetTemp.add(node))
|
||||
}
|
||||
}
|
||||
const highlightTextList = [...highlightTextSetTemp]
|
||||
setTotalCount(highlightTextList.length)
|
||||
highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
|
||||
const changeIndex = () => {
|
||||
let index: number
|
||||
switch (searchTargetIndex) {
|
||||
case SearchTargetIndex.Next:
|
||||
{
|
||||
index = (searchResultIndex + 1) % highlightTextList.length
|
||||
}
|
||||
break
|
||||
case SearchTargetIndex.Prev:
|
||||
{
|
||||
index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
|
||||
}
|
||||
break
|
||||
default: {
|
||||
index = searchResultIndex
|
||||
}
|
||||
}
|
||||
return Math.max(index, 0)
|
||||
}
|
||||
|
||||
const targetIndex = (() => {
|
||||
switch (searchCompleted) {
|
||||
case SearchCompletedState.NotSearched: {
|
||||
setSearchCompleted(SearchCompletedState.FirstSearched)
|
||||
const index = findWindowVerticalCenterElementIndex(highlightTextList)
|
||||
if (index !== null) {
|
||||
setSearchResultIndex(index)
|
||||
return index
|
||||
} else {
|
||||
setSearchResultIndex(0)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
case SearchCompletedState.FirstSearched: {
|
||||
return changeIndex()
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
if (targetIndex === null) {
|
||||
return null
|
||||
} else {
|
||||
const totalCount = highlightTextSet.size
|
||||
if (targetIndex >= totalCount) {
|
||||
return totalCount - 1
|
||||
} else {
|
||||
return targetIndex
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||
setAllRanges(ranges)
|
||||
setCurrentIndex(0)
|
||||
}
|
||||
}
|
||||
}, [target, filter, isCaseSensitive, isWholeWord])
|
||||
|
||||
const _searchHandlerDebounce = debounce(() => {
|
||||
implementation.search()
|
||||
}, 300)
|
||||
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
|
||||
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value.trim()
|
||||
if (value.length === 0) {
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
} else {
|
||||
// 用户输入时允许滚动
|
||||
setShouldScroll(true)
|
||||
searchHandler()
|
||||
}
|
||||
prevSearchText.current = value
|
||||
}
|
||||
|
||||
const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { code, key, shiftKey } = event
|
||||
if (key === 'Process') {
|
||||
return
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'Enter':
|
||||
{
|
||||
if (shiftKey) {
|
||||
implementation.searchPrev()
|
||||
} else {
|
||||
implementation.searchNext()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
{
|
||||
implementation.disable()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||
|
||||
const userOutlinedButtonOnClick = () => {
|
||||
if (onIncludeUserChange) {
|
||||
onIncludeUserChange(!includeUser)
|
||||
}
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const implementation = {
|
||||
disable() {
|
||||
setEnableContentSearch(false)
|
||||
restoreHighlight()
|
||||
setShouldScroll(false)
|
||||
},
|
||||
enable(initialText?: string) {
|
||||
setEnableContentSearch(true)
|
||||
setShouldScroll(false) // Default to false, search itself might set it to true
|
||||
if (searchInputRef.current) {
|
||||
const inputEl = searchInputRef.current
|
||||
if (initialText && initialText.trim().length > 0) {
|
||||
inputEl.value = initialText
|
||||
// Trigger search after setting initial text
|
||||
// Need to make sure search() uses the new value
|
||||
// and also to focus and select
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
setShouldScroll(true)
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex, true) // Ensure scrolling
|
||||
} else {
|
||||
// If search returns null (e.g., empty input or no matches with initial text), clear state
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
const implementation = useMemo(
|
||||
() => ({
|
||||
disable: () => {
|
||||
setEnableContentSearch(false)
|
||||
CSS.highlights.clear()
|
||||
},
|
||||
enable: (initialText?: string) => {
|
||||
setEnableContentSearch(true)
|
||||
if (searchInputRef.current) {
|
||||
const inputEl = searchInputRef.current
|
||||
if (initialText && initialText.trim().length > 0) {
|
||||
inputEl.value = initialText
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
search()
|
||||
CSS.highlights.clear()
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
})
|
||||
// Only search if there's existing text and no new initialText
|
||||
if (inputEl.value.trim()) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
setSearchResultIndex(targetIndex)
|
||||
// locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
|
||||
}
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
searchNext() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search(SearchTargetIndex.Next)
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex)
|
||||
},
|
||||
searchNext: () => {
|
||||
if (allRanges.length > 0) {
|
||||
setCurrentIndex((prev) => (prev < allRanges.length - 1 ? prev + 1 : 0))
|
||||
}
|
||||
}
|
||||
},
|
||||
searchPrev() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search(SearchTargetIndex.Prev)
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex)
|
||||
},
|
||||
searchPrev: () => {
|
||||
if (allRanges.length > 0) {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allRanges.length - 1))
|
||||
}
|
||||
}
|
||||
},
|
||||
resetSearchState() {
|
||||
if (enableContentSearch) {
|
||||
},
|
||||
resetSearchState: () => {
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
// Maybe also reset index? Depends on desired behavior
|
||||
// setSearchResultIndex(0);
|
||||
},
|
||||
search: () => {
|
||||
search()
|
||||
locateByIndex(true)
|
||||
},
|
||||
silentSearch: () => {
|
||||
search()
|
||||
locateByIndex(false)
|
||||
},
|
||||
focus: () => {
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
}),
|
||||
[allRanges.length, locateByIndex, search]
|
||||
)
|
||||
|
||||
const _searchHandlerDebounce = useMemo(() => debounce(implementation.search, 300), [implementation.search])
|
||||
|
||||
const searchHandler = useCallback(() => {
|
||||
_searchHandlerDebounce()
|
||||
}, [_searchHandlerDebounce])
|
||||
|
||||
const userInputHandler = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value.trim()
|
||||
if (value.length === 0) {
|
||||
resetSearch()
|
||||
} else {
|
||||
searchHandler()
|
||||
}
|
||||
prevSearchText.current = value
|
||||
},
|
||||
search() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex, shouldScroll)
|
||||
[searchHandler, resetSearch]
|
||||
)
|
||||
|
||||
const keyDownHandler = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const value = (event.target as HTMLInputElement).value.trim()
|
||||
if (value.length === 0) {
|
||||
resetSearch()
|
||||
return
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
implementation.searchPrev()
|
||||
} else {
|
||||
// If search returns null (e.g., empty input), clear state
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
implementation.searchNext()
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
implementation.disable()
|
||||
}
|
||||
},
|
||||
silentSearch() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
// 只更新索引,不触发滚动
|
||||
locateByIndex(targetIndex, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
focus() {
|
||||
searchInputFocus()
|
||||
}
|
||||
}
|
||||
[implementation, resetSearch]
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disable() {
|
||||
implementation.disable()
|
||||
},
|
||||
enable(initialText?: string) {
|
||||
implementation.enable(initialText)
|
||||
},
|
||||
searchNext() {
|
||||
implementation.searchNext()
|
||||
},
|
||||
searchPrev() {
|
||||
implementation.searchPrev()
|
||||
},
|
||||
search() {
|
||||
implementation.search()
|
||||
},
|
||||
silentSearch() {
|
||||
implementation.silentSearch()
|
||||
},
|
||||
focus() {
|
||||
implementation.focus()
|
||||
}
|
||||
}))
|
||||
const searchInputFocus = useCallback(() => {
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||
}, [])
|
||||
|
||||
const userOutlinedButtonOnClick = useCallback(() => {
|
||||
onIncludeUserChange?.(!includeUser)
|
||||
searchInputFocus()
|
||||
}, [includeUser, onIncludeUserChange, searchInputFocus])
|
||||
|
||||
useImperativeHandle(ref, () => implementation, [implementation])
|
||||
|
||||
useEffect(() => {
|
||||
locateByIndex()
|
||||
}, [currentIndex, locateByIndex])
|
||||
|
||||
// Re-run search when options change and search is active
|
||||
useEffect(() => {
|
||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||
implementation.search()
|
||||
search()
|
||||
}
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
|
||||
|
||||
const prevButtonOnClick = () => {
|
||||
implementation.searchPrev()
|
||||
@ -589,11 +368,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
<Separator></Separator>
|
||||
<SearchResults>
|
||||
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
||||
totalCount > 0 ? (
|
||||
allRanges.length > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
|
||||
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
|
||||
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<NoResults>{t('common.no_results')}</NoResults>
|
||||
@ -603,10 +382,10 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
)}
|
||||
</SearchResults>
|
||||
<ToolBar>
|
||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
|
||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ChevronUp size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
|
||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ChevronDown size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={closeButtonOnClick}>
|
||||
|
||||
@ -1,87 +1,59 @@
|
||||
import { Dropdown } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ContextMenuProps {
|
||||
children: React.ReactNode
|
||||
onContextMenu?: (e: React.MouseEvent) => void
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu, style }) => {
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string | undefined>(undefined)
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
onContextMenu?.(e)
|
||||
},
|
||||
[onContextMenu]
|
||||
)
|
||||
const contextMenuItems = useMemo(() => {
|
||||
if (!selectedText) return []
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
setContextMenuPosition(null)
|
||||
}
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取右键菜单项
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => {
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
window.api?.quoteToMainWindow(selectedText)
|
||||
return [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => {
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
window.api?.quoteToMainWindow(selectedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}, [selectedText, t])
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
const selectedText = window.getSelection()?.toString()
|
||||
setSelectedText(selectedText)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextContainer onContextMenu={handleContextMenu} className="context-menu-container" style={style}>
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
</Dropdown>
|
||||
)}
|
||||
<Dropdown onOpenChange={onOpenChange} menu={{ items: contextMenuItems }} trigger={['contextMenu']}>
|
||||
{children}
|
||||
</ContextContainer>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextContainer = styled.div``
|
||||
|
||||
export default ContextMenu
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Collapse } from 'antd'
|
||||
import { merge } from 'lodash'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { FC, memo, useMemo, useState } from 'react'
|
||||
|
||||
interface CustomCollapseProps {
|
||||
@ -78,6 +79,14 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
destroyInactivePanel={destroyInactivePanel}
|
||||
collapsible={collapsible}
|
||||
onChange={setActiveKeys}
|
||||
expandIcon={({ isActive }) => (
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color="var(--color-text-3)"
|
||||
strokeWidth={1.5}
|
||||
style={{ transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
)}
|
||||
items={[
|
||||
{
|
||||
styles: collapseItemStyles,
|
||||
|
||||
114
src/renderer/src/components/EditableNumber/index.tsx
Normal file
114
src/renderer/src/components/EditableNumber/index.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { InputNumber } from 'antd'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface EditableNumberProps {
|
||||
value?: number | null
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
precision?: number
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
changeOnBlur?: boolean
|
||||
onChange?: (value: number | null) => void
|
||||
onBlur?: () => void
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
size?: 'small' | 'middle' | 'large'
|
||||
suffix?: string
|
||||
prefix?: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}
|
||||
|
||||
const EditableNumber: FC<EditableNumberProps> = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 0.01,
|
||||
precision,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
changeOnBlur = false,
|
||||
style,
|
||||
className,
|
||||
size = 'middle',
|
||||
align = 'end'
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value)
|
||||
}, [value])
|
||||
|
||||
const handleFocus = () => {
|
||||
if (disabled) return
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleInputChange = (newValue: number | null) => {
|
||||
onChange?.(newValue ?? null)
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false)
|
||||
onBlur?.()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleBlur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue(value)
|
||||
setIsEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<InputNumber
|
||||
style={{ ...style, opacity: isEditing ? 1 : 0 }}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
precision={precision}
|
||||
size={size}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
controls={isEditing}
|
||||
changeOnBlur={changeOnBlur}
|
||||
/>
|
||||
<DisplayText style={style} className={className} $align={align} $isEditing={isEditing}>
|
||||
{value ?? placeholder}
|
||||
</DisplayText>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const DisplayText = styled.div<{
|
||||
$align: 'start' | 'center' | 'end'
|
||||
$isEditing: boolean
|
||||
}>`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: ${({ $isEditing }) => ($isEditing ? 'none' : 'flex')};
|
||||
align-items: center;
|
||||
justify-content: ${({ $align }) => $align};
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
export default EditableNumber
|
||||
@ -10,7 +10,7 @@ import {
|
||||
PushpinOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
@ -303,7 +303,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
<Spacer />
|
||||
<ButtonsGroup className={isWindows || isLinux ? 'windows' : ''}>
|
||||
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
|
||||
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleGoBack(appInfo.id)}>
|
||||
<ArrowLeftOutlined />
|
||||
@ -452,7 +452,7 @@ const ButtonsGroup = styled.div`
|
||||
gap: 5px;
|
||||
-webkit-app-region: no-drag;
|
||||
&.windows {
|
||||
margin-right: ${isWindows ? '130px' : isLinux ? '100px' : 0};
|
||||
margin-right: ${isWin ? '130px' : isLinux ? '100px' : 0};
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 50px;
|
||||
padding: 0 3px;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
@ -21,6 +22,7 @@ const WebviewContainer = memo(
|
||||
onNavigateCallback: (appid: string, url: string) => void
|
||||
}) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
const { enableSpellCheck } = useSettings()
|
||||
|
||||
const setRef = (appid: string) => {
|
||||
onSetRefCallback(appid, null)
|
||||
@ -46,6 +48,14 @@ const WebviewContainer = memo(
|
||||
onNavigateCallback(appid, event.url)
|
||||
}
|
||||
|
||||
const handleDomReady = () => {
|
||||
const webviewId = webviewRef.current?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||
}
|
||||
}
|
||||
|
||||
webviewRef.current.addEventListener('dom-ready', handleDomReady)
|
||||
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
|
||||
|
||||
@ -55,6 +65,7 @@ const WebviewContainer = memo(
|
||||
return () => {
|
||||
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
|
||||
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
|
||||
}
|
||||
// because the appid and url are enough, no need to add onLoadedCallback
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@ -35,17 +35,38 @@ const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
|
||||
<SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount>
|
||||
<ActionButtons>
|
||||
<Tooltip title={t('common.save')}>
|
||||
<ActionButton icon={<Save size={16} />} disabled={isActionDisabled} onClick={() => handleAction('save')} />
|
||||
<Button
|
||||
shape="circle"
|
||||
color="default"
|
||||
variant="text"
|
||||
icon={<Save size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={() => handleAction('save')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.copy')}>
|
||||
<ActionButton icon={<Copy size={16} />} disabled={isActionDisabled} onClick={() => handleAction('copy')} />
|
||||
<Button
|
||||
shape="circle"
|
||||
color="default"
|
||||
variant="text"
|
||||
icon={<Copy size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={() => handleAction('copy')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<ActionButton danger icon={<Trash size={16} />} onClick={() => handleAction('delete')} />
|
||||
<Button
|
||||
shape="circle"
|
||||
color="danger"
|
||||
variant="text"
|
||||
danger
|
||||
icon={<Trash size={16} />}
|
||||
onClick={() => handleAction('delete')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ActionButtons>
|
||||
<Tooltip title={t('chat.navigation.close')}>
|
||||
<ActionButton icon={<X size={16} />} onClick={handleClose} />
|
||||
<Button shape="circle" color="default" variant="text" icon={<X size={16} />} onClick={handleClose} />
|
||||
</Tooltip>
|
||||
</ActionBar>
|
||||
</Container>
|
||||
@ -53,45 +74,38 @@ const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
padding: 36px 20px;
|
||||
background-color: var(--color-background);
|
||||
border-top: 1px solid var(--color-border);
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
`
|
||||
|
||||
const ActionBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--color-background);
|
||||
padding: 4px 4px;
|
||||
border-radius: 99px;
|
||||
box-shadow: 0px 2px 8px 0px rgb(128 128 128 / 20%);
|
||||
border: 0.5px solid var(--color-border);
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 50%;
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const SelectionCount = styled.div`
|
||||
margin-right: 15px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
padding-left: 8px;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
export default MultiSelectActionPopup
|
||||
|
||||
@ -32,16 +32,23 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
title={null}
|
||||
width="920px"
|
||||
width={700}
|
||||
transitionName="animation-move-down"
|
||||
styles={{
|
||||
content: {
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
border: `1px solid var(--color-frame-border)`
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 16
|
||||
},
|
||||
body: { height: '85vh' }
|
||||
body: {
|
||||
height: '80vh',
|
||||
maxHeight: 'inherit',
|
||||
padding: 0
|
||||
}
|
||||
}}
|
||||
centered
|
||||
closable={false}
|
||||
footer={null}>
|
||||
<HistoryPage />
|
||||
</Modal>
|
||||
|
||||
@ -388,8 +388,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 20,
|
||||
border: '1px solid var(--color-border)'
|
||||
paddingBottom: 16
|
||||
},
|
||||
body: {
|
||||
maxHeight: 'inherit',
|
||||
padding: 0
|
||||
}
|
||||
}}
|
||||
closeIcon={null}
|
||||
|
||||
298
src/renderer/src/components/S3BackupManager.tsx
Normal file
298
src/renderer/src/components/S3BackupManager.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { restoreFromS3 } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Modal, Table, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface S3Config {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key: string
|
||||
root?: string
|
||||
}
|
||||
|
||||
interface S3BackupManagerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
s3Config: {
|
||||
endpoint?: string
|
||||
region?: string
|
||||
bucket?: string
|
||||
access_key_id?: string
|
||||
secret_access_key?: string
|
||||
root?: string
|
||||
}
|
||||
restoreMethod?: (fileName: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) {
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 5,
|
||||
total: 0
|
||||
})
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { endpoint, region, bucket, access_key_id, secret_access_key, root } = s3Config
|
||||
|
||||
const fetchBackupFiles = useCallback(async () => {
|
||||
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
|
||||
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const files = await window.api.backup.listS3Files({
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
} as S3Config)
|
||||
setBackupFiles(files)
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: files.length
|
||||
}))
|
||||
} catch (error: any) {
|
||||
window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message }))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [endpoint, region, bucket, access_key_id, secret_access_key, root, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchBackupFiles()
|
||||
setSelectedRowKeys([])
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: 1
|
||||
}))
|
||||
}
|
||||
}, [visible, fetchBackupFiles])
|
||||
|
||||
const handleTableChange = (pagination: any) => {
|
||||
setPagination(pagination)
|
||||
}
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
window.message.warning(t('settings.data.s3.manager.select.warning'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
|
||||
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.s3.manager.delete.confirm.title'),
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: t('settings.data.s3.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
|
||||
okText: t('settings.data.s3.manager.delete.confirm.title'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
// 依次删除选中的文件
|
||||
for (const key of selectedRowKeys) {
|
||||
await window.api.backup.deleteS3File(key.toString(), {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
} as S3Config)
|
||||
}
|
||||
window.message.success(
|
||||
t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length })
|
||||
)
|
||||
setSelectedRowKeys([])
|
||||
await fetchBackupFiles()
|
||||
} catch (error: any) {
|
||||
window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteSingle = async (fileName: string) => {
|
||||
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
|
||||
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.s3.manager.delete.confirm.title'),
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: t('settings.data.s3.manager.delete.confirm.single', { fileName }),
|
||||
okText: t('settings.data.s3.manager.delete.confirm.title'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await window.api.backup.deleteS3File(fileName, {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
} as S3Config)
|
||||
window.message.success(t('settings.data.s3.manager.delete.success.single'))
|
||||
await fetchBackupFiles()
|
||||
} catch (error: any) {
|
||||
window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRestore = async (fileName: string) => {
|
||||
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
|
||||
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.s3.restore.confirm.title'),
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: t('settings.data.s3.restore.confirm.content'),
|
||||
okText: t('settings.data.s3.restore.confirm.ok'),
|
||||
cancelText: t('settings.data.s3.restore.confirm.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setRestoring(true)
|
||||
try {
|
||||
await (restoreMethod || restoreFromS3)(fileName)
|
||||
window.message.success(t('settings.data.s3.restore.success'))
|
||||
onClose() // 关闭模态框
|
||||
} catch (error: any) {
|
||||
window.message.error(t('settings.data.s3.restore.error', { message: error.message }))
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('settings.data.s3.manager.columns.fileName'),
|
||||
dataIndex: 'fileName',
|
||||
key: 'fileName',
|
||||
ellipsis: {
|
||||
showTitle: false
|
||||
},
|
||||
render: (fileName: string) => (
|
||||
<Tooltip placement="topLeft" title={fileName}>
|
||||
{fileName}
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.data.s3.manager.columns.modifiedTime'),
|
||||
dataIndex: 'modifiedTime',
|
||||
key: 'modifiedTime',
|
||||
width: 180,
|
||||
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
},
|
||||
{
|
||||
title: t('settings.data.s3.manager.columns.size'),
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 120,
|
||||
render: (size: number) => formatFileSize(size)
|
||||
},
|
||||
{
|
||||
title: t('settings.data.s3.manager.columns.actions'),
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_: any, record: BackupFile) => (
|
||||
<>
|
||||
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
|
||||
{t('settings.data.s3.manager.restore')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => handleDeleteSingle(record.fileName)}
|
||||
disabled={deleting || restoring}>
|
||||
{t('settings.data.s3.manager.delete')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys: React.Key[]) => {
|
||||
setSelectedRowKeys(selectedRowKeys)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.s3.manager.title')}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||
{t('settings.data.s3.manager.refresh')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}
|
||||
loading={deleting}>
|
||||
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
|
||||
</Button>,
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('settings.data.s3.manager.close')}
|
||||
</Button>
|
||||
]}>
|
||||
<Table
|
||||
rowKey="fileName"
|
||||
columns={columns}
|
||||
dataSource={backupFiles}
|
||||
rowSelection={rowSelection}
|
||||
pagination={pagination}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
size="middle"
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
258
src/renderer/src/components/S3Modals.tsx
Normal file
258
src/renderer/src/components/S3Modals.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import { backupToS3, handleData } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Input, Modal, Select, Spin } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export function useS3BackupModal() {
|
||||
const [customFileName, setCustomFileName] = useState('')
|
||||
const [isModalVisible, setIsModalVisible] = useState(false)
|
||||
const [backuping, setBackuping] = useState(false)
|
||||
|
||||
const handleBackup = async () => {
|
||||
setBackuping(true)
|
||||
try {
|
||||
await backupToS3({ customFileName, showMessage: true })
|
||||
} finally {
|
||||
setBackuping(false)
|
||||
setIsModalVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false)
|
||||
}
|
||||
|
||||
const showBackupModal = useCallback(async () => {
|
||||
// 获取默认文件名
|
||||
const deviceType = await window.api.system.getDeviceType()
|
||||
const hostname = await window.api.system.getHostname()
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
setCustomFileName(defaultFileName)
|
||||
setIsModalVisible(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isModalVisible,
|
||||
handleBackup,
|
||||
handleCancel,
|
||||
backuping,
|
||||
customFileName,
|
||||
setCustomFileName,
|
||||
showBackupModal
|
||||
}
|
||||
}
|
||||
|
||||
type S3BackupModalProps = {
|
||||
isModalVisible: boolean
|
||||
handleBackup: () => Promise<void>
|
||||
handleCancel: () => void
|
||||
backuping: boolean
|
||||
customFileName: string
|
||||
setCustomFileName: (value: string) => void
|
||||
}
|
||||
|
||||
export function S3BackupModal({
|
||||
isModalVisible,
|
||||
handleBackup,
|
||||
handleCancel,
|
||||
backuping,
|
||||
customFileName,
|
||||
setCustomFileName
|
||||
}: S3BackupModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.s3.backup.modal.title')}
|
||||
open={isModalVisible}
|
||||
onOk={handleBackup}
|
||||
onCancel={handleCancel}
|
||||
okButtonProps={{ loading: backuping }}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Input
|
||||
value={customFileName}
|
||||
onChange={(e) => setCustomFileName(e.target.value)}
|
||||
placeholder={t('settings.data.s3.backup.modal.filename.placeholder')}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface UseS3RestoreModalProps {
|
||||
endpoint: string | undefined
|
||||
region: string | undefined
|
||||
bucket: string | undefined
|
||||
access_key_id: string | undefined
|
||||
secret_access_key: string | undefined
|
||||
root?: string | undefined
|
||||
}
|
||||
|
||||
export function useS3RestoreModal({
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
}: UseS3RestoreModalProps) {
|
||||
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [loadingFiles, setLoadingFiles] = useState(false)
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showRestoreModal = useCallback(async () => {
|
||||
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
|
||||
window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsRestoreModalVisible(true)
|
||||
setLoadingFiles(true)
|
||||
try {
|
||||
const files = await window.api.backup.listS3Files({
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
})
|
||||
setBackupFiles(files)
|
||||
} catch (error: any) {
|
||||
window.message.error({
|
||||
content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }),
|
||||
key: 'list-files-error'
|
||||
})
|
||||
} finally {
|
||||
setLoadingFiles(false)
|
||||
}
|
||||
}, [endpoint, region, bucket, access_key_id, secret_access_key, root, t])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!selectedFile || !endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
|
||||
window.message.error({
|
||||
content: !selectedFile
|
||||
? t('settings.data.s3.restore.file.required')
|
||||
: t('settings.data.s3.restore.config.incomplete'),
|
||||
key: 'restore-error'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.s3.restore.confirm.title'),
|
||||
content: t('settings.data.s3.restore.confirm.content'),
|
||||
okText: t('settings.data.s3.restore.confirm.ok'),
|
||||
cancelText: t('settings.data.s3.restore.confirm.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setRestoring(true)
|
||||
try {
|
||||
const data = await window.api.backup.restoreFromS3({
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root,
|
||||
fileName: selectedFile
|
||||
})
|
||||
await handleData(JSON.parse(data))
|
||||
window.message.success(t('settings.data.s3.restore.success'))
|
||||
setIsRestoreModalVisible(false)
|
||||
} catch (error: any) {
|
||||
window.message.error({
|
||||
content: t('settings.data.s3.restore.error', { message: error.message }),
|
||||
key: 'restore-error'
|
||||
})
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [selectedFile, endpoint, region, bucket, access_key_id, secret_access_key, root, t])
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsRestoreModalVisible(false)
|
||||
}
|
||||
|
||||
return {
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles,
|
||||
showRestoreModal
|
||||
}
|
||||
}
|
||||
|
||||
type S3RestoreModalProps = ReturnType<typeof useS3RestoreModal>
|
||||
|
||||
export function S3RestoreModal({
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles
|
||||
}: S3RestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.s3.restore.modal.title')}
|
||||
open={isRestoreModalVisible}
|
||||
onOk={handleRestore}
|
||||
onCancel={handleCancel}
|
||||
okButtonProps={{ loading: restoring }}
|
||||
width={600}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('settings.data.s3.restore.modal.select.placeholder')}
|
||||
value={selectedFile}
|
||||
onChange={setSelectedFile}
|
||||
options={backupFiles.map(formatFileOption)}
|
||||
loading={loadingFiles}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
typeof option?.label === 'string' ? option.label.toLowerCase().includes(input.toLowerCase()) : false
|
||||
}
|
||||
/>
|
||||
{loadingFiles && (
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileOption(file: BackupFile) {
|
||||
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
|
||||
const size = formatFileSize(file.size)
|
||||
return {
|
||||
label: `${file.fileName} (${date}, ${size})`,
|
||||
value: file.fileName
|
||||
}
|
||||
}
|
||||
@ -48,17 +48,17 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
|
||||
}, [throttledInternalScrollHandler, clearScrollingTimeout])
|
||||
|
||||
return (
|
||||
<Container
|
||||
<ScrollBarContainer
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
$isScrolling={isScrolling}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
</Container>
|
||||
</ScrollBarContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $isScrolling: boolean }>`
|
||||
const ScrollBarContainer = styled.div<{ $isScrolling: boolean }>`
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
|
||||
192
src/renderer/src/components/Selector.tsx
Normal file
192
src/renderer/src/components/Selector.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { Dropdown, DropdownProps } from 'antd'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
interface SelectorOption<V = string | number> {
|
||||
label: string | ReactNode
|
||||
value: V
|
||||
type?: 'group'
|
||||
options?: SelectorOption<V>[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface BaseSelectorProps<V = string | number> {
|
||||
options: SelectorOption<V>[]
|
||||
placeholder?: string
|
||||
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
|
||||
/** 字体大小 */
|
||||
size?: number
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface SingleSelectorProps<V> extends BaseSelectorProps<V> {
|
||||
multiple?: false
|
||||
value?: V
|
||||
onChange: (value: V) => void
|
||||
}
|
||||
|
||||
interface MultipleSelectorProps<V> extends BaseSelectorProps<V> {
|
||||
multiple: true
|
||||
value?: V[]
|
||||
onChange: (value: V[]) => void
|
||||
}
|
||||
|
||||
type SelectorProps<V> = SingleSelectorProps<V> | MultipleSelectorProps<V>
|
||||
|
||||
const Selector = <V extends string | number>({
|
||||
options,
|
||||
value,
|
||||
onChange = () => {},
|
||||
placement = 'bottomRight',
|
||||
size = 13,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
multiple = false
|
||||
}: SelectorProps<V>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 1)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectedValues = useMemo(() => {
|
||||
if (multiple) {
|
||||
return (value as V[]) || []
|
||||
}
|
||||
return value !== undefined ? [value as V] : []
|
||||
}, [value, multiple])
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (selectedValues.length > 0) {
|
||||
const findLabels = (opts: SelectorOption<V>[]): (string | ReactNode)[] => {
|
||||
const labels: (string | ReactNode)[] = []
|
||||
for (const opt of opts) {
|
||||
if (selectedValues.some((v) => v == opt.value)) {
|
||||
labels.push(opt.label)
|
||||
}
|
||||
if (opt.options) {
|
||||
labels.push(...findLabels(opt.options))
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
const labels = findLabels(options)
|
||||
if (labels.length === 0) return placeholder
|
||||
if (labels.length === 1) return labels[0]
|
||||
return t('common.selectedItems', { count: labels.length })
|
||||
}
|
||||
return placeholder
|
||||
}, [selectedValues, placeholder, options, t])
|
||||
|
||||
const items = useMemo(() => {
|
||||
const mapOption = (option: SelectorOption<V>) => ({
|
||||
key: option.value,
|
||||
label: option.label,
|
||||
extra: <CheckIcon>{selectedValues.some((v) => v == option.value) && <Check size={14} />}</CheckIcon>,
|
||||
disabled: option.disabled,
|
||||
type: option.type || (option.options ? 'group' : undefined),
|
||||
children: option.options?.map(mapOption)
|
||||
})
|
||||
|
||||
return options.map(mapOption)
|
||||
}, [options, selectedValues])
|
||||
|
||||
function onClick(e: { key: string }) {
|
||||
if (disabled) return
|
||||
|
||||
const newValue = e.key as V
|
||||
if (multiple) {
|
||||
const newValues = selectedValues.includes(newValue)
|
||||
? selectedValues.filter((v) => v !== newValue)
|
||||
: [...selectedValues, newValue]
|
||||
;(onChange as MultipleSelectorProps<V>['onChange'])(newValues)
|
||||
} else {
|
||||
;(onChange as SingleSelectorProps<V>['onChange'])(newValue)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange: DropdownProps['onOpenChange'] = (nextOpen, info) => {
|
||||
if (disabled) return
|
||||
|
||||
if (info.source === 'trigger' || nextOpen) {
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="selector-dropdown"
|
||||
menu={{ items, onClick }}
|
||||
trigger={['click']}
|
||||
placement={placement}
|
||||
open={open && !disabled}
|
||||
onOpenChange={handleOpenChange}>
|
||||
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
|
||||
{label}
|
||||
<LabelIcon size={size + 3} />
|
||||
</Label>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
const LabelIcon = styled(ChevronsUpDown)`
|
||||
border-radius: 4px;
|
||||
padding: 2px 0;
|
||||
background-color: var(--color-background-soft);
|
||||
transition: background-color 0.2s;
|
||||
`
|
||||
|
||||
const Label = styled.div<{ $size: number; $open: boolean; $disabled: boolean; $isPlaceholder: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 99px;
|
||||
padding: 3px 2px 3px 10px;
|
||||
font-size: ${({ $size }) => $size}px;
|
||||
line-height: 1;
|
||||
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
|
||||
opacity: ${({ $disabled }) => ($disabled ? 0.6 : 1)};
|
||||
color: ${({ $isPlaceholder }) => ($isPlaceholder ? 'var(--color-text-2)' : 'inherit')};
|
||||
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
&:hover {
|
||||
${({ $disabled }) =>
|
||||
!$disabled &&
|
||||
css`
|
||||
background-color: var(--color-background-mute);
|
||||
${LabelIcon} {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
`}
|
||||
}
|
||||
${({ $open, $disabled }) =>
|
||||
$open &&
|
||||
!$disabled &&
|
||||
css`
|
||||
background-color: var(--color-background-mute);
|
||||
${LabelIcon} {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
const CheckIcon = styled.div`
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
`
|
||||
|
||||
export default Selector
|
||||
@ -1,6 +1,5 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@ -18,7 +17,6 @@ const spinnerVariants = {
|
||||
}
|
||||
|
||||
export default function Spinner({ text }: Props) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Searching
|
||||
variants={spinnerVariants}
|
||||
@ -31,7 +29,7 @@ export default function Spinner({ text }: Props) {
|
||||
ease: 'easeInOut'
|
||||
}}>
|
||||
<Search size={16} style={{ color: 'unset' }} />
|
||||
<span>{t(text)}</span>
|
||||
<span>{text}</span>
|
||||
</Searching>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
@ -78,7 +78,7 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
@ -91,5 +91,5 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
`
|
||||
|
||||
@ -4,10 +4,11 @@ export const DEFAULT_MAX_TOKENS = 4096
|
||||
export const SYSTEM_PROMPT_THRESHOLD = 128
|
||||
export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6
|
||||
export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0
|
||||
export const DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT = 1
|
||||
|
||||
export const platform = window.electron?.process?.platform
|
||||
export const isMac = platform === 'darwin'
|
||||
export const isWindows = platform === 'win32' || platform === 'win64'
|
||||
export const isWin = platform === 'win32' || platform === 'win64'
|
||||
export const isLinux = platform === 'linux'
|
||||
|
||||
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'
|
||||
|
||||
@ -145,7 +145,7 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
|
||||
import NomicLogo from '@renderer/assets/images/providers/nomic.png'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { getBaseModelName } from '@renderer/utils'
|
||||
import { getLowerBaseModelName } from '@renderer/utils'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts'
|
||||
@ -184,7 +184,7 @@ const visionAllowedModels = [
|
||||
'deepseek-vl(?:[\\w-]+)?',
|
||||
'kimi-latest',
|
||||
'gemma-3(?:-[\\w-]+)',
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)'
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?'
|
||||
]
|
||||
|
||||
const visionExcludedModels = [
|
||||
@ -273,6 +273,10 @@ export function isFunctionCallingModel(model: Model): boolean {
|
||||
return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id)
|
||||
}
|
||||
|
||||
if (model.provider === 'doubao') {
|
||||
return FUNCTION_CALLING_REGEX.test(model.id) || FUNCTION_CALLING_REGEX.test(model.name)
|
||||
}
|
||||
|
||||
if (['deepseek', 'anthropic'].includes(model.provider)) {
|
||||
return true
|
||||
}
|
||||
@ -1352,12 +1356,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',
|
||||
@ -2475,6 +2473,10 @@ export function isGeminiReasoningModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.id.startsWith('gemini') && model.id.includes('thinking')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (model.id.includes('gemini-2.5')) {
|
||||
return true
|
||||
}
|
||||
@ -2505,14 +2507,16 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseName = getBaseModelName(model.id, '/').toLowerCase()
|
||||
const baseName = getLowerBaseModelName(model.id, '/')
|
||||
|
||||
return (
|
||||
baseName.startsWith('qwen3') ||
|
||||
[
|
||||
'qwen-plus',
|
||||
'qwen-plus-latest',
|
||||
'qwen-plus-0428',
|
||||
'qwen-plus-2025-04-28',
|
||||
'qwen-turbo',
|
||||
'qwen-turbo-latest',
|
||||
'qwen-turbo-0428',
|
||||
'qwen-turbo-2025-04-28'
|
||||
@ -2525,7 +2529,7 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return DOUBAO_THINKING_MODEL_REGEX.test(model.id)
|
||||
return DOUBAO_THINKING_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_MODEL_REGEX.test(model.name)
|
||||
}
|
||||
|
||||
export function isClaudeReasoningModel(model?: Model): boolean {
|
||||
@ -2547,6 +2551,10 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isEmbeddingModel(model)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.provider === 'doubao') {
|
||||
return (
|
||||
REASONING_REGEX.test(model.name) ||
|
||||
@ -2614,7 +2622,7 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseName = getBaseModelName(model.id, '/').toLowerCase()
|
||||
const baseName = getLowerBaseModelName(model.id, '/')
|
||||
|
||||
// 不管哪个供应商都判断了
|
||||
if (model.id.includes('claude')) {
|
||||
@ -2708,7 +2716,7 @@ export function isGenerateImageModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseName = getBaseModelName(model.id, '/').toLowerCase()
|
||||
const baseName = getLowerBaseModelName(model.id, '/')
|
||||
if (GENERATE_IMAGE_MODELS.includes(baseName)) {
|
||||
return true
|
||||
}
|
||||
@ -2720,7 +2728,7 @@ export function isSupportedDisableGenerationModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(model.id)
|
||||
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(getLowerBaseModelName(model.id))
|
||||
}
|
||||
|
||||
export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record<string, any> {
|
||||
@ -2853,13 +2861,14 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } |
|
||||
|
||||
// Doubao 支持思考模式的模型正则
|
||||
export const DOUBAO_THINKING_MODEL_REGEX =
|
||||
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?)(?:-[\w-]+)?/i
|
||||
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i
|
||||
|
||||
// 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(1-5-thinking-pro-m|seed-1\.6|seed-1-6-[\w-]+)(?:-[\w-]+)*/i
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX =
|
||||
/doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-[\w-]+)*/i
|
||||
|
||||
export function isDoubaoThinkingAutoModel(model: Model): boolean {
|
||||
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id)
|
||||
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name)
|
||||
}
|
||||
|
||||
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
|
||||
|
||||
@ -38,7 +38,20 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
boxShadowSecondary: 'none',
|
||||
defaultShadow: 'none',
|
||||
dangerShadow: 'none',
|
||||
primaryShadow: 'none'
|
||||
primaryShadow: 'none',
|
||||
controlHeight: 30,
|
||||
paddingInline: 10
|
||||
},
|
||||
Input: {
|
||||
controlHeight: 30,
|
||||
colorBorder: 'var(--color-border)'
|
||||
},
|
||||
InputNumber: {
|
||||
colorBorder: 'var(--color-border)'
|
||||
},
|
||||
Select: {
|
||||
controlHeight: 30,
|
||||
colorBorder: 'var(--color-border)'
|
||||
},
|
||||
Collapse: {
|
||||
headerBg: 'transparent'
|
||||
@ -50,13 +63,47 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
fontFamily: 'var(--code-font-family)'
|
||||
},
|
||||
Segmented: {
|
||||
itemActiveBg: 'var(--color-background-mute)',
|
||||
itemHoverBg: 'var(--color-background-mute)'
|
||||
itemActiveBg: 'var(--color-background-soft)',
|
||||
itemHoverBg: 'var(--color-background-soft)',
|
||||
trackBg: 'rgba(153,153,153,0.15)'
|
||||
},
|
||||
Switch: {
|
||||
colorTextQuaternary: 'rgba(153,153,153,0.20)',
|
||||
trackMinWidth: 40,
|
||||
handleSize: 19,
|
||||
trackMinWidthSM: 28,
|
||||
trackHeightSM: 17,
|
||||
handleSizeSM: 14,
|
||||
trackPadding: 1.5
|
||||
},
|
||||
Dropdown: {
|
||||
controlPaddingHorizontal: 8,
|
||||
borderRadiusLG: 10,
|
||||
borderRadiusSM: 8
|
||||
},
|
||||
Popover: {
|
||||
borderRadiusLG: 10
|
||||
},
|
||||
Slider: {
|
||||
handleLineWidth: 1.5,
|
||||
handleSize: 15,
|
||||
handleSizeHover: 15,
|
||||
dotSize: 7,
|
||||
railSize: 5,
|
||||
colorBgElevated: '#ffffff'
|
||||
},
|
||||
Modal: {
|
||||
colorBgElevated: 'var(--modal-background)'
|
||||
},
|
||||
Divider: {
|
||||
colorSplit: 'rgba(128,128,128,0.15)'
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: colorPrimary,
|
||||
fontFamily: 'var(--font-family)'
|
||||
fontFamily: 'var(--font-family)',
|
||||
colorBgMask: _theme === 'dark' ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.8)',
|
||||
motionDurationMid: '100ms'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@ -10,6 +10,7 @@ import { createContext, type PropsWithChildren, use, useCallback, useEffect, use
|
||||
|
||||
interface CodeStyleContextType {
|
||||
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
highlightStreamingCode: (code: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
cleanupTokenizers: (callerId: string) => void
|
||||
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
|
||||
highlightCode: (code: string, language: string) => Promise<string>
|
||||
@ -22,6 +23,7 @@ interface CodeStyleContextType {
|
||||
|
||||
const defaultCodeStyleContext: CodeStyleContextType = {
|
||||
highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
|
||||
highlightStreamingCode: async () => ({ lines: [], recall: 0 }),
|
||||
cleanupTokenizers: () => {},
|
||||
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
|
||||
highlightCode: async () => '',
|
||||
@ -114,6 +116,15 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
shikiStreamService.cleanupTokenizers(callerId)
|
||||
}, [])
|
||||
|
||||
// 高亮流式输出的代码
|
||||
const highlightStreamingCode = useCallback(
|
||||
async (fullContent: string, language: string, callerId: string) => {
|
||||
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
return shikiStreamService.highlightStreamingCode(fullContent, normalizedLang, activeShikiTheme, callerId)
|
||||
},
|
||||
[activeShikiTheme, languageMap]
|
||||
)
|
||||
|
||||
// 获取 Shiki pre 标签属性
|
||||
const getShikiPreProperties = useCallback(
|
||||
async (language: string) => {
|
||||
@ -148,6 +159,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
highlightCodeChunk,
|
||||
highlightStreamingCode,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
@ -159,6 +171,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
}),
|
||||
[
|
||||
highlightCodeChunk,
|
||||
highlightStreamingCode,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { isWindows } from '@renderer/config/constant'
|
||||
import { isWin } from '@renderer/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -8,7 +8,7 @@ export function useFullScreenNotice() {
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, isFullscreen) => {
|
||||
if (isWindows && isFullscreen) {
|
||||
if (isWin && isFullscreen) {
|
||||
window.message.info({
|
||||
content: t('common.fullscreen'),
|
||||
duration: 3,
|
||||
|
||||
@ -169,7 +169,8 @@ export const useKnowledge = (baseId: string) => {
|
||||
processingStatus: 'pending',
|
||||
processingProgress: 0,
|
||||
processingError: '',
|
||||
uniqueId: undefined
|
||||
uniqueId: undefined,
|
||||
updated_at: Date.now()
|
||||
})
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import {
|
||||
SendMessageShortcut,
|
||||
setAssistantIconType,
|
||||
setAutoCheckUpdate as _setAutoCheckUpdate,
|
||||
setEarlyAccess as _setEarlyAccess,
|
||||
setLaunchOnBoot,
|
||||
setLaunchToTray,
|
||||
setPinTopicsToTop,
|
||||
@ -12,6 +11,8 @@ import {
|
||||
setShowTokens,
|
||||
setSidebarIcons,
|
||||
setTargetLanguage,
|
||||
setTestChannel as _setTestChannel,
|
||||
setTestPlan as _setTestPlan,
|
||||
setTheme,
|
||||
SettingsState,
|
||||
setTopicPosition,
|
||||
@ -20,7 +21,7 @@ import {
|
||||
setWindowStyle
|
||||
} from '@renderer/store/settings'
|
||||
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
|
||||
export function useSettings() {
|
||||
const settings = useAppSelector((state) => state.settings)
|
||||
@ -60,9 +61,14 @@ export function useSettings() {
|
||||
window.api.setAutoUpdate(isAutoUpdate)
|
||||
},
|
||||
|
||||
setEarlyAccess(isEarlyAccess: boolean) {
|
||||
dispatch(_setEarlyAccess(isEarlyAccess))
|
||||
window.api.setFeedUrl(isEarlyAccess ? FeedUrl.EARLY_ACCESS : FeedUrl.PRODUCTION)
|
||||
setTestPlan(isTestPlan: boolean) {
|
||||
dispatch(_setTestPlan(isTestPlan))
|
||||
window.api.setTestPlan(isTestPlan)
|
||||
},
|
||||
|
||||
setTestChannel(channel: UpgradeChannel) {
|
||||
dispatch(_setTestChannel(channel))
|
||||
window.api.setTestChannel(channel)
|
||||
},
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { orderBy } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
@ -72,7 +72,7 @@ export function useShortcutDisplay(key: string) {
|
||||
case 'ctrl':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
case 'command':
|
||||
return isMac ? '⌘' : isWindows ? 'Win' : 'Super'
|
||||
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
|
||||
case 'alt':
|
||||
return isMac ? '⌥' : 'Alt'
|
||||
case 'shift':
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { RootState, useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setTagsOrder, updateAssistants } from '@renderer/store/assistants'
|
||||
import { setTagsOrder, updateTagCollapse } from '@renderer/store/assistants'
|
||||
import { flatMap, groupBy, uniq } from 'lodash'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -12,6 +12,8 @@ const selectAssistantsState = (state: RootState) => state.assistants
|
||||
// 记忆化 tagsOrder 选择器(自动处理默认值)--- 这是一个选择器,用于从 store 中获取 tagsOrder 的值。因为之前的tagsOrder是后面新加的,不这样做会报错,所以这里需要处理一下默认值
|
||||
const selectTagsOrder = createSelector([selectAssistantsState], (assistants) => assistants.tagsOrder ?? [])
|
||||
|
||||
const selectCollapsedTags = createSelector([selectAssistantsState], (assistants) => assistants.collapsedTags ?? {})
|
||||
|
||||
// 定义useTags的返回类型,包含所有标签和获取特定标签的助手函数
|
||||
// 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性
|
||||
// 但是为了方便管理,增加了一个获取特定标签的助手函数
|
||||
@ -20,6 +22,7 @@ export const useTags = () => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const savedTagsOrder = useAppSelector(selectTagsOrder)
|
||||
const collapsedTags = useAppSelector(selectCollapsedTags)
|
||||
|
||||
// 计算所有标签
|
||||
const allTags = useMemo(() => {
|
||||
@ -38,28 +41,6 @@ export const useTags = () => {
|
||||
[assistants]
|
||||
)
|
||||
|
||||
const updateTagsOrder = useCallback(
|
||||
(newOrder: string[]) => {
|
||||
dispatch(setTagsOrder(newOrder))
|
||||
updateAssistants(
|
||||
assistants.map((assistant) => {
|
||||
if (!assistant.tags || assistant.tags.length === 0) {
|
||||
return assistant
|
||||
}
|
||||
const newTags = [...assistant.tags]
|
||||
newTags.sort((a, b) => {
|
||||
return newOrder.indexOf(a) - newOrder.indexOf(b)
|
||||
})
|
||||
return {
|
||||
...assistant,
|
||||
tags: newTags
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[assistants, dispatch]
|
||||
)
|
||||
|
||||
const getGroupedAssistants = useMemo(() => {
|
||||
// 按标签分组,处理多标签的情况
|
||||
const assistantsByTags = flatMap(assistants, (assistant) => {
|
||||
@ -100,10 +81,26 @@ export const useTags = () => {
|
||||
return grouped
|
||||
}, [assistants, t, savedTagsOrder])
|
||||
|
||||
const updateTagsOrder = useCallback(
|
||||
(newOrder: string[]) => {
|
||||
dispatch(setTagsOrder(newOrder))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
const toggleTagCollapse = useCallback(
|
||||
(tag: string) => {
|
||||
dispatch(updateTagCollapse(tag))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
return {
|
||||
allTags,
|
||||
getAssistantsByTag,
|
||||
getGroupedAssistants,
|
||||
updateTagsOrder
|
||||
updateTagsOrder,
|
||||
collapsedTags,
|
||||
toggleTagCollapse
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addSubscribeSource as _addSubscribeSource,
|
||||
type CompressionConfig,
|
||||
removeSubscribeSource as _removeSubscribeSource,
|
||||
setCompressionConfig,
|
||||
setDefaultProvider as _setDefaultProvider,
|
||||
setSubscribeSources as _setSubscribeSources,
|
||||
updateCompressionConfig,
|
||||
updateSubscribeBlacklist as _updateSubscribeBlacklist,
|
||||
updateWebSearchProvider,
|
||||
updateWebSearchProviders
|
||||
@ -90,3 +93,14 @@ export const useBlacklist = () => {
|
||||
setSubscribeSources
|
||||
}
|
||||
}
|
||||
|
||||
export const useWebSearchSettings = () => {
|
||||
const state = useAppSelector((state) => state.websearch)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
...state,
|
||||
setCompressionConfig: (config: CompressionConfig) => dispatch(setCompressionConfig(config)),
|
||||
updateCompressionConfig: (config: Partial<CompressionConfig>) => dispatch(updateCompressionConfig(config))
|
||||
}
|
||||
}
|
||||
|
||||
@ -412,6 +412,7 @@
|
||||
"search": "Search",
|
||||
"select": "Select",
|
||||
"selectedMessages": "Selected {{count}} messages",
|
||||
"selectedItems": "Selected {{count}} items",
|
||||
"success": "Success",
|
||||
"topics": "Topics",
|
||||
"warning": "Warning",
|
||||
@ -702,6 +703,13 @@
|
||||
"success.siyuan.export": "Successfully exported to Siyuan Note",
|
||||
"warn.yuque.exporting": "Exporting to Yuque, please do not request export repeatedly!",
|
||||
"warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!",
|
||||
"websearch": {
|
||||
"rag": "Executing RAG...",
|
||||
"rag_complete": "Keeping {{countAfter}} out of {{countBefore}} results...",
|
||||
"rag_failed": "RAG failed, returning empty results...",
|
||||
"cutoff": "Truncating search content...",
|
||||
"fetch_complete": "Completed {{count}} searches..."
|
||||
},
|
||||
"download.success": "Download successfully",
|
||||
"download.failed": "Download failed"
|
||||
},
|
||||
@ -775,6 +783,7 @@
|
||||
"dimensions": "Dimensions {{dimensions}}",
|
||||
"edit": "Edit Model",
|
||||
"embedding": "Embedding",
|
||||
"embedding_dimensions": "Embedding Dimensions",
|
||||
"embedding_model": "Embedding Model",
|
||||
"embedding_model_tooltip": "Add in Settings->Model Provider->Manage",
|
||||
"function_calling": "Function Calling",
|
||||
@ -864,7 +873,7 @@
|
||||
"paint_course": "tutorial",
|
||||
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap",
|
||||
"prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts",
|
||||
"proxy_required": "Open the proxy and enable “TUN mode” to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
|
||||
"proxy_required": "Open the proxy and enable \"TUN mode\" to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
|
||||
"image_file_required": "Please upload an image first",
|
||||
"image_file_retry": "Please re-upload an image first",
|
||||
"image_placeholder": "No image available",
|
||||
@ -1087,11 +1096,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",
|
||||
@ -1103,6 +1112,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",
|
||||
@ -1235,6 +1248,70 @@
|
||||
"maxBackups": "Maximum Backups",
|
||||
"maxBackups.unlimited": "Unlimited"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3 Compatible Storage",
|
||||
"title.help": "Object storage services compatible with AWS S3 API, such as AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, etc.",
|
||||
"endpoint": "API Endpoint",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "Region",
|
||||
"region.placeholder": "Region, e.g: us-east-1",
|
||||
"bucket": "Bucket",
|
||||
"bucket.placeholder": "Bucket, e.g: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "Backup Directory (Optional)",
|
||||
"root.placeholder": "e.g: /cherry-studio",
|
||||
"backup.operation": "Backup Operation",
|
||||
"backup.button": "Backup Now",
|
||||
"backup.manager.button": "Manage Backups",
|
||||
"backup.modal.title": "S3 Backup",
|
||||
"backup.modal.filename.placeholder": "Please enter backup filename",
|
||||
"backup.success": "S3 backup successful",
|
||||
"backup.error": "S3 backup failed: {{message}}",
|
||||
"autoSync": "Auto Sync",
|
||||
"autoSync.off": "Off",
|
||||
"autoSync.minute": "Every {{count}} minute",
|
||||
"autoSync.hour": "Every {{count}} hour",
|
||||
"maxBackups": "Maximum Backups",
|
||||
"maxBackups.unlimited": "Unlimited",
|
||||
"skipBackupFile": "Lightweight Backup",
|
||||
"skipBackupFile.help": "When enabled, file data will be skipped during backup, only configuration information will be backed up, significantly reducing backup file size",
|
||||
"syncStatus": "Sync Status",
|
||||
"syncStatus.noSync": "Not synced",
|
||||
"syncStatus.error": "Sync error: {{message}}",
|
||||
"syncStatus.lastSync": "Last sync: {{time}}",
|
||||
"manager.title": "S3 Backup File Manager",
|
||||
"manager.refresh": "Refresh",
|
||||
"manager.delete.selected": "Delete Selected ({{count}})",
|
||||
"manager.close": "Close",
|
||||
"manager.columns.fileName": "File Name",
|
||||
"manager.columns.modifiedTime": "Modified Time",
|
||||
"manager.columns.size": "File Size",
|
||||
"manager.columns.actions": "Actions",
|
||||
"manager.restore": "Restore",
|
||||
"manager.delete": "Delete",
|
||||
"manager.config.incomplete": "Please fill in complete S3 configuration",
|
||||
"manager.files.fetch.error": "Failed to fetch backup file list: {{message}}",
|
||||
"manager.delete.confirm.title": "Confirm Delete",
|
||||
"manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.",
|
||||
"manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.",
|
||||
"manager.delete.success.multiple": "Successfully deleted {{count}} backup files",
|
||||
"manager.delete.success.single": "Backup file deleted successfully",
|
||||
"manager.delete.error": "Failed to delete backup file: {{message}}",
|
||||
"manager.select.warning": "Please select backup files to delete",
|
||||
"restore.modal.title": "S3 Data Restore",
|
||||
"restore.modal.select.placeholder": "Please select backup file to restore",
|
||||
"restore.confirm.title": "Confirm Restore Data",
|
||||
"restore.confirm.content": "Restoring data will overwrite all current data. This action cannot be undone. Are you sure you want to continue?",
|
||||
"restore.confirm.ok": "Confirm Restore",
|
||||
"restore.confirm.cancel": "Cancel",
|
||||
"restore.success": "Data restore successful",
|
||||
"restore.error": "Data restore failed: {{message}}",
|
||||
"restore.config.incomplete": "Please fill in complete S3 configuration",
|
||||
"restore.file.required": "Please select backup file to restore"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
"button": "Check",
|
||||
@ -1379,8 +1456,14 @@
|
||||
"general.emoji_picker": "Emoji Picker",
|
||||
"general.image_upload": "Image Upload",
|
||||
"general.auto_check_update.title": "Auto Update",
|
||||
"general.early_access.title": "Early Access",
|
||||
"general.early_access.tooltip": "Enable to use the latest version from GitHub, which may be slower. Please backup your data in advance.",
|
||||
"general.test_plan.title": "Test Plan",
|
||||
"general.test_plan.tooltip": "Participate in the test plan to experience the latest features faster, but also brings more risks, please backup your data in advance",
|
||||
"general.test_plan.beta_version": "Beta Version (Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "Features may change at any time, bugs are more, upgrade quickly",
|
||||
"general.test_plan.rc_version": "Preview Version (RC)",
|
||||
"general.test_plan.rc_version_tooltip": "Close to stable version, features are basically stable, bugs are few",
|
||||
"general.test_plan.version_options": "Version Options",
|
||||
"general.test_plan.version_channel_not_match": "Preview and test version switching will take effect after the next stable version is released",
|
||||
"general.reset.button": "Reset",
|
||||
"general.reset.title": "Data Reset",
|
||||
"general.restore.button": "Restore",
|
||||
@ -1388,6 +1471,8 @@
|
||||
"general.user_name": "User Name",
|
||||
"general.user_name.placeholder": "Enter your name",
|
||||
"general.view_webdav_settings": "View WebDAV settings",
|
||||
"general.spell_check": "Spell Check",
|
||||
"general.spell_check.languages": "Use spell check for",
|
||||
"input.auto_translate_with_space": "Quickly translate with 3 spaces",
|
||||
"input.show_translate_confirm": "Show translation confirmation dialog",
|
||||
"input.target_language": "Target language",
|
||||
@ -1464,7 +1549,8 @@
|
||||
"version": "Version"
|
||||
},
|
||||
"errors": {
|
||||
"32000": "MCP server failed to start, please check the parameters according to the tutorial"
|
||||
"32000": "MCP server failed to start, please check the parameters according to the tutorial",
|
||||
"toolNotFound": "Tool {{name}} not found"
|
||||
},
|
||||
"serverPlural": "servers",
|
||||
"serverSingular": "server",
|
||||
@ -1512,6 +1598,7 @@
|
||||
"registry": "Package Registry",
|
||||
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
|
||||
"registryDefault": "Default",
|
||||
"customRegistryPlaceholder": "Enter private registry URL, e.g.: https://npm.company.com",
|
||||
"not_support": "Model not supported",
|
||||
"user": "User",
|
||||
"system": "System",
|
||||
@ -1829,8 +1916,33 @@
|
||||
"overwrite_tooltip": "Force use search service instead of LLM",
|
||||
"apikey": "API key",
|
||||
"free": "Free",
|
||||
"content_limit": "Content length limit",
|
||||
"content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated."
|
||||
"compression": {
|
||||
"title": "Search Result Compression",
|
||||
"method": "Compression Method",
|
||||
"method.none": "None",
|
||||
"method.cutoff": "Cutoff",
|
||||
"cutoff.limit": "Cutoff Limit",
|
||||
"cutoff.limit.placeholder": "Enter length",
|
||||
"cutoff.limit.tooltip": "Limit the content length of search results, content exceeding the limit will be truncated (e.g., 2000 characters)",
|
||||
"cutoff.unit.char": "Char",
|
||||
"cutoff.unit.token": "Token",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "Document Count",
|
||||
"rag.document_count.default": "Default",
|
||||
"rag.document_count.tooltip": "Expected number of documents to extract from each search result, the actual total number of extracted documents is this value multiplied by the number of search results.",
|
||||
"rag.embedding_dimensions.auto_get": "Auto Get Dimensions",
|
||||
"rag.embedding_dimensions.placeholder": "Leave empty",
|
||||
"rag.embedding_dimensions.tooltip": "If left blank, the dimensions parameter will not be passed",
|
||||
"info": {
|
||||
"dimensions_auto_success": "Dimensions auto-obtained successfully, dimensions: {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "Please select an embedding model first",
|
||||
"dimensions_auto_failed": "Failed to auto-obtain dimensions",
|
||||
"provider_not_found": "Provider not found",
|
||||
"rag_failed": "RAG failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "Quick Phrases",
|
||||
|
||||
@ -412,6 +412,7 @@
|
||||
"search": "検索",
|
||||
"select": "選択",
|
||||
"selectedMessages": "{{count}}件のメッセージを選択しました",
|
||||
"selectedItems": "{{count}}件の項目を選択しました",
|
||||
"success": "成功",
|
||||
"topics": "トピック",
|
||||
"warning": "警告",
|
||||
@ -701,6 +702,13 @@
|
||||
"warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!",
|
||||
"warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!",
|
||||
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません",
|
||||
"websearch": {
|
||||
"rag": "RAGを実行中...",
|
||||
"rag_complete": "{{countBefore}}個の結果から{{countAfter}}個を保持...",
|
||||
"rag_failed": "RAGが失敗しました。空の結果を返します...",
|
||||
"cutoff": "検索内容を切り詰めています...",
|
||||
"fetch_complete": "{{count}}回の検索を完了しました..."
|
||||
},
|
||||
"download.success": "ダウンロードに成功しました",
|
||||
"download.failed": "ダウンロードに失敗しました",
|
||||
"error.fetchTopicName": "トピック名の取得に失敗しました"
|
||||
@ -775,6 +783,7 @@
|
||||
"dimensions": "{{dimensions}} 次元",
|
||||
"edit": "モデルを編集",
|
||||
"embedding": "埋め込み",
|
||||
"embedding_dimensions": "埋め込み次元",
|
||||
"embedding_model": "埋め込み模型",
|
||||
"embedding_model_tooltip": "設定->モデルサービス->管理で追加",
|
||||
"function_calling": "関数呼び出し",
|
||||
@ -1085,11 +1094,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": "データのコピーに失敗しました",
|
||||
@ -1101,6 +1110,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": "ナレッジベースファイルを削除",
|
||||
@ -1215,6 +1228,70 @@
|
||||
"maxBackups": "最大バックアップ数",
|
||||
"maxBackups.unlimited": "無制限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3互換ストレージ",
|
||||
"title.help": "AWS S3 APIと互換性のあるオブジェクトストレージサービス(例:AWS S3、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COSなど)",
|
||||
"endpoint": "APIエンドポイント",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "リージョン",
|
||||
"region.placeholder": "Region、例: us-east-1",
|
||||
"bucket": "バケット",
|
||||
"bucket.placeholder": "Bucket、例: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "バックアップディレクトリ(オプション)",
|
||||
"root.placeholder": "例:/cherry-studio",
|
||||
"backup.operation": "バックアップ操作",
|
||||
"backup.button": "今すぐバックアップ",
|
||||
"backup.manager.button": "バックアップ管理",
|
||||
"backup.modal.title": "S3バックアップ",
|
||||
"backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
|
||||
"backup.success": "S3バックアップ成功",
|
||||
"backup.error": "S3バックアップ失敗: {{message}}",
|
||||
"autoSync": "自動同期",
|
||||
"autoSync.off": "オフ",
|
||||
"autoSync.minute": "{{count}}分毎",
|
||||
"autoSync.hour": "{{count}}時間毎",
|
||||
"maxBackups": "最大バックアップ数",
|
||||
"maxBackups.unlimited": "無制限",
|
||||
"skipBackupFile": "軽量バックアップ",
|
||||
"skipBackupFile.help": "有効にすると、バックアップ時にファイルデータがスキップされ、設定情報のみがバックアップされ、バックアップファイルのサイズが大幅に削減されます。",
|
||||
"syncStatus": "同期ステータス",
|
||||
"syncStatus.noSync": "未同期",
|
||||
"syncStatus.error": "同期エラー: {{message}}",
|
||||
"syncStatus.lastSync": "最終同期: {{time}}",
|
||||
"manager.title": "S3バックアップファイルマネージャー",
|
||||
"manager.refresh": "更新",
|
||||
"manager.delete.selected": "選択項目を削除 ({{count}})",
|
||||
"manager.close": "閉じる",
|
||||
"manager.columns.fileName": "ファイル名",
|
||||
"manager.columns.modifiedTime": "変更日時",
|
||||
"manager.columns.size": "ファイルサイズ",
|
||||
"manager.columns.actions": "操作",
|
||||
"manager.restore": "復元",
|
||||
"manager.delete": "削除",
|
||||
"manager.config.incomplete": "完全なS3設定情報を入力してください",
|
||||
"manager.files.fetch.error": "バックアップファイルリストの取得に失敗しました: {{message}}",
|
||||
"manager.delete.confirm.title": "削除の確認",
|
||||
"manager.delete.confirm.multiple": "選択した{{count}}個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"manager.delete.confirm.single": "バックアップファイル「{{fileName}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"manager.delete.success.multiple": "{{count}}個のバックアップファイルを正常に削除しました",
|
||||
"manager.delete.success.single": "バックアップファイルの削除に成功しました",
|
||||
"manager.delete.error": "バックアップファイルの削除に失敗しました: {{message}}",
|
||||
"manager.select.warning": "削除するバックアップファイルを選択してください",
|
||||
"restore.modal.title": "S3データ復元",
|
||||
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
|
||||
"restore.confirm.title": "データ復元の確認",
|
||||
"restore.confirm.content": "データを復元すると、現在のすべてのデータが上書きされます。この操作は元に戻せません。続行してもよろしいですか?",
|
||||
"restore.confirm.ok": "復元を確認",
|
||||
"restore.confirm.cancel": "キャンセル",
|
||||
"restore.success": "データの復元に成功しました",
|
||||
"restore.error": "データの復元に失敗しました: {{message}}",
|
||||
"restore.config.incomplete": "完全なS3設定情報を入力してください",
|
||||
"restore.file.required": "復元するバックアップファイルを選択してください"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
"button": "接続確認",
|
||||
@ -1383,6 +1460,8 @@
|
||||
"general.user_name": "ユーザー名",
|
||||
"general.user_name.placeholder": "ユーザー名を入力",
|
||||
"general.view_webdav_settings": "WebDAV設定を表示",
|
||||
"general.spell_check": "スペルチェック",
|
||||
"general.spell_check.languages": "スペルチェック言語",
|
||||
"input.auto_translate_with_space": "スペースを3回押して翻訳",
|
||||
"input.target_language": "目標言語",
|
||||
"input.target_language.chinese": "簡体字中国語",
|
||||
@ -1466,7 +1545,8 @@
|
||||
"updateSuccess": "サーバーが正常に更新されました",
|
||||
"url": "URL",
|
||||
"errors": {
|
||||
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
|
||||
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください",
|
||||
"toolNotFound": "ツール {{name}} が見つかりません"
|
||||
},
|
||||
"editMcpJson": "MCP 設定を編集",
|
||||
"installHelp": "インストールヘルプを取得",
|
||||
@ -1506,6 +1586,7 @@
|
||||
"registry": "パッケージ管理レジストリ",
|
||||
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
|
||||
"registryDefault": "デフォルト",
|
||||
"customRegistryPlaceholder": "プライベート倉庫のアドレスを入力してください(例:https://npm.company.com)",
|
||||
"not_support": "モデルはサポートされていません",
|
||||
"user": "ユーザー",
|
||||
"system": "システム",
|
||||
@ -1817,12 +1898,43 @@
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
|
||||
"apikey": "API キー",
|
||||
"free": "無料",
|
||||
"content_limit": "内容の長さ制限",
|
||||
"content_limit_tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます。"
|
||||
"compression": {
|
||||
"title": "検索結果の圧縮",
|
||||
"method": "圧縮方法",
|
||||
"method.none": "圧縮しない",
|
||||
"method.cutoff": "切り捨て",
|
||||
"cutoff.limit": "切り捨て長",
|
||||
"cutoff.limit.placeholder": "長さを入力",
|
||||
"cutoff.limit.tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます(例:2000文字)",
|
||||
"cutoff.unit.char": "文字",
|
||||
"cutoff.unit.token": "トークン",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "文書数",
|
||||
"rag.document_count.default": "デフォルト",
|
||||
"rag.document_count.tooltip": "単一の検索結果から抽出する文書数。実際に抽出される文書数は、この値に検索結果数を乗じたものです。",
|
||||
"rag.embedding_dimensions.auto_get": "次元を自動取得",
|
||||
"rag.embedding_dimensions.placeholder": "次元を設定しない",
|
||||
"rag.embedding_dimensions.tooltip": "空の場合、dimensions パラメーターは渡されません",
|
||||
"info": {
|
||||
"dimensions_auto_success": "次元が自動取得されました。次元: {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "まず埋め込みモデルを選択してください",
|
||||
"dimensions_auto_failed": "次元の自動取得に失敗しました",
|
||||
"provider_not_found": "プロバイダーが見つかりません",
|
||||
"rag_failed": "RAG に失敗しました"
|
||||
}
|
||||
}
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.early_access.title": "早期アクセス",
|
||||
"general.early_access.tooltip": "有効にすると、GitHub の最新バージョンを使用します。ダウンロード速度が遅く、不安定な場合があります。データを事前にバックアップしてください。",
|
||||
"general.test_plan.title": "テストプラン",
|
||||
"general.test_plan.tooltip": "テストプランに参加すると、最新の機能をより早く体験できますが、同時により多くのリスクが伴います。データを事前にバックアップしてください。",
|
||||
"general.test_plan.beta_version": "ベータ版(Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "機能が変更される可能性があります。バグが多く、迅速にアップグレードされます。",
|
||||
"general.test_plan.rc_version": "プレビュー版(RC)",
|
||||
"general.test_plan.rc_version_tooltip": "安定版に近い機能ですが、バグが少なく、迅速にアップグレードされます。",
|
||||
"general.test_plan.version_options": "バージョンオプション",
|
||||
"general.test_plan.version_channel_not_match": "プレビュー版とテスト版の切り替えは、次の正式版リリース時に有効になります。",
|
||||
"quickPhrase": {
|
||||
"title": "クイックフレーズ",
|
||||
"add": "フレーズを追加",
|
||||
|
||||
@ -412,6 +412,7 @@
|
||||
"search": "Поиск",
|
||||
"select": "Выбрать",
|
||||
"selectedMessages": "Выбрано {{count}} сообщений",
|
||||
"selectedItems": "Выбрано {{count}} элементов",
|
||||
"success": "Успешно",
|
||||
"topics": "Топики",
|
||||
"warning": "Предупреждение",
|
||||
@ -701,6 +702,13 @@
|
||||
"success.siyuan.export": "Успешный экспорт в Siyuan",
|
||||
"warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!",
|
||||
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!",
|
||||
"websearch": {
|
||||
"rag": "Выполнение RAG...",
|
||||
"rag_complete": "Сохранено {{countAfter}} из {{countBefore}} результатов...",
|
||||
"rag_failed": "RAG не удалось, возвращается пустой результат...",
|
||||
"cutoff": "Обрезка содержимого поиска...",
|
||||
"fetch_complete": "Завершено {{count}} поисков..."
|
||||
},
|
||||
"download.success": "Скачано успешно",
|
||||
"download.failed": "Скачивание не удалось",
|
||||
"error.fetchTopicName": "Не удалось назвать топик"
|
||||
@ -775,6 +783,7 @@
|
||||
"dimensions": "{{dimensions}} мер",
|
||||
"edit": "Редактировать модель",
|
||||
"embedding": "Встраиваемые",
|
||||
"embedding_dimensions": "Встраиваемые размерности",
|
||||
"embedding_model": "Встраиваемые модели",
|
||||
"embedding_model_tooltip": "Добавьте в настройки->модель сервиса->управление",
|
||||
"function_calling": "Вызов функции",
|
||||
@ -1085,11 +1094,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": "Не удалось скопировать данные",
|
||||
@ -1101,6 +1110,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": "Удалить файлы базы знаний",
|
||||
@ -1233,6 +1246,70 @@
|
||||
"maxBackups": "Максимальное количество резервных копий",
|
||||
"maxBackups.unlimited": "Без ограничений"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3-совместимое хранилище",
|
||||
"title.help": "Сервисы объектного хранения, совместимые с AWS S3 API, такие как AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS и т.д.",
|
||||
"endpoint": "Конечная точка API",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "Регион",
|
||||
"region.placeholder": "Регион, например: us-east-1",
|
||||
"bucket": "Корзина",
|
||||
"bucket.placeholder": "Корзина, например: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "Каталог резервных копий (необязательно)",
|
||||
"root.placeholder": "например: /cherry-studio",
|
||||
"backup.operation": "Операция резервного копирования",
|
||||
"backup.button": "Создать резервную копию сейчас",
|
||||
"backup.manager.button": "Управление резервными копиями",
|
||||
"backup.modal.title": "Резервное копирование S3",
|
||||
"backup.modal.filename.placeholder": "Пожалуйста, введите имя файла резервной копии",
|
||||
"backup.success": "Резервное копирование S3 успешно",
|
||||
"backup.error": "Ошибка резервного копирования S3: {{message}}",
|
||||
"autoSync": "Автосинхронизация",
|
||||
"autoSync.off": "Выкл.",
|
||||
"autoSync.minute": "Каждые {{count}} мин.",
|
||||
"autoSync.hour": "Каждые {{count}} ч.",
|
||||
"maxBackups": "Макс. резервных копий",
|
||||
"maxBackups.unlimited": "Неограниченно",
|
||||
"skipBackupFile": "Облегченное резервное копирование",
|
||||
"skipBackupFile.help": "Если включено, данные файлов будут пропущены во время резервного копирования, будет скопирована только информация о конфигурации, что значительно уменьшит размер файла резервной копии.",
|
||||
"syncStatus": "Статус синхронизации",
|
||||
"syncStatus.noSync": "Не синхронизировано",
|
||||
"syncStatus.error": "Ошибка синхронизации: {{message}}",
|
||||
"syncStatus.lastSync": "Последняя синхронизация: {{time}}",
|
||||
"manager.title": "Менеджер файлов резервных копий S3",
|
||||
"manager.refresh": "Обновить",
|
||||
"manager.delete.selected": "Удалить выбранные ({{count}})",
|
||||
"manager.close": "Закрыть",
|
||||
"manager.columns.fileName": "Имя файла",
|
||||
"manager.columns.modifiedTime": "Время изменения",
|
||||
"manager.columns.size": "Размер файла",
|
||||
"manager.columns.actions": "Действия",
|
||||
"manager.restore": "Восстановить",
|
||||
"manager.delete": "Удалить",
|
||||
"manager.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
|
||||
"manager.files.fetch.error": "Не удалось получить список файлов резервных копий: {{message}}",
|
||||
"manager.delete.confirm.title": "Подтвердить удаление",
|
||||
"manager.delete.confirm.multiple": "Вы уверены, что хотите удалить {{count}} выбранных файлов резервных копий? Это действие нельзя отменить.",
|
||||
"manager.delete.confirm.single": "Вы уверены, что хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.",
|
||||
"manager.delete.success.multiple": "Успешно удалено {{count}} файлов резервных копий",
|
||||
"manager.delete.success.single": "Файл резервной копии успешно удален",
|
||||
"manager.delete.error": "Не удалось удалить файл резервной копии: {{message}}",
|
||||
"manager.select.warning": "Пожалуйста, выберите файлы резервных копий для удаления",
|
||||
"restore.modal.title": "Восстановление данных S3",
|
||||
"restore.modal.select.placeholder": "Пожалуйста, выберите файл резервной копии для восстановления",
|
||||
"restore.confirm.title": "Подтвердить восстановление данных",
|
||||
"restore.confirm.content": "Восстановление данных перезапишет все текущие данные. Это действие нельзя отменить. Вы уверены, что хотите продолжить?",
|
||||
"restore.confirm.ok": "Подтвердить восстановление",
|
||||
"restore.confirm.cancel": "Отмена",
|
||||
"restore.success": "Восстановление данных успешно",
|
||||
"restore.error": "Ошибка восстановления данных: {{message}}",
|
||||
"restore.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
|
||||
"restore.file.required": "Пожалуйста, выберите файл резервной копии для восстановления"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
"button": "Проверить",
|
||||
@ -1383,6 +1460,8 @@
|
||||
"general.user_name": "Имя пользователя",
|
||||
"general.user_name.placeholder": "Введите ваше имя",
|
||||
"general.view_webdav_settings": "Просмотр настроек WebDAV",
|
||||
"general.spell_check": "Проверка орфографии",
|
||||
"general.spell_check.languages": "Языки проверки орфографии",
|
||||
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
|
||||
"input.target_language": "Целевой язык",
|
||||
"input.target_language.chinese": "Китайский упрощенный",
|
||||
@ -1458,7 +1537,8 @@
|
||||
"version": "Версия"
|
||||
},
|
||||
"errors": {
|
||||
"32000": "MCP сервер не запущен, пожалуйста, проверьте параметры"
|
||||
"32000": "MCP сервер не запущен, пожалуйста, проверьте параметры",
|
||||
"toolNotFound": "Инструмент {{name}} не найден"
|
||||
},
|
||||
"serverPlural": "серверы",
|
||||
"serverSingular": "сервер",
|
||||
@ -1506,6 +1586,7 @@
|
||||
"registry": "Реестр пакетов",
|
||||
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
|
||||
"registryDefault": "По умолчанию",
|
||||
"customRegistryPlaceholder": "Введите адрес частного склада, например: https://npm.company.com",
|
||||
"not_support": "Модель не поддерживается",
|
||||
"user": "Пользователь",
|
||||
"system": "Система",
|
||||
@ -1817,12 +1898,43 @@
|
||||
"overwrite_tooltip": "Использовать провайдера поиска вместо LLM",
|
||||
"apikey": "API ключ",
|
||||
"free": "Бесплатно",
|
||||
"content_limit": "Ограничение длины текста",
|
||||
"content_limit_tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан."
|
||||
"compression": {
|
||||
"title": "Сжатие результатов поиска",
|
||||
"method": "Метод сжатия",
|
||||
"method.none": "Не сжимать",
|
||||
"method.cutoff": "Обрезка",
|
||||
"cutoff.limit": "Лимит обрезки",
|
||||
"cutoff.limit.placeholder": "Введите длину",
|
||||
"cutoff.limit.tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан (например, 2000 символов)",
|
||||
"cutoff.unit.char": "Символы",
|
||||
"cutoff.unit.token": "Токены",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "Количество документов",
|
||||
"rag.document_count.default": "По умолчанию",
|
||||
"rag.document_count.tooltip": "Ожидаемое количество документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных документов равно этому значению, умноженному на количество результатов поиска.",
|
||||
"rag.embedding_dimensions.auto_get": "Автоматически получить размерности",
|
||||
"rag.embedding_dimensions.placeholder": "Не устанавливать размерности",
|
||||
"rag.embedding_dimensions.tooltip": "Если оставить пустым, параметр dimensions не будет передан",
|
||||
"info": {
|
||||
"dimensions_auto_success": "Размерности успешно получены, размерности: {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "Пожалуйста, сначала выберите модель встраивания",
|
||||
"dimensions_auto_failed": "Не удалось получить размерности",
|
||||
"provider_not_found": "Поставщик не найден",
|
||||
"rag_failed": "RAG не удалось"
|
||||
}
|
||||
}
|
||||
},
|
||||
"general.auto_check_update.title": "Автоматическое обновление",
|
||||
"general.early_access.title": "Ранний доступ",
|
||||
"general.early_access.tooltip": "Включить для использования последней версии из GitHub, что может быть медленнее и нестабильно. Пожалуйста, сделайте резервную копию данных заранее.",
|
||||
"general.test_plan.title": "Тестовый план",
|
||||
"general.test_plan.tooltip": "Участвовать в тестовом плане, чтобы быстрее получать новые функции, но при этом возникает больше рисков, пожалуйста, сделайте резервную копию данных заранее",
|
||||
"general.test_plan.beta_version": "Тестовая версия (Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "Функции могут меняться в любое время, ошибки больше, обновление происходит быстрее",
|
||||
"general.test_plan.rc_version": "Предварительная версия (RC)",
|
||||
"general.test_plan.rc_version_tooltip": "Похожа на стабильную версию, функции стабильны, ошибки меньше, обновление происходит быстрее",
|
||||
"general.test_plan.version_options": "Варианты версии",
|
||||
"general.test_plan.version_channel_not_match": "Предварительная и тестовая версия будут доступны после выхода следующей стабильной версии",
|
||||
"quickPhrase": {
|
||||
"title": "Быстрые фразы",
|
||||
"add": "Добавить фразу",
|
||||
|
||||
@ -412,6 +412,7 @@
|
||||
"search": "搜索",
|
||||
"select": "选择",
|
||||
"selectedMessages": "选中 {{count}} 条消息",
|
||||
"selectedItems": "已选择 {{count}} 项",
|
||||
"success": "成功",
|
||||
"topics": "话题",
|
||||
"warning": "警告",
|
||||
@ -702,6 +703,13 @@
|
||||
"success.siyuan.export": "导出到思源笔记成功",
|
||||
"warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!",
|
||||
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!",
|
||||
"websearch": {
|
||||
"rag": "正在执行 RAG...",
|
||||
"rag_complete": "保留 {{countBefore}} 个结果中的 {{countAfter}} 个...",
|
||||
"rag_failed": "RAG 失败,返回空结果...",
|
||||
"cutoff": "正在截断搜索内容...",
|
||||
"fetch_complete": "已完成 {{count}} 次搜索..."
|
||||
},
|
||||
"download.success": "下载成功",
|
||||
"download.failed": "下载失败"
|
||||
},
|
||||
@ -775,6 +783,7 @@
|
||||
"dimensions": "{{dimensions}} 维",
|
||||
"edit": "编辑模型",
|
||||
"embedding": "嵌入",
|
||||
"embedding_dimensions": "嵌入维度",
|
||||
"embedding_model": "嵌入模型",
|
||||
"embedding_model_tooltip": "在设置->模型服务中点击管理按钮添加",
|
||||
"function_calling": "函数调用",
|
||||
@ -863,8 +872,8 @@
|
||||
"learn_more": "了解更多",
|
||||
"paint_course": "教程",
|
||||
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
|
||||
"prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词",
|
||||
"proxy_required": "打开代理并开启”TUN模式“查看生成图片或复制到浏览器打开,后续会支持国内直连",
|
||||
"prompt_placeholder_en": "输入\"英文\"图片描述,目前 Imagen 仅支持英文提示词",
|
||||
"proxy_required": "打开代理并开启\"TUN模式\"查看生成图片或复制到浏览器打开,后续会支持国内直连",
|
||||
"image_file_required": "请先上传图片",
|
||||
"image_file_retry": "请重新上传图片",
|
||||
"image_placeholder": "暂无图片",
|
||||
@ -960,7 +969,7 @@
|
||||
"magic_prompt_option_tip": "智能优化放大提示词"
|
||||
},
|
||||
"text_desc_required": "请先输入图片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"req_error_text": "运行失败,请重试。提示词避免\"版权词\"和\"敏感词\"哦。",
|
||||
"req_error_token": "请检查令牌有效性",
|
||||
"req_error_no_balance": "请检查令牌有效性",
|
||||
"image_handle_required": "请先上传图片",
|
||||
@ -1087,11 +1096,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": "复制数据失败",
|
||||
@ -1103,6 +1112,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": "删除知识库文件",
|
||||
@ -1235,7 +1248,71 @@
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 用户名",
|
||||
"maxBackups": "最大备份数",
|
||||
"maxBackups.unlimited": "无限制"
|
||||
"maxBackups.unlimited": "不限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3 兼容存储",
|
||||
"title.help": "与AWS S3 API兼容的对象存储服务, 例如AWS S3, Cloudflare R2, 阿里云OSS, 腾讯云COS等",
|
||||
"endpoint": "API 地址",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "区域",
|
||||
"region.placeholder": "Region, 例如: us-east-1",
|
||||
"bucket": "存储桶",
|
||||
"bucket.placeholder": "Bucket, 例如: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "备份目录(可选)",
|
||||
"root.placeholder": "例如:/cherry-studio",
|
||||
"backup.operation": "备份操作",
|
||||
"backup.button": "立即备份",
|
||||
"backup.manager.button": "管理备份",
|
||||
"backup.modal.title": "S3 备份",
|
||||
"backup.modal.filename.placeholder": "请输入备份文件名",
|
||||
"backup.success": "S3 备份成功",
|
||||
"backup.error": "S3 备份失败: {{message}}",
|
||||
"autoSync": "自动同步",
|
||||
"autoSync.off": "关闭",
|
||||
"autoSync.minute": "每 {{count}} 分钟",
|
||||
"autoSync.hour": "每 {{count}} 小时",
|
||||
"maxBackups": "最大备份数",
|
||||
"maxBackups.unlimited": "不限",
|
||||
"skipBackupFile": "精简备份",
|
||||
"skipBackupFile.help": "开启后备份时将跳过文件数据,仅备份配置信息,显著减小备份文件体积",
|
||||
"syncStatus": "同步状态",
|
||||
"syncStatus.noSync": "未同步",
|
||||
"syncStatus.error": "同步错误: {{message}}",
|
||||
"syncStatus.lastSync": "上次同步: {{time}}",
|
||||
"manager.title": "S3 备份文件管理",
|
||||
"manager.refresh": "刷新",
|
||||
"manager.delete.selected": "删除选中 ({{count}})",
|
||||
"manager.close": "关闭",
|
||||
"manager.columns.fileName": "文件名",
|
||||
"manager.columns.modifiedTime": "修改时间",
|
||||
"manager.columns.size": "文件大小",
|
||||
"manager.columns.actions": "操作",
|
||||
"manager.restore": "恢复",
|
||||
"manager.delete": "删除",
|
||||
"manager.config.incomplete": "请填写完整的 S3 配置信息",
|
||||
"manager.files.fetch.error": "获取备份文件列表失败: {{message}}",
|
||||
"manager.delete.confirm.title": "确认删除",
|
||||
"manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作不可撤销。",
|
||||
"manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作不可撤销。",
|
||||
"manager.delete.success.multiple": "成功删除 {{count}} 个备份文件",
|
||||
"manager.delete.success.single": "删除备份文件成功",
|
||||
"manager.delete.error": "删除备份文件失败: {{message}}",
|
||||
"manager.select.warning": "请选择要删除的备份文件",
|
||||
"restore.modal.title": "S3 数据恢复",
|
||||
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
|
||||
"restore.confirm.title": "确认恢复数据",
|
||||
"restore.confirm.content": "恢复数据将覆盖当前所有数据,此操作不可撤销。确定要继续吗?",
|
||||
"restore.confirm.ok": "确认恢复",
|
||||
"restore.confirm.cancel": "取消",
|
||||
"restore.success": "数据恢复成功",
|
||||
"restore.error": "数据恢复失败: {{message}}",
|
||||
"restore.config.incomplete": "请填写完整的 S3 配置信息",
|
||||
"restore.file.required": "请选择要恢复的备份文件"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@ -1379,16 +1456,24 @@
|
||||
"general.emoji_picker": "表情选择器",
|
||||
"general.image_upload": "图片上传",
|
||||
"general.auto_check_update.title": "自动更新",
|
||||
"general.early_access.title": "抢先体验",
|
||||
"general.early_access.tooltip": "开启后,将使用 GitHub 的最新版本,下载速度可能较慢,请务必提前备份数据",
|
||||
"general.test_plan.title": "测试计划",
|
||||
"general.test_plan.tooltip": "参与测试计划,可以更快体验到最新功能,但同时也会带来更多风险,务必提前做好备份",
|
||||
"general.test_plan.beta_version": "测试版(Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "功能可能随时变化,bug较多,升级较快",
|
||||
"general.test_plan.rc_version": "预览版(RC)",
|
||||
"general.test_plan.rc_version_tooltip": "接近正式版,功能基本稳定,bug较少",
|
||||
"general.test_plan.version_options": "版本选择",
|
||||
"general.test_plan.version_channel_not_match": "预览版和测试版的切换将在下一个正式版发布时生效",
|
||||
"general.reset.button": "重置",
|
||||
"general.reset.title": "重置数据",
|
||||
"general.restore.button": "恢复",
|
||||
"general.title": "常规设置",
|
||||
"general.user_name": "用户名",
|
||||
"general.user_name.placeholder": "请输入用户名",
|
||||
"general.user_name.placeholder": "输入您的姓名",
|
||||
"general.view_webdav_settings": "查看 WebDAV 设置",
|
||||
"input.auto_translate_with_space": "快速敲击3次空格翻译",
|
||||
"general.spell_check": "拼写检查",
|
||||
"general.spell_check.languages": "拼写检查语言",
|
||||
"input.auto_translate_with_space": "3个空格快速翻译",
|
||||
"input.show_translate_confirm": "显示翻译确认对话框",
|
||||
"input.target_language": "目标语言",
|
||||
"input.target_language.chinese": "简体中文",
|
||||
@ -1464,7 +1549,8 @@
|
||||
"version": "版本"
|
||||
},
|
||||
"errors": {
|
||||
"32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整"
|
||||
"32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整",
|
||||
"toolNotFound": "未找到工具 {{name}}"
|
||||
},
|
||||
"serverPlural": "服务器",
|
||||
"serverSingular": "服务器",
|
||||
@ -1512,6 +1598,7 @@
|
||||
"registry": "包管理源",
|
||||
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题",
|
||||
"registryDefault": "默认",
|
||||
"customRegistryPlaceholder": "请输入私有仓库地址,如: https://npm.company.com",
|
||||
"not_support": "模型不支持",
|
||||
"user": "用户",
|
||||
"system": "系统",
|
||||
@ -1829,8 +1916,33 @@
|
||||
"title": "网络搜索",
|
||||
"apikey": "API 密钥",
|
||||
"free": "免费",
|
||||
"content_limit": "内容长度限制",
|
||||
"content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断"
|
||||
"compression": {
|
||||
"title": "搜索结果压缩",
|
||||
"method": "压缩方法",
|
||||
"method.none": "不压缩",
|
||||
"method.cutoff": "截断",
|
||||
"cutoff.limit": "截断长度",
|
||||
"cutoff.limit.placeholder": "输入长度",
|
||||
"cutoff.limit.tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断(例如 2000 字符)",
|
||||
"cutoff.unit.char": "字符",
|
||||
"cutoff.unit.token": "Token",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "文档数量",
|
||||
"rag.document_count.default": "默认",
|
||||
"rag.document_count.tooltip": "预期从单个搜索结果中提取的文档数量,实际提取的总数量是这个值乘以搜索结果数量。",
|
||||
"rag.embedding_dimensions.auto_get": "自动获取维度",
|
||||
"rag.embedding_dimensions.placeholder": "不设置维度",
|
||||
"rag.embedding_dimensions.tooltip": "留空则不传递 dimensions 参数",
|
||||
"info": {
|
||||
"dimensions_auto_success": "维度自动获取成功,维度为 {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "请先选择嵌入模型",
|
||||
"dimensions_auto_failed": "维度自动获取失败",
|
||||
"provider_not_found": "未找到服务商",
|
||||
"rag_failed": "RAG 失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "快捷短语",
|
||||
|
||||
@ -412,6 +412,7 @@
|
||||
"search": "搜尋",
|
||||
"select": "選擇",
|
||||
"selectedMessages": "選中 {{count}} 條訊息",
|
||||
"selectedItems": "已選擇 {{count}} 項",
|
||||
"success": "成功",
|
||||
"topics": "話題",
|
||||
"warning": "警告",
|
||||
@ -702,6 +703,13 @@
|
||||
"success.siyuan.export": "導出到思源筆記成功",
|
||||
"warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!",
|
||||
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!",
|
||||
"websearch": {
|
||||
"rag": "正在執行 RAG...",
|
||||
"rag_complete": "保留 {{countBefore}} 個結果中的 {{countAfter}} 個...",
|
||||
"rag_failed": "RAG 失敗,返回空結果...",
|
||||
"cutoff": "正在截斷搜尋內容...",
|
||||
"fetch_complete": "已完成 {{count}} 次搜尋..."
|
||||
},
|
||||
"download.success": "下載成功",
|
||||
"download.failed": "下載失敗"
|
||||
},
|
||||
@ -775,6 +783,7 @@
|
||||
"dimensions": "{{dimensions}} 維",
|
||||
"edit": "編輯模型",
|
||||
"embedding": "嵌入",
|
||||
"embedding_dimensions": "嵌入維度",
|
||||
"embedding_model": "嵌入模型",
|
||||
"embedding_model_tooltip": "在設定->模型服務中點選管理按鈕新增",
|
||||
"function_calling": "函數調用",
|
||||
@ -1087,11 +1096,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": "複製數據失敗",
|
||||
@ -1103,6 +1112,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": "刪除知識庫檔案",
|
||||
@ -1233,7 +1246,71 @@
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 使用者名稱",
|
||||
"maxBackups": "最大備份數量",
|
||||
"maxBackups.unlimited": "無限制"
|
||||
"maxBackups.unlimited": "不限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3 相容儲存",
|
||||
"title.help": "與AWS S3 API相容的物件儲存服務,例如AWS S3、Cloudflare R2、阿里雲OSS、騰訊雲COS等",
|
||||
"endpoint": "API 位址",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "區域",
|
||||
"region.placeholder": "Region,例如: us-east-1",
|
||||
"bucket": "儲存桶",
|
||||
"bucket.placeholder": "Bucket,例如: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "備份目錄(可選)",
|
||||
"root.placeholder": "例如:/cherry-studio",
|
||||
"backup.operation": "備份操作",
|
||||
"backup.button": "立即備份",
|
||||
"backup.manager.button": "管理備份",
|
||||
"backup.modal.title": "S3 備份",
|
||||
"backup.modal.filename.placeholder": "請輸入備份檔案名稱",
|
||||
"backup.success": "S3 備份成功",
|
||||
"backup.error": "S3 備份失敗: {{message}}",
|
||||
"autoSync": "自動同步",
|
||||
"autoSync.off": "關閉",
|
||||
"autoSync.minute": "每 {{count}} 分鐘",
|
||||
"autoSync.hour": "每 {{count}} 小時",
|
||||
"maxBackups": "最大備份數",
|
||||
"maxBackups.unlimited": "不限",
|
||||
"skipBackupFile": "精簡備份",
|
||||
"skipBackupFile.help": "開啟後備份時將跳過檔案資料,僅備份設定資訊,顯著減小備份檔案體積",
|
||||
"syncStatus": "同步狀態",
|
||||
"syncStatus.noSync": "未同步",
|
||||
"syncStatus.error": "同步錯誤: {{message}}",
|
||||
"syncStatus.lastSync": "上次同步: {{time}}",
|
||||
"manager.title": "S3 備份檔案管理",
|
||||
"manager.refresh": "重新整理",
|
||||
"manager.delete.selected": "刪除選中 ({{count}})",
|
||||
"manager.close": "關閉",
|
||||
"manager.columns.fileName": "檔案名稱",
|
||||
"manager.columns.modifiedTime": "修改時間",
|
||||
"manager.columns.size": "檔案大小",
|
||||
"manager.columns.actions": "操作",
|
||||
"manager.restore": "恢復",
|
||||
"manager.delete": "刪除",
|
||||
"manager.config.incomplete": "請填寫完整的 S3 設定資訊",
|
||||
"manager.files.fetch.error": "取得備份檔案清單失敗: {{message}}",
|
||||
"manager.delete.confirm.title": "確認刪除",
|
||||
"manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份檔案嗎?此操作不可撤銷。",
|
||||
"manager.delete.confirm.single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作不可撤銷。",
|
||||
"manager.delete.success.multiple": "成功刪除 {{count}} 個備份檔案",
|
||||
"manager.delete.success.single": "刪除備份檔案成功",
|
||||
"manager.delete.error": "刪除備份檔案失敗: {{message}}",
|
||||
"manager.select.warning": "請選擇要刪除的備份檔案",
|
||||
"restore.modal.title": "S3 資料恢復",
|
||||
"restore.modal.select.placeholder": "請選擇要恢復的備份檔案",
|
||||
"restore.confirm.title": "確認恢復資料",
|
||||
"restore.confirm.content": "恢復資料將覆寫當前所有資料,此操作不可撤銷。確定要繼續嗎?",
|
||||
"restore.confirm.ok": "確認恢復",
|
||||
"restore.confirm.cancel": "取消",
|
||||
"restore.success": "資料恢復成功",
|
||||
"restore.error": "資料恢復失敗: {{message}}",
|
||||
"restore.config.incomplete": "請填寫完整的 S3 設定資訊",
|
||||
"restore.file.required": "請選擇要恢復的備份檔案"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@ -1385,6 +1462,8 @@
|
||||
"general.user_name": "使用者名稱",
|
||||
"general.user_name.placeholder": "輸入您的名稱",
|
||||
"general.view_webdav_settings": "檢視 WebDAV 設定",
|
||||
"general.spell_check": "拼寫檢查",
|
||||
"general.spell_check.languages": "拼寫檢查語言",
|
||||
"input.auto_translate_with_space": "快速敲擊 3 次空格翻譯",
|
||||
"input.show_translate_confirm": "顯示翻譯確認對話框",
|
||||
"input.target_language": "目標語言",
|
||||
@ -1461,7 +1540,8 @@
|
||||
"version": "版本"
|
||||
},
|
||||
"errors": {
|
||||
"32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整"
|
||||
"32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整",
|
||||
"toolNotFound": "未找到工具 {{name}}"
|
||||
},
|
||||
"serverPlural": "伺服器",
|
||||
"serverSingular": "伺服器",
|
||||
@ -1509,6 +1589,7 @@
|
||||
"registry": "套件管理源",
|
||||
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題",
|
||||
"registryDefault": "預設",
|
||||
"customRegistryPlaceholder": "請輸入私有倉庫位址,如: https://npm.company.com",
|
||||
"not_support": "不支援此模型",
|
||||
"user": "用戶",
|
||||
"system": "系統",
|
||||
@ -1820,12 +1901,43 @@
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
|
||||
"apikey": "API 金鑰",
|
||||
"free": "免費",
|
||||
"content_limit": "內容長度限制",
|
||||
"content_limit_tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷"
|
||||
"compression": {
|
||||
"title": "搜尋結果壓縮",
|
||||
"method": "壓縮方法",
|
||||
"method.none": "不壓縮",
|
||||
"method.cutoff": "截斷",
|
||||
"cutoff.limit": "截斷長度",
|
||||
"cutoff.limit.placeholder": "輸入長度",
|
||||
"cutoff.limit.tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字符)",
|
||||
"cutoff.unit.char": "字符",
|
||||
"cutoff.unit.token": "Token",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "文檔數量",
|
||||
"rag.document_count.default": "預設",
|
||||
"rag.document_count.tooltip": "預期從單個搜尋結果中提取的文檔數量,實際提取的總數量是這個值乘以搜尋結果數量。",
|
||||
"rag.embedding_dimensions.auto_get": "自動獲取維度",
|
||||
"rag.embedding_dimensions.placeholder": "不設置維度",
|
||||
"rag.embedding_dimensions.tooltip": "留空則不傳遞 dimensions 參數",
|
||||
"info": {
|
||||
"dimensions_auto_success": "維度自動獲取成功,維度為 {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "請先選擇嵌入模型",
|
||||
"dimensions_auto_failed": "維度自動獲取失敗",
|
||||
"provider_not_found": "未找到服務商",
|
||||
"rag_failed": "RAG 失敗"
|
||||
}
|
||||
}
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.early_access.title": "搶先體驗",
|
||||
"general.early_access.tooltip": "開啟後,將使用 GitHub 的最新版本,下載速度可能較慢,請務必提前備份數據",
|
||||
"general.test_plan.title": "測試計畫",
|
||||
"general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據",
|
||||
"general.test_plan.beta_version": "測試版本(Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "功能可能會隨時變化,錯誤較多,升級較快",
|
||||
"general.test_plan.rc_version": "預覽版本(RC)",
|
||||
"general.test_plan.rc_version_tooltip": "相對穩定,請務必提前備份數據",
|
||||
"general.test_plan.version_options": "版本選項",
|
||||
"general.test_plan.version_channel_not_match": "預覽版和測試版的切換將在下一個正式版發布時生效",
|
||||
"quickPhrase": {
|
||||
"title": "快捷短語",
|
||||
"add": "新增短語",
|
||||
|
||||
@ -12,9 +12,9 @@ function initKeyv() {
|
||||
|
||||
function initAutoSync() {
|
||||
setTimeout(() => {
|
||||
const { webdavAutoSync } = store.getState().settings
|
||||
const { webdavAutoSync, s3 } = store.getState().settings
|
||||
const { nutstoreAutoSync } = store.getState().nutstore
|
||||
if (webdavAutoSync) {
|
||||
if (webdavAutoSync || (s3 && s3.autoSync)) {
|
||||
startAutoSync()
|
||||
}
|
||||
if (nutstoreAutoSync) {
|
||||
|
||||
@ -14,6 +14,7 @@ import { Agent, KnowledgeBase } from '@renderer/types'
|
||||
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
||||
import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import stringWidth from 'string-width'
|
||||
@ -150,7 +151,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
maskClosable={false}
|
||||
afterClose={onClose}
|
||||
okText={t('agents.add.title')}
|
||||
width={800}
|
||||
width={600}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Form
|
||||
@ -212,6 +213,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@ -4,7 +4,7 @@ import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Button, Form, Input, Modal, Radio, Space } from 'antd'
|
||||
import { Button, Flex, Form, Input, Modal, Radio } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -98,7 +98,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
title={t('agents.import.title')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
footer={
|
||||
<Flex justify="end" gap={8}>
|
||||
<Button onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button type="primary" onClick={() => form.submit()} loading={loading}>
|
||||
{t('agents.import.button')}
|
||||
</Button>
|
||||
</Flex>
|
||||
}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Form form={form} onFinish={onFinish} layout="vertical">
|
||||
@ -120,15 +127,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
<Button onClick={() => form.submit()}>{t('agents.import.select_file')}</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button type="primary" onClick={() => form.submit()} loading={loading}>
|
||||
{t('agents.import.button')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { handleDelete } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
@ -48,6 +50,24 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
<ImageInfo>
|
||||
<div>{formatFileSize(file.size)}</div>
|
||||
</ImageInfo>
|
||||
<DeleteButton
|
||||
title={t('files.delete.title')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.modal.confirm({
|
||||
title: t('files.delete.title'),
|
||||
content: t('files.delete.content'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
handleDelete(file.id, t)
|
||||
},
|
||||
icon: <ExclamationCircleOutlined style={{ color: 'red' }} />
|
||||
})
|
||||
}}>
|
||||
<DeleteOutlined />
|
||||
</DeleteButton>
|
||||
</ImageWrapper>
|
||||
</Col>
|
||||
))}
|
||||
@ -159,4 +179,26 @@ const ImageInfo = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const DeleteButton = styled.div`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(FileList)
|
||||
|
||||
@ -7,13 +7,10 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import db from '@renderer/databases'
|
||||
import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import store from '@renderer/store'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Empty, Flex, Popconfirm } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
@ -34,34 +31,6 @@ const FilesPage: FC = () => {
|
||||
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
|
||||
const tempFilesSort = (files: FileType[]) => {
|
||||
return files.sort((a, b) => {
|
||||
const aIsTemp = a.origin_name.startsWith('temp_file')
|
||||
const bIsTemp = b.origin_name.startsWith('temp_file')
|
||||
if (aIsTemp && !bIsTemp) return 1
|
||||
if (!aIsTemp && bIsTemp) return -1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
const sortFiles = (files: FileType[]) => {
|
||||
return [...files].sort((a, b) => {
|
||||
let comparison = 0
|
||||
switch (sortField) {
|
||||
case 'created_at':
|
||||
comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix()
|
||||
break
|
||||
case 'size':
|
||||
comparison = a.size - b.size
|
||||
break
|
||||
case 'name':
|
||||
comparison = a.origin_name.localeCompare(b.origin_name)
|
||||
break
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
|
||||
const files = useLiveQuery<FileType[]>(() => {
|
||||
if (fileType === 'all') {
|
||||
return db.files.orderBy('count').toArray().then(tempFilesSort)
|
||||
@ -69,106 +38,7 @@ const FilesPage: FC = () => {
|
||||
return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
|
||||
}, [fileType])
|
||||
|
||||
const sortedFiles = files ? sortFiles(files) : []
|
||||
|
||||
const handleDelete = async (fileId: string) => {
|
||||
const file = await FileManager.getFile(fileId)
|
||||
if (!file) return
|
||||
|
||||
const paintings = await store.getState().paintings.paintings
|
||||
const paintingsFiles = paintings.flatMap((p) => p.files)
|
||||
|
||||
if (paintingsFiles.some((p) => p.id === fileId)) {
|
||||
window.modal.warning({ content: t('files.delete.paintings.warning'), centered: true })
|
||||
return
|
||||
}
|
||||
if (file) {
|
||||
await FileManager.deleteFile(fileId, true)
|
||||
}
|
||||
|
||||
const relatedBlocks = await db.message_blocks.where('file.id').equals(fileId).toArray()
|
||||
|
||||
const blockIdsToDelete = relatedBlocks.map((block) => block.id)
|
||||
|
||||
const blocksByMessageId: Record<string, string[]> = {}
|
||||
for (const block of relatedBlocks) {
|
||||
if (!blocksByMessageId[block.messageId]) {
|
||||
blocksByMessageId[block.messageId] = []
|
||||
}
|
||||
blocksByMessageId[block.messageId].push(block.id)
|
||||
}
|
||||
|
||||
try {
|
||||
const affectedMessageIds = [...new Set(relatedBlocks.map((b) => b.messageId))]
|
||||
|
||||
if (affectedMessageIds.length === 0 && blockIdsToDelete.length > 0) {
|
||||
// This case should ideally not happen if relatedBlocks were found,
|
||||
// but handle it just in case: only delete blocks.
|
||||
await db.message_blocks.bulkDelete(blockIdsToDelete)
|
||||
Logger.log(
|
||||
`Deleted ${blockIdsToDelete.length} blocks related to file ${fileId}. No associated messages found (unexpected).`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await db.transaction('rw', db.topics, db.message_blocks, async () => {
|
||||
// Fetch all topics (potential performance bottleneck if many topics)
|
||||
const allTopics = await db.topics.toArray()
|
||||
const topicsToUpdate: Record<string, { messages: Message[] }> = {} // Store updates keyed by topicId
|
||||
|
||||
for (const topic of allTopics) {
|
||||
let topicModified = false
|
||||
// Ensure topic.messages exists and is an array before mapping
|
||||
const currentMessages = Array.isArray(topic.messages) ? topic.messages : []
|
||||
const updatedMessages = currentMessages.map((message) => {
|
||||
// Check if this message is affected
|
||||
if (affectedMessageIds.includes(message.id)) {
|
||||
// Ensure message.blocks exists and is an array
|
||||
const currentBlocks = Array.isArray(message.blocks) ? message.blocks : []
|
||||
const originalBlockCount = currentBlocks.length
|
||||
// Filter out the blocks marked for deletion
|
||||
const newBlocks = currentBlocks.filter((blockId) => !blockIdsToDelete.includes(blockId))
|
||||
if (newBlocks.length < originalBlockCount) {
|
||||
topicModified = true
|
||||
return { ...message, blocks: newBlocks } // Return updated message
|
||||
}
|
||||
}
|
||||
return message // Return original message
|
||||
})
|
||||
|
||||
if (topicModified) {
|
||||
// Store the update for this topic
|
||||
topicsToUpdate[topic.id] = { messages: updatedMessages }
|
||||
}
|
||||
}
|
||||
|
||||
// Apply updates to topics
|
||||
const updatePromises = Object.entries(topicsToUpdate).map(([topicId, updateData]) =>
|
||||
db.topics.update(topicId, updateData)
|
||||
)
|
||||
await Promise.all(updatePromises)
|
||||
|
||||
// Finally, delete the MessageBlocks
|
||||
await db.message_blocks.bulkDelete(blockIdsToDelete)
|
||||
})
|
||||
|
||||
Logger.log(`Deleted ${blockIdsToDelete.length} blocks and updated relevant topic messages for file ${fileId}.`)
|
||||
} catch (error) {
|
||||
Logger.error(`Error updating topics or deleting blocks for file ${fileId}:`, error)
|
||||
window.modal.error({ content: t('files.delete.db_error'), centered: true }) // 提示数据库操作失败
|
||||
// Consider whether to attempt to restore the physical file (usually difficult)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRename = async (fileId: string) => {
|
||||
const file = await FileManager.getFile(fileId)
|
||||
if (file) {
|
||||
const newName = await TextEditPopup.show({ text: file.origin_name })
|
||||
if (newName) {
|
||||
FileManager.updateFile({ ...file, origin_name: newName })
|
||||
}
|
||||
}
|
||||
}
|
||||
const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : []
|
||||
|
||||
const dataSource = sortedFiles?.map((file) => {
|
||||
return {
|
||||
@ -189,7 +59,7 @@ const FilesPage: FC = () => {
|
||||
description={t('files.delete.content')}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleDelete(file.id)}
|
||||
onConfirm={() => handleDelete(file.id, t)}
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
@ -310,7 +180,6 @@ const SideNav = styled.div`
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-primary);
|
||||
border: 0.5px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { ArrowLeftOutlined, EnterOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { Input, InputRef } from 'antd'
|
||||
import { Divider, Input, InputRef } from 'antd'
|
||||
import { last } from 'lodash'
|
||||
import { Search } from 'lucide-react'
|
||||
import { ChevronLeft, CornerDownLeft, Search } from 'lucide-react'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -73,26 +73,35 @@ const TopicsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
{stack.length > 1 && (
|
||||
<HeaderLeft>
|
||||
<MenuIcon onClick={goBack}>
|
||||
<ArrowLeftOutlined />
|
||||
</MenuIcon>
|
||||
</HeaderLeft>
|
||||
)}
|
||||
<SearchInput
|
||||
placeholder={t('history.search.placeholder')}
|
||||
type="search"
|
||||
value={search}
|
||||
autoFocus
|
||||
allowClear
|
||||
<HStack style={{ padding: '0 12px', marginTop: 8 }}>
|
||||
<Input
|
||||
prefix={
|
||||
stack.length > 1 ? (
|
||||
<SearchIcon className="back-icon" onClick={goBack}>
|
||||
<ChevronLeft size={16} />
|
||||
</SearchIcon>
|
||||
) : (
|
||||
<SearchIcon>
|
||||
<Search size={15} />
|
||||
</SearchIcon>
|
||||
)
|
||||
}
|
||||
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
|
||||
ref={inputRef}
|
||||
placeholder={t('history.search.placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value.trimStart())}
|
||||
suffix={search.length >= 2 ? <EnterOutlined /> : <Search size={16} />}
|
||||
allowClear
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
style={{ paddingLeft: 0 }}
|
||||
variant="borderless"
|
||||
size="middle"
|
||||
onPressEnter={onSearch}
|
||||
/>
|
||||
</Header>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
|
||||
<TopicsHistory
|
||||
keywords={search}
|
||||
onClick={onTopicClick as any}
|
||||
@ -118,50 +127,23 @@ const Container = styled.div`
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background-color: var(--color-background-mute);
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom: 0.5px solid var(--color-frame-border);
|
||||
`
|
||||
|
||||
const HeaderLeft = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 15px;
|
||||
`
|
||||
|
||||
const MenuIcon = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
const SearchIcon = styled.div`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
&:hover {
|
||||
background-color: var(--color-background);
|
||||
.anticon {
|
||||
color: var(--color-text-1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-right: 2px;
|
||||
&.back-icon {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SearchInput = styled(Input)`
|
||||
border-radius: 30px;
|
||||
width: 800px;
|
||||
height: 36px;
|
||||
`
|
||||
|
||||
export default TopicsPage
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { ArrowRightOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
|
||||
import { locateToMessage } from '@renderer/services/MessagesService'
|
||||
@ -10,6 +8,7 @@ import { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { Button } from 'antd'
|
||||
import { Forward } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -20,7 +19,6 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
const SearchMessage: FC<Props> = ({ message, ...props }) => {
|
||||
const navigate = NavigationService.navigate!
|
||||
const { messageStyle } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
const [topic, setTopic] = useState<Topic | null>(null)
|
||||
|
||||
@ -43,18 +41,18 @@ const SearchMessage: FC<Props> = ({ message, ...props }) => {
|
||||
|
||||
return (
|
||||
<MessageEditingProvider>
|
||||
<MessagesContainer {...props} className={messageStyle}>
|
||||
<ContainerWrapper style={{ paddingTop: 20, paddingBottom: 20, position: 'relative' }}>
|
||||
<MessagesContainer {...props}>
|
||||
<ContainerWrapper>
|
||||
<MessageItem message={message} topic={topic} hideMenuBar={true} />
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 10 }}
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 16, top: 16 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
icon={<ArrowRightOutlined />}
|
||||
icon={<Forward size={16} />}
|
||||
/>
|
||||
<HStack mt="10px" justifyContent="center">
|
||||
<Button onClick={() => locateToMessage(navigate, message)} icon={<ArrowRightOutlined />}>
|
||||
<Button onClick={() => locateToMessage(navigate, message)} icon={<Forward size={16} />}>
|
||||
{t('history.locate.message')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@ -74,12 +72,11 @@ const MessagesContainer = styled.div`
|
||||
`
|
||||
|
||||
const ContainerWrapper = styled.div`
|
||||
width: 800px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.message {
|
||||
padding: 0;
|
||||
}
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
export default SearchMessage
|
||||
|
||||
@ -151,7 +151,8 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const ContainerWrapper = styled.div`
|
||||
width: 800px;
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons'
|
||||
import { MessageOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
|
||||
@ -13,6 +12,7 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { Button, Divider, Empty } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { Forward } from 'lucide-react'
|
||||
import { FC, useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -25,7 +25,6 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
const navigate = NavigationService.navigate!
|
||||
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
|
||||
const { messageStyle } = useSettings()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
@ -48,8 +47,8 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
|
||||
return (
|
||||
<MessageEditingProvider>
|
||||
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll} className={messageStyle}>
|
||||
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
|
||||
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll}>
|
||||
<ContainerWrapper>
|
||||
{topic?.messages.map((message) => (
|
||||
<div key={message.id} style={{ position: 'relative' }}>
|
||||
<MessageItem message={message} topic={topic} hideMenuBar={true} />
|
||||
@ -58,7 +57,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
icon={<ArrowRightOutlined />}
|
||||
icon={<Forward size={16} />}
|
||||
/>
|
||||
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
|
||||
</div>
|
||||
@ -86,12 +85,10 @@ const MessagesContainer = styled.div`
|
||||
`
|
||||
|
||||
const ContainerWrapper = styled.div`
|
||||
width: 800px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.message {
|
||||
padding: 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default TopicMessages
|
||||
|
||||
@ -78,7 +78,8 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
|
||||
}
|
||||
|
||||
const ContainerWrapper = styled.div`
|
||||
width: 800px;
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
@ -7,6 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { FC, useMemo, useState } from 'react'
|
||||
@ -54,28 +55,30 @@ const Chat: FC<Props> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const contentSearchFilter = (node: Node): boolean => {
|
||||
if (node.parentNode) {
|
||||
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
|
||||
while (parentNode?.parentNode) {
|
||||
if (parentNode.classList.contains('MessageFooter')) {
|
||||
return false
|
||||
}
|
||||
const contentSearchFilter: NodeFilter = {
|
||||
acceptNode(node) {
|
||||
if (node.parentNode) {
|
||||
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
|
||||
while (parentNode?.parentNode) {
|
||||
if (parentNode.classList.contains('MessageFooter')) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
|
||||
if (filterIncludeUser) {
|
||||
if (parentNode?.classList.contains('message-content-container')) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if (parentNode?.classList.contains('message-content-container-assistant')) {
|
||||
return true
|
||||
if (filterIncludeUser) {
|
||||
if (parentNode?.classList.contains('message-content-container')) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
} else {
|
||||
if (parentNode?.classList.contains('message-content-container-assistant')) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
}
|
||||
parentNode = parentNode.parentNode as HTMLElement
|
||||
}
|
||||
parentNode = parentNode.parentNode as HTMLElement
|
||||
return NodeFilter.FILTER_REJECT
|
||||
} else {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,15 +109,8 @@ const Chat: FC<Props> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container id="chat" className={messageStyle}>
|
||||
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
|
||||
<Main ref={mainRef} id="chat-main" vertical flex={1} justify="space-between" style={{ maxWidth }}>
|
||||
<ContentSearch
|
||||
ref={contentSearchRef}
|
||||
searchTarget={mainRef as React.RefObject<HTMLElement>}
|
||||
filter={contentSearchFilter}
|
||||
includeUser={filterIncludeUser}
|
||||
onIncludeUserChange={userOutlinedItemClickHandler}
|
||||
/>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
@ -123,6 +119,13 @@ const Chat: FC<Props> = (props) => {
|
||||
onComponentUpdate={messagesComponentUpdateHandler}
|
||||
onFirstUpdate={messagesComponentFirstUpdateHandler}
|
||||
/>
|
||||
<ContentSearch
|
||||
ref={contentSearchRef}
|
||||
searchTarget={mainRef as React.RefObject<HTMLElement>}
|
||||
filter={contentSearchFilter}
|
||||
includeUser={filterIncludeUser}
|
||||
onIncludeUserChange={userOutlinedItemClickHandler}
|
||||
/>
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||
|
||||
@ -77,7 +77,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
showInputEstimatedTokens,
|
||||
autoTranslateWithSpace,
|
||||
enableQuickPanelTriggers,
|
||||
enableBackspaceDeleteModel
|
||||
enableBackspaceDeleteModel,
|
||||
enableSpellCheck
|
||||
} = useSettings()
|
||||
const [expended, setExpend] = useState(false)
|
||||
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
||||
@ -138,17 +139,21 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
_text = text
|
||||
_files = files
|
||||
|
||||
const resizeTextArea = useCallback(() => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
// 如果已经手动设置了高度,则不自动调整
|
||||
if (textareaHeight) {
|
||||
return
|
||||
const resizeTextArea = useCallback(
|
||||
(force: boolean = false) => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
// 如果已经手动设置了高度,则不自动调整
|
||||
if (textareaHeight && !force) {
|
||||
return
|
||||
}
|
||||
if (textArea?.scrollHeight) {
|
||||
textArea.style.height = Math.min(textArea.scrollHeight, 400) + 'px'
|
||||
}
|
||||
}
|
||||
textArea.style.height = 'auto'
|
||||
textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px`
|
||||
}
|
||||
}, [textareaHeight])
|
||||
},
|
||||
[textareaHeight]
|
||||
)
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (inputEmpty || loading) {
|
||||
@ -748,13 +753,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
className="inputbar">
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<Container
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
className="inputbar">
|
||||
<QuickPanelView setInputText={setText} />
|
||||
<InputBarContainer
|
||||
id="inputbar"
|
||||
@ -780,14 +785,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
: t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })
|
||||
}
|
||||
autoFocus
|
||||
contextMenu="true"
|
||||
variant="borderless"
|
||||
spellCheck={false}
|
||||
spellCheck={enableSpellCheck}
|
||||
rows={2}
|
||||
ref={textareaRef}
|
||||
style={{
|
||||
fontSize,
|
||||
minHeight: textareaHeight ? `${textareaHeight}px` : undefined
|
||||
minHeight: textareaHeight ? `${textareaHeight}px` : '30px'
|
||||
}}
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
@ -851,8 +855,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
</ToolbarMenu>
|
||||
</Toolbar>
|
||||
</InputBarContainer>
|
||||
</NarrowLayout>
|
||||
</Container>
|
||||
</Container>
|
||||
</NarrowLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -887,16 +891,15 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 0 16px 16px 16px;
|
||||
`
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
margin: 14px 20px;
|
||||
margin-top: 0;
|
||||
border-radius: 15px;
|
||||
padding-top: 6px; // 为拖动手柄留出空间
|
||||
padding-top: 8px; // 为拖动手柄留出空间
|
||||
background-color: var(--color-background-opacity);
|
||||
|
||||
&.file-dragging {
|
||||
@ -919,7 +922,7 @@ const InputBarContainer = styled.div`
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
paddingLeft: 0,
|
||||
padding: '6px 15px 8px' // 减小顶部padding
|
||||
padding: '6px 15px 0px' // 减小顶部padding
|
||||
}
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
@ -934,16 +937,17 @@ const Textarea = styled(TextArea)`
|
||||
&.ant-input {
|
||||
line-height: 1.4;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
`
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 4px;
|
||||
height: 30px;
|
||||
padding: 5px 8px;
|
||||
height: 40px;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
@ -45,7 +45,7 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Popover content={PopoverContent}>
|
||||
<Popover content={PopoverContent} arrow={false}>
|
||||
<MenuOutlined /> {contextCount.current} / {formatMaxCount(contextCount.max)}
|
||||
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
|
||||
<ArrowUpOutlined />
|
||||
|
||||
@ -54,9 +54,10 @@ const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation })
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
arrow={false}
|
||||
overlay={tooltipContent}
|
||||
placement="top"
|
||||
color="var(--color-background-mute)"
|
||||
color="var(--color-background)"
|
||||
styles={{
|
||||
body: {
|
||||
border: '1px solid var(--color-border)',
|
||||
|
||||
@ -27,7 +27,7 @@ const CodeBlock: React.FC<Props> = ({ children, className, id, onSave }) => {
|
||||
{children}
|
||||
</CodeBlockView>
|
||||
) : (
|
||||
<code className={className} style={{ textWrap: 'wrap' }}>
|
||||
<code className={className} style={{ textWrap: 'wrap', fontSize: '95%', padding: '2px 4px' }}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
|
||||
@ -8,8 +8,8 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
|
||||
import { parseJSON } from '@renderer/utils'
|
||||
import { escapeBrackets, removeSvgEmptyLines } from '@renderer/utils/formats'
|
||||
import { findCitationInChildren, getCodeBlockId } from '@renderer/utils/markdown'
|
||||
import { removeSvgEmptyLines } from '@renderer/utils/formats'
|
||||
import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { type FC, memo, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -52,7 +52,7 @@ const Markdown: FC<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(() => {
|
||||
|
||||
@ -93,7 +93,7 @@ describe('CitationTooltip', () => {
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip-wrapper')
|
||||
expect(tooltip).toHaveAttribute('data-placement', 'top')
|
||||
expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)')
|
||||
expect(tooltip).toHaveAttribute('data-color', 'var(--color-background)')
|
||||
|
||||
const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}')
|
||||
expect(styles.body).toEqual({
|
||||
|
||||
@ -42,13 +42,13 @@ vi.mock('@renderer/utils', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/formats', () => ({
|
||||
escapeBrackets: vi.fn((str) => str),
|
||||
removeSvgEmptyLines: vi.fn((str) => str)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/markdown', () => ({
|
||||
findCitationInChildren: vi.fn(() => '{"id": 1, "url": "https://example.com"}'),
|
||||
getCodeBlockId: vi.fn(() => 'code-block-1')
|
||||
getCodeBlockId: vi.fn(() => 'code-block-1'),
|
||||
processLatexBrackets: vi.fn((str) => str)
|
||||
}))
|
||||
|
||||
// Mock components with more realistic behavior
|
||||
@ -212,16 +212,6 @@ describe('Markdown', () => {
|
||||
expect(markdown).not.toHaveTextContent('Paused')
|
||||
})
|
||||
|
||||
it('should process content through format utilities', async () => {
|
||||
const { escapeBrackets, removeSvgEmptyLines } = await import('@renderer/utils/formats')
|
||||
const content = 'Content with [brackets] and SVG'
|
||||
|
||||
render(<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()
|
||||
|
||||
@ -47,7 +47,7 @@ exports[`CitationTooltip > basic rendering > should match snapshot 1`] = `
|
||||
}
|
||||
|
||||
<div
|
||||
data-color="var(--color-background-mute)"
|
||||
data-color="var(--color-background)"
|
||||
data-placement="top"
|
||||
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
|
||||
data-testid="tooltip-wrapper"
|
||||
|
||||
@ -5,13 +5,19 @@ import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock'
|
||||
import { WebSearchSource } from '@renderer/types'
|
||||
import { type CitationMessageBlock, MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CitationsList from '../CitationsList'
|
||||
|
||||
function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
||||
const { t } = useTranslation()
|
||||
const formattedCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, block.id))
|
||||
const { websearch } = useSelector((state: RootState) => state.runtime)
|
||||
const message = useSelector((state: RootState) => state.messages.entities[block.messageId])
|
||||
const userMessageId = message?.askId || block.messageId // 如果没有 askId 则回退到 messageId
|
||||
|
||||
const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI
|
||||
const hasCitations = useMemo(() => {
|
||||
return (
|
||||
@ -21,8 +27,32 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
||||
)
|
||||
}, [formattedCitations, block.knowledge, hasGeminiBlock])
|
||||
|
||||
const getWebSearchStatusText = (requestId: string) => {
|
||||
const status = websearch.activeSearches[requestId] ?? { phase: 'default' }
|
||||
|
||||
switch (status.phase) {
|
||||
case 'fetch_complete':
|
||||
return t('message.websearch.fetch_complete', {
|
||||
count: status.countAfter ?? 0
|
||||
})
|
||||
case 'rag':
|
||||
return t('message.websearch.rag')
|
||||
case 'rag_complete':
|
||||
return t('message.websearch.rag_complete', {
|
||||
countBefore: status.countBefore ?? 0,
|
||||
countAfter: status.countAfter ?? 0
|
||||
})
|
||||
case 'rag_failed':
|
||||
return t('message.websearch.rag_failed')
|
||||
case 'cutoff':
|
||||
return t('message.websearch.cutoff')
|
||||
default:
|
||||
return t('message.searching')
|
||||
}
|
||||
}
|
||||
|
||||
if (block.status === MessageBlockStatus.PROCESSING) {
|
||||
return <Spinner text="message.searching" />
|
||||
return <Spinner text={getWebSearchStatusText(userMessageId)} />
|
||||
}
|
||||
|
||||
if (!hasCitations) {
|
||||
|
||||
@ -31,7 +31,7 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock }> = ({ block }) =>
|
||||
}
|
||||
|
||||
const Alert = styled(AntdAlert)`
|
||||
margin: 0.5rem 0;
|
||||
margin: 0.5rem 0 !important;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
@ -18,12 +18,12 @@ const ImageBlock: React.FC<Props> = ({ block }) => {
|
||||
? [`file://${block?.file?.path}`]
|
||||
: []
|
||||
return (
|
||||
<Container style={{ marginBottom: 8 }}>
|
||||
<Container>
|
||||
{images.map((src, index) => (
|
||||
<ImageViewer
|
||||
src={src}
|
||||
key={`image-${index}`}
|
||||
style={{ maxWidth: 500, maxHeight: 500, padding: 5, borderRadius: 8 }}
|
||||
style={{ maxWidth: 500, maxHeight: 500, padding: 0, borderRadius: 8 }}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
@ -34,6 +34,5 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
`
|
||||
export default React.memo(ImageBlock)
|
||||
|
||||
@ -3,7 +3,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||
import { lightbulbVariants } from '@renderer/utils/motionVariants'
|
||||
import { Collapse, message as antdMessage, Tooltip } from 'antd'
|
||||
import { Lightbulb } from 'lucide-react'
|
||||
import { ChevronRight, Lightbulb } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -57,6 +57,14 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
|
||||
size="small"
|
||||
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
|
||||
className="message-thought-container"
|
||||
expandIcon={({ isActive }) => (
|
||||
<ChevronRight
|
||||
color="var(--color-text-3)"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
style={{ transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
)}
|
||||
expandIconPosition="end"
|
||||
items={[
|
||||
{
|
||||
@ -142,7 +150,7 @@ const ThinkingTimeSeconds = memo(
|
||||
)
|
||||
|
||||
const CollapseContainer = styled(Collapse)`
|
||||
margin-bottom: 15px;
|
||||
margin: 15px 0;
|
||||
`
|
||||
|
||||
const MessageTitleLabel = styled.div`
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
margin-bottom: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
|
||||
@ -164,17 +164,7 @@ export default React.memo(MessageBlockRenderer)
|
||||
|
||||
const ImageBlockGroup = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
max-width: 960px;
|
||||
/* > * {
|
||||
min-width: 200px;
|
||||
} */
|
||||
@media (min-width: 1536px) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
max-width: 1280px;
|
||||
> * {
|
||||
min-width: 250px;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user