Merge branch 'main' into feat/aisdk-package

This commit is contained in:
suyao 2025-06-29 03:57:28 +08:00
commit 592a7ddc3f
No known key found for this signature in database
193 changed files with 8034 additions and 2945 deletions

View File

@ -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"> <h1 align="center">
<a href="https://github.com/CherryHQ/cherry-studio/releases"> <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> <img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>

View File

@ -107,9 +107,10 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
- 新功能:可选数据保存目录 界面优化:优化多处界面样式,气泡样式改版,自动调整代码预览边栏宽度
- 快捷助手:支持单独选择助手,支持暂停、上下文、思考过程、流式 知识库:修复知识库引用不显示问题,修复部分嵌入模型适配问题
- 划词助手:系统托盘菜单开关 备份与恢复:修复超过 2GB 大文件无法恢复问题
- 翻译:新增 Markdown 预览选项 文件处理:添加 .doc 文件支持
- 新供应商:新增 Vertex AI 服务商 划词助手:支持自定义 CSS 样式
- 错误修复和界面优化 MCP基于 Pyodide 实现 Python MCP 服务
其他错误修复和优化

View File

@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "1.4.4", "version": "1.4.7",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@ -62,6 +62,7 @@
"@libsql/win32-x64-msvc": "^0.4.7", "@libsql/win32-x64-msvc": "^0.4.7",
"@strongtz/win32-arm64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7",
"jsdom": "26.1.0", "jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"notion-helper": "^1.3.22", "notion-helper": "^1.3.22",
"os-proxy-config": "^1.1.2", "os-proxy-config": "^1.1.2",
"selection-hook": "^0.9.23", "selection-hook": "^0.9.23",
@ -124,6 +125,7 @@
"@types/react-infinite-scroll-component": "^5.0.0", "@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-window": "^1", "@types/react-window": "^1",
"@types/tinycolor2": "^1", "@types/tinycolor2": "^1",
"@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.23.12", "@uiw/codemirror-extensions-langs": "^4.23.12",
"@uiw/codemirror-themes-all": "^4.23.12", "@uiw/codemirror-themes-all": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12", "@uiw/react-codemirror": "^4.23.12",
@ -177,10 +179,10 @@
"mermaid": "^11.6.0", "mermaid": "^11.6.0",
"mime": "^4.0.4", "mime": "^4.0.4",
"motion": "^12.10.5", "motion": "^12.10.5",
"node-stream-zip": "^1.15.0",
"npx-scope-finder": "^1.2.0", "npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1", "officeparser": "^4.1.1",
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", "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", "p-queue": "^8.1.0",
"playwright": "^1.52.0", "playwright": "^1.52.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
@ -213,12 +215,13 @@
"styled-components": "^6.1.11", "styled-components": "^6.1.11",
"tar": "^7.4.3", "tar": "^7.4.3",
"tiny-pinyin": "^1.3.2", "tiny-pinyin": "^1.3.2",
"tokenx": "^0.4.1", "tokenx": "^1.1.0",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"vite": "6.2.6", "vite": "6.2.6",
"vitest": "^3.1.4", "vitest": "^3.1.4",
"webdav": "^5.8.0", "webdav": "^5.8.0",
"word-extractor": "^1.0.4",
"zipread": "^1.3.3" "zipread": "^1.3.3"
}, },
"resolutions": { "resolutions": {

View File

@ -3,6 +3,8 @@ export enum IpcChannel {
App_ClearCache = 'app:clear-cache', App_ClearCache = 'app:clear-cache',
App_SetLaunchOnBoot = 'app:set-launch-on-boot', App_SetLaunchOnBoot = 'app:set-launch-on-boot',
App_SetLanguage = 'app:set-language', 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_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update', App_CheckForUpdate = 'app:check-for-update',
App_Reload = 'app:reload', App_Reload = 'app:reload',
@ -13,13 +15,17 @@ export enum IpcChannel {
App_SetTrayOnClose = 'app:set-tray-on-close', App_SetTrayOnClose = 'app:set-tray-on-close',
App_SetTheme = 'app:set-theme', App_SetTheme = 'app:set-theme',
App_SetAutoUpdate = 'app:set-auto-update', 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_HandleZoomFactor = 'app:handle-zoom-factor',
App_Select = 'app:select', App_Select = 'app:select',
App_HasWritePermission = 'app:has-write-permission', App_HasWritePermission = 'app:has-write-permission',
App_Copy = 'app:copy', App_Copy = 'app:copy',
App_SetStopQuitApp = 'app:set-stop-quit-app', App_SetStopQuitApp = 'app:set-stop-quit-app',
App_SetAppDataPath = 'app:set-app-data-path', 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_RelaunchApp = 'app:relaunch-app',
App_IsBinaryExist = 'app:is-binary-exist', App_IsBinaryExist = 'app:is-binary-exist',
App_GetBinaryPath = 'app:get-binary-path', App_GetBinaryPath = 'app:get-binary-path',
@ -32,6 +38,7 @@ export enum IpcChannel {
Notification_OnClick = 'notification:on-click', Notification_OnClick = 'notification:on-click',
Webview_SetOpenLinkExternal = 'webview:set-open-link-external', Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
// Open // Open
Open_Path = 'open:path', Open_Path = 'open:path',
@ -64,6 +71,9 @@ export enum IpcChannel {
Mcp_ServersUpdated = 'mcp:servers-updated', Mcp_ServersUpdated = 'mcp:servers-updated',
Mcp_CheckConnectivity = 'mcp:check-connectivity', Mcp_CheckConnectivity = 'mcp:check-connectivity',
// Python
Python_Execute = 'python:execute',
//copilot //copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message', Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token', Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@ -143,6 +153,11 @@ export enum IpcChannel {
Backup_CheckConnection = 'backup:checkConnection', Backup_CheckConnection = 'backup:checkConnection',
Backup_CreateDirectory = 'backup:createDirectory', Backup_CreateDirectory = 'backup:createDirectory',
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile', 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
Zip_Compress = 'zip:compress', Zip_Compress = 'zip:compress',

View File

@ -1,7 +1,7 @@
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac'] 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 thirdPartyApplicationExts = ['.draftsExport']
export const bookExts = ['.epub'] export const bookExts = ['.epub']
const textExtsByCategory = new Map([ const textExtsByCategory = new Map([
@ -406,6 +406,16 @@ export const defaultLanguage = 'en-US'
export enum FeedUrl { export enum FeedUrl {
PRODUCTION = 'https://releases.cherry-ai.com', 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 defaultTimeout = 5 * 1000 * 60
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']

View File

@ -2,12 +2,12 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const os = require('os') const os = require('os')
const { execSync } = require('child_process') const { execSync } = require('child_process')
const AdmZip = require('adm-zip') const StreamZip = require('node-stream-zip')
const { downloadWithRedirects } = require('./download') const { downloadWithRedirects } = require('./download')
// Base URL for downloading bun binaries // Base URL for downloading bun binaries
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download' 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 // Mapping of platform+arch to binary package name
const BUN_PACKAGES = { const BUN_PACKAGES = {
@ -66,35 +66,36 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
// Extract the zip file using adm-zip // Extract the zip file using adm-zip
console.log(`Extracting ${packageName} to ${binDir}...`) console.log(`Extracting ${packageName} to ${binDir}...`)
const zip = new AdmZip(tempFilename) const zip = new StreamZip.async({ file: tempFilename })
zip.extractAllTo(tempdir, true)
// Move files using Node.js fs // Get all entries in the zip file
const sourceDir = path.join(tempdir, packageName.split('.')[0]) const entries = await zip.entries()
const files = fs.readdirSync(sourceDir)
for (const file of files) { // Extract files directly to binDir, flattening the directory structure
const sourcePath = path.join(sourceDir, file) for (const entry of Object.values(entries)) {
const destPath = path.join(binDir, file) 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) console.log(`Extracting ${entry.name} -> ${filename}`)
fs.unlinkSync(sourcePath) await zip.extract(entry.name, outputPath)
// Make executable files executable on Unix-like systems
// Set executable permissions for non-Windows platforms if (platform !== 'win32') {
if (platform !== 'win32') { try {
try { fs.chmodSync(outputPath, 0o755)
// 755 permission: rwxr-xr-x } catch (chmodError) {
fs.chmodSync(destPath, '755') console.error(`Warning: Failed to set executable permissions on ${filename}`)
} catch (error) { return false
console.warn(`Warning: Failed to set executable permissions: ${error.message}`) }
} }
console.log(`Extracted ${entry.name} -> ${outputPath}`)
} }
} }
await zip.close()
// Clean up // Clean up
fs.unlinkSync(tempFilename) fs.unlinkSync(tempFilename)
fs.rmSync(sourceDir, { recursive: true })
console.log(`Successfully installed bun ${version} for ${platformKey}`) console.log(`Successfully installed bun ${version} for ${platformKey}`)
return true return true
} catch (error) { } catch (error) {

View File

@ -2,34 +2,33 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const os = require('os') const os = require('os')
const { execSync } = require('child_process') const { execSync } = require('child_process')
const tar = require('tar') const StreamZip = require('node-stream-zip')
const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download') const { downloadWithRedirects } = require('./download')
// Base URL for downloading uv binaries // Base URL for downloading uv binaries
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download' 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 // Mapping of platform+arch to binary package name
const UV_PACKAGES = { const UV_PACKAGES = {
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz', 'darwin-arm64': 'uv-aarch64-apple-darwin.zip',
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz', 'darwin-x64': 'uv-x86_64-apple-darwin.zip',
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip', 'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
'win32-ia32': 'uv-i686-pc-windows-msvc.zip', 'win32-ia32': 'uv-i686-pc-windows-msvc.zip',
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip', 'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz', 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip',
'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz', 'linux-ia32': 'uv-i686-unknown-linux-gnu.zip',
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz', 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip',
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz', 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip',
'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz', 'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip',
'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz', 'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip',
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz', 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip',
// MUSL variants // MUSL variants
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz', 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip',
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz', 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip',
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz', 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip',
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz', 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip',
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz' '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}...`) console.log(`Extracting ${packageName} to ${binDir}...`)
// 根据文件扩展名选择解压方法 const zip = new StreamZip.async({ file: tempFilename })
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
})
// Move files using Node.js fs // Get all entries in the zip file
const sourceDir = path.join(tempdir, packageName.split('.')[0]) const entries = await zip.entries()
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)
// 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') { if (platform !== 'win32') {
try { try {
fs.chmodSync(destPath, '755') fs.chmodSync(outputPath, 0o755)
} catch (error) { } catch (chmodError) {
console.warn(`Warning: Failed to set executable permissions: ${error.message}`) 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}`) console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
return true return true
} catch (error) { } catch (error) {

33
src/main/bootstrap.ts Normal file
View 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()

View File

@ -1,7 +1,11 @@
// don't reorder this file, it's used to initialize the app data dir and
// other which should be run before the main process is ready
// eslint-disable-next-line
import './bootstrap'
import '@main/config' import '@main/config'
import { electronApp, optimizer } from '@electron-toolkit/utils' import { electronApp, optimizer } from '@electron-toolkit/utils'
import { initAppDataDir } from '@main/utils/file'
import { replaceDevtoolsFont } from '@main/utils/windowUtil' import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app } from 'electron' import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer' 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 { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService' import { windowService } from './services/WindowService'
initAppDataDir()
Logger.initialize() Logger.initialize()
/** /**

View File

@ -1,13 +1,14 @@
import fs from 'node:fs' import fs from 'node:fs'
import { arch } from 'node:os' import { arch } from 'node:os'
import path from 'node:path'
import { isMac, isWin } from '@main/constant' import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom' import { handleZoomFactor } from '@main/utils/zoom'
import { FeedUrl } from '@shared/config/constant' import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types' 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 log from 'electron-log'
import { Notification } from 'src/renderer/src/types/notification' import { Notification } from 'src/renderer/src/types/notification'
@ -24,6 +25,7 @@ import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService' import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService' import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager' import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { searchService } from './services/SearchService' import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService' import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
@ -34,7 +36,7 @@ import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService' import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils' import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes' 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' import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage() const fileManager = new FileStorage()
@ -47,6 +49,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow) const appUpdater = new AppUpdater(mainWindow)
const notificationService = new NotificationService(mainWindow) const notificationService = new NotificationService(mainWindow)
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({ ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(), version: app.getVersion(),
isPackaged: app.isPackaged, isPackaged: app.isPackaged,
@ -57,7 +62,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
resourcesPath: getResourcePath(), resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path, logsPath: log.transports.file.getFile().path,
arch: arch(), 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) => { ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
@ -85,6 +91,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setLanguage(language) 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 // launch on boot
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => { ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac // Set login item settings for windows and mac
@ -115,8 +142,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setAutoUpdate(isActive) configManager.setAutoUpdate(isActive)
}) })
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => { ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => {
appUpdater.setFeedUrl(feedUrl) 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) => { 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 // Set app data path
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => { ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
updateConfig(filePath) updateAppDataConfig(filePath)
app.setPath('userData', 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 // 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 { 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 } return { success: true }
} catch (error: any) { } catch (error: any) {
log.error('Failed to copy user data:', error) log.error('Failed to copy user data:', error)
@ -234,8 +305,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}) })
// Relaunch app // Relaunch app
ipcMain.handle(IpcChannel.App_RelaunchApp, () => { ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
app.relaunch() app.relaunch(options)
app.exit(0) 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_CheckConnection, backupManager.checkConnection)
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory) ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile) 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 // file
ipcMain.handle(IpcChannel.File_Open, fileManager.open) 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_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity) 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_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name)) ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js')) ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
@ -422,6 +506,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
setOpenLinkExternal(webviewId, isExternal) 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 // store sync
storeSyncService.registerIpcHandler() storeSyncService.registerIpcHandler()

View File

@ -16,6 +16,7 @@ const FILE_LOADER_MAP: Record<string, string> = {
// 内置类型 // 内置类型
'.pdf': 'common', '.pdf': 'common',
'.csv': 'common', '.csv': 'common',
'.doc': 'common',
'.docx': 'common', '.docx': 'common',
'.pptx': 'common', '.pptx': 'common',
'.xlsx': 'common', '.xlsx': 'common',

View 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'
}
}
}
}
}

View File

@ -6,6 +6,7 @@ import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch' import FetchServer from './fetch'
import FileSystemServer from './filesystem' import FileSystemServer from './filesystem'
import MemoryServer from './memory' import MemoryServer from './memory'
import PythonServer from './python'
import ThinkingServer from './sequentialthinking' import ThinkingServer from './sequentialthinking'
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server { 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 const difyKey = envs.DIFY_KEY
return new DifyKnowledgeServer(difyKey, args).server return new DifyKnowledgeServer(difyKey, args).server
} }
case '@cherry/python': {
return new PythonServer().server
}
default: default:
throw new Error(`Unknown in-memory MCP server: ${name}`) throw new Error(`Unknown in-memory MCP server: ${name}`)
} }

View 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

View File

@ -106,6 +106,7 @@ class SequentialThinkingServer {
type: 'text', type: 'text',
text: JSON.stringify( text: JSON.stringify(
{ {
thought: validatedInput.thought,
thoughtNumber: validatedInput.thoughtNumber, thoughtNumber: validatedInput.thoughtNumber,
totalThoughts: validatedInput.totalThoughts, totalThoughts: validatedInput.totalThoughts,
nextThoughtNeeded: validatedInput.nextThoughtNeeded, nextThoughtNeeded: validatedInput.nextThoughtNeeded,

View File

@ -17,7 +17,7 @@ export default abstract class BaseReranker {
* Get Rerank Request Url * Get Rerank Request Url
*/ */
protected getRerankUrl() { 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' return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
} }
@ -50,7 +50,7 @@ export default abstract class BaseReranker {
documents, documents,
top_k: topN top_k: topN
} }
} else if (provider === 'dashscope') { } else if (provider === 'bailian') {
return { return {
model: this.base.rerankModel, model: this.base.rerankModel,
input: { input: {
@ -82,11 +82,11 @@ export default abstract class BaseReranker {
*/ */
protected extractRerankResult(data: any) { protected extractRerankResult(data: any) {
const provider = this.base.rerankModelProvider const provider = this.base.rerankModelProvider
if (provider === 'dashscope') { if (provider === 'bailian') {
return data.output.results return data.output.results
} else if (provider === 'voyageai') { } else if (provider === 'voyageai') {
return data.data return data.data
} else if (provider === 'mis-tei') { } else if (provider?.includes('tei')) {
return data.map((item: any) => { return data.map((item: any) => {
return { return {
index: item.index, index: item.index,

View File

@ -1,11 +1,11 @@
import { isWin } from '@main/constant' import { isWin } from '@main/constant'
import { locales } from '@main/utils/locales' 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 { IpcChannel } from '@shared/IpcChannel'
import { UpdateInfo } from 'builder-util-runtime' import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron' import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log' 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 path from 'path'
import icon from '../../../build/icon.png?asset' import icon from '../../../build/icon.png?asset'
@ -14,6 +14,8 @@ import { configManager } from './ConfigManager'
export default class AppUpdater { export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
constructor(mainWindow: BrowserWindow) { constructor(mainWindow: BrowserWindow) {
logger.transports.file.level = 'info' logger.transports.file.level = 'info'
@ -22,9 +24,7 @@ export default class AppUpdater {
autoUpdater.forceDevUpdateConfig = !app.isPackaged autoUpdater.forceDevUpdateConfig = !app.isPackaged
autoUpdater.autoDownload = configManager.getAutoUpdate() autoUpdater.autoDownload = configManager.getAutoUpdate()
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate() autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.setFeedURL(configManager.getFeedUrl())
// 检测下载错误
autoUpdater.on('error', (error) => { autoUpdater.on('error', (error) => {
// 简单记录错误信息和时间戳 // 简单记录错误信息和时间戳
logger.error('更新异常', { logger.error('更新异常', {
@ -64,6 +64,35 @@ export default class AppUpdater {
this.autoUpdater = autoUpdater 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() { private async _getIpCountry() {
try { try {
// add timeout using AbortController // add timeout using AbortController
@ -93,9 +122,72 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = isActive autoUpdater.autoInstallOnAppQuit = isActive
} }
public setFeedUrl(feedUrl: FeedUrl) { private _getChannelByVersion(version: string) {
autoUpdater.setFeedURL(feedUrl) if (version.includes(`-${UpgradeChannel.BETA}.`)) {
configManager.setFeedUrl(feedUrl) 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() { public async checkForUpdates() {
@ -106,23 +198,26 @@ export default class AppUpdater {
} }
} }
const ipCountry = await this._getIpCountry() await this._setFeedUrl()
logger.info('ipCountry', ipCountry)
if (ipCountry !== 'CN') { // disable downgrade after change the channel
this.autoUpdater.setFeedURL(FeedUrl.EARLY_ACCESS) this.autoUpdater.allowDowngrade = false
}
// github and gitcode don't support multiple range download
this.autoUpdater.disableDifferentialDownload = true
try { try {
const update = await this.autoUpdater.checkForUpdates() this.updateCheckResult = await this.autoUpdater.checkForUpdates()
if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) { if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
// 如果 autoDownload 为 false则需要再调用下面的函数触发下 // 如果 autoDownload 为 false则需要再调用下面的函数触发下
// do not use await, because it will block the return of this function // 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 { return {
currentVersion: this.autoUpdater.currentVersion, currentVersion: this.autoUpdater.currentVersion,
updateInfo: update?.updateInfo updateInfo: this.updateCheckResult?.updateInfo
} }
} catch (error) { } catch (error) {
logger.error('Failed to check for update:', 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') return releaseNotes.map((note) => note.note).join('\n')
} }
} }
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo { interface ReleaseNoteInfo {
readonly version: string readonly version: string
readonly note: string | null readonly note: string | null

View File

@ -1,5 +1,6 @@
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { WebDavConfig } from '@types' import { WebDavConfig } from '@types'
import { S3Config } from '@types'
import archiver from 'archiver' import archiver from 'archiver'
import { exec } from 'child_process' import { exec } from 'child_process'
import { app } from 'electron' import { app } from 'electron'
@ -10,6 +11,7 @@ import * as path from 'path'
import { CreateDirectoryOptions, FileStat } from 'webdav' import { CreateDirectoryOptions, FileStat } from 'webdav'
import { getDataPath } from '../utils' import { getDataPath } from '../utils'
import S3Storage from './RemoteStorage'
import WebDav from './WebDav' import WebDav from './WebDav'
import { windowService } from './WindowService' import { windowService } from './WindowService'
@ -25,6 +27,11 @@ class BackupManager {
this.restoreFromWebdav = this.restoreFromWebdav.bind(this) this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this) this.listWebdavFiles = this.listWebdavFiles.bind(this)
this.deleteWebdavFile = this.deleteWebdavFile.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> { private async setWritableRecursive(dirPath: string): Promise<void> {
@ -85,7 +92,11 @@ class BackupManager {
const onProgress = (processData: { stage: string; progress: number; total: number }) => { const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData) 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 { try {
@ -147,18 +158,23 @@ class BackupManager {
let totalBytes = 0 let totalBytes = 0
let processedBytes = 0 let processedBytes = 0
// 首先计算总文件数和总大小 // 首先计算总文件数和总大小,但不记录详细日志
const calculateTotals = async (dirPath: string) => { const calculateTotals = async (dirPath: string) => {
const items = await fs.readdir(dirPath, { withFileTypes: true }) try {
for (const item of items) { const items = await fs.readdir(dirPath, { withFileTypes: true })
const fullPath = path.join(dirPath, item.name) for (const item of items) {
if (item.isDirectory()) { const fullPath = path.join(dirPath, item.name)
await calculateTotals(fullPath) if (item.isDirectory()) {
} else { await calculateTotals(fullPath)
totalEntries++ } else {
const stats = await fs.stat(fullPath) totalEntries++
totalBytes += stats.size 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 }) => { const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData) 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 { try {
@ -382,21 +402,54 @@ class BackupManager {
destination: string, destination: string,
onProgress: (size: number) => void onProgress: (size: number) => void
): Promise<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 countFiles = async (dir: string): Promise<number> => {
const destPath = path.join(destination, item.name) 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()) { totalFiles = await countFiles(source)
await fs.ensureDir(destPath)
await this.copyDirWithProgress(sourcePath, destPath, onProgress) // 复制文件并更新进度
} else { const copyDir = async (src: string, dest: string): Promise<void> => {
const stats = await fs.stat(sourcePath) const items = await fs.readdir(src, { withFileTypes: true })
await fs.copy(sourcePath, destPath)
onProgress(stats.size) 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) { async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
@ -423,6 +476,141 @@ class BackupManager {
throw new Error(error.message || 'Failed to delete backup file') 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 export default BackupManager

View File

@ -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 { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron' import { app } from 'electron'
import Store from 'electron-store' import Store from 'electron-store'
@ -16,7 +16,8 @@ export enum ConfigKeys {
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate', AutoUpdate = 'autoUpdate',
FeedUrl = 'feedUrl', TestPlan = 'testPlan',
TestChannel = 'testChannel',
EnableDataCollection = 'enableDataCollection', EnableDataCollection = 'enableDataCollection',
SelectionAssistantEnabled = 'selectionAssistantEnabled', SelectionAssistantEnabled = 'selectionAssistantEnabled',
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode', SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
@ -142,12 +143,20 @@ export class ConfigManager {
this.set(ConfigKeys.AutoUpdate, value) this.set(ConfigKeys.AutoUpdate, value)
} }
getFeedUrl(): string { getTestPlan(): boolean {
return this.get<string>(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION) return this.get<boolean>(ConfigKeys.TestPlan, false)
} }
setFeedUrl(value: FeedUrl) { setTestPlan(value: boolean) {
this.set(ConfigKeys.FeedUrl, value) this.set(ConfigKeys.TestPlan, value)
}
getTestChannel(): UpgradeChannel {
return this.get<UpgradeChannel>(ConfigKeys.TestChannel)
}
setTestChannel(value: UpgradeChannel) {
this.set(ConfigKeys.TestChannel, value)
} }
getEnableDataCollection(): boolean { getEnableDataCollection(): boolean {

View File

@ -4,18 +4,29 @@ import { locales } from '../utils/locales'
import { configManager } from './ConfigManager' import { configManager } from './ConfigManager'
class ContextMenu { class ContextMenu {
public contextMenu(w: Electron.BrowserWindow) { public contextMenu(w: Electron.WebContents) {
w.webContents.on('context-menu', (_event, properties) => { w.on('context-menu', (_event, properties) => {
const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties) const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties)
const filtered = template.filter((item) => item.visible !== false) const filtered = template.filter((item) => item.visible !== false)
if (filtered.length > 0) { 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() menu.popup()
} }
}) })
} }
private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] { private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] {
const locale = locales[configManager.getLanguage()] const locale = locales[configManager.getLanguage()]
const { common } = locale.translation const { common } = locale.translation
const template: MenuItemConstructorOptions[] = [ const template: MenuItemConstructorOptions[] = [
@ -23,7 +34,7 @@ class ContextMenu {
id: 'inspect', id: 'inspect',
label: common.inspect, label: common.inspect,
click: () => { click: () => {
w.webContents.toggleDevTools() w.toggleDevTools()
}, },
enabled: true enabled: true
} }
@ -72,6 +83,53 @@ class ContextMenu {
return template 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() export const contextMenu = new ContextMenu()

View File

@ -19,6 +19,7 @@ import { getDocument } from 'officeparser/pdfjs-dist-build/pdf.js'
import * as path from 'path' import * as path from 'path'
import { chdir } from 'process' import { chdir } from 'process'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import WordExtractor from 'word-extractor'
class FileStorage { class FileStorage {
private storageDir = getFilesDir() private storageDir = getFilesDir()
@ -220,10 +221,20 @@ class FileStorage {
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => { public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
const filePath = path.join(this.storageDir, id) 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() const originalCwd = process.cwd()
try { try {
chdir(this.tempDir) 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) const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd) chdir(originalCwd)
return data return data
@ -352,7 +363,7 @@ class FileStorage {
public open = async ( public open = async (
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
options: OpenDialogOptions options: OpenDialogOptions
): Promise<{ fileName: string; filePath: string; content: Buffer } | null> => { ): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => {
try { try {
const result: OpenDialogReturnValue = await dialog.showOpenDialog({ const result: OpenDialogReturnValue = await dialog.showOpenDialog({
title: '打开文件', title: '打开文件',
@ -364,8 +375,16 @@ class FileStorage {
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0] const filePath = result.filePaths[0]
const fileName = filePath.split('/').pop() || '' const fileName = filePath.split('/').pop() || ''
const content = await readFile(filePath) const stats = await fs.promises.stat(filePath)
return { fileName, filePath, content }
// 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 return null

View File

@ -16,13 +16,14 @@
import * as fs from 'node:fs' import * as fs from 'node:fs'
import path from 'node:path' 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 type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { LibSqlDb } from '@cherrystudio/embedjs-libsql' import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap' import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
import { WebLoader } from '@cherrystudio/embedjs-loader-web' import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import Embeddings from '@main/embeddings/Embeddings' import Embeddings from '@main/embeddings/Embeddings'
import { addFileLoader } from '@main/loader' import { addFileLoader } from '@main/loader'
import { NoteLoader } from '@main/loader/noteLoader'
import Reranker from '@main/reranker/Reranker' import Reranker from '@main/reranker/Reranker'
import { windowService } from '@main/services/WindowService' import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils' import { getDataPath } from '@main/utils'
@ -143,7 +144,7 @@ class KnowledgeService {
this.getRagApplication(base) 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) const ragApplication = await this.getRagApplication(base)
await ragApplication.reset() await ragApplication.reset()
} }
@ -333,6 +334,7 @@ class KnowledgeService {
): LoaderTask { ): LoaderTask {
const { base, item, forceReload } = options const { base, item, forceReload } = options
const content = item.content as string const content = item.content as string
const sourceUrl = (item as any).sourceUrl
const encoder = new TextEncoder() const encoder = new TextEncoder()
const contentBytes = encoder.encode(content) const contentBytes = encoder.encode(content)
@ -342,7 +344,12 @@ class KnowledgeService {
state: LoaderTaskItemState.PENDING, state: LoaderTaskItemState.PENDING,
task: () => { task: () => {
const loaderReturn = ragApplication.addLoader( 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 forceReload
) as Promise<LoaderReturn> ) as Promise<LoaderReturn>

View 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()

View File

@ -1,57 +1,83 @@
// import Logger from 'electron-log' import Logger from 'electron-log'
// import { Operator } from 'opendal' import type { Operator as OperatorType } from 'opendal'
const { Operator } = require('opendal')
// export default class RemoteStorage { export default class S3Storage {
// public instance: Operator | undefined 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 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. * @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: * For example, use minio as remote storage:
// * *
// * ```typescript * ```typescript
// * const storage = new RemoteStorage('s3', { * const storage = new S3Storage('s3', {
// * endpoint: 'http://localhost:9000', * endpoint: 'http://localhost:9000',
// * region: 'us-east-1', * region: 'us-east-1',
// * bucket: 'testbucket', * bucket: 'testbucket',
// * access_key_id: 'user', * access_key_id: 'user',
// * secret_access_key: 'password', * secret_access_key: 'password',
// * root: '/path/to/basepath', * root: '/path/to/basepath',
// * }) * })
// * ``` * ```
// */ */
// constructor(scheme: string, options?: Record<string, string> | undefined | null) { constructor(scheme: string, options?: Record<string, string> | undefined | null) {
// this.instance = new Operator(scheme, options) this.instance = new Operator(scheme, options)
// this.putFileContents = this.putFileContents.bind(this) this.putFileContents = this.putFileContents.bind(this)
// this.getFileContents = this.getFileContents.bind(this) this.getFileContents = this.getFileContents.bind(this)
// } }
// public putFileContents = async (filename: string, data: string | Buffer) => { public putFileContents = async (filename: string, data: string | Buffer) => {
// if (!this.instance) { if (!this.instance) {
// return new Error('RemoteStorage client not initialized') return new Error('RemoteStorage client not initialized')
// } }
// try { try {
// return await this.instance.write(filename, data) return await this.instance.write(filename, data)
// } catch (error) { } catch (error) {
// Logger.error('[RemoteStorage] Error putting file contents:', error) Logger.error('[RemoteStorage] Error putting file contents:', error)
// throw error throw error
// } }
// } }
// public getFileContents = async (filename: string) => { public getFileContents = async (filename: string) => {
// if (!this.instance) { if (!this.instance) {
// throw new Error('RemoteStorage client not initialized') throw new Error('RemoteStorage client not initialized')
// } }
// try { try {
// return await this.instance.read(filename) return await this.instance.read(filename)
// } catch (error) { } catch (error) {
// Logger.error('[RemoteStorage] Error getting file contents:', error) Logger.error('[RemoteStorage] Error getting file contents:', error)
// throw 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
}
}
}

View File

@ -95,6 +95,7 @@ export class WindowService {
this.setupMaximize(mainWindow, mainWindowState.isMaximized) this.setupMaximize(mainWindow, mainWindowState.isMaximized)
this.setupContextMenu(mainWindow) this.setupContextMenu(mainWindow)
this.setupSpellCheck(mainWindow)
this.setupWindowEvents(mainWindow) this.setupWindowEvents(mainWindow)
this.setupWebContentsHandlers(mainWindow) this.setupWebContentsHandlers(mainWindow)
this.setupWindowLifecycleEvents(mainWindow) this.setupWindowLifecycleEvents(mainWindow)
@ -102,6 +103,18 @@ export class WindowService {
this.loadMainWindowContent(mainWindow) 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) { private setupMainWindowMonitor(mainWindow: BrowserWindow) {
mainWindow.webContents.on('render-process-gone', (_, details) => { mainWindow.webContents.on('render-process-gone', (_, details) => {
Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`) Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`)
@ -130,9 +143,10 @@ export class WindowService {
} }
private setupContextMenu(mainWindow: BrowserWindow) { private setupContextMenu(mainWindow: BrowserWindow) {
contextMenu.contextMenu(mainWindow) contextMenu.contextMenu(mainWindow.webContents)
app.on('browser-window-created', (_, win) => { // setup context menu for all webviews like miniapp
contextMenu.contextMenu(win) app.on('web-contents-created', (_, webContents) => {
contextMenu.contextMenu(webContents)
}) })
// Dangerous API // Dangerous API

View File

@ -92,6 +92,7 @@ describe('file', () => {
it('should return DOCUMENT for document extensions', () => { it('should return DOCUMENT for document extensions', () => {
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT) expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT) expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.doc')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT) expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT) expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT) expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)

View File

@ -8,6 +8,20 @@ import { FileType, FileTypes } from '@types'
import { app } from 'electron' import { app } from 'electron'
import { v4 as uuidv4 } from 'uuid' 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>() const fileTypeMap = new Map<string, FileTypes>()
@ -35,46 +49,70 @@ export function hasWritePermission(path: string) {
function getAppDataPathFromConfig() { function getAppDataPathFromConfig() {
try { try {
const configPath = path.join(getConfigDir(), 'config.json') const configPath = path.join(getConfigDir(), 'config.json')
if (fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) return null
if (config.appDataPath && fs.existsSync(config.appDataPath) && hasWritePermission(config.appDataPath)) {
return config.appDataPath
}
} }
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) { } catch (error) {
return null return null
} }
return null
} }
export function initAppDataDir() { export function updateAppDataConfig(appDataPath: string) {
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) {
const configDir = getConfigDir() const configDir = getConfigDir()
if (!fs.existsSync(configDir)) { if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true }) fs.mkdirSync(configDir, { recursive: true })
} }
// config.json
// appDataPath: [{ executablePath: string, dataPath: string }]
const configPath = path.join(getConfigDir(), 'config.json') const configPath = path.join(getConfigDir(), 'config.json')
if (!fs.existsSync(configPath)) { 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 return
} }
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) 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)) fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
} }

View File

@ -1,8 +1,17 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import { FeedUrl } from '@shared/config/constant' import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel' 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 { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { Notification } from 'src/renderer/src/types/notification' import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav' import { CreateDirectoryOptions } from 'webdav'
@ -17,11 +26,14 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog), showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang), 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), setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive),
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive), setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive), setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, 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), setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
handleZoomFactor: (delta: number, reset: boolean = false) => handleZoomFactor: (delta: number, reset: boolean = false) =>
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset), ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
@ -29,9 +41,13 @@ const api = {
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options), select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path), hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, 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), 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), openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize), getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
@ -64,7 +80,13 @@ const api = {
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options), ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => 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: { file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
@ -176,6 +198,10 @@ const api = {
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo), getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server) 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: { shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options) openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
}, },
@ -218,7 +244,9 @@ const api = {
}, },
webview: { webview: {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) => 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: { storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),

View File

@ -2,42 +2,45 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" /> <meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy" <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:" /> 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> <title>Cherry Studio Selection Toolbar</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script> <script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
<style> <style>
html { html {
margin: 0; 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; body {
-moz-user-select: none; margin: 0 !important;
-ms-user-select: none; padding: 0 !important;
user-select: none; overflow: hidden !important;
} width: 100vw !important;
height: 100vh !important;
#root { -webkit-user-select: none;
margin: 0; -moz-user-select: none;
padding: 0; -ms-user-select: none;
width: max-content !important; user-select: none;
height: fit-content !important; }
}
</style> #root {
margin: 0 !important;
padding: 0 !important;
width: max-content !important;
height: fit-content !important;
}
</style>
</body> </body>
</html> </html>

View File

@ -42,11 +42,19 @@ export class AihubmixAPIClient extends BaseApiClient {
constructor(provider: Provider) { constructor(provider: Provider) {
super(provider) super(provider)
const providerExtraHeaders = {
...provider,
extra_headers: {
...provider.extra_headers,
'APP-Code': 'MLTG2087'
}
}
// 初始化各个client - 现在有类型安全 // 初始化各个client - 现在有类型安全
const claudeClient = new AnthropicAPIClient(provider) const claudeClient = new AnthropicAPIClient(providerExtraHeaders)
const geminiClient = new GeminiAPIClient({ ...provider, apiHost: 'https://aihubmix.com/gemini' }) const geminiClient = new GeminiAPIClient({ ...providerExtraHeaders, apiHost: 'https://aihubmix.com/gemini' })
const openaiClient = new OpenAIResponseAPIClient(provider) const openaiClient = new OpenAIResponseAPIClient(providerExtraHeaders)
const defaultClient = new OpenAIAPIClient(provider) const defaultClient = new OpenAIAPIClient(providerExtraHeaders)
this.clients.set('claude', claudeClient) this.clients.set('claude', claudeClient)
this.clients.set('gemini', geminiClient) this.clients.set('gemini', geminiClient)
@ -58,6 +66,13 @@ export class AihubmixAPIClient extends BaseApiClient {
this.currentClient = this.defaultClient as BaseApiClient this.currentClient = this.defaultClient as BaseApiClient
} }
override getBaseURL(): string {
if (!this.currentClient) {
return this.provider.apiHost
}
return this.currentClient.getBaseURL()
}
/** /**
* client是BaseApiClient的实例 * client是BaseApiClient的实例
*/ */

View File

@ -66,7 +66,7 @@ import {
mcpToolCallResponseToAnthropicMessage, mcpToolCallResponseToAnthropicMessage,
mcpToolsToAnthropicTools mcpToolsToAnthropicTools
} from '@renderer/utils/mcp-tools' } 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 { buildSystemPrompt } from '@renderer/utils/prompt'
import { BaseApiClient } from '../BaseApiClient' import { BaseApiClient } from '../BaseApiClient'
@ -94,7 +94,8 @@ export class AnthropicAPIClient extends BaseApiClient<
baseURL: this.getBaseURL(), baseURL: this.getBaseURL(),
dangerouslyAllowBrowser: true, dangerouslyAllowBrowser: true,
defaultHeaders: { defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19' 'anthropic-beta': 'output-128k-2025-02-19',
...this.provider.extra_headers
} }
}) })
return this.sdkInstance return this.sdkInstance
@ -191,7 +192,7 @@ export class AnthropicAPIClient extends BaseApiClient<
const parts: MessageParam['content'] = [ const parts: MessageParam['content'] = [
{ {
type: 'text', type: 'text',
text: getMainTextContent(message) text: await this.getMessageContent(message)
} }
] ]

View File

@ -176,7 +176,10 @@ export class GeminiAPIClient extends BaseApiClient<
apiVersion: this.getApiVersion(), apiVersion: this.getApiVersion(),
httpOptions: { httpOptions: {
baseUrl: this.getBaseURL(), 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[] toolCalls: FunctionCall[]
): Content[] { ): Content[] {
const parts: Part[] = [] const parts: Part[] = []
const modelParts: Part[] = []
if (output) { if (output) {
parts.push({ modelParts.push({
text: output text: output
}) })
} }
toolCalls.forEach((toolCall) => { toolCalls.forEach((toolCall) => {
parts.push({ modelParts.push({
functionCall: toolCall functionCall: toolCall
}) })
}) })
parts.push( parts.push(
...toolResults ...toolResults
.map((ts) => ts.parts) .map((ts) => ts.parts)
@ -700,10 +706,22 @@ export class GeminiAPIClient extends BaseApiClient<
.filter((p) => p !== undefined) .filter((p) => p !== undefined)
) )
const lastMessage = currentReqMessages[currentReqMessages.length - 1] const userMessage: Content = {
if (lastMessage) { role: 'user',
lastMessage.parts?.push(...parts) parts: []
} }
if (modelParts.length > 0) {
currentReqMessages.push({
role: 'model',
parts: modelParts
})
}
if (parts.length > 0) {
userMessage.parts?.push(...parts)
currentReqMessages.push(userMessage)
}
return currentReqMessages 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> { private async uploadFile(file: FileType): Promise<File> {

View File

@ -113,6 +113,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
} }
if (!reasoningEffort) { if (!reasoningEffort) {
if (model.provider === 'openrouter') {
return { reasoning: { enabled: false, exclude: true } }
}
if (isSupportedThinkingTokenQwenModel(model)) { if (isSupportedThinkingTokenQwenModel(model)) {
return { enable_thinking: false } return { enable_thinking: false }
} }
@ -122,10 +125,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
} }
if (isSupportedThinkingTokenGeminiModel(model)) { if (isSupportedThinkingTokenGeminiModel(model)) {
// openrouter没有提供一个不推理的选项先隐藏
if (this.provider.id === 'openrouter') {
return { reasoning: { max_tokens: 0, exclude: true } }
}
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return { reasoning_effort: 'none' } return { reasoning_effort: 'none' }
} }

View File

@ -159,6 +159,7 @@ export abstract class OpenAIBaseClient<
baseURL: this.getBaseURL(), baseURL: this.getBaseURL(),
defaultHeaders: { defaultHeaders: {
...this.defaultHeaders(), ...this.defaultHeaders(),
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}), ...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {}) ...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
} }

View File

@ -81,7 +81,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
apiKey: this.apiKey, apiKey: this.apiKey,
baseURL: this.getBaseURL(), baseURL: this.getBaseURL(),
defaultHeaders: { defaultHeaders: {
...this.defaultHeaders() ...this.defaultHeaders(),
...this.provider.extra_headers
} }
}) })
} }
@ -425,6 +426,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
const toolCalls: OpenAIResponseSdkToolCall[] = [] const toolCalls: OpenAIResponseSdkToolCall[] = []
const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
let hasBeenCollectedToolCalls = false let hasBeenCollectedToolCalls = false
let hasReasoningSummary = false
return () => ({ return () => ({
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) { async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 处理chunk // 处理chunk
@ -496,6 +498,16 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
outputItems.push(chunk.item) outputItems.push(chunk.item)
} }
break 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': case 'response.reasoning_summary_text.delta':
controller.enqueue({ controller.enqueue({
type: ChunkType.THINKING_DELTA, type: ChunkType.THINKING_DELTA,

View File

@ -255,6 +255,10 @@ function buildParamsWithToolResults(
// 从回复中构建助手消息 // 从回复中构建助手消息
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls) const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
if (output && ctx._internal.toolProcessingState) {
ctx._internal.toolProcessingState.output = undefined
}
// 估算新增消息的 token 消耗并累加到 usage 中 // 估算新增消息的 token 消耗并累加到 usage 中
if (ctx._internal.observer?.usage && newReqMessages.length > currentReqMessages.length) { if (ctx._internal.observer?.usage && newReqMessages.length > currentReqMessages.length) {
try { try {

View File

@ -58,166 +58,80 @@
} }
} }
.mention-models-dropdown { .ant-dropdown-menu .ant-dropdown-menu-sub {
&.ant-dropdown { max-height: 50vh;
background: rgba(var(--color-base-rgb), 0.65) !important; width: max-content;
backdrop-filter: blur(35px) saturate(150%) !important; overflow-y: auto;
animation-duration: 0.15s !important; overflow-x: hidden;
} border: 0.5px solid var(--color-border);
/* 移动其他样式到 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 { .ant-dropdown {
background-color: var(--ant-color-bg-elevated);
overflow: hidden;
border-radius: var(--ant-border-radius-lg);
.ant-dropdown-menu { .ant-dropdown-menu {
max-height: 50vh; max-height: 50vh;
overflow-y: auto; overflow-y: auto;
border: 0.5px solid var(--color-border); 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 { .ant-dropdown-arrow + .ant-dropdown-menu {
border: none; border: none;
} }
} }
.ant-select-dropdown { .ant-select-dropdown {
border: 0.5px solid var(--color-border); 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 { .ant-collapse {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -227,8 +141,14 @@
} }
.ant-collapse-content { .ant-collapse-content {
border-top: 1px solid var(--color-border) !important; border-top: 0.5px solid var(--color-border) !important;
.ant-color-picker & { .ant-color-picker & {
border-top: none !important; border-top: none !important;
} }
} }
.ant-slider {
.ant-slider-handle::after {
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
}
}

View File

@ -47,7 +47,7 @@
--color-list-item: #222; --color-list-item: #222;
--color-list-item-hover: #1e1e1e; --color-list-item-hover: #1e1e1e;
--modal-background: #1f1f1f; --modal-background: #111111;
--color-highlight: rgba(0, 0, 0, 1); --color-highlight: rgba(0, 0, 0, 1);
--color-background-highlight: rgba(255, 255, 0, 0.9); --color-background-highlight: rgba(255, 255, 0, 0.9);
@ -66,9 +66,9 @@
--settings-width: 250px; --settings-width: 250px;
--scrollbar-width: 5px; --scrollbar-width: 5px;
--chat-background: #111111; --chat-background: transparent;
--chat-background-user: #28b561; --chat-background-user: rgba(255, 255, 255, 0.08);
--chat-background-assistant: #2c2c2c; --chat-background-assistant: transparent;
--chat-text-user: var(--color-black); --chat-text-user: var(--color-black);
--list-item-border-radius: 20px; --list-item-border-radius: 20px;
@ -132,8 +132,8 @@
--navbar-background-mac: rgba(255, 255, 255, 0.55); --navbar-background-mac: rgba(255, 255, 255, 0.55);
--navbar-background: rgba(244, 244, 244); --navbar-background: rgba(244, 244, 244);
--chat-background: #f3f3f3; --chat-background: transparent;
--chat-background-user: #95ec69; --chat-background-user: rgba(0, 0, 0, 0.045);
--chat-background-assistant: #ffffff; --chat-background-assistant: transparent;
--chat-text-user: var(--color-text); --chat-text-user: var(--color-text);
} }

View File

@ -111,27 +111,7 @@ ul {
word-wrap: break-word; word-wrap: break-word;
} }
.bubble { .bubble:not(.multi-select-mode) {
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;
}
.block-wrapper { .block-wrapper {
display: flow-root; display: flow-root;
} }
@ -149,30 +129,35 @@ ul {
} }
.message-user { .message-user {
color: var(--chat-text-user); .message-header {
.message-content-container-user .anticon { flex-direction: row-reverse;
color: var(--chat-text-user) !important; 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 { .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 { code {
color: var(--color-text); color: var(--color-text);
} }
@ -188,11 +173,17 @@ ul {
color: var(--color-icon); color: var(--color-icon);
} }
span.highlight { ::highlight(search-matches) {
background-color: var(--color-background-highlight); background-color: var(--color-background-highlight);
color: var(--color-highlight); color: var(--color-highlight);
} }
span.highlight.selected { ::highlight(current-match) {
background-color: var(--color-background-highlight-accent); background-color: var(--color-background-highlight-accent);
} }
textarea {
&::-webkit-resizer {
display: none;
}
}

View File

@ -98,7 +98,6 @@
border: none; border: none;
border-top: 0.5px solid var(--color-border); border-top: 0.5px solid var(--color-border);
margin: 20px 0; margin: 20px 0;
background-color: var(--color-border);
} }
span { span {
@ -119,7 +118,7 @@
} }
pre { pre {
border-radius: 5px; border-radius: 8px;
overflow-x: auto; overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace; font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
@ -157,15 +156,28 @@
} }
table { table {
border-collapse: collapse; --table-border-radius: 8px;
margin: 1em 0; margin: 1em 0;
width: 100%; width: 100%;
border-radius: var(--table-border-radius);
overflow: hidden;
border-collapse: separate;
border: 0.5px solid var(--color-border);
border-spacing: 0;
} }
th, th,
td { 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; padding: 0.5em;
&:last-child {
border-right: none;
}
}
tr:last-child td {
border-bottom: none;
} }
th { th {
@ -238,6 +250,10 @@
text-decoration: underline; text-decoration: underline;
} }
} }
> *:last-child {
margin-bottom: 0 !important;
}
} }
.footnotes { .footnotes {
@ -309,7 +325,7 @@ mjx-container {
/* CodeMirror 相关样式 */ /* CodeMirror 相关样式 */
.cm-editor { .cm-editor {
border-radius: 5px; border-radius: inherit;
&.cm-focused { &.cm-focused {
outline: none; outline: none;
@ -317,7 +333,7 @@ mjx-container {
.cm-scroller { .cm-scroller {
font-family: var(--code-font-family); font-family: var(--code-font-family);
border-radius: 5px; border-radius: inherit;
.cm-gutters { .cm-gutters {
line-height: 1.6; line-height: 1.6;

View File

@ -5,22 +5,57 @@ html {
} }
:root { :root {
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95); // Basic Colors
--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;
--color-primary: #00b96b; --color-primary: #00b96b;
--color-error: #f44336; --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'] { [theme-mode='light'] {
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95); --selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5);
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5); --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); --selection-toolbar-background: rgba(245, 245, 245, 0.95);
--color-selection-toolbar-text: rgba(0, 0, 0, 1); --selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04); --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);
} }

View File

@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { uuid } from '@renderer/utils' import { uuid } from '@renderer/utils'
import { getReactStyleFromToken } from '@renderer/utils/shiki' import { getReactStyleFromToken } from '@renderer/utils/shiki'
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core' import { ThemedToken } from 'shiki/core'
import styled from 'styled-components' import styled from 'styled-components'
@ -18,19 +18,20 @@ interface CodePreviewProps {
/** /**
* Shiki * Shiki
* *
* - shiki tokenizer * - shiki tokenizer
* - tokenizer * -
* -
*/ */
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle() const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible) const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([]) const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
const codeContentRef = useRef<HTMLDivElement>(null) const [isInViewport, setIsInViewport] = useState(false)
const prevCodeLengthRef = useRef(0) const codeContainerRef = useRef<HTMLDivElement>(null)
const safeCodeStringRef = useRef(children) const processingRef = useRef(false)
const highlightQueueRef = useRef<Promise<void>>(Promise.resolve()) const latestRequestedContentRef = useRef<string | null>(null)
const callerId = useRef(`${Date.now()}-${uuid()}`).current const callerId = useRef(`${Date.now()}-${uuid()}`).current
const shikiThemeRef = useRef(activeShikiTheme) const shikiThemeRef = useRef(activeShikiTheme)
@ -45,7 +46,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />, icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'), tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
visible: () => { visible: () => {
const scrollHeight = codeContentRef.current?.scrollHeight const scrollHeight = codeContainerRef.current?.scrollHeight
return codeCollapsible && (scrollHeight ?? 0) > 350 return codeCollapsible && (scrollHeight ?? 0) > 350
}, },
onClick: () => setIsExpanded((prev) => !prev) onClick: () => setIsExpanded((prev) => !prev)
@ -77,81 +78,63 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
setIsUnwrapped(!codeWrappable) setIsUnwrapped(!codeWrappable)
}, [codeWrappable]) }, [codeWrappable])
// 处理尾部空白字符
const safeCodeString = useMemo(() => {
return typeof children === 'string' ? children.trimEnd() : ''
}, [children])
const highlightCode = useCallback(async () => { 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 if (processingRef.current) return
const endPos = safeCodeString.length
// 添加到处理队列,确保按顺序处理 processingRef.current = true
highlightQueueRef.current = highlightQueueRef.current.then(async () => {
// FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮
if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) {
cleanupTokenizers(callerId)
prevCodeLengthRef.current = 0
safeCodeStringRef.current = ''
const result = await highlightCodeChunk(safeCodeString, language, callerId) try {
setTokenLines(result.lines) // 循环处理,确保会处理最新内容
while (latestRequestedContentRef.current !== null) {
const contentToProcess = latestRequestedContentRef.current
latestRequestedContentRef.current = null // 标记开始处理
prevCodeLengthRef.current = safeCodeString.length // 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮
safeCodeStringRef.current = safeCodeString 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]
})
}
} }
} finally {
// 跳过 race condition延迟到后续任务 processingRef.current = false
if (prevCodeLengthRef.current !== startPos) { }
return }, [highlightStreamingCode, language, callerId, children])
}
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])
// 主题变化时强制重新高亮 // 主题变化时强制重新高亮
useEffect(() => { useEffect(() => {
if (shikiThemeRef.current !== activeShikiTheme) { if (shikiThemeRef.current !== activeShikiTheme) {
prevCodeLengthRef.current++
shikiThemeRef.current = activeShikiTheme shikiThemeRef.current = activeShikiTheme
cleanupTokenizers(callerId)
setTokenLines([])
} }
}, [activeShikiTheme]) }, [activeShikiTheme, callerId, cleanupTokenizers])
// 组件卸载时清理资源 // 组件卸载时清理资源
useEffect(() => { useEffect(() => {
return () => cleanupTokenizers(callerId) return () => cleanupTokenizers(callerId)
}, [callerId, cleanupTokenizers]) }, [callerId, cleanupTokenizers])
// 触发代码高亮 // 视口检测逻辑,进入视口后触发第一次代码高亮
// - 进入视口后触发第一次高亮
// - 内容变化后触发之后的高亮
useEffect(() => { useEffect(() => {
let isMounted = true const codeElement = codeContainerRef.current
if (prevCodeLengthRef.current > 0) {
setTimeout(highlightCode, 0)
return
}
const codeElement = codeContentRef.current
if (!codeElement) return if (!codeElement) return
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].intersectionRatio > 0 && isMounted) { if (entries[0].intersectionRatio > 0) {
setTimeout(highlightCode, 0) setIsInViewport(true)
observer.disconnect() observer.disconnect()
} }
}, },
@ -161,21 +144,35 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
) )
observer.observe(codeElement) observer.observe(codeElement)
return () => observer.disconnect()
}, []) // 只执行一次
return () => { // 触发代码高亮
isMounted = false useEffect(() => {
observer.disconnect() if (!isInViewport) return
}
}, [highlightCode])
const hasHighlightedCode = useMemo(() => { setTimeout(highlightCode, 0)
return tokenLines.length > 0 }, [isInViewport, highlightCode])
}, [tokenLines.length])
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 ( return (
<ContentContainer <ContentContainer
ref={codeContentRef} ref={codeContainerRef}
$lineNumbers={codeShowLineNumbers}
$wrap={codeWrappable && !isUnwrapped} $wrap={codeWrappable && !isUnwrapped}
$fadeIn={hasHighlightedCode} $fadeIn={hasHighlightedCode}
style={{ style={{
@ -183,7 +180,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none' maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
}}> }}>
{hasHighlightedCode ? ( {hasHighlightedCode ? (
<ShikiTokensRenderer language={language} tokenLines={tokenLines} /> <ShikiTokensRenderer language={language} tokenLines={tokenLines} showLineNumbers={codeShowLineNumbers} />
) : ( ) : (
<CodePlaceholder>{children}</CodePlaceholder> <CodePlaceholder>{children}</CodePlaceholder>
)} )}
@ -191,97 +188,103 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
) )
} }
interface ShikiTokensRendererProps {
language: string
tokenLines: ThemedToken[][]
showLineNumbers?: boolean
}
/** /**
* Shiki tokens * Shiki tokens
* *
* 便 virtual list * 便 virtual list
*/ */
const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo( const ShikiTokensRenderer: React.FC<ShikiTokensRendererProps> = memo(({ language, tokenLines, showLineNumbers }) => {
({ language, tokenLines }) => { const { getShikiPreProperties } = useCodeStyle()
const { getShikiPreProperties } = useCodeStyle() const rendererRef = useRef<HTMLPreElement>(null)
const rendererRef = useRef<HTMLPreElement>(null)
// 设置 pre 标签属性 // 设置 pre 标签属性
useEffect(() => { useLayoutEffect(() => {
getShikiPreProperties(language).then((properties) => { getShikiPreProperties(language).then((properties) => {
const pre = rendererRef.current const pre = rendererRef.current
if (pre) { if (pre) {
pre.className = properties.class pre.className = properties.class
pre.style.cssText = properties.style pre.style.cssText = properties.style
pre.tabIndex = properties.tabindex pre.tabIndex = properties.tabindex
} }
}) })
}, [language, getShikiPreProperties]) }, [language, getShikiPreProperties])
return ( return (
<pre className="shiki" ref={rendererRef}> <pre className="shiki" ref={rendererRef}>
<code> <code>
{tokenLines.map((lineTokens, lineIndex) => ( {tokenLines.map((lineTokens, lineIndex) => (
<span key={`line-${lineIndex}`} className="line"> <span key={`line-${lineIndex}`} className="line">
{showLineNumbers && <span className="line-number">{lineIndex + 1}</span>}
<span className="line-content">
{lineTokens.map((token, tokenIndex) => ( {lineTokens.map((token, tokenIndex) => (
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}> <span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
{token.content} {token.content}
</span> </span>
))} ))}
</span> </span>
))} </span>
</code> ))}
</pre> </code>
) </pre>
} )
) })
const ContentContainer = styled.div<{ const ContentContainer = styled.div<{
$lineNumbers: boolean
$wrap: boolean $wrap: boolean
$fadeIn: boolean $fadeIn: boolean
}>` }>`
position: relative; position: relative;
overflow: auto; overflow: auto;
border: 0.5px solid transparent; border-radius: inherit;
border-radius: 5px;
margin-top: 0; margin-top: 0;
/* gutter 宽度默认值 */
--gutter-width: 0.6rem;
.shiki { .shiki {
padding: 1em; padding: 1em;
border-radius: inherit;
code { code {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.line { .line {
display: block; display: flex;
align-items: flex-start;
min-height: 1.3rem; min-height: 1.3rem;
padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')};
* { .line-number {
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; width: var(--gutter-width);
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; 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 { @keyframes contentFadeIn {
from { from {
opacity: 0; 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` const CodePlaceholder = styled.div`

View File

@ -273,6 +273,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
align-items: center; align-items: center;
color: var(--color-text); color: var(--color-text);
font-size: 14px; font-size: 14px;
line-height: 1;
font-weight: bold; font-weight: bold;
padding: 0 10px; padding: 0 10px;
border-top-left-radius: 8px; border-top-left-radius: 8px;
@ -288,6 +289,10 @@ const SplitViewWrapper = styled.div`
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
} }
&:not(:has(+ [class*='Container'])) {
border-radius: 0 0 8px 8px;
}
` `
export default memo(CodeBlockView) export default memo(CodeBlockView)

View File

@ -227,10 +227,10 @@ const CodeEditor = ({
...customBasicSetup // override basicSetup ...customBasicSetup // override basicSetup
}} }}
style={{ style={{
...style,
fontSize: `${fontSize - 1}px`, fontSize: `${fontSize - 1}px`,
border: '0.5px solid transparent', marginTop: 0,
marginTop: 0 borderRadius: 'inherit',
...style
}} }}
/> />
) )

View File

@ -3,13 +3,10 @@ import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
const HIGHLIGHT_CLASS = 'highlight'
const HIGHLIGHT_SELECT_CLASS = 'selected'
interface Props { interface Props {
children?: React.ReactNode children?: React.ReactNode
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
@ -18,19 +15,14 @@ interface Props {
* *
* `true``node` * `true``node`
*/ */
filter: (node: Node) => boolean filter: NodeFilter
includeUser?: boolean includeUser?: boolean
onIncludeUserChange?: (value: boolean) => void onIncludeUserChange?: (value: boolean) => void
} }
enum SearchCompletedState { enum SearchCompletedState {
NotSearched, NotSearched,
FirstSearched Searched
}
enum SearchTargetIndex {
Next,
Prev
} }
export interface ContentSearchRef { export interface ContentSearchRef {
@ -47,60 +39,20 @@ export interface ContentSearchRef {
focus(): void focus(): void
} }
interface MatchInfo {
index: number
length: number
text: string
}
const escapeRegExp = (string: string): string => { const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
} }
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => { const findRangesInTarget = (
if (!elementList || elementList.length === 0) { target: HTMLElement,
return null filter: NodeFilter,
}
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,
searchText: string, searchText: string,
highlightClass: string,
isCaseSensitive: boolean, isCaseSensitive: boolean,
isWholeWord: boolean isWholeWord: boolean
): HTMLSpanElement[] | null => { ): Range[] => {
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement CSS.highlights.clear()
if (textNodeParentNode) { const ranges: Range[] = []
if (textNodeParentNode.classList.contains(highlightClass)) {
return null
}
}
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
return null
}
const textContent = textNode.textContent
const escapedSearchText = escapeRegExp(searchText) const escapedSearchText = escapeRegExp(searchText)
// 检查搜索文本是否仅包含拉丁字母 // 检查搜索文本是否仅包含拉丁字母
@ -109,89 +61,66 @@ const highlightText = (
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感 // 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi' const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText 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 // 1. 拼接所有文本节点内容
const matches: MatchInfo[] = [] while (treeWalker.nextNode()) {
while ((match = regex.exec(textContent)) !== null) { allTextNodes.push({
if (typeof match.index === 'number' && typeof match[0] === 'string') { node: treeWalker.currentNode,
matches.push({ index: match.index, length: match[0].length, text: match[0] }) startOffset: fullText.length
} else { })
console.error('Unexpected match format:', match) fullText += treeWalker.currentNode.nodeValue
}
} }
if (matches.length === 0) { // 2.在完整文本中查找匹配项
return null let match: RegExpExecArray | null = null
} while ((match = searchRegex.exec(fullText))) {
const matchStart = match.index
const matchEnd = matchStart + match[0].length
const parentNode = textNode.parentNode // 3. 将匹配项的索引映射回DOM Range
if (!parentNode) { let startNode: Node | null = null
return null let endNode: Node | null = null
} let startOffset = 0
let endOffset = 0
const fragment = document.createDocumentFragment() // 找到起始节点和偏移
let currentIndex = 0 for (const nodeInfo of allTextNodes) {
const highlightTextSet = new Set<HTMLSpanElement>() if (
matchStart >= nodeInfo.startOffset &&
matches.forEach(({ index, length, text }) => { matchStart < nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
if (index > currentIndex) { ) {
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index))) startNode = nodeInfo.node
} startOffset = matchStart - nodeInfo.startOffset
const highlightSpan = document.createElement('span') break
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)
} }
} 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) { return ranges
groups.push(currentTextGroup)
}
const newChildren = groups.map((group) => {
if (group instanceof Node) {
return group
} else {
return document.createTextNode(group.text)
}
})
node.replaceChildren(...newChildren)
} }
// eslint-disable-next-line @eslint-react/no-forward-ref // 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 containerRef = React.useRef<HTMLDivElement>(null)
const searchInputRef = React.useRef<HTMLInputElement>(null) const searchInputRef = React.useRef<HTMLInputElement>(null)
const [searchResultIndex, setSearchResultIndex] = useState(0)
const [totalCount, setTotalCount] = useState(0)
const [enableContentSearch, setEnableContentSearch] = useState(false) const [enableContentSearch, setEnableContentSearch] = useState(false)
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched) const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
const [isCaseSensitive, setIsCaseSensitive] = useState(false) const [isCaseSensitive, setIsCaseSensitive] = useState(false)
const [isWholeWord, setIsWholeWord] = useState(false) const [isWholeWord, setIsWholeWord] = useState(false)
const [shouldScroll, setShouldScroll] = useState(false) const [allRanges, setAllRanges] = useState<Range[]>([])
const highlightTextSet = useState(new Set<Node>())[0] const [currentIndex, setCurrentIndex] = useState(0)
const prevSearchText = useRef('') const prevSearchText = useRef('')
const { t } = useTranslation() const { t } = useTranslation()
const locateByIndex = (index: number, shouldScroll = true) => { const resetSearch = useCallback(() => {
if (target) { CSS.highlights.clear()
const highlightTextNodes = [...highlightTextSet] as HTMLElement[] setAllRanges([])
highlightTextNodes.sort((a, b) => { setSearchCompleted(SearchCompletedState.NotSearched)
const { top: aTop } = a.getBoundingClientRect() }, [])
const { top: bTop } = b.getBoundingClientRect()
return aTop - bTop const locateByIndex = useCallback(
}) (shouldScroll = true) => {
for (const node of highlightTextNodes) { // 清理旧的高亮
node.classList.remove(HIGHLIGHT_SELECT_CLASS) CSS.highlights.clear()
}
setSearchResultIndex(index) if (allRanges.length > 0) {
if (highlightTextNodes.length > 0) { // 1. 创建并注册所有匹配项的高亮
const highlightTextNode = highlightTextNodes[index] ?? null const allMatchesHighlight = new Highlight(...allRanges)
if (highlightTextNode) { CSS.highlights.set('search-matches', allMatchesHighlight)
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
// 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) { if (shouldScroll) {
highlightTextNode.scrollIntoView({ parentElement?.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'center' block: 'center',
// inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码 inline: 'nearest'
}) })
} }
} }
} }
} },
} [allRanges, currentIndex]
)
const restoreHighlight = () => { const search = useCallback(() => {
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 searchText = searchInputRef.current?.value.trim() ?? null const searchText = searchInputRef.current?.value.trim() ?? null
setSearchCompleted(SearchCompletedState.Searched)
if (target && searchText !== null && searchText !== '') { if (target && searchText !== null && searchText !== '') {
restoreHighlight() const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT) setAllRanges(ranges)
let textNode: Node | null setCurrentIndex(0)
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
} }
} }, [target, filter, isCaseSensitive, isWholeWord])
const _searchHandlerDebounce = debounce(() => { const implementation = useMemo(
implementation.search() () => ({
}, 300) disable: () => {
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce]) setEnableContentSearch(false)
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => { CSS.highlights.clear()
const value = event.target.value.trim() },
if (value.length === 0) { enable: (initialText?: string) => {
restoreHighlight() setEnableContentSearch(true)
setTotalCount(0) if (searchInputRef.current) {
setSearchResultIndex(0) const inputEl = searchInputRef.current
setSearchCompleted(SearchCompletedState.NotSearched) if (initialText && initialText.trim().length > 0) {
} else { inputEl.value = initialText
// 用户输入时允许滚动 requestAnimationFrame(() => {
setShouldScroll(true) inputEl.focus()
searchHandler() inputEl.select()
} search()
prevSearchText.current = value CSS.highlights.clear()
}
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)
setSearchCompleted(SearchCompletedState.NotSearched) setSearchCompleted(SearchCompletedState.NotSearched)
} })
}) } else {
} else { requestAnimationFrame(() => {
requestAnimationFrame(() => { inputEl.focus()
inputEl.focus() inputEl.select()
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
}
} }
} }
} },
}, searchNext: () => {
searchNext() { if (allRanges.length > 0) {
if (enableContentSearch) { setCurrentIndex((prev) => (prev < allRanges.length - 1 ? prev + 1 : 0))
const targetIndex = search(SearchTargetIndex.Next)
if (targetIndex !== null) {
locateByIndex(targetIndex)
} }
} },
}, searchPrev: () => {
searchPrev() { if (allRanges.length > 0) {
if (enableContentSearch) { setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allRanges.length - 1))
const targetIndex = search(SearchTargetIndex.Prev)
if (targetIndex !== null) {
locateByIndex(targetIndex)
} }
} },
}, resetSearchState: () => {
resetSearchState() {
if (enableContentSearch) {
setSearchCompleted(SearchCompletedState.NotSearched) 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() { [searchHandler, resetSearch]
if (enableContentSearch) { )
const targetIndex = search()
if (targetIndex !== null) { const keyDownHandler = useCallback(
locateByIndex(targetIndex, shouldScroll) (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 { } else {
// If search returns null (e.g., empty input), clear state implementation.searchNext()
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
} }
} else if (event.key === 'Escape') {
implementation.disable()
} }
}, },
silentSearch() { [implementation, resetSearch]
if (enableContentSearch) { )
const targetIndex = search()
if (targetIndex !== null) {
// 只更新索引,不触发滚动
locateByIndex(targetIndex, false)
}
}
},
focus() {
searchInputFocus()
}
}
useImperativeHandle(ref, () => ({ const searchInputFocus = useCallback(() => {
disable() { requestAnimationFrame(() => searchInputRef.current?.focus())
implementation.disable() }, [])
},
enable(initialText?: string) { const userOutlinedButtonOnClick = useCallback(() => {
implementation.enable(initialText) onIncludeUserChange?.(!includeUser)
}, searchInputFocus()
searchNext() { }, [includeUser, onIncludeUserChange, searchInputFocus])
implementation.searchNext()
}, useImperativeHandle(ref, () => implementation, [implementation])
searchPrev() {
implementation.searchPrev() useEffect(() => {
}, locateByIndex()
search() { }, [currentIndex, locateByIndex])
implementation.search()
},
silentSearch() {
implementation.silentSearch()
},
focus() {
implementation.focus()
}
}))
// Re-run search when options change and search is active
useEffect(() => { useEffect(() => {
if (enableContentSearch && searchInputRef.current?.value.trim()) { if (enableContentSearch && searchInputRef.current?.value.trim()) {
implementation.search() search()
} }
}, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency }, [isCaseSensitive, isWholeWord, enableContentSearch, search])
const prevButtonOnClick = () => { const prevButtonOnClick = () => {
implementation.searchPrev() implementation.searchPrev()
@ -589,11 +368,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
<Separator></Separator> <Separator></Separator>
<SearchResults> <SearchResults>
{searchCompleted !== SearchCompletedState.NotSearched ? ( {searchCompleted !== SearchCompletedState.NotSearched ? (
totalCount > 0 ? ( allRanges.length > 0 ? (
<> <>
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount> <SearchResultCount>{currentIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator> <SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount> <SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
</> </>
) : ( ) : (
<NoResults>{t('common.no_results')}</NoResults> <NoResults>{t('common.no_results')}</NoResults>
@ -603,10 +382,10 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
)} )}
</SearchResults> </SearchResults>
<ToolBar> <ToolBar>
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}> <ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
<ChevronUp size={18} /> <ChevronUp size={18} />
</ToolbarButton> </ToolbarButton>
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}> <ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
<ChevronDown size={18} /> <ChevronDown size={18} />
</ToolbarButton> </ToolbarButton>
<ToolbarButton type="text" onClick={closeButtonOnClick}> <ToolbarButton type="text" onClick={closeButtonOnClick}>

View File

@ -1,87 +1,59 @@
import { Dropdown } from 'antd' import { Dropdown } from 'antd'
import { useCallback, useEffect, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ContextMenuProps { interface ContextMenuProps {
children: React.ReactNode 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 { t } = useTranslation()
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) const [selectedText, setSelectedText] = useState<string | undefined>(undefined)
const [selectedText, setSelectedText] = useState<string>('')
const handleContextMenu = useCallback( const contextMenuItems = useMemo(() => {
(e: React.MouseEvent) => { if (!selectedText) return []
e.preventDefault()
const _selectedText = window.getSelection()?.toString()
if (_selectedText) {
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setSelectedText(_selectedText)
}
onContextMenu?.(e)
},
[onContextMenu]
)
useEffect(() => { return [
const handleClick = () => { {
setContextMenuPosition(null) key: 'copy',
} label: t('common.copy'),
document.addEventListener('click', handleClick) onClick: () => {
return () => { if (selectedText) {
document.removeEventListener('click', handleClick) navigator.clipboard
} .writeText(selectedText)
}, []) .then(() => {
window.message.success({ content: t('message.copied'), key: 'copy-message' })
// 获取右键菜单项 })
const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [ .catch(() => {
{ window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
key: 'copy', })
label: t('common.copy'), }
onClick: () => { }
if (selectedText) { },
navigator.clipboard {
.writeText(selectedText) key: 'quote',
.then(() => { label: t('chat.message.quote'),
window.message.success({ content: t('message.copied'), key: 'copy-message' }) onClick: () => {
}) if (selectedText) {
.catch(() => { window.api?.quoteToMainWindow(selectedText)
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 ( return (
<ContextContainer onContextMenu={handleContextMenu} className="context-menu-container" style={style}> <Dropdown onOpenChange={onOpenChange} menu={{ items: contextMenuItems }} trigger={['contextMenu']}>
{contextMenuPosition && (
<Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedText) }}
open={true}
trigger={['contextMenu']}>
<div />
</Dropdown>
)}
{children} {children}
</ContextContainer> </Dropdown>
) )
} }
const ContextContainer = styled.div``
export default ContextMenu export default ContextMenu

View File

@ -1,5 +1,6 @@
import { Collapse } from 'antd' import { Collapse } from 'antd'
import { merge } from 'lodash' import { merge } from 'lodash'
import { ChevronRight } from 'lucide-react'
import { FC, memo, useMemo, useState } from 'react' import { FC, memo, useMemo, useState } from 'react'
interface CustomCollapseProps { interface CustomCollapseProps {
@ -78,6 +79,14 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
destroyInactivePanel={destroyInactivePanel} destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible} collapsible={collapsible}
onChange={setActiveKeys} onChange={setActiveKeys}
expandIcon={({ isActive }) => (
<ChevronRight
size={16}
color="var(--color-text-3)"
strokeWidth={1.5}
style={{ transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
)}
items={[ items={[
{ {
styles: collapseItemStyles, styles: collapseItemStyles,

View 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

View File

@ -10,7 +10,7 @@ import {
PushpinOutlined, PushpinOutlined,
ReloadOutlined ReloadOutlined
} from '@ant-design/icons' } 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 { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge' import { useBridge } from '@renderer/hooks/useBridge'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
@ -303,7 +303,7 @@ const MinappPopupContainer: React.FC = () => {
</Tooltip> </Tooltip>
)} )}
<Spacer /> <Spacer />
<ButtonsGroup className={isWindows || isLinux ? 'windows' : ''}> <ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom"> <Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleGoBack(appInfo.id)}> <Button onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined /> <ArrowLeftOutlined />
@ -452,7 +452,7 @@ const ButtonsGroup = styled.div`
gap: 5px; gap: 5px;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
&.windows { &.windows {
margin-right: ${isWindows ? '130px' : isLinux ? '100px' : 0}; margin-right: ${isWin ? '130px' : isLinux ? '100px' : 0};
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
border-radius: 50px; border-radius: 50px;
padding: 0 3px; padding: 0 3px;

View File

@ -1,3 +1,4 @@
import { useSettings } from '@renderer/hooks/useSettings'
import { WebviewTag } from 'electron' import { WebviewTag } from 'electron'
import { memo, useEffect, useRef } from 'react' import { memo, useEffect, useRef } from 'react'
@ -21,6 +22,7 @@ const WebviewContainer = memo(
onNavigateCallback: (appid: string, url: string) => void onNavigateCallback: (appid: string, url: string) => void
}) => { }) => {
const webviewRef = useRef<WebviewTag | null>(null) const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck } = useSettings()
const setRef = (appid: string) => { const setRef = (appid: string) => {
onSetRefCallback(appid, null) onSetRefCallback(appid, null)
@ -46,6 +48,14 @@ const WebviewContainer = memo(
onNavigateCallback(appid, event.url) 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-finish-load', handleLoaded)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate) webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
@ -55,6 +65,7 @@ const WebviewContainer = memo(
return () => { return () => {
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded) webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate) 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 // because the appid and url are enough, no need to add onLoadedCallback
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -35,17 +35,38 @@ const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
<SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount> <SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount>
<ActionButtons> <ActionButtons>
<Tooltip title={t('common.save')}> <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>
<Tooltip title={t('common.copy')}> <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>
<Tooltip title={t('common.delete')}> <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> </Tooltip>
</ActionButtons> </ActionButtons>
<Tooltip title={t('chat.navigation.close')}> <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> </Tooltip>
</ActionBar> </ActionBar>
</Container> </Container>
@ -53,45 +74,38 @@ const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
} }
const Container = styled.div` const Container = styled.div`
width: 100%; position: fixed;
padding: 36px 20px; inset: auto 0 0 0;
background-color: var(--color-background); z-index: 1000;
border-top: 1px solid var(--color-border); display: flex;
justify-content: center;
align-items: center;
padding: 16px;
` `
const ActionBar = styled.div` const ActionBar = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; 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` const ActionButtons = styled.div`
display: flex;
gap: 16px;
`
const ActionButton = styled(Button)`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 8px;
padding: 8px 16px;
border-radius: 50%;
.anticon {
font-size: 16px;
}
&:hover {
background-color: var(--color-background-mute);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
` `
const SelectionCount = styled.div` const SelectionCount = styled.div`
margin-right: 15px;
color: var(--color-text-2); color: var(--color-text-2);
font-size: 14px; font-size: 14px;
padding-left: 8px;
flex-shrink: 0;
` `
export default MultiSelectActionPopup export default MultiSelectActionPopup

View File

@ -32,16 +32,23 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onCancel={onCancel} onCancel={onCancel}
afterClose={onClose} afterClose={onClose}
title={null} title={null}
width="920px" width={700}
transitionName="animation-move-down" transitionName="animation-move-down"
styles={{ styles={{
content: { content: {
borderRadius: 20,
padding: 0, padding: 0,
border: `1px solid var(--color-frame-border)` overflow: 'hidden',
paddingBottom: 16
}, },
body: { height: '85vh' } body: {
height: '80vh',
maxHeight: 'inherit',
padding: 0
}
}} }}
centered centered
closable={false}
footer={null}> footer={null}>
<HistoryPage /> <HistoryPage />
</Modal> </Modal>

View File

@ -388,8 +388,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
borderRadius: 20, borderRadius: 20,
padding: 0, padding: 0,
overflow: 'hidden', overflow: 'hidden',
paddingBottom: 20, paddingBottom: 16
border: '1px solid var(--color-border)' },
body: {
maxHeight: 'inherit',
padding: 0
} }
}} }}
closeIcon={null} closeIcon={null}

View 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>
)
}

View 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
}
}

View File

@ -48,17 +48,17 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
}, [throttledInternalScrollHandler, clearScrollingTimeout]) }, [throttledInternalScrollHandler, clearScrollingTimeout])
return ( return (
<Container <ScrollBarContainer
{...htmlProps} // Pass other HTML attributes {...htmlProps} // Pass other HTML attributes
$isScrolling={isScrolling} $isScrolling={isScrolling}
onScroll={combinedOnScroll} // Use the combined handler onScroll={combinedOnScroll} // Use the combined handler
ref={passedRef}> ref={passedRef}>
{children} {children}
</Container> </ScrollBarContainer>
) )
} }
const Container = styled.div<{ $isScrolling: boolean }>` const ScrollBarContainer = styled.div<{ $isScrolling: boolean }>`
overflow-y: auto; overflow-y: auto;
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
transition: background 2s ease; transition: background 2s ease;

View 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

View File

@ -1,6 +1,5 @@
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
@ -18,7 +17,6 @@ const spinnerVariants = {
} }
export default function Spinner({ text }: Props) { export default function Spinner({ text }: Props) {
const { t } = useTranslation()
return ( return (
<Searching <Searching
variants={spinnerVariants} variants={spinnerVariants}
@ -31,7 +29,7 @@ export default function Spinner({ text }: Props) {
ease: 'easeInOut' ease: 'easeInOut'
}}> }}>
<Search size={16} style={{ color: 'unset' }} /> <Search size={16} style={{ color: 'unset' }} />
<span>{t(text)}</span> <span>{text}</span>
</Searching> </Searching>
) )
} }

View File

@ -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 { useFullscreen } from '@renderer/hooks/useFullscreen'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import type { FC, PropsWithChildren } from 'react' import type { FC, PropsWithChildren } from 'react'
@ -78,7 +78,7 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px; 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; justify-content: flex-end;
` `
@ -91,5 +91,5 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
padding: 0 ${isMac ? '20px' : 0}; padding: 0 ${isMac ? '20px' : 0};
font-weight: bold; font-weight: bold;
color: var(--color-text-1); 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')};
` `

View File

@ -4,10 +4,11 @@ export const DEFAULT_MAX_TOKENS = 4096
export const SYSTEM_PROMPT_THRESHOLD = 128 export const SYSTEM_PROMPT_THRESHOLD = 128
export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6 export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6
export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0 export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0
export const DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT = 1
export const platform = window.electron?.process?.platform export const platform = window.electron?.process?.platform
export const isMac = platform === 'darwin' 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 isLinux = platform === 'linux'
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu' export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'

View File

@ -145,7 +145,7 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
import NomicLogo from '@renderer/assets/images/providers/nomic.png' import NomicLogo from '@renderer/assets/images/providers/nomic.png'
import { getProviderByModel } from '@renderer/services/AssistantService' import { getProviderByModel } from '@renderer/services/AssistantService'
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { getBaseModelName } from '@renderer/utils' import { getLowerBaseModelName } from '@renderer/utils'
import OpenAI from 'openai' import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts' import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts'
@ -184,7 +184,7 @@ const visionAllowedModels = [
'deepseek-vl(?:[\\w-]+)?', 'deepseek-vl(?:[\\w-]+)?',
'kimi-latest', 'kimi-latest',
'gemma-3(?:-[\\w-]+)', 'gemma-3(?:-[\\w-]+)',
'doubao-seed-1[.-]6(?:-[\\w-]+)' 'doubao-seed-1[.-]6(?:-[\\w-]+)?'
] ]
const visionExcludedModels = [ 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) 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)) { if (['deepseek', 'anthropic'].includes(model.provider)) {
return true return true
} }
@ -1352,12 +1356,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'DeepSeek-V3', name: 'DeepSeek-V3',
group: 'DeepSeek' group: 'DeepSeek'
}, },
{
id: 'deepseek-v3-250324',
provider: 'doubao',
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{ {
id: 'doubao-pro-32k-241215', id: 'doubao-pro-32k-241215',
provider: 'doubao', provider: 'doubao',
@ -2475,6 +2473,10 @@ export function isGeminiReasoningModel(model?: Model): boolean {
return false return false
} }
if (model.id.startsWith('gemini') && model.id.includes('thinking')) {
return true
}
if (model.id.includes('gemini-2.5')) { if (model.id.includes('gemini-2.5')) {
return true return true
} }
@ -2505,14 +2507,16 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
return false return false
} }
const baseName = getBaseModelName(model.id, '/').toLowerCase() const baseName = getLowerBaseModelName(model.id, '/')
return ( return (
baseName.startsWith('qwen3') || baseName.startsWith('qwen3') ||
[ [
'qwen-plus',
'qwen-plus-latest', 'qwen-plus-latest',
'qwen-plus-0428', 'qwen-plus-0428',
'qwen-plus-2025-04-28', 'qwen-plus-2025-04-28',
'qwen-turbo',
'qwen-turbo-latest', 'qwen-turbo-latest',
'qwen-turbo-0428', 'qwen-turbo-0428',
'qwen-turbo-2025-04-28' 'qwen-turbo-2025-04-28'
@ -2525,7 +2529,7 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean {
return false 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 { export function isClaudeReasoningModel(model?: Model): boolean {
@ -2547,6 +2551,10 @@ export function isReasoningModel(model?: Model): boolean {
return false return false
} }
if (isEmbeddingModel(model)) {
return false
}
if (model.provider === 'doubao') { if (model.provider === 'doubao') {
return ( return (
REASONING_REGEX.test(model.name) || REASONING_REGEX.test(model.name) ||
@ -2614,7 +2622,7 @@ export function isWebSearchModel(model: Model): boolean {
return false return false
} }
const baseName = getBaseModelName(model.id, '/').toLowerCase() const baseName = getLowerBaseModelName(model.id, '/')
// 不管哪个供应商都判断了 // 不管哪个供应商都判断了
if (model.id.includes('claude')) { if (model.id.includes('claude')) {
@ -2708,7 +2716,7 @@ export function isGenerateImageModel(model: Model): boolean {
return false return false
} }
const baseName = getBaseModelName(model.id, '/').toLowerCase() const baseName = getLowerBaseModelName(model.id, '/')
if (GENERATE_IMAGE_MODELS.includes(baseName)) { if (GENERATE_IMAGE_MODELS.includes(baseName)) {
return true return true
} }
@ -2720,7 +2728,7 @@ export function isSupportedDisableGenerationModel(model: Model): boolean {
return false 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> { export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record<string, any> {
@ -2853,13 +2861,14 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } |
// Doubao 支持思考模式的模型正则 // Doubao 支持思考模式的模型正则
export const DOUBAO_THINKING_MODEL_REGEX = 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 // 支持 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 { 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.*$') export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')

View File

@ -38,7 +38,20 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
boxShadowSecondary: 'none', boxShadowSecondary: 'none',
defaultShadow: 'none', defaultShadow: 'none',
dangerShadow: '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: { Collapse: {
headerBg: 'transparent' headerBg: 'transparent'
@ -50,13 +63,47 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
fontFamily: 'var(--code-font-family)' fontFamily: 'var(--code-font-family)'
}, },
Segmented: { Segmented: {
itemActiveBg: 'var(--color-background-mute)', itemActiveBg: 'var(--color-background-soft)',
itemHoverBg: 'var(--color-background-mute)' 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: { token: {
colorPrimary: colorPrimary, 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} {children}

View File

@ -10,6 +10,7 @@ import { createContext, type PropsWithChildren, use, useCallback, useEffect, use
interface CodeStyleContextType { interface CodeStyleContextType {
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult> highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
highlightStreamingCode: (code: string, language: string, callerId: string) => Promise<HighlightChunkResult>
cleanupTokenizers: (callerId: string) => void cleanupTokenizers: (callerId: string) => void
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties> getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
highlightCode: (code: string, language: string) => Promise<string> highlightCode: (code: string, language: string) => Promise<string>
@ -22,6 +23,7 @@ interface CodeStyleContextType {
const defaultCodeStyleContext: CodeStyleContextType = { const defaultCodeStyleContext: CodeStyleContextType = {
highlightCodeChunk: async () => ({ lines: [], recall: 0 }), highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
highlightStreamingCode: async () => ({ lines: [], recall: 0 }),
cleanupTokenizers: () => {}, cleanupTokenizers: () => {},
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }), getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
highlightCode: async () => '', highlightCode: async () => '',
@ -114,6 +116,15 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
shikiStreamService.cleanupTokenizers(callerId) 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 标签属性 // 获取 Shiki pre 标签属性
const getShikiPreProperties = useCallback( const getShikiPreProperties = useCallback(
async (language: string) => { async (language: string) => {
@ -148,6 +159,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
highlightCodeChunk, highlightCodeChunk,
highlightStreamingCode,
cleanupTokenizers, cleanupTokenizers,
getShikiPreProperties, getShikiPreProperties,
highlightCode, highlightCode,
@ -159,6 +171,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
}), }),
[ [
highlightCodeChunk, highlightCodeChunk,
highlightStreamingCode,
cleanupTokenizers, cleanupTokenizers,
getShikiPreProperties, getShikiPreProperties,
highlightCode, highlightCode,

View File

@ -30,6 +30,14 @@ export function useAppInit() {
console.timeEnd('init') console.timeEnd('init')
}, []) }, [])
useEffect(() => {
window.api.getDataPathFromArgs().then((dataPath) => {
if (dataPath) {
window.navigate('/settings/data', { replace: true })
}
})
}, [])
useUpdateHandler() useUpdateHandler()
useFullScreenNotice() useFullScreenNotice()

View File

@ -1,4 +1,4 @@
import { isWindows } from '@renderer/config/constant' import { isWin } from '@renderer/config/constant'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -8,7 +8,7 @@ export function useFullScreenNotice() {
useEffect(() => { useEffect(() => {
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, isFullscreen) => { const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, isFullscreen) => {
if (isWindows && isFullscreen) { if (isWin && isFullscreen) {
window.message.info({ window.message.info({
content: t('common.fullscreen'), content: t('common.fullscreen'),
duration: 3, duration: 3,

View File

@ -169,7 +169,8 @@ export const useKnowledge = (baseId: string) => {
processingStatus: 'pending', processingStatus: 'pending',
processingProgress: 0, processingProgress: 0,
processingError: '', processingError: '',
uniqueId: undefined uniqueId: undefined,
updated_at: Date.now()
}) })
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }

View File

@ -4,7 +4,6 @@ import {
SendMessageShortcut, SendMessageShortcut,
setAssistantIconType, setAssistantIconType,
setAutoCheckUpdate as _setAutoCheckUpdate, setAutoCheckUpdate as _setAutoCheckUpdate,
setEarlyAccess as _setEarlyAccess,
setLaunchOnBoot, setLaunchOnBoot,
setLaunchToTray, setLaunchToTray,
setPinTopicsToTop, setPinTopicsToTop,
@ -12,6 +11,8 @@ import {
setShowTokens, setShowTokens,
setSidebarIcons, setSidebarIcons,
setTargetLanguage, setTargetLanguage,
setTestChannel as _setTestChannel,
setTestPlan as _setTestPlan,
setTheme, setTheme,
SettingsState, SettingsState,
setTopicPosition, setTopicPosition,
@ -20,7 +21,7 @@ import {
setWindowStyle setWindowStyle
} from '@renderer/store/settings' } from '@renderer/store/settings'
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types' import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
import { FeedUrl } from '@shared/config/constant' import { UpgradeChannel } from '@shared/config/constant'
export function useSettings() { export function useSettings() {
const settings = useAppSelector((state) => state.settings) const settings = useAppSelector((state) => state.settings)
@ -60,9 +61,14 @@ export function useSettings() {
window.api.setAutoUpdate(isAutoUpdate) window.api.setAutoUpdate(isAutoUpdate)
}, },
setEarlyAccess(isEarlyAccess: boolean) { setTestPlan(isTestPlan: boolean) {
dispatch(_setEarlyAccess(isEarlyAccess)) dispatch(_setTestPlan(isTestPlan))
window.api.setFeedUrl(isEarlyAccess ? FeedUrl.EARLY_ACCESS : FeedUrl.PRODUCTION) window.api.setTestPlan(isTestPlan)
},
setTestChannel(channel: UpgradeChannel) {
dispatch(_setTestChannel(channel))
window.api.setTestChannel(channel)
}, },
setTheme(theme: ThemeMode) { setTheme(theme: ThemeMode) {

View File

@ -1,4 +1,4 @@
import { isMac, isWindows } from '@renderer/config/constant' import { isMac, isWin } from '@renderer/config/constant'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { orderBy } from 'lodash' import { orderBy } from 'lodash'
import { useCallback } from 'react' import { useCallback } from 'react'
@ -72,7 +72,7 @@ export function useShortcutDisplay(key: string) {
case 'ctrl': case 'ctrl':
return isMac ? '⌃' : 'Ctrl' return isMac ? '⌃' : 'Ctrl'
case 'command': case 'command':
return isMac ? '⌘' : isWindows ? 'Win' : 'Super' return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'alt': case 'alt':
return isMac ? '⌥' : 'Alt' return isMac ? '⌥' : 'Alt'
case 'shift': case 'shift':

View File

@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit' import { createSelector } from '@reduxjs/toolkit'
import { RootState, useAppDispatch, useAppSelector } from '@renderer/store' 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 { flatMap, groupBy, uniq } from 'lodash'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -12,6 +12,8 @@ const selectAssistantsState = (state: RootState) => state.assistants
// 记忆化 tagsOrder 选择器(自动处理默认值)--- 这是一个选择器,用于从 store 中获取 tagsOrder 的值。因为之前的tagsOrder是后面新加的不这样做会报错所以这里需要处理一下默认值 // 记忆化 tagsOrder 选择器(自动处理默认值)--- 这是一个选择器,用于从 store 中获取 tagsOrder 的值。因为之前的tagsOrder是后面新加的不这样做会报错所以这里需要处理一下默认值
const selectTagsOrder = createSelector([selectAssistantsState], (assistants) => assistants.tagsOrder ?? []) const selectTagsOrder = createSelector([selectAssistantsState], (assistants) => assistants.tagsOrder ?? [])
const selectCollapsedTags = createSelector([selectAssistantsState], (assistants) => assistants.collapsedTags ?? {})
// 定义useTags的返回类型包含所有标签和获取特定标签的助手函数 // 定义useTags的返回类型包含所有标签和获取特定标签的助手函数
// 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性 // 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性
// 但是为了方便管理,增加了一个获取特定标签的助手函数 // 但是为了方便管理,增加了一个获取特定标签的助手函数
@ -20,6 +22,7 @@ export const useTags = () => {
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const savedTagsOrder = useAppSelector(selectTagsOrder) const savedTagsOrder = useAppSelector(selectTagsOrder)
const collapsedTags = useAppSelector(selectCollapsedTags)
// 计算所有标签 // 计算所有标签
const allTags = useMemo(() => { const allTags = useMemo(() => {
@ -38,28 +41,6 @@ export const useTags = () => {
[assistants] [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 getGroupedAssistants = useMemo(() => {
// 按标签分组,处理多标签的情况 // 按标签分组,处理多标签的情况
const assistantsByTags = flatMap(assistants, (assistant) => { const assistantsByTags = flatMap(assistants, (assistant) => {
@ -100,10 +81,26 @@ export const useTags = () => {
return grouped return grouped
}, [assistants, t, savedTagsOrder]) }, [assistants, t, savedTagsOrder])
const updateTagsOrder = useCallback(
(newOrder: string[]) => {
dispatch(setTagsOrder(newOrder))
},
[dispatch]
)
const toggleTagCollapse = useCallback(
(tag: string) => {
dispatch(updateTagCollapse(tag))
},
[dispatch]
)
return { return {
allTags, allTags,
getAssistantsByTag, getAssistantsByTag,
getGroupedAssistants, getGroupedAssistants,
updateTagsOrder updateTagsOrder,
collapsedTags,
toggleTagCollapse
} }
} }

View File

@ -1,9 +1,12 @@
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { import {
addSubscribeSource as _addSubscribeSource, addSubscribeSource as _addSubscribeSource,
type CompressionConfig,
removeSubscribeSource as _removeSubscribeSource, removeSubscribeSource as _removeSubscribeSource,
setCompressionConfig,
setDefaultProvider as _setDefaultProvider, setDefaultProvider as _setDefaultProvider,
setSubscribeSources as _setSubscribeSources, setSubscribeSources as _setSubscribeSources,
updateCompressionConfig,
updateSubscribeBlacklist as _updateSubscribeBlacklist, updateSubscribeBlacklist as _updateSubscribeBlacklist,
updateWebSearchProvider, updateWebSearchProvider,
updateWebSearchProviders updateWebSearchProviders
@ -90,3 +93,14 @@ export const useBlacklist = () => {
setSubscribeSources 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))
}
}

View File

@ -412,6 +412,7 @@
"search": "Search", "search": "Search",
"select": "Select", "select": "Select",
"selectedMessages": "Selected {{count}} messages", "selectedMessages": "Selected {{count}} messages",
"selectedItems": "Selected {{count}} items",
"success": "Success", "success": "Success",
"topics": "Topics", "topics": "Topics",
"warning": "Warning", "warning": "Warning",
@ -702,6 +703,13 @@
"success.siyuan.export": "Successfully exported to Siyuan Note", "success.siyuan.export": "Successfully exported to Siyuan Note",
"warn.yuque.exporting": "Exporting to Yuque, please do not request export repeatedly!", "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!", "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.success": "Download successfully",
"download.failed": "Download failed" "download.failed": "Download failed"
}, },
@ -775,6 +783,7 @@
"dimensions": "Dimensions {{dimensions}}", "dimensions": "Dimensions {{dimensions}}",
"edit": "Edit Model", "edit": "Edit Model",
"embedding": "Embedding", "embedding": "Embedding",
"embedding_dimensions": "Embedding Dimensions",
"embedding_model": "Embedding Model", "embedding_model": "Embedding Model",
"embedding_model_tooltip": "Add in Settings->Model Provider->Manage", "embedding_model_tooltip": "Add in Settings->Model Provider->Manage",
"function_calling": "Function Calling", "function_calling": "Function Calling",
@ -864,7 +873,7 @@
"paint_course": "tutorial", "paint_course": "tutorial",
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap", "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", "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_required": "Please upload an image first",
"image_file_retry": "Please re-upload an image first", "image_file_retry": "Please re-upload an image first",
"image_placeholder": "No image available", "image_placeholder": "No image available",
@ -1087,11 +1096,11 @@
"app_data": "App Data", "app_data": "App Data",
"app_data.select": "Modify Directory", "app_data.select": "Modify Directory",
"app_data.select_title": "Change App Data 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.restart_notice": "The app may need to restart multiple times to apply the changes",
"app_data.copy_data_option": "Copy data from original directory to new directory", "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.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.path_changed_without_copy": "Path changed successfully",
"app_data.copying_warning": "Data copying, do not force quit app", "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.copying": "Copying data to new location...",
"app_data.copy_success": "Successfully copied data to new location", "app_data.copy_success": "Successfully copied data to new location",
"app_data.copy_failed": "Failed to copy data", "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_root_path": "New path cannot be the root path",
"app_data.select_error_write_permission": "New path does not have write permission", "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.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": "Knowledge Base Files",
"app_knowledge.button.delete": "Delete File", "app_knowledge.button.delete": "Delete File",
"app_knowledge.remove_all": "Remove Knowledge Base Files", "app_knowledge.remove_all": "Remove Knowledge Base Files",
@ -1235,6 +1248,70 @@
"maxBackups": "Maximum Backups", "maxBackups": "Maximum Backups",
"maxBackups.unlimited": "Unlimited" "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": { "yuque": {
"check": { "check": {
"button": "Check", "button": "Check",
@ -1379,8 +1456,14 @@
"general.emoji_picker": "Emoji Picker", "general.emoji_picker": "Emoji Picker",
"general.image_upload": "Image Upload", "general.image_upload": "Image Upload",
"general.auto_check_update.title": "Auto Update", "general.auto_check_update.title": "Auto Update",
"general.early_access.title": "Early Access", "general.test_plan.title": "Test Plan",
"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.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.button": "Reset",
"general.reset.title": "Data Reset", "general.reset.title": "Data Reset",
"general.restore.button": "Restore", "general.restore.button": "Restore",
@ -1388,6 +1471,8 @@
"general.user_name": "User Name", "general.user_name": "User Name",
"general.user_name.placeholder": "Enter your name", "general.user_name.placeholder": "Enter your name",
"general.view_webdav_settings": "View WebDAV settings", "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.auto_translate_with_space": "Quickly translate with 3 spaces",
"input.show_translate_confirm": "Show translation confirmation dialog", "input.show_translate_confirm": "Show translation confirmation dialog",
"input.target_language": "Target language", "input.target_language": "Target language",
@ -1464,7 +1549,8 @@
"version": "Version" "version": "Version"
}, },
"errors": { "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", "serverPlural": "servers",
"serverSingular": "server", "serverSingular": "server",
@ -1512,6 +1598,7 @@
"registry": "Package Registry", "registry": "Package Registry",
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.", "registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
"registryDefault": "Default", "registryDefault": "Default",
"customRegistryPlaceholder": "Enter private registry URL, e.g.: https://npm.company.com",
"not_support": "Model not supported", "not_support": "Model not supported",
"user": "User", "user": "User",
"system": "System", "system": "System",
@ -1829,8 +1916,33 @@
"overwrite_tooltip": "Force use search service instead of LLM", "overwrite_tooltip": "Force use search service instead of LLM",
"apikey": "API key", "apikey": "API key",
"free": "Free", "free": "Free",
"content_limit": "Content length limit", "compression": {
"content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated." "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": { "quickPhrase": {
"title": "Quick Phrases", "title": "Quick Phrases",

View File

@ -412,6 +412,7 @@
"search": "検索", "search": "検索",
"select": "選択", "select": "選択",
"selectedMessages": "{{count}}件のメッセージを選択しました", "selectedMessages": "{{count}}件のメッセージを選択しました",
"selectedItems": "{{count}}件の項目を選択しました",
"success": "成功", "success": "成功",
"topics": "トピック", "topics": "トピック",
"warning": "警告", "warning": "警告",
@ -701,6 +702,13 @@
"warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!", "warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!",
"warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!", "warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!",
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません", "error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません",
"websearch": {
"rag": "RAGを実行中...",
"rag_complete": "{{countBefore}}個の結果から{{countAfter}}個を保持...",
"rag_failed": "RAGが失敗しました。空の結果を返します...",
"cutoff": "検索内容を切り詰めています...",
"fetch_complete": "{{count}}回の検索を完了しました..."
},
"download.success": "ダウンロードに成功しました", "download.success": "ダウンロードに成功しました",
"download.failed": "ダウンロードに失敗しました", "download.failed": "ダウンロードに失敗しました",
"error.fetchTopicName": "トピック名の取得に失敗しました" "error.fetchTopicName": "トピック名の取得に失敗しました"
@ -775,6 +783,7 @@
"dimensions": "{{dimensions}} 次元", "dimensions": "{{dimensions}} 次元",
"edit": "モデルを編集", "edit": "モデルを編集",
"embedding": "埋め込み", "embedding": "埋め込み",
"embedding_dimensions": "埋め込み次元",
"embedding_model": "埋め込み模型", "embedding_model": "埋め込み模型",
"embedding_model_tooltip": "設定->モデルサービス->管理で追加", "embedding_model_tooltip": "設定->モデルサービス->管理で追加",
"function_calling": "関数呼び出し", "function_calling": "関数呼び出し",
@ -1085,11 +1094,11 @@
"app_data": "アプリデータ", "app_data": "アプリデータ",
"app_data.select": "ディレクトリを変更", "app_data.select": "ディレクトリを変更",
"app_data.select_title": "アプリデータディレクトリの変更", "app_data.select_title": "アプリデータディレクトリの変更",
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります", "app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります",
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます", "app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます",
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください", "app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください",
"app_data.path_changed_without_copy": "パスが変更されましたが、データがコピーされていません", "app_data.path_changed_without_copy": "パスが変更されました",
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください", "app_data.copying_warning": "データコピー中、アプリを強制終了しないでください。コピーが完了すると、アプリが自動的に再起動します。",
"app_data.copying": "新しい場所にデータをコピーしています...", "app_data.copying": "新しい場所にデータをコピーしています...",
"app_data.copy_success": "データを新しい場所に正常にコピーしました", "app_data.copy_success": "データを新しい場所に正常にコピーしました",
"app_data.copy_failed": "データのコピーに失敗しました", "app_data.copy_failed": "データのコピーに失敗しました",
@ -1101,6 +1110,10 @@
"app_data.select_error_root_path": "新しいパスはルートパスにできません", "app_data.select_error_root_path": "新しいパスはルートパスにできません",
"app_data.select_error_write_permission": "新しいパスに書き込み権限がありません", "app_data.select_error_write_permission": "新しいパスに書き込み権限がありません",
"app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません", "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": "知識ベースファイル",
"app_knowledge.button.delete": "ファイルを削除", "app_knowledge.button.delete": "ファイルを削除",
"app_knowledge.remove_all": "ナレッジベースファイルを削除", "app_knowledge.remove_all": "ナレッジベースファイルを削除",
@ -1215,6 +1228,70 @@
"maxBackups": "最大バックアップ数", "maxBackups": "最大バックアップ数",
"maxBackups.unlimited": "無制限" "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": { "yuque": {
"check": { "check": {
"button": "接続確認", "button": "接続確認",
@ -1383,6 +1460,8 @@
"general.user_name": "ユーザー名", "general.user_name": "ユーザー名",
"general.user_name.placeholder": "ユーザー名を入力", "general.user_name.placeholder": "ユーザー名を入力",
"general.view_webdav_settings": "WebDAV設定を表示", "general.view_webdav_settings": "WebDAV設定を表示",
"general.spell_check": "スペルチェック",
"general.spell_check.languages": "スペルチェック言語",
"input.auto_translate_with_space": "スペースを3回押して翻訳", "input.auto_translate_with_space": "スペースを3回押して翻訳",
"input.target_language": "目標言語", "input.target_language": "目標言語",
"input.target_language.chinese": "簡体字中国語", "input.target_language.chinese": "簡体字中国語",
@ -1466,7 +1545,8 @@
"updateSuccess": "サーバーが正常に更新されました", "updateSuccess": "サーバーが正常に更新されました",
"url": "URL", "url": "URL",
"errors": { "errors": {
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください" "32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください",
"toolNotFound": "ツール {{name}} が見つかりません"
}, },
"editMcpJson": "MCP 設定を編集", "editMcpJson": "MCP 設定を編集",
"installHelp": "インストールヘルプを取得", "installHelp": "インストールヘルプを取得",
@ -1506,6 +1586,7 @@
"registry": "パッケージ管理レジストリ", "registry": "パッケージ管理レジストリ",
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。", "registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
"registryDefault": "デフォルト", "registryDefault": "デフォルト",
"customRegistryPlaceholder": "プライベート倉庫のアドレスを入力してくださいhttps://npm.company.com",
"not_support": "モデルはサポートされていません", "not_support": "モデルはサポートされていません",
"user": "ユーザー", "user": "ユーザー",
"system": "システム", "system": "システム",
@ -1817,12 +1898,43 @@
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する", "overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
"apikey": "API キー", "apikey": "API キー",
"free": "無料", "free": "無料",
"content_limit": "内容の長さ制限", "compression": {
"content_limit_tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます。" "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.auto_check_update.title": "自動更新",
"general.early_access.title": "早期アクセス", "general.test_plan.title": "テストプラン",
"general.early_access.tooltip": "有効にすると、GitHub の最新バージョンを使用します。ダウンロード速度が遅く、不安定な場合があります。データを事前にバックアップしてください。", "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": { "quickPhrase": {
"title": "クイックフレーズ", "title": "クイックフレーズ",
"add": "フレーズを追加", "add": "フレーズを追加",

View File

@ -412,6 +412,7 @@
"search": "Поиск", "search": "Поиск",
"select": "Выбрать", "select": "Выбрать",
"selectedMessages": "Выбрано {{count}} сообщений", "selectedMessages": "Выбрано {{count}} сообщений",
"selectedItems": "Выбрано {{count}} элементов",
"success": "Успешно", "success": "Успешно",
"topics": "Топики", "topics": "Топики",
"warning": "Предупреждение", "warning": "Предупреждение",
@ -701,6 +702,13 @@
"success.siyuan.export": "Успешный экспорт в Siyuan", "success.siyuan.export": "Успешный экспорт в Siyuan",
"warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!", "warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!",
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!", "warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!",
"websearch": {
"rag": "Выполнение RAG...",
"rag_complete": "Сохранено {{countAfter}} из {{countBefore}} результатов...",
"rag_failed": "RAG не удалось, возвращается пустой результат...",
"cutoff": "Обрезка содержимого поиска...",
"fetch_complete": "Завершено {{count}} поисков..."
},
"download.success": "Скачано успешно", "download.success": "Скачано успешно",
"download.failed": "Скачивание не удалось", "download.failed": "Скачивание не удалось",
"error.fetchTopicName": "Не удалось назвать топик" "error.fetchTopicName": "Не удалось назвать топик"
@ -775,6 +783,7 @@
"dimensions": "{{dimensions}} мер", "dimensions": "{{dimensions}} мер",
"edit": "Редактировать модель", "edit": "Редактировать модель",
"embedding": "Встраиваемые", "embedding": "Встраиваемые",
"embedding_dimensions": "Встраиваемые размерности",
"embedding_model": "Встраиваемые модели", "embedding_model": "Встраиваемые модели",
"embedding_model_tooltip": "Добавьте в настройки->модель сервиса->управление", "embedding_model_tooltip": "Добавьте в настройки->модель сервиса->управление",
"function_calling": "Вызов функции", "function_calling": "Вызов функции",
@ -1085,11 +1094,11 @@
"app_data": "Данные приложения", "app_data": "Данные приложения",
"app_data.select": "Изменить директорию", "app_data.select": "Изменить директорию",
"app_data.select_title": "Изменить директорию данных приложения", "app_data.select_title": "Изменить директорию данных приложения",
"app_data.restart_notice": "Для применения изменений потребуется перезапуск приложения", "app_data.restart_notice": "Для применения изменений может потребоваться несколько перезапусков приложения",
"app_data.copy_data_option": "Копировать данные из исходной директории в новую директорию", "app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию",
"app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы", "app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы",
"app_data.path_changed_without_copy": "Путь изменен успешно, но данные не скопированы", "app_data.path_changed_without_copy": "Путь изменен успешно",
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение", "app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение, приложение будет перезапущено после копирования",
"app_data.copying": "Копирование данных в новое место...", "app_data.copying": "Копирование данных в новое место...",
"app_data.copy_success": "Данные успешно скопированы в новое место", "app_data.copy_success": "Данные успешно скопированы в новое место",
"app_data.copy_failed": "Не удалось скопировать данные", "app_data.copy_failed": "Не удалось скопировать данные",
@ -1101,6 +1110,10 @@
"app_data.select_error_root_path": "Новый путь не может быть корневым", "app_data.select_error_root_path": "Новый путь не может быть корневым",
"app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись", "app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись",
"app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто", "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": "Файлы базы знаний",
"app_knowledge.button.delete": "Удалить файл", "app_knowledge.button.delete": "Удалить файл",
"app_knowledge.remove_all": "Удалить файлы базы знаний", "app_knowledge.remove_all": "Удалить файлы базы знаний",
@ -1233,6 +1246,70 @@
"maxBackups": "Максимальное количество резервных копий", "maxBackups": "Максимальное количество резервных копий",
"maxBackups.unlimited": "Без ограничений" "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": { "yuque": {
"check": { "check": {
"button": "Проверить", "button": "Проверить",
@ -1383,6 +1460,8 @@
"general.user_name": "Имя пользователя", "general.user_name": "Имя пользователя",
"general.user_name.placeholder": "Введите ваше имя", "general.user_name.placeholder": "Введите ваше имя",
"general.view_webdav_settings": "Просмотр настроек WebDAV", "general.view_webdav_settings": "Просмотр настроек WebDAV",
"general.spell_check": "Проверка орфографии",
"general.spell_check.languages": "Языки проверки орфографии",
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов", "input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
"input.target_language": "Целевой язык", "input.target_language": "Целевой язык",
"input.target_language.chinese": "Китайский упрощенный", "input.target_language.chinese": "Китайский упрощенный",
@ -1458,7 +1537,8 @@
"version": "Версия" "version": "Версия"
}, },
"errors": { "errors": {
"32000": "MCP сервер не запущен, пожалуйста, проверьте параметры" "32000": "MCP сервер не запущен, пожалуйста, проверьте параметры",
"toolNotFound": "Инструмент {{name}} не найден"
}, },
"serverPlural": "серверы", "serverPlural": "серверы",
"serverSingular": "сервер", "serverSingular": "сервер",
@ -1506,6 +1586,7 @@
"registry": "Реестр пакетов", "registry": "Реестр пакетов",
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.", "registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
"registryDefault": "По умолчанию", "registryDefault": "По умолчанию",
"customRegistryPlaceholder": "Введите адрес частного склада, например: https://npm.company.com",
"not_support": "Модель не поддерживается", "not_support": "Модель не поддерживается",
"user": "Пользователь", "user": "Пользователь",
"system": "Система", "system": "Система",
@ -1817,12 +1898,43 @@
"overwrite_tooltip": "Использовать провайдера поиска вместо LLM", "overwrite_tooltip": "Использовать провайдера поиска вместо LLM",
"apikey": "API ключ", "apikey": "API ключ",
"free": "Бесплатно", "free": "Бесплатно",
"content_limit": "Ограничение длины текста", "compression": {
"content_limit_tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан." "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.auto_check_update.title": "Автоматическое обновление",
"general.early_access.title": "Ранний доступ", "general.test_plan.title": "Тестовый план",
"general.early_access.tooltip": "Включить для использования последней версии из GitHub, что может быть медленнее и нестабильно. Пожалуйста, сделайте резервную копию данных заранее.", "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": { "quickPhrase": {
"title": "Быстрые фразы", "title": "Быстрые фразы",
"add": "Добавить фразу", "add": "Добавить фразу",

View File

@ -412,6 +412,7 @@
"search": "搜索", "search": "搜索",
"select": "选择", "select": "选择",
"selectedMessages": "选中 {{count}} 条消息", "selectedMessages": "选中 {{count}} 条消息",
"selectedItems": "已选择 {{count}} 项",
"success": "成功", "success": "成功",
"topics": "话题", "topics": "话题",
"warning": "警告", "warning": "警告",
@ -702,6 +703,13 @@
"success.siyuan.export": "导出到思源笔记成功", "success.siyuan.export": "导出到思源笔记成功",
"warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!", "warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!",
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!", "warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!",
"websearch": {
"rag": "正在执行 RAG...",
"rag_complete": "保留 {{countBefore}} 个结果中的 {{countAfter}} 个...",
"rag_failed": "RAG 失败,返回空结果...",
"cutoff": "正在截断搜索内容...",
"fetch_complete": "已完成 {{count}} 次搜索..."
},
"download.success": "下载成功", "download.success": "下载成功",
"download.failed": "下载失败" "download.failed": "下载失败"
}, },
@ -775,6 +783,7 @@
"dimensions": "{{dimensions}} 维", "dimensions": "{{dimensions}} 维",
"edit": "编辑模型", "edit": "编辑模型",
"embedding": "嵌入", "embedding": "嵌入",
"embedding_dimensions": "嵌入维度",
"embedding_model": "嵌入模型", "embedding_model": "嵌入模型",
"embedding_model_tooltip": "在设置->模型服务中点击管理按钮添加", "embedding_model_tooltip": "在设置->模型服务中点击管理按钮添加",
"function_calling": "函数调用", "function_calling": "函数调用",
@ -863,8 +872,8 @@
"learn_more": "了解更多", "learn_more": "了解更多",
"paint_course": "教程", "paint_course": "教程",
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
"prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词", "prompt_placeholder_en": "输入\"英文\"图片描述,目前 Imagen 仅支持英文提示词",
"proxy_required": "打开代理并开启”TUN模式“查看生成图片或复制到浏览器打开,后续会支持国内直连", "proxy_required": "打开代理并开启\"TUN模式\"查看生成图片或复制到浏览器打开,后续会支持国内直连",
"image_file_required": "请先上传图片", "image_file_required": "请先上传图片",
"image_file_retry": "请重新上传图片", "image_file_retry": "请重新上传图片",
"image_placeholder": "暂无图片", "image_placeholder": "暂无图片",
@ -960,7 +969,7 @@
"magic_prompt_option_tip": "智能优化放大提示词" "magic_prompt_option_tip": "智能优化放大提示词"
}, },
"text_desc_required": "请先输入图片描述", "text_desc_required": "请先输入图片描述",
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。", "req_error_text": "运行失败,请重试。提示词避免\"版权词\"和\"敏感词\"哦。",
"req_error_token": "请检查令牌有效性", "req_error_token": "请检查令牌有效性",
"req_error_no_balance": "请检查令牌有效性", "req_error_no_balance": "请检查令牌有效性",
"image_handle_required": "请先上传图片", "image_handle_required": "请先上传图片",
@ -1087,11 +1096,11 @@
"app_data": "应用数据", "app_data": "应用数据",
"app_data.select": "修改目录", "app_data.select": "修改目录",
"app_data.select_title": "更改应用数据目录", "app_data.select_title": "更改应用数据目录",
"app_data.restart_notice": "应用需要重启以应用更改", "app_data.restart_notice": "应用可能会重启多次以应用更改",
"app_data.copy_data_option": "复制数据,开启后会将原始目录数据复制到新目录", "app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录",
"app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用", "app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用",
"app_data.path_changed_without_copy": "路径已更改成功,但数据未复制", "app_data.path_changed_without_copy": "路径已更改成功",
"app_data.copying_warning": "数据复制中不要强制退出app", "app_data.copying_warning": "数据复制中不要强制退出app, 复制完成后会自动重启应用",
"app_data.copying": "正在将数据复制到新位置...", "app_data.copying": "正在将数据复制到新位置...",
"app_data.copy_success": "已成功复制数据到新位置", "app_data.copy_success": "已成功复制数据到新位置",
"app_data.copy_failed": "复制数据失败", "app_data.copy_failed": "复制数据失败",
@ -1103,6 +1112,10 @@
"app_data.select_error_root_path": "新路径不能是根路径", "app_data.select_error_root_path": "新路径不能是根路径",
"app_data.select_error_write_permission": "新路径没有写入权限", "app_data.select_error_write_permission": "新路径没有写入权限",
"app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出", "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": "知识库文件",
"app_knowledge.button.delete": "删除文件", "app_knowledge.button.delete": "删除文件",
"app_knowledge.remove_all": "删除知识库文件", "app_knowledge.remove_all": "删除知识库文件",
@ -1235,7 +1248,71 @@
"title": "WebDAV", "title": "WebDAV",
"user": "WebDAV 用户名", "user": "WebDAV 用户名",
"maxBackups": "最大备份数", "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": { "yuque": {
"check": { "check": {
@ -1379,16 +1456,24 @@
"general.emoji_picker": "表情选择器", "general.emoji_picker": "表情选择器",
"general.image_upload": "图片上传", "general.image_upload": "图片上传",
"general.auto_check_update.title": "自动更新", "general.auto_check_update.title": "自动更新",
"general.early_access.title": "抢先体验", "general.test_plan.title": "测试计划",
"general.early_access.tooltip": "开启后,将使用 GitHub 的最新版本,下载速度可能较慢,请务必提前备份数据", "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.button": "重置",
"general.reset.title": "重置数据", "general.reset.title": "重置数据",
"general.restore.button": "恢复", "general.restore.button": "恢复",
"general.title": "常规设置", "general.title": "常规设置",
"general.user_name": "用户名", "general.user_name": "用户名",
"general.user_name.placeholder": "请输入用户名", "general.user_name.placeholder": "输入您的姓名",
"general.view_webdav_settings": "查看 WebDAV 设置", "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.show_translate_confirm": "显示翻译确认对话框",
"input.target_language": "目标语言", "input.target_language": "目标语言",
"input.target_language.chinese": "简体中文", "input.target_language.chinese": "简体中文",
@ -1464,7 +1549,8 @@
"version": "版本" "version": "版本"
}, },
"errors": { "errors": {
"32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整" "32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整",
"toolNotFound": "未找到工具 {{name}}"
}, },
"serverPlural": "服务器", "serverPlural": "服务器",
"serverSingular": "服务器", "serverSingular": "服务器",
@ -1512,6 +1598,7 @@
"registry": "包管理源", "registry": "包管理源",
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题", "registryTooltip": "选择用于安装包的源,以解决默认源的网络问题",
"registryDefault": "默认", "registryDefault": "默认",
"customRegistryPlaceholder": "请输入私有仓库地址,如: https://npm.company.com",
"not_support": "模型不支持", "not_support": "模型不支持",
"user": "用户", "user": "用户",
"system": "系统", "system": "系统",
@ -1829,8 +1916,33 @@
"title": "网络搜索", "title": "网络搜索",
"apikey": "API 密钥", "apikey": "API 密钥",
"free": "免费", "free": "免费",
"content_limit": "内容长度限制", "compression": {
"content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断" "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": { "quickPhrase": {
"title": "快捷短语", "title": "快捷短语",

View File

@ -412,6 +412,7 @@
"search": "搜尋", "search": "搜尋",
"select": "選擇", "select": "選擇",
"selectedMessages": "選中 {{count}} 條訊息", "selectedMessages": "選中 {{count}} 條訊息",
"selectedItems": "已選擇 {{count}} 項",
"success": "成功", "success": "成功",
"topics": "話題", "topics": "話題",
"warning": "警告", "warning": "警告",
@ -702,6 +703,13 @@
"success.siyuan.export": "導出到思源筆記成功", "success.siyuan.export": "導出到思源筆記成功",
"warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!", "warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!",
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!", "warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!",
"websearch": {
"rag": "正在執行 RAG...",
"rag_complete": "保留 {{countBefore}} 個結果中的 {{countAfter}} 個...",
"rag_failed": "RAG 失敗,返回空結果...",
"cutoff": "正在截斷搜尋內容...",
"fetch_complete": "已完成 {{count}} 次搜尋..."
},
"download.success": "下載成功", "download.success": "下載成功",
"download.failed": "下載失敗" "download.failed": "下載失敗"
}, },
@ -775,6 +783,7 @@
"dimensions": "{{dimensions}} 維", "dimensions": "{{dimensions}} 維",
"edit": "編輯模型", "edit": "編輯模型",
"embedding": "嵌入", "embedding": "嵌入",
"embedding_dimensions": "嵌入維度",
"embedding_model": "嵌入模型", "embedding_model": "嵌入模型",
"embedding_model_tooltip": "在設定->模型服務中點選管理按鈕新增", "embedding_model_tooltip": "在設定->模型服務中點選管理按鈕新增",
"function_calling": "函數調用", "function_calling": "函數調用",
@ -1087,11 +1096,11 @@
"app_data": "應用數據", "app_data": "應用數據",
"app_data.select": "修改目錄", "app_data.select": "修改目錄",
"app_data.select_title": "變更應用數據目錄", "app_data.select_title": "變更應用數據目錄",
"app_data.restart_notice": "變更數據目錄後需要重啟應用才能生效", "app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效",
"app_data.copy_data_option": "複製數據, 開啟後會將原始目錄數據複製到新目錄", "app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄",
"app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用", "app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用",
"app_data.path_changed_without_copy": "路徑已變更成功,但數據未複製", "app_data.path_changed_without_copy": "路徑已變更成功",
"app_data.copying_warning": "數據複製中,不要強制退出應用", "app_data.copying_warning": "數據複製中,不要強制退出應用, 複製完成後會自動重啟應用",
"app_data.copying": "正在複製數據到新位置...", "app_data.copying": "正在複製數據到新位置...",
"app_data.copy_success": "成功複製數據到新位置", "app_data.copy_success": "成功複製數據到新位置",
"app_data.copy_failed": "複製數據失敗", "app_data.copy_failed": "複製數據失敗",
@ -1103,6 +1112,10 @@
"app_data.select_error_root_path": "新路徑不能是根路徑", "app_data.select_error_root_path": "新路徑不能是根路徑",
"app_data.select_error_write_permission": "新路徑沒有寫入權限", "app_data.select_error_write_permission": "新路徑沒有寫入權限",
"app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出", "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": "知識庫文件",
"app_knowledge.button.delete": "刪除檔案", "app_knowledge.button.delete": "刪除檔案",
"app_knowledge.remove_all": "刪除知識庫檔案", "app_knowledge.remove_all": "刪除知識庫檔案",
@ -1233,7 +1246,71 @@
"title": "WebDAV", "title": "WebDAV",
"user": "WebDAV 使用者名稱", "user": "WebDAV 使用者名稱",
"maxBackups": "最大備份數量", "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": { "yuque": {
"check": { "check": {
@ -1385,6 +1462,8 @@
"general.user_name": "使用者名稱", "general.user_name": "使用者名稱",
"general.user_name.placeholder": "輸入您的名稱", "general.user_name.placeholder": "輸入您的名稱",
"general.view_webdav_settings": "檢視 WebDAV 設定", "general.view_webdav_settings": "檢視 WebDAV 設定",
"general.spell_check": "拼寫檢查",
"general.spell_check.languages": "拼寫檢查語言",
"input.auto_translate_with_space": "快速敲擊 3 次空格翻譯", "input.auto_translate_with_space": "快速敲擊 3 次空格翻譯",
"input.show_translate_confirm": "顯示翻譯確認對話框", "input.show_translate_confirm": "顯示翻譯確認對話框",
"input.target_language": "目標語言", "input.target_language": "目標語言",
@ -1461,7 +1540,8 @@
"version": "版本" "version": "版本"
}, },
"errors": { "errors": {
"32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整" "32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整",
"toolNotFound": "未找到工具 {{name}}"
}, },
"serverPlural": "伺服器", "serverPlural": "伺服器",
"serverSingular": "伺服器", "serverSingular": "伺服器",
@ -1509,6 +1589,7 @@
"registry": "套件管理源", "registry": "套件管理源",
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題", "registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題",
"registryDefault": "預設", "registryDefault": "預設",
"customRegistryPlaceholder": "請輸入私有倉庫位址,如: https://npm.company.com",
"not_support": "不支援此模型", "not_support": "不支援此模型",
"user": "用戶", "user": "用戶",
"system": "系統", "system": "系統",
@ -1820,12 +1901,43 @@
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋", "overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
"apikey": "API 金鑰", "apikey": "API 金鑰",
"free": "免費", "free": "免費",
"content_limit": "內容長度限制", "compression": {
"content_limit_tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷" "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.auto_check_update.title": "自動更新",
"general.early_access.title": "搶先體驗", "general.test_plan.title": "測試計畫",
"general.early_access.tooltip": "開啟後,將使用 GitHub 的最新版本,下載速度可能較慢,請務必提前備份數據", "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": { "quickPhrase": {
"title": "快捷短語", "title": "快捷短語",
"add": "新增短語", "add": "新增短語",

View File

@ -12,9 +12,9 @@ function initKeyv() {
function initAutoSync() { function initAutoSync() {
setTimeout(() => { setTimeout(() => {
const { webdavAutoSync } = store.getState().settings const { webdavAutoSync, s3 } = store.getState().settings
const { nutstoreAutoSync } = store.getState().nutstore const { nutstoreAutoSync } = store.getState().nutstore
if (webdavAutoSync) { if (webdavAutoSync || (s3 && s3.autoSync)) {
startAutoSync() startAutoSync()
} }
if (nutstoreAutoSync) { if (nutstoreAutoSync) {

View File

@ -14,6 +14,7 @@ import { Agent, KnowledgeBase } from '@renderer/types'
import { getLeadingEmoji, uuid } from '@renderer/utils' import { getLeadingEmoji, uuid } from '@renderer/utils'
import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd' import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd'
import TextArea from 'antd/es/input/TextArea' import TextArea from 'antd/es/input/TextArea'
import { ChevronDown } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import stringWidth from 'string-width' import stringWidth from 'string-width'
@ -150,7 +151,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
maskClosable={false} maskClosable={false}
afterClose={onClose} afterClose={onClose}
okText={t('agents.add.title')} okText={t('agents.add.title')}
width={800} width={600}
transitionName="animation-move-down" transitionName="animation-move-down"
centered> centered>
<Form <Form
@ -212,6 +213,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
.toLowerCase() .toLowerCase()
.includes(input.toLowerCase()) .includes(input.toLowerCase())
} }
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
/> />
</Form.Item> </Form.Item>
)} )}

View File

@ -4,7 +4,7 @@ import { getDefaultModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Agent } from '@renderer/types' import { Agent } from '@renderer/types'
import { uuid } from '@renderer/utils' 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 { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -98,7 +98,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
title={t('agents.import.title')} title={t('agents.import.title')}
open={open} open={open}
onCancel={onCancel} 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" transitionName="animation-move-down"
centered> centered>
<Form form={form} onFinish={onFinish} layout="vertical"> <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> <Button onClick={() => form.submit()}>{t('agents.import.select_file')}</Button>
</Form.Item> </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> </Form>
</Modal> </Modal>
) )

View File

@ -1,3 +1,5 @@
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { handleDelete } from '@renderer/services/FileAction'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { FileType, FileTypes } from '@renderer/types' import { FileType, FileTypes } from '@renderer/types'
import { formatFileSize } from '@renderer/utils' import { formatFileSize } from '@renderer/utils'
@ -48,6 +50,24 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
<ImageInfo> <ImageInfo>
<div>{formatFileSize(file.size)}</div> <div>{formatFileSize(file.size)}</div>
</ImageInfo> </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> </ImageWrapper>
</Col> </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) export default memo(FileList)

View File

@ -7,13 +7,10 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import ListItem from '@renderer/components/ListItem' 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 db from '@renderer/databases'
import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import store from '@renderer/store'
import { FileType, FileTypes } from '@renderer/types' import { FileType, FileTypes } from '@renderer/types'
import { Message } from '@renderer/types/newMessage'
import { formatFileSize } from '@renderer/utils' import { formatFileSize } from '@renderer/utils'
import { Button, Empty, Flex, Popconfirm } from 'antd' import { Button, Empty, Flex, Popconfirm } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -34,34 +31,6 @@ const FilesPage: FC = () => {
const [sortField, setSortField] = useState<SortField>('created_at') const [sortField, setSortField] = useState<SortField>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc') 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[]>(() => { const files = useLiveQuery<FileType[]>(() => {
if (fileType === 'all') { if (fileType === 'all') {
return db.files.orderBy('count').toArray().then(tempFilesSort) 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) return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
}, [fileType]) }, [fileType])
const sortedFiles = files ? sortFiles(files) : [] const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : []
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 dataSource = sortedFiles?.map((file) => { const dataSource = sortedFiles?.map((file) => {
return { return {
@ -189,7 +59,7 @@ const FilesPage: FC = () => {
description={t('files.delete.content')} description={t('files.delete.content')}
okText={t('common.confirm')} okText={t('common.confirm')}
cancelText={t('common.cancel')} cancelText={t('common.cancel')}
onConfirm={() => handleDelete(file.id)} onConfirm={() => handleDelete(file.id, t)}
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}> icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
<Button type="text" danger icon={<DeleteOutlined />} /> <Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
@ -310,7 +180,6 @@ const SideNav = styled.div`
background-color: var(--color-background-soft); background-color: var(--color-background-soft);
color: var(--color-primary); color: var(--color-primary);
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
color: var(--color-text);
} }
} }
` `

View File

@ -1,11 +1,11 @@
import { ArrowLeftOutlined, EnterOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types' import { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage' import type { Message } from '@renderer/types/newMessage'
import { Input, InputRef } from 'antd' import { Divider, Input, InputRef } from 'antd'
import { last } from 'lodash' import { last } from 'lodash'
import { Search } from 'lucide-react' import { ChevronLeft, CornerDownLeft, Search } from 'lucide-react'
import { FC, useEffect, useRef, useState } from 'react' import { FC, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -73,26 +73,35 @@ const TopicsPage: FC = () => {
return ( return (
<Container> <Container>
<Header> <HStack style={{ padding: '0 12px', marginTop: 8 }}>
{stack.length > 1 && ( <Input
<HeaderLeft> prefix={
<MenuIcon onClick={goBack}> stack.length > 1 ? (
<ArrowLeftOutlined /> <SearchIcon className="back-icon" onClick={goBack}>
</MenuIcon> <ChevronLeft size={16} />
</HeaderLeft> </SearchIcon>
)} ) : (
<SearchInput <SearchIcon>
placeholder={t('history.search.placeholder')} <Search size={15} />
type="search" </SearchIcon>
value={search} )
autoFocus }
allowClear suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
ref={inputRef} ref={inputRef}
placeholder={t('history.search.placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value.trimStart())} 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} onPressEnter={onSearch}
/> />
</Header> </HStack>
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<TopicsHistory <TopicsHistory
keywords={search} keywords={search}
onClick={onTopicClick as any} onClick={onTopicClick as any}
@ -118,50 +127,23 @@ const Container = styled.div`
height: 100%; height: 100%;
` `
const Header = styled.div` const SearchIcon = styled.div`
display: flex; width: 32px;
flex-direction: row; height: 32px;
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;
border-radius: 50%; border-radius: 50%;
&:hover { display: flex;
background-color: var(--color-background); flex-direction: row;
.anticon { justify-content: center;
color: var(--color-text-1); 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 export default TopicsPage

View File

@ -1,7 +1,5 @@
import { ArrowRightOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import { useSettings } from '@renderer/hooks/useSettings'
import { getTopicById } from '@renderer/hooks/useTopic' import { getTopicById } from '@renderer/hooks/useTopic'
import { default as MessageItem } from '@renderer/pages/home/Messages/Message' import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
import { locateToMessage } from '@renderer/services/MessagesService' import { locateToMessage } from '@renderer/services/MessagesService'
@ -10,6 +8,7 @@ import { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage' import type { Message } from '@renderer/types/newMessage'
import { runAsyncFunction } from '@renderer/utils' import { runAsyncFunction } from '@renderer/utils'
import { Button } from 'antd' import { Button } from 'antd'
import { Forward } from 'lucide-react'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -20,7 +19,6 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
const SearchMessage: FC<Props> = ({ message, ...props }) => { const SearchMessage: FC<Props> = ({ message, ...props }) => {
const navigate = NavigationService.navigate! const navigate = NavigationService.navigate!
const { messageStyle } = useSettings()
const { t } = useTranslation() const { t } = useTranslation()
const [topic, setTopic] = useState<Topic | null>(null) const [topic, setTopic] = useState<Topic | null>(null)
@ -43,18 +41,18 @@ const SearchMessage: FC<Props> = ({ message, ...props }) => {
return ( return (
<MessageEditingProvider> <MessageEditingProvider>
<MessagesContainer {...props} className={messageStyle}> <MessagesContainer {...props}>
<ContainerWrapper style={{ paddingTop: 20, paddingBottom: 20, position: 'relative' }}> <ContainerWrapper>
<MessageItem message={message} topic={topic} hideMenuBar={true} /> <MessageItem message={message} topic={topic} hideMenuBar={true} />
<Button <Button
type="text" type="text"
size="middle" 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)} onClick={() => locateToMessage(navigate, message)}
icon={<ArrowRightOutlined />} icon={<Forward size={16} />}
/> />
<HStack mt="10px" justifyContent="center"> <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')} {t('history.locate.message')}
</Button> </Button>
</HStack> </HStack>
@ -74,12 +72,11 @@ const MessagesContainer = styled.div`
` `
const ContainerWrapper = styled.div` const ContainerWrapper = styled.div`
width: 800px; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.message { padding: 16px;
padding: 0; position: relative;
}
` `
export default SearchMessage export default SearchMessage

View File

@ -151,7 +151,8 @@ const Container = styled.div`
` `
const ContainerWrapper = styled.div` const ContainerWrapper = styled.div`
width: 800px; width: 100%;
padding: 0 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
` `

View File

@ -1,9 +1,8 @@
import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons' import { MessageOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup' import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import useScrollPosition from '@renderer/hooks/useScrollPosition' import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings'
import { getAssistantById } from '@renderer/services/AssistantService' import { getAssistantById } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
@ -13,6 +12,7 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types' import { Topic } from '@renderer/types'
import { Button, Divider, Empty } from 'antd' import { Button, Divider, Empty } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
import { Forward } from 'lucide-react'
import { FC, useEffect } from 'react' import { FC, useEffect } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
@ -25,7 +25,6 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
const TopicMessages: FC<Props> = ({ topic, ...props }) => { const TopicMessages: FC<Props> = ({ topic, ...props }) => {
const navigate = NavigationService.navigate! const navigate = NavigationService.navigate!
const { handleScroll, containerRef } = useScrollPosition('TopicMessages') const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
const { messageStyle } = useSettings()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
useEffect(() => { useEffect(() => {
@ -48,8 +47,8 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
return ( return (
<MessageEditingProvider> <MessageEditingProvider>
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll} className={messageStyle}> <MessagesContainer {...props} ref={containerRef} onScroll={handleScroll}>
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}> <ContainerWrapper>
{topic?.messages.map((message) => ( {topic?.messages.map((message) => (
<div key={message.id} style={{ position: 'relative' }}> <div key={message.id} style={{ position: 'relative' }}>
<MessageItem message={message} topic={topic} hideMenuBar={true} /> <MessageItem message={message} topic={topic} hideMenuBar={true} />
@ -58,7 +57,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
size="middle" size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }} style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
onClick={() => locateToMessage(navigate, message)} onClick={() => locateToMessage(navigate, message)}
icon={<ArrowRightOutlined />} icon={<Forward size={16} />}
/> />
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" /> <Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
</div> </div>
@ -86,12 +85,10 @@ const MessagesContainer = styled.div`
` `
const ContainerWrapper = styled.div` const ContainerWrapper = styled.div`
width: 800px; width: 100%;
padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.message {
padding: 0;
}
` `
export default TopicMessages export default TopicMessages

View File

@ -78,7 +78,8 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
} }
const ContainerWrapper = styled.div` const ContainerWrapper = styled.div`
width: 800px; width: 100%;
padding: 0 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
` `

View File

@ -7,6 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowTopics } from '@renderer/hooks/useStore' import { useShowTopics } from '@renderer/hooks/useStore'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd' import { Flex } from 'antd'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import React, { FC, useMemo, useState } from 'react' import React, { FC, useMemo, useState } from 'react'
@ -54,28 +55,30 @@ const Chat: FC<Props> = (props) => {
} }
}) })
const contentSearchFilter = (node: Node): boolean => { const contentSearchFilter: NodeFilter = {
if (node.parentNode) { acceptNode(node) {
let parentNode: HTMLElement | null = node.parentNode as HTMLElement if (node.parentNode) {
while (parentNode?.parentNode) { let parentNode: HTMLElement | null = node.parentNode as HTMLElement
if (parentNode.classList.contains('MessageFooter')) { while (parentNode?.parentNode) {
return false if (parentNode.classList.contains('MessageFooter')) {
} return NodeFilter.FILTER_REJECT
}
if (filterIncludeUser) { if (filterIncludeUser) {
if (parentNode?.classList.contains('message-content-container')) { if (parentNode?.classList.contains('message-content-container')) {
return true return NodeFilter.FILTER_ACCEPT
} }
} else { } else {
if (parentNode?.classList.contains('message-content-container-assistant')) { if (parentNode?.classList.contains('message-content-container-assistant')) {
return true 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 ( 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 }}> <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 <Messages
key={props.activeTopic.id} key={props.activeTopic.id}
assistant={assistant} assistant={assistant}
@ -123,6 +119,13 @@ const Chat: FC<Props> = (props) => {
onComponentUpdate={messagesComponentUpdateHandler} onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler} onFirstUpdate={messagesComponentFirstUpdateHandler}
/> />
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
<QuickPanelProvider> <QuickPanelProvider>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} /> <Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />} {isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}

View File

@ -77,7 +77,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
showInputEstimatedTokens, showInputEstimatedTokens,
autoTranslateWithSpace, autoTranslateWithSpace,
enableQuickPanelTriggers, enableQuickPanelTriggers,
enableBackspaceDeleteModel enableBackspaceDeleteModel,
enableSpellCheck
} = useSettings() } = useSettings()
const [expended, setExpend] = useState(false) const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0) const [estimateTokenCount, setEstimateTokenCount] = useState(0)
@ -138,17 +139,21 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
_text = text _text = text
_files = files _files = files
const resizeTextArea = useCallback(() => { const resizeTextArea = useCallback(
const textArea = textareaRef.current?.resizableTextArea?.textArea (force: boolean = false) => {
if (textArea) { const textArea = textareaRef.current?.resizableTextArea?.textArea
// 如果已经手动设置了高度,则不自动调整 if (textArea) {
if (textareaHeight) { // 如果已经手动设置了高度,则不自动调整
return 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 () => { const sendMessage = useCallback(async () => {
if (inputEmpty || loading) { if (inputEmpty || loading) {
@ -748,13 +753,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
} }
return ( return (
<Container <NarrowLayout style={{ width: '100%' }}>
onDragOver={handleDragOver} <Container
onDrop={handleDrop} onDragOver={handleDragOver}
onDragEnter={handleDragEnter} onDrop={handleDrop}
onDragLeave={handleDragLeave} onDragEnter={handleDragEnter}
className="inputbar"> onDragLeave={handleDragLeave}
<NarrowLayout style={{ width: '100%' }}> className="inputbar">
<QuickPanelView setInputText={setText} /> <QuickPanelView setInputText={setText} />
<InputBarContainer <InputBarContainer
id="inputbar" id="inputbar"
@ -780,14 +785,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
: t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) }) : t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })
} }
autoFocus autoFocus
contextMenu="true"
variant="borderless" variant="borderless"
spellCheck={false} spellCheck={enableSpellCheck}
rows={2} rows={2}
ref={textareaRef} ref={textareaRef}
style={{ style={{
fontSize, fontSize,
minHeight: textareaHeight ? `${textareaHeight}px` : undefined minHeight: textareaHeight ? `${textareaHeight}px` : '30px'
}} }}
styles={{ textarea: TextareaStyle }} styles={{ textarea: TextareaStyle }}
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => { onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
@ -851,8 +855,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
</ToolbarMenu> </ToolbarMenu>
</Toolbar> </Toolbar>
</InputBarContainer> </InputBarContainer>
</NarrowLayout> </Container>
</Container> </NarrowLayout>
) )
} }
@ -887,16 +891,15 @@ const Container = styled.div`
flex-direction: column; flex-direction: column;
position: relative; position: relative;
z-index: 2; z-index: 2;
padding: 0 16px 16px 16px;
` `
const InputBarContainer = styled.div` const InputBarContainer = styled.div`
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative; position: relative;
margin: 14px 20px;
margin-top: 0;
border-radius: 15px; border-radius: 15px;
padding-top: 6px; // 为拖动手柄留出空间 padding-top: 8px; // 为拖动手柄留出空间
background-color: var(--color-background-opacity); background-color: var(--color-background-opacity);
&.file-dragging { &.file-dragging {
@ -919,7 +922,7 @@ const InputBarContainer = styled.div`
const TextareaStyle: CSSProperties = { const TextareaStyle: CSSProperties = {
paddingLeft: 0, paddingLeft: 0,
padding: '6px 15px 8px' // 减小顶部padding padding: '6px 15px 0px' // 减小顶部padding
} }
const Textarea = styled(TextArea)` const Textarea = styled(TextArea)`
@ -934,16 +937,17 @@ const Textarea = styled(TextArea)`
&.ant-input { &.ant-input {
line-height: 1.4; line-height: 1.4;
} }
&::-webkit-scrollbar {
width: 3px;
}
` `
const Toolbar = styled.div` const Toolbar = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
padding: 0 8px; padding: 5px 8px;
padding-bottom: 0; height: 40px;
margin-bottom: 4px;
height: 30px;
gap: 16px; gap: 16px;
position: relative; position: relative;
z-index: 2; z-index: 2;

View File

@ -45,7 +45,7 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
return ( return (
<Container> <Container>
<Popover content={PopoverContent}> <Popover content={PopoverContent} arrow={false}>
<MenuOutlined /> {contextCount.current} / {formatMaxCount(contextCount.max)} <MenuOutlined /> {contextCount.current} / {formatMaxCount(contextCount.max)}
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} /> <Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
<ArrowUpOutlined /> <ArrowUpOutlined />

View File

@ -54,9 +54,10 @@ const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation })
return ( return (
<Tooltip <Tooltip
arrow={false}
overlay={tooltipContent} overlay={tooltipContent}
placement="top" placement="top"
color="var(--color-background-mute)" color="var(--color-background)"
styles={{ styles={{
body: { body: {
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',

View File

@ -27,7 +27,7 @@ const CodeBlock: React.FC<Props> = ({ children, className, id, onSave }) => {
{children} {children}
</CodeBlockView> </CodeBlockView>
) : ( ) : (
<code className={className} style={{ textWrap: 'wrap' }}> <code className={className} style={{ textWrap: 'wrap', fontSize: '95%', padding: '2px 4px' }}>
{children} {children}
</code> </code>
) )

View File

@ -8,8 +8,8 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage' import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
import { parseJSON } from '@renderer/utils' import { parseJSON } from '@renderer/utils'
import { escapeBrackets, removeSvgEmptyLines } from '@renderer/utils/formats' import { removeSvgEmptyLines } from '@renderer/utils/formats'
import { findCitationInChildren, getCodeBlockId } from '@renderer/utils/markdown' import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { type FC, memo, useCallback, useMemo } from 'react' import { type FC, memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -52,7 +52,7 @@ const Markdown: FC<Props> = ({ block }) => {
const empty = isEmpty(block.content) const empty = isEmpty(block.content)
const paused = block.status === 'paused' const paused = block.status === 'paused'
const content = empty && paused ? t('message.chat.completion.paused') : block.content const content = empty && paused ? t('message.chat.completion.paused') : block.content
return removeSvgEmptyLines(escapeBrackets(content)) return removeSvgEmptyLines(processLatexBrackets(content))
}, [block, t]) }, [block, t])
const rehypePlugins = useMemo(() => { const rehypePlugins = useMemo(() => {

View File

@ -93,7 +93,7 @@ describe('CitationTooltip', () => {
const tooltip = screen.getByTestId('tooltip-wrapper') const tooltip = screen.getByTestId('tooltip-wrapper')
expect(tooltip).toHaveAttribute('data-placement', 'top') 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') || '{}') const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}')
expect(styles.body).toEqual({ expect(styles.body).toEqual({

View File

@ -42,13 +42,13 @@ vi.mock('@renderer/utils', () => ({
})) }))
vi.mock('@renderer/utils/formats', () => ({ vi.mock('@renderer/utils/formats', () => ({
escapeBrackets: vi.fn((str) => str),
removeSvgEmptyLines: vi.fn((str) => str) removeSvgEmptyLines: vi.fn((str) => str)
})) }))
vi.mock('@renderer/utils/markdown', () => ({ vi.mock('@renderer/utils/markdown', () => ({
findCitationInChildren: vi.fn(() => '{"id": 1, "url": "https://example.com"}'), 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 // Mock components with more realistic behavior
@ -212,16 +212,6 @@ describe('Markdown', () => {
expect(markdown).not.toHaveTextContent('Paused') 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', () => { it('should match snapshot', () => {
const { container } = render(<Markdown block={createMainTextBlock()} />) const { container } = render(<Markdown block={createMainTextBlock()} />)
expect(container.firstChild).toMatchSnapshot() expect(container.firstChild).toMatchSnapshot()

View File

@ -47,7 +47,7 @@ exports[`CitationTooltip > basic rendering > should match snapshot 1`] = `
} }
<div <div
data-color="var(--color-background-mute)" data-color="var(--color-background)"
data-placement="top" data-placement="top"
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}" data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
data-testid="tooltip-wrapper" data-testid="tooltip-wrapper"

View File

@ -5,13 +5,19 @@ import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock'
import { WebSearchSource } from '@renderer/types' import { WebSearchSource } from '@renderer/types'
import { type CitationMessageBlock, MessageBlockStatus } from '@renderer/types/newMessage' import { type CitationMessageBlock, MessageBlockStatus } from '@renderer/types/newMessage'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
import CitationsList from '../CitationsList' import CitationsList from '../CitationsList'
function CitationBlock({ block }: { block: CitationMessageBlock }) { function CitationBlock({ block }: { block: CitationMessageBlock }) {
const { t } = useTranslation()
const formattedCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, block.id)) 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 hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI
const hasCitations = useMemo(() => { const hasCitations = useMemo(() => {
return ( return (
@ -21,8 +27,32 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) {
) )
}, [formattedCitations, block.knowledge, hasGeminiBlock]) }, [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) { if (block.status === MessageBlockStatus.PROCESSING) {
return <Spinner text="message.searching" /> return <Spinner text={getWebSearchStatusText(userMessageId)} />
} }
if (!hasCitations) { if (!hasCitations) {

View File

@ -31,7 +31,7 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock }> = ({ block }) =>
} }
const Alert = styled(AntdAlert)` const Alert = styled(AntdAlert)`
margin: 0.5rem 0; margin: 0.5rem 0 !important;
padding: 10px; padding: 10px;
font-size: 12px; font-size: 12px;
` `

View File

@ -18,12 +18,12 @@ const ImageBlock: React.FC<Props> = ({ block }) => {
? [`file://${block?.file?.path}`] ? [`file://${block?.file?.path}`]
: [] : []
return ( return (
<Container style={{ marginBottom: 8 }}> <Container>
{images.map((src, index) => ( {images.map((src, index) => (
<ImageViewer <ImageViewer
src={src} src={src}
key={`image-${index}`} key={`image-${index}`}
style={{ maxWidth: 500, maxHeight: 500, padding: 5, borderRadius: 8 }} style={{ maxWidth: 500, maxHeight: 500, padding: 0, borderRadius: 8 }}
/> />
))} ))}
</Container> </Container>
@ -34,6 +34,5 @@ const Container = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
margin-top: 8px;
` `
export default React.memo(ImageBlock) export default React.memo(ImageBlock)

View File

@ -3,7 +3,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
import { lightbulbVariants } from '@renderer/utils/motionVariants' import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Collapse, message as antdMessage, Tooltip } from 'antd' 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 { motion } from 'motion/react'
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -57,6 +57,14 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
size="small" size="small"
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))} onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
className="message-thought-container" 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" expandIconPosition="end"
items={[ items={[
{ {
@ -142,7 +150,7 @@ const ThinkingTimeSeconds = memo(
) )
const CollapseContainer = styled(Collapse)` const CollapseContainer = styled(Collapse)`
margin-bottom: 15px; margin: 15px 0;
` `
const MessageTitleLabel = styled.div` const MessageTitleLabel = styled.div`

View File

@ -2,7 +2,7 @@
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = ` exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
.c0 { .c0 {
margin-bottom: 15px; margin: 15px 0;
} }
.c1 { .c1 {

View File

@ -164,17 +164,7 @@ export default React.memo(MessageBlockRenderer)
const ImageBlockGroup = styled.div` const ImageBlockGroup = styled.div`
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(3, minmax(200px, 1fr));
gap: 8px; gap: 8px;
max-width: 960px; 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