Compare commits
3 Commits
v1.4.7
...
fix/quick-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2f24ac3a | ||
|
|
97b7eebf7d | ||
|
|
4d553beb85 |
30
README.md
30
README.md
@@ -1,33 +1,3 @@
|
||||
<div align="right" >
|
||||
<details>
|
||||
<summary >🌐 Language</summary>
|
||||
<div>
|
||||
<div align="right">
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=en">English</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-CN">简体中文</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-TW">繁體中文</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ja">日本語</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ko">한국어</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=hi">हिन्दी</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=th">ไทย</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fr">Français</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=de">Deutsch</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=es">Español</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=it">Itapano</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ru">Русский</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pt">Português</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=nl">Nederlands</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pl">Polski</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ar">العربية</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fa">فارسی</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=tr">Türkçe</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=vi">Tiếng Việt</a></p>
|
||||
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=id">Bahasa Indonesia</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h1 align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/releases">
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
|
||||
|
||||
@@ -107,10 +107,11 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
界面优化:优化多处界面样式,气泡样式改版,自动调整代码预览边栏宽度
|
||||
知识库:修复知识库引用不显示问题,修复部分嵌入模型适配问题
|
||||
备份与恢复:修复超过 2GB 大文件无法恢复问题
|
||||
文件处理:添加 .doc 文件支持
|
||||
划词助手:支持自定义 CSS 样式
|
||||
MCP:基于 Pyodide 实现 Python MCP 服务
|
||||
其他错误修复和优化
|
||||
划词助手:支持文本选择快捷键、开关快捷键、思考块支持和引用功能
|
||||
复制功能:新增纯文本复制(去除Markdown格式符号)
|
||||
知识库:支持设置向量维度,修复Ollama分数错误和维度编辑问题
|
||||
多语言:增加模型名称多语言提示和翻译源语言手动选择
|
||||
文件管理:修复主题/消息删除时文件未清理问题,优化文件选择流程
|
||||
模型:修复Gemini模型推理预算、Voyage AI嵌入问题和DeepSeek翻译模型更新
|
||||
图像功能:统一图片查看器,支持Base64图片渲染,修复图片预览相关问题
|
||||
UI:实现标签折叠/拖拽排序,修复气泡溢出,增加引文索引显示
|
||||
|
||||
@@ -68,16 +68,12 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['pyodide'],
|
||||
esbuildOptions: {
|
||||
target: 'esnext' // for dev
|
||||
}
|
||||
exclude: ['pyodide']
|
||||
},
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext', // for build
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.7",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -62,7 +62,6 @@
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"notion-helper": "^1.3.22",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"selection-hook": "^0.9.23",
|
||||
@@ -124,7 +123,6 @@
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/react-window": "^1",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/word-extractor": "^1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.12",
|
||||
"@uiw/codemirror-themes-all": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
@@ -178,6 +176,7 @@
|
||||
"mermaid": "^11.6.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
@@ -191,7 +190,7 @@
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
@@ -200,10 +199,10 @@
|
||||
"redux": "^5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^7.1.0",
|
||||
"rehype-mathjax": "^7.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-cjk-friendly": "^1.2.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remove-markdown": "^0.6.2",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
@@ -213,13 +212,12 @@
|
||||
"styled-components": "^6.1.11",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^1.1.0",
|
||||
"tokenx": "^0.4.1",
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "6.2.6",
|
||||
"vitest": "^3.1.4",
|
||||
"webdav": "^5.8.0",
|
||||
"word-extractor": "^1.0.4",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -3,8 +3,6 @@ export enum IpcChannel {
|
||||
App_ClearCache = 'app:clear-cache',
|
||||
App_SetLaunchOnBoot = 'app:set-launch-on-boot',
|
||||
App_SetLanguage = 'app:set-language',
|
||||
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
||||
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
||||
App_CheckForUpdate = 'app:check-for-update',
|
||||
App_Reload = 'app:reload',
|
||||
@@ -15,18 +13,9 @@ export enum IpcChannel {
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
App_SetTheme = 'app:set-theme',
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_SetTestPlan = 'app:set-test-plan',
|
||||
App_SetTestChannel = 'app:set-test-channel',
|
||||
App_SetFeedUrl = 'app:set-feed-url',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
App_Select = 'app:select',
|
||||
App_HasWritePermission = 'app:has-write-permission',
|
||||
App_Copy = 'app:copy',
|
||||
App_SetStopQuitApp = 'app:set-stop-quit-app',
|
||||
App_SetAppDataPath = 'app:set-app-data-path',
|
||||
App_GetDataPathFromArgs = 'app:get-data-path-from-args',
|
||||
App_FlushAppData = 'app:flush-app-data',
|
||||
App_IsNotEmptyDir = 'app:is-not-empty-dir',
|
||||
App_RelaunchApp = 'app:relaunch-app',
|
||||
|
||||
App_IsBinaryExist = 'app:is-binary-exist',
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
@@ -38,7 +27,6 @@ export enum IpcChannel {
|
||||
Notification_OnClick = 'notification:on-click',
|
||||
|
||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
||||
|
||||
// Open
|
||||
Open_Path = 'open:path',
|
||||
@@ -71,9 +59,6 @@ export enum IpcChannel {
|
||||
Mcp_ServersUpdated = 'mcp:servers-updated',
|
||||
Mcp_CheckConnectivity = 'mcp:check-connectivity',
|
||||
|
||||
// Python
|
||||
Python_Execute = 'python:execute',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
export const documentExts = ['.pdf', '.doc', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const thirdPartyApplicationExts = ['.draftsExport']
|
||||
export const bookExts = ['.epub']
|
||||
const textExtsByCategory = new Map([
|
||||
@@ -406,16 +406,6 @@ export const defaultLanguage = 'en-US'
|
||||
|
||||
export enum FeedUrl {
|
||||
PRODUCTION = 'https://releases.cherry-ai.com',
|
||||
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download',
|
||||
PRERELEASE_LOWEST = 'https://github.com/CherryHQ/cherry-studio/releases/download/v1.4.0'
|
||||
EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
||||
}
|
||||
|
||||
export enum UpgradeChannel {
|
||||
LATEST = 'latest', // 最新稳定版本
|
||||
RC = 'rc', // 公测版本
|
||||
BETA = 'beta' // 预览版本
|
||||
}
|
||||
|
||||
export const defaultTimeout = 5 * 1000 * 60
|
||||
|
||||
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,12 +2,12 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const StreamZip = require('node-stream-zip')
|
||||
const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading bun binaries
|
||||
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
|
||||
const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version
|
||||
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const BUN_PACKAGES = {
|
||||
@@ -66,36 +66,35 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
|
||||
|
||||
// Extract the zip file using adm-zip
|
||||
console.log(`Extracting ${packageName} to ${binDir}...`)
|
||||
const zip = new StreamZip.async({ file: tempFilename })
|
||||
const zip = new AdmZip(tempFilename)
|
||||
zip.extractAllTo(tempdir, true)
|
||||
|
||||
// Get all entries in the zip file
|
||||
const entries = await zip.entries()
|
||||
// Move files using Node.js fs
|
||||
const sourceDir = path.join(tempdir, packageName.split('.')[0])
|
||||
const files = fs.readdirSync(sourceDir)
|
||||
|
||||
// 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)
|
||||
for (const file of files) {
|
||||
const sourcePath = path.join(sourceDir, file)
|
||||
const destPath = path.join(binDir, file)
|
||||
|
||||
console.log(`Extracting ${entry.name} -> ${filename}`)
|
||||
await zip.extract(entry.name, outputPath)
|
||||
// Make executable files executable on Unix-like systems
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(outputPath, 0o755)
|
||||
} catch (chmodError) {
|
||||
console.error(`Warning: Failed to set executable permissions on ${filename}`)
|
||||
return false
|
||||
}
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
fs.unlinkSync(sourcePath)
|
||||
|
||||
// Set executable permissions for non-Windows platforms
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
// 755 permission: rwxr-xr-x
|
||||
fs.chmodSync(destPath, '755')
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
|
||||
}
|
||||
console.log(`Extracted ${entry.name} -> ${outputPath}`)
|
||||
}
|
||||
}
|
||||
await zip.close()
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(tempFilename)
|
||||
fs.rmSync(sourceDir, { recursive: true })
|
||||
|
||||
console.log(`Successfully installed bun ${version} for ${platformKey}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,33 +2,34 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const StreamZip = require('node-stream-zip')
|
||||
const tar = require('tar')
|
||||
const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading uv binaries
|
||||
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
|
||||
const DEFAULT_UV_VERSION = '0.7.13'
|
||||
const DEFAULT_UV_VERSION = '0.6.14'
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const UV_PACKAGES = {
|
||||
'darwin-arm64': 'uv-aarch64-apple-darwin.zip',
|
||||
'darwin-x64': 'uv-x86_64-apple-darwin.zip',
|
||||
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
|
||||
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
|
||||
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
|
||||
'win32-ia32': 'uv-i686-pc-windows-msvc.zip',
|
||||
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
|
||||
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip',
|
||||
'linux-ia32': 'uv-i686-unknown-linux-gnu.zip',
|
||||
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip',
|
||||
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip',
|
||||
'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip',
|
||||
'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip',
|
||||
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip',
|
||||
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz',
|
||||
'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz',
|
||||
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz',
|
||||
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz',
|
||||
'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz',
|
||||
'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz',
|
||||
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz',
|
||||
// MUSL variants
|
||||
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip',
|
||||
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip',
|
||||
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip',
|
||||
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip',
|
||||
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip'
|
||||
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz',
|
||||
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,35 +66,46 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
// Get all entries in the zip file
|
||||
const entries = await zip.entries()
|
||||
// Move files using Node.js fs
|
||||
const sourceDir = path.join(tempdir, packageName.split('.')[0])
|
||||
const files = fs.readdirSync(sourceDir)
|
||||
for (const file of files) {
|
||||
const sourcePath = path.join(sourceDir, file)
|
||||
const destPath = path.join(binDir, file)
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
fs.unlinkSync(sourcePath)
|
||||
|
||||
// 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
|
||||
// Set executable permissions for non-Windows platforms
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(outputPath, 0o755)
|
||||
} catch (chmodError) {
|
||||
console.error(`Warning: Failed to set executable permissions on ${filename}`)
|
||||
return false
|
||||
fs.chmodSync(destPath, '755')
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
|
||||
}
|
||||
}
|
||||
console.log(`Extracted ${entry.name} -> ${outputPath}`)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(tempFilename)
|
||||
fs.rmSync(sourceDir, { recursive: true })
|
||||
}
|
||||
|
||||
await zip.close()
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { occupiedDirs } from '@shared/config/constant'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { initAppDataDir } from './utils/file'
|
||||
|
||||
app.isPackaged && initAppDataDir()
|
||||
|
||||
// 在主进程中复制 appData 中某些一直被占用的文件
|
||||
// 在renderer进程还没有启动时,主进程可以复制这些文件到新的appData中
|
||||
function copyOccupiedDirsInMainProcess() {
|
||||
const newAppDataPath = process.argv
|
||||
.slice(1)
|
||||
.find((arg) => arg.startsWith('--new-data-path='))
|
||||
?.split('--new-data-path=')[1]
|
||||
if (!newAppDataPath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const appDataPath = app.getPath('userData')
|
||||
occupiedDirs.forEach((dir) => {
|
||||
const dirPath = path.join(appDataPath, dir)
|
||||
const newDirPath = path.join(newAppDataPath, dir)
|
||||
if (fs.existsSync(dirPath)) {
|
||||
fs.cpSync(dirPath, newDirPath, { recursive: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
copyOccupiedDirsInMainProcess()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
if (isDev) {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// don't reorder this file, it's used to initialize the app data dir and
|
||||
// other which should be run before the main process is ready
|
||||
// eslint-disable-next-line
|
||||
import './bootstrap'
|
||||
|
||||
import '@main/config'
|
||||
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
@@ -25,6 +20,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { setUserDataDir } from './utils/file'
|
||||
|
||||
Logger.initialize()
|
||||
|
||||
@@ -76,6 +72,9 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
} else {
|
||||
// Portable dir must be setup before app ready
|
||||
setUserDataDir()
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
|
||||
161
src/main/ipc.ts
161
src/main/ipc.ts
@@ -1,14 +1,13 @@
|
||||
import fs from 'node:fs'
|
||||
import { arch } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
@@ -25,7 +24,6 @@ import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
@@ -36,7 +34,7 @@ import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file'
|
||||
import { getCacheDir, getConfigDir, getFilesDir } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
@@ -49,9 +47,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
|
||||
// Initialize Python service with main window
|
||||
pythonService.setMainWindow(mainWindow)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Info, () => ({
|
||||
version: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
@@ -62,8 +57,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
resourcesPath: getResourcePath(),
|
||||
logsPath: log.transports.file.getFile().path,
|
||||
arch: arch(),
|
||||
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env,
|
||||
installPath: path.dirname(app.getPath('exe'))
|
||||
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
|
||||
}))
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
|
||||
@@ -91,27 +85,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setLanguage(language)
|
||||
})
|
||||
|
||||
// spell check
|
||||
ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => {
|
||||
// disable spell check for all webviews
|
||||
const webviews = webContents.getAllWebContents()
|
||||
webviews.forEach((webview) => {
|
||||
webview.session.setSpellCheckerEnabled(isEnable)
|
||||
})
|
||||
})
|
||||
|
||||
// spell check languages
|
||||
ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => {
|
||||
if (languages.length === 0) {
|
||||
return
|
||||
}
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
windows.forEach((window) => {
|
||||
window.webContents.session.setSpellCheckerLanguages(languages)
|
||||
})
|
||||
configManager.set('spellCheckLanguages', languages)
|
||||
})
|
||||
|
||||
// launch on boot
|
||||
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
|
||||
// Set login item settings for windows and mac
|
||||
@@ -142,20 +115,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => {
|
||||
log.info('set test plan', isActive)
|
||||
if (isActive !== configManager.getTestPlan()) {
|
||||
appUpdater.cancelDownload()
|
||||
configManager.setTestPlan(isActive)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => {
|
||||
log.info('set test channel', channel)
|
||||
if (channel !== configManager.getTestChannel()) {
|
||||
appUpdater.cancelDownload()
|
||||
configManager.setTestChannel(channel)
|
||||
}
|
||||
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => {
|
||||
appUpdater.setFeedUrl(feedUrl)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
@@ -214,102 +175,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
let preventQuitListener: ((event: Electron.Event) => void) | null = null
|
||||
ipcMain.handle(IpcChannel.App_SetStopQuitApp, (_, stop: boolean = false, reason: string = '') => {
|
||||
if (stop) {
|
||||
// Only add listener if not already added
|
||||
if (!preventQuitListener) {
|
||||
preventQuitListener = (event: Electron.Event) => {
|
||||
event.preventDefault()
|
||||
notificationService.sendNotification({
|
||||
title: reason,
|
||||
message: reason
|
||||
} as Notification)
|
||||
}
|
||||
app.on('before-quit', preventQuitListener)
|
||||
}
|
||||
} else {
|
||||
// Remove listener if it exists
|
||||
if (preventQuitListener) {
|
||||
app.removeListener('before-quit', preventQuitListener)
|
||||
preventQuitListener = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Select app data path
|
||||
ipcMain.handle(IpcChannel.App_Select, async (_, options: Electron.OpenDialogOptions) => {
|
||||
try {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(options)
|
||||
if (canceled || filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
return filePaths[0]
|
||||
} catch (error: any) {
|
||||
log.error('Failed to select app data path:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_HasWritePermission, async (_, filePath: string) => {
|
||||
return hasWritePermission(filePath)
|
||||
})
|
||||
|
||||
// Set app data path
|
||||
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
|
||||
updateAppDataConfig(filePath)
|
||||
app.setPath('userData', filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => {
|
||||
return process.argv
|
||||
.slice(1)
|
||||
.find((arg) => arg.startsWith('--new-data-path='))
|
||||
?.split('--new-data-path=')[1]
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_FlushAppData, () => {
|
||||
BrowserWindow.getAllWindows().forEach((w) => {
|
||||
w.webContents.session.flushStorageData()
|
||||
w.webContents.session.cookies.flushStore()
|
||||
|
||||
w.webContents.session.closeAllConnections()
|
||||
})
|
||||
|
||||
session.defaultSession.flushStorageData()
|
||||
session.defaultSession.cookies.flushStore()
|
||||
session.defaultSession.closeAllConnections()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => {
|
||||
return fs.readdirSync(path).length > 0
|
||||
})
|
||||
|
||||
// Copy user data to new location
|
||||
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string, occupiedDirs: string[] = []) => {
|
||||
try {
|
||||
await fs.promises.cp(oldPath, newPath, {
|
||||
recursive: true,
|
||||
filter: (src) => {
|
||||
if (occupiedDirs.some((dir) => src.startsWith(path.resolve(dir)))) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
log.error('Failed to copy user data:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Relaunch app
|
||||
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
|
||||
app.relaunch(options)
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
// check for update
|
||||
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
|
||||
return await appUpdater.checkForUpdates()
|
||||
@@ -448,14 +313,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
|
||||
|
||||
// Register Python execution handler
|
||||
ipcMain.handle(
|
||||
IpcChannel.Python_Execute,
|
||||
async (_, script: string, context?: Record<string, any>, timeout?: number) => {
|
||||
return await pythonService.executeScript(script, context, timeout)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
|
||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
||||
@@ -501,12 +358,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
setOpenLinkExternal(webviewId, isExternal)
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
|
||||
const webview = webContents.fromId(webviewId)
|
||||
if (!webview) return
|
||||
webview.session.setSpellCheckerEnabled(isEnable)
|
||||
})
|
||||
|
||||
// store sync
|
||||
storeSyncService.registerIpcHandler()
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ const FILE_LOADER_MAP: Record<string, string> = {
|
||||
// 内置类型
|
||||
'.pdf': 'common',
|
||||
'.csv': 'common',
|
||||
'.doc': 'common',
|
||||
'.docx': 'common',
|
||||
'.pptx': 'common',
|
||||
'.xlsx': 'common',
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
|
||||
import { cleanString } from '@cherrystudio/embedjs-utils'
|
||||
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
|
||||
import md5 from 'md5'
|
||||
|
||||
export class NoteLoader extends BaseLoader<{ type: 'NoteLoader' }> {
|
||||
private readonly text: string
|
||||
private readonly sourceUrl?: string
|
||||
|
||||
constructor({
|
||||
text,
|
||||
sourceUrl,
|
||||
chunkSize,
|
||||
chunkOverlap
|
||||
}: {
|
||||
text: string
|
||||
sourceUrl?: string
|
||||
chunkSize?: number
|
||||
chunkOverlap?: number
|
||||
}) {
|
||||
super(`NoteLoader_${md5(text + (sourceUrl || ''))}`, { text, sourceUrl }, chunkSize ?? 2000, chunkOverlap ?? 0)
|
||||
this.text = text
|
||||
this.sourceUrl = sourceUrl
|
||||
}
|
||||
|
||||
override async *getUnfilteredChunks() {
|
||||
const chunker = new RecursiveCharacterTextSplitter({
|
||||
chunkSize: this.chunkSize,
|
||||
chunkOverlap: this.chunkOverlap
|
||||
})
|
||||
|
||||
const chunks = await chunker.splitText(cleanString(this.text))
|
||||
|
||||
for (const chunk of chunks) {
|
||||
yield {
|
||||
pageContent: chunk,
|
||||
metadata: {
|
||||
type: 'NoteLoader' as const,
|
||||
source: this.sourceUrl || 'note'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import DifyKnowledgeServer from './dify-knowledge'
|
||||
import FetchServer from './fetch'
|
||||
import FileSystemServer from './filesystem'
|
||||
import MemoryServer from './memory'
|
||||
import PythonServer from './python'
|
||||
import ThinkingServer from './sequentialthinking'
|
||||
|
||||
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
|
||||
@@ -32,9 +31,6 @@ export function createInMemoryMCPServer(name: string, args: string[] = [], envs:
|
||||
const difyKey = envs.DIFY_KEY
|
||||
return new DifyKnowledgeServer(difyKey, args).server
|
||||
}
|
||||
case '@cherry/python': {
|
||||
return new PythonServer().server
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { pythonService } from '@main/services/PythonService'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
/**
|
||||
* Python MCP Server for executing Python code using Pyodide
|
||||
*/
|
||||
class PythonServer {
|
||||
public server: Server
|
||||
|
||||
constructor() {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'python-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
this.setupRequestHandlers()
|
||||
}
|
||||
|
||||
private setupRequestHandlers() {
|
||||
// List available tools
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'python_execute',
|
||||
description: `Execute Python code using Pyodide in a sandboxed environment. Supports most Python standard library and scientific packages.
|
||||
The code will be executed with Python 3.12.
|
||||
Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start
|
||||
with a comment of the form:
|
||||
# /// script
|
||||
# dependencies = ['pydantic']
|
||||
# ///
|
||||
print('python code here')`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description: 'The Python code to execute'
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
description: 'Optional context variables to pass to the Python execution environment',
|
||||
additionalProperties: true
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds (default: 60000)',
|
||||
default: 60000
|
||||
}
|
||||
},
|
||||
required: ['code']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Handle tool calls
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
if (name !== 'python_execute') {
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Tool ${name} not found`)
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
code,
|
||||
context = {},
|
||||
timeout = 60000
|
||||
} = args as {
|
||||
code: string
|
||||
context?: Record<string, any>
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'Code parameter is required and must be a string')
|
||||
}
|
||||
|
||||
Logger.info('Executing Python code via Pyodide')
|
||||
|
||||
const result = await pythonService.executeScript(code, context, timeout)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error('Python execution error:', errorMessage)
|
||||
|
||||
throw new McpError(ErrorCode.InternalError, `Python execution failed: ${errorMessage}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default PythonServer
|
||||
@@ -106,7 +106,6 @@ class SequentialThinkingServer {
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
thought: validatedInput.thought,
|
||||
thoughtNumber: validatedInput.thoughtNumber,
|
||||
totalThoughts: validatedInput.totalThoughts,
|
||||
nextThoughtNeeded: validatedInput.nextThoughtNeeded,
|
||||
|
||||
@@ -17,7 +17,7 @@ export default abstract class BaseReranker {
|
||||
* Get Rerank Request Url
|
||||
*/
|
||||
protected getRerankUrl() {
|
||||
if (this.base.rerankModelProvider === 'bailian') {
|
||||
if (this.base.rerankModelProvider === 'dashscope') {
|
||||
return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default abstract class BaseReranker {
|
||||
documents,
|
||||
top_k: topN
|
||||
}
|
||||
} else if (provider === 'bailian') {
|
||||
} else if (provider === 'dashscope') {
|
||||
return {
|
||||
model: this.base.rerankModel,
|
||||
input: {
|
||||
@@ -82,11 +82,11 @@ export default abstract class BaseReranker {
|
||||
*/
|
||||
protected extractRerankResult(data: any) {
|
||||
const provider = this.base.rerankModelProvider
|
||||
if (provider === 'bailian') {
|
||||
if (provider === 'dashscope') {
|
||||
return data.output.results
|
||||
} else if (provider === 'voyageai') {
|
||||
return data.data
|
||||
} else if (provider?.includes('tei')) {
|
||||
} else if (provider === 'mis-tei') {
|
||||
return data.map((item: any) => {
|
||||
return {
|
||||
index: item.index,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater } from 'electron-updater'
|
||||
import path from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
@@ -14,8 +14,6 @@ import { configManager } from './ConfigManager'
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
private cancellationToken: CancellationToken = new CancellationToken()
|
||||
private updateCheckResult: UpdateCheckResult | null = null
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
logger.transports.file.level = 'info'
|
||||
@@ -24,7 +22,9 @@ export default class AppUpdater {
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||
autoUpdater.setFeedURL(configManager.getFeedUrl())
|
||||
|
||||
// 检测下载错误
|
||||
autoUpdater.on('error', (error) => {
|
||||
// 简单记录错误信息和时间戳
|
||||
logger.error('更新异常', {
|
||||
@@ -64,35 +64,6 @@ export default class AppUpdater {
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) {
|
||||
try {
|
||||
logger.info('get pre release version from github', channel)
|
||||
const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
})
|
||||
const data = (await responses.json()) as GithubReleaseInfo[]
|
||||
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
|
||||
return item.prerelease && item.tag_name.includes(`-${channel}.`)
|
||||
})
|
||||
|
||||
logger.info('release info', release)
|
||||
|
||||
if (!release) {
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info('release info', release.tag_name)
|
||||
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
|
||||
} catch (error) {
|
||||
logger.error('Failed to get latest not draft version from github:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async _getIpCountry() {
|
||||
try {
|
||||
// add timeout using AbortController
|
||||
@@ -122,72 +93,9 @@ export default class AppUpdater {
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
}
|
||||
|
||||
private _getChannelByVersion(version: string) {
|
||||
if (version.includes(`-${UpgradeChannel.BETA}.`)) {
|
||||
return UpgradeChannel.BETA
|
||||
}
|
||||
if (version.includes(`-${UpgradeChannel.RC}.`)) {
|
||||
return UpgradeChannel.RC
|
||||
}
|
||||
return UpgradeChannel.LATEST
|
||||
}
|
||||
|
||||
private _getTestChannel() {
|
||||
const currentChannel = this._getChannelByVersion(app.getVersion())
|
||||
const savedChannel = configManager.getTestChannel()
|
||||
|
||||
if (currentChannel === UpgradeChannel.LATEST) {
|
||||
return savedChannel || UpgradeChannel.RC
|
||||
}
|
||||
|
||||
if (savedChannel === currentChannel) {
|
||||
return savedChannel
|
||||
}
|
||||
|
||||
// if the upgrade channel is not equal to the current channel, use the latest channel
|
||||
return UpgradeChannel.LATEST
|
||||
}
|
||||
|
||||
private async _setFeedUrl() {
|
||||
const testPlan = configManager.getTestPlan()
|
||||
if (testPlan) {
|
||||
const channel = this._getTestChannel()
|
||||
|
||||
if (channel === UpgradeChannel.LATEST) {
|
||||
this.autoUpdater.channel = UpgradeChannel.LATEST
|
||||
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
|
||||
return
|
||||
}
|
||||
|
||||
const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel)
|
||||
if (preReleaseUrl) {
|
||||
this.autoUpdater.setFeedURL(preReleaseUrl)
|
||||
this.autoUpdater.channel = channel
|
||||
return
|
||||
}
|
||||
|
||||
// if no prerelease url, use lowest prerelease version to avoid error
|
||||
this.autoUpdater.setFeedURL(FeedUrl.PRERELEASE_LOWEST)
|
||||
this.autoUpdater.channel = UpgradeChannel.LATEST
|
||||
return
|
||||
}
|
||||
|
||||
this.autoUpdater.channel = UpgradeChannel.LATEST
|
||||
this.autoUpdater.setFeedURL(FeedUrl.PRODUCTION)
|
||||
|
||||
const ipCountry = await this._getIpCountry()
|
||||
logger.info('ipCountry', ipCountry)
|
||||
if (ipCountry.toLowerCase() !== 'cn') {
|
||||
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
|
||||
}
|
||||
}
|
||||
|
||||
public cancelDownload() {
|
||||
this.cancellationToken.cancel()
|
||||
this.cancellationToken = new CancellationToken()
|
||||
if (this.autoUpdater.autoDownload) {
|
||||
this.updateCheckResult?.cancellationToken?.cancel()
|
||||
}
|
||||
public setFeedUrl(feedUrl: FeedUrl) {
|
||||
autoUpdater.setFeedURL(feedUrl)
|
||||
configManager.setFeedUrl(feedUrl)
|
||||
}
|
||||
|
||||
public async checkForUpdates() {
|
||||
@@ -198,26 +106,23 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
await this._setFeedUrl()
|
||||
|
||||
// disable downgrade after change the channel
|
||||
this.autoUpdater.allowDowngrade = false
|
||||
|
||||
// github and gitcode don't support multiple range download
|
||||
this.autoUpdater.disableDifferentialDownload = true
|
||||
const ipCountry = await this._getIpCountry()
|
||||
logger.info('ipCountry', ipCountry)
|
||||
if (ipCountry !== 'CN') {
|
||||
this.autoUpdater.setFeedURL(FeedUrl.EARLY_ACCESS)
|
||||
}
|
||||
|
||||
try {
|
||||
this.updateCheckResult = await this.autoUpdater.checkForUpdates()
|
||||
if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
|
||||
const update = await this.autoUpdater.checkForUpdates()
|
||||
if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
|
||||
// 如果 autoDownload 为 false,则需要再调用下面的函数触发下
|
||||
// do not use await, because it will block the return of this function
|
||||
logger.info('downloadUpdate manual by check for updates', this.cancellationToken)
|
||||
this.autoUpdater.downloadUpdate(this.cancellationToken)
|
||||
this.autoUpdater.downloadUpdate()
|
||||
}
|
||||
|
||||
return {
|
||||
currentVersion: this.autoUpdater.currentVersion,
|
||||
updateInfo: this.updateCheckResult?.updateInfo
|
||||
updateInfo: update?.updateInfo
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check for update:', error)
|
||||
@@ -273,11 +178,7 @@ export default class AppUpdater {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
}
|
||||
|
||||
interface ReleaseNoteInfo {
|
||||
readonly version: string
|
||||
readonly note: string | null
|
||||
|
||||
@@ -9,7 +9,6 @@ import StreamZip from 'node-stream-zip'
|
||||
import * as path from 'path'
|
||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@@ -254,7 +253,7 @@ class BackupManager {
|
||||
Logger.log('[backup] step 3: restore Data directory')
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = getDataPath()
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
|
||||
const dataExists = await fs.pathExists(sourcePath)
|
||||
const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { defaultLanguage, FeedUrl, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
@@ -16,8 +16,7 @@ export enum ConfigKeys {
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
TestPlan = 'testPlan',
|
||||
TestChannel = 'testChannel',
|
||||
FeedUrl = 'feedUrl',
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
@@ -143,20 +142,12 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.AutoUpdate, value)
|
||||
}
|
||||
|
||||
getTestPlan(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.TestPlan, false)
|
||||
getFeedUrl(): string {
|
||||
return this.get<string>(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION)
|
||||
}
|
||||
|
||||
setTestPlan(value: boolean) {
|
||||
this.set(ConfigKeys.TestPlan, value)
|
||||
}
|
||||
|
||||
getTestChannel(): UpgradeChannel {
|
||||
return this.get<UpgradeChannel>(ConfigKeys.TestChannel)
|
||||
}
|
||||
|
||||
setTestChannel(value: UpgradeChannel) {
|
||||
this.set(ConfigKeys.TestChannel, value)
|
||||
setFeedUrl(value: FeedUrl) {
|
||||
this.set(ConfigKeys.FeedUrl, value)
|
||||
}
|
||||
|
||||
getEnableDataCollection(): boolean {
|
||||
|
||||
@@ -4,29 +4,18 @@ import { locales } from '../utils/locales'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ContextMenu {
|
||||
public contextMenu(w: Electron.WebContents) {
|
||||
w.on('context-menu', (_event, properties) => {
|
||||
public contextMenu(w: Electron.BrowserWindow) {
|
||||
w.webContents.on('context-menu', (_event, properties) => {
|
||||
const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties)
|
||||
const filtered = template.filter((item) => item.visible !== false)
|
||||
if (filtered.length > 0) {
|
||||
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)
|
||||
const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)])
|
||||
menu.popup()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] {
|
||||
private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { common } = locale.translation
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
@@ -34,7 +23,7 @@ class ContextMenu {
|
||||
id: 'inspect',
|
||||
label: common.inspect,
|
||||
click: () => {
|
||||
w.toggleDevTools()
|
||||
w.webContents.toggleDevTools()
|
||||
},
|
||||
enabled: true
|
||||
}
|
||||
@@ -83,53 +72,6 @@ class ContextMenu {
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
private createSpellCheckMenuItem(
|
||||
properties: Electron.ContextMenuParams,
|
||||
w: Electron.WebContents
|
||||
): MenuItemConstructorOptions {
|
||||
const hasText = properties.selectionText.length > 0
|
||||
|
||||
return {
|
||||
id: 'learnSpelling',
|
||||
label: '&Learn Spelling',
|
||||
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
|
||||
click: () => {
|
||||
w.session.addWordToSpellCheckerDictionary(properties.misspelledWord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createDictionarySuggestions(
|
||||
properties: Electron.ContextMenuParams,
|
||||
w: Electron.WebContents
|
||||
): MenuItemConstructorOptions[] {
|
||||
const hasText = properties.selectionText.length > 0
|
||||
|
||||
if (!hasText || !properties.misspelledWord) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (properties.dictionarySuggestions.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: 'dictionarySuggestions',
|
||||
label: 'No Guesses Found',
|
||||
visible: true,
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return properties.dictionarySuggestions.map((suggestion) => ({
|
||||
id: 'dictionarySuggestions',
|
||||
label: suggestion,
|
||||
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
|
||||
click: (menuItem: Electron.MenuItem) => {
|
||||
w.replaceMisspelling(menuItem.label)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export const contextMenu = new ContextMenu()
|
||||
|
||||
@@ -19,7 +19,6 @@ import { getDocument } from 'officeparser/pdfjs-dist-build/pdf.js'
|
||||
import * as path from 'path'
|
||||
import { chdir } from 'process'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import WordExtractor from 'word-extractor'
|
||||
|
||||
class FileStorage {
|
||||
private storageDir = getFilesDir()
|
||||
@@ -221,20 +220,10 @@ class FileStorage {
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
|
||||
const fileExtension = path.extname(filePath)
|
||||
|
||||
if (documentExts.includes(fileExtension)) {
|
||||
if (documentExts.includes(path.extname(filePath))) {
|
||||
const originalCwd = process.cwd()
|
||||
try {
|
||||
chdir(this.tempDir)
|
||||
|
||||
if (fileExtension === '.doc') {
|
||||
const extractor = new WordExtractor()
|
||||
const extracted = await extractor.extract(filePath)
|
||||
chdir(originalCwd)
|
||||
return extracted.getBody()
|
||||
}
|
||||
|
||||
const data = await officeParser.parseOfficeAsync(filePath)
|
||||
chdir(originalCwd)
|
||||
return data
|
||||
@@ -363,7 +352,7 @@ class FileStorage {
|
||||
public open = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options: OpenDialogOptions
|
||||
): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => {
|
||||
): Promise<{ fileName: string; filePath: string; content: Buffer } | null> => {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '打开文件',
|
||||
@@ -375,16 +364,8 @@ class FileStorage {
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
|
||||
// If the file is less than 2GB, read the content
|
||||
if (stats.size < 2 * 1024 * 1024 * 1024) {
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, filePath, content, size: stats.size }
|
||||
}
|
||||
|
||||
// For large files, only return file information, do not read content
|
||||
return { fileName, filePath, size: stats.size }
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, filePath, content }
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -16,22 +16,21 @@
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { RAGApplication, RAGApplicationBuilder } from '@cherrystudio/embedjs'
|
||||
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@cherrystudio/embedjs'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
|
||||
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import Embeddings from '@main/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/loader'
|
||||
import { NoteLoader } from '@main/loader/noteLoader'
|
||||
import Reranker from '@main/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -89,7 +88,7 @@ const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
|
||||
}
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(getDataPath(), 'KnowledgeBase')
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
|
||||
// Byte based
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
@@ -144,7 +143,7 @@ class KnowledgeService {
|
||||
this.getRagApplication(base)
|
||||
}
|
||||
|
||||
public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
|
||||
public reset = async (_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise<void> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
await ragApplication.reset()
|
||||
}
|
||||
@@ -334,7 +333,6 @@ class KnowledgeService {
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const content = item.content as string
|
||||
const sourceUrl = (item as any).sourceUrl
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const contentBytes = encoder.encode(content)
|
||||
@@ -344,12 +342,7 @@ class KnowledgeService {
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () => {
|
||||
const loaderReturn = ragApplication.addLoader(
|
||||
new NoteLoader({
|
||||
text: content,
|
||||
sourceUrl,
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}),
|
||||
new TextLoader({ text: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }),
|
||||
forceReload
|
||||
) as Promise<LoaderReturn>
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
interface PythonExecutionRequest {
|
||||
id: string
|
||||
script: string
|
||||
context: Record<string, any>
|
||||
timeout: number
|
||||
}
|
||||
|
||||
interface PythonExecutionResponse {
|
||||
id: string
|
||||
result?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for executing Python code by communicating with the PyodideService in the renderer process
|
||||
*/
|
||||
export class PythonService {
|
||||
private static instance: PythonService | null = null
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
private pendingRequests = new Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>()
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
this.setupIpcHandlers()
|
||||
}
|
||||
|
||||
public static getInstance(): PythonService {
|
||||
if (!PythonService.instance) {
|
||||
PythonService.instance = new PythonService()
|
||||
}
|
||||
return PythonService.instance
|
||||
}
|
||||
|
||||
private setupIpcHandlers() {
|
||||
// Handle responses from renderer
|
||||
ipcMain.on('python-execution-response', (_, response: PythonExecutionResponse) => {
|
||||
const request = this.pendingRequests.get(response.id)
|
||||
if (request) {
|
||||
this.pendingRequests.delete(response.id)
|
||||
if (response.error) {
|
||||
request.reject(new Error(response.error))
|
||||
} else {
|
||||
request.resolve(response.result || '')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public setMainWindow(mainWindow: BrowserWindow) {
|
||||
this.mainWindow = mainWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Python code by sending request to renderer PyodideService
|
||||
*/
|
||||
public async executeScript(
|
||||
script: string,
|
||||
context: Record<string, any> = {},
|
||||
timeout: number = 60000
|
||||
): Promise<string> {
|
||||
if (!this.mainWindow) {
|
||||
throw new Error('Main window not set in PythonService')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = randomUUID()
|
||||
|
||||
// Store the request
|
||||
this.pendingRequests.set(requestId, { resolve, reject })
|
||||
|
||||
// Set up timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId)
|
||||
reject(new Error('Python execution timed out'))
|
||||
}, timeout + 5000) // Add 5s buffer for IPC communication
|
||||
|
||||
// Update resolve/reject to clear timeout
|
||||
const originalResolve = resolve
|
||||
const originalReject = reject
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve: (value: string) => {
|
||||
clearTimeout(timeoutId)
|
||||
originalResolve(value)
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timeoutId)
|
||||
originalReject(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Send request to renderer
|
||||
const request: PythonExecutionRequest = { id: requestId, script, context, timeout }
|
||||
this.mainWindow?.webContents.send('python-execution-request', request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const pythonService = PythonService.getInstance()
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
import { isMac } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron'
|
||||
|
||||
@@ -6,7 +6,6 @@ import icon from '../../../build/tray_icon.png?asset'
|
||||
import iconDark from '../../../build/tray_icon_dark.png?asset'
|
||||
import iconLight from '../../../build/tray_icon_light.png?asset'
|
||||
import { ConfigKeys, configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
export class TrayService {
|
||||
@@ -30,14 +29,14 @@ export class TrayService {
|
||||
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
|
||||
const tray = new Tray(iconPath)
|
||||
|
||||
if (isWin) {
|
||||
if (process.platform === 'win32') {
|
||||
tray.setImage(iconPath)
|
||||
} else if (isMac) {
|
||||
} else if (process.platform === 'darwin') {
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
const resizedImage = image.resize({ width: 16, height: 16 })
|
||||
resizedImage.setTemplateImage(true)
|
||||
tray.setImage(resizedImage)
|
||||
} else if (isLinux) {
|
||||
} else if (process.platform === 'linux') {
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
const resizedImage = image.resize({ width: 16, height: 16 })
|
||||
tray.setImage(resizedImage)
|
||||
@@ -47,7 +46,7 @@ export class TrayService {
|
||||
|
||||
this.updateContextMenu()
|
||||
|
||||
if (isLinux) {
|
||||
if (process.platform === 'linux') {
|
||||
this.tray.setContextMenu(this.contextMenu)
|
||||
}
|
||||
|
||||
@@ -70,31 +69,19 @@ export class TrayService {
|
||||
|
||||
private updateContextMenu() {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { tray: trayLocale, selection: selectionLocale } = locale.translation
|
||||
const { tray: trayLocale } = locale.translation
|
||||
|
||||
const quickAssistantEnabled = configManager.getEnableQuickAssistant()
|
||||
const selectionAssistantEnabled = configManager.getSelectionAssistantEnabled()
|
||||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: trayLocale.show_window,
|
||||
click: () => windowService.showMainWindow()
|
||||
},
|
||||
quickAssistantEnabled && {
|
||||
enableQuickAssistant && {
|
||||
label: trayLocale.show_mini_window,
|
||||
click: () => windowService.showMiniWindow()
|
||||
},
|
||||
isWin && {
|
||||
label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'),
|
||||
// type: 'checkbox',
|
||||
// checked: selectionAssistantEnabled,
|
||||
click: () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
this.updateContextMenu()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: trayLocale.quit,
|
||||
@@ -131,10 +118,6 @@ export class TrayService {
|
||||
configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
}
|
||||
|
||||
private quit() {
|
||||
|
||||
@@ -56,7 +56,7 @@ export class WindowService {
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: false,
|
||||
transparent: isMac,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
@@ -95,7 +95,6 @@ export class WindowService {
|
||||
|
||||
this.setupMaximize(mainWindow, mainWindowState.isMaximized)
|
||||
this.setupContextMenu(mainWindow)
|
||||
this.setupSpellCheck(mainWindow)
|
||||
this.setupWindowEvents(mainWindow)
|
||||
this.setupWebContentsHandlers(mainWindow)
|
||||
this.setupWindowLifecycleEvents(mainWindow)
|
||||
@@ -103,18 +102,6 @@ export class WindowService {
|
||||
this.loadMainWindowContent(mainWindow)
|
||||
}
|
||||
|
||||
private setupSpellCheck(mainWindow: BrowserWindow) {
|
||||
const enableSpellCheck = configManager.get('enableSpellCheck', false)
|
||||
if (enableSpellCheck) {
|
||||
try {
|
||||
const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[]
|
||||
spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages)
|
||||
} catch (error) {
|
||||
Logger.error('Failed to set spell check languages:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupMainWindowMonitor(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.on('render-process-gone', (_, details) => {
|
||||
Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`)
|
||||
@@ -143,10 +130,9 @@ export class WindowService {
|
||||
}
|
||||
|
||||
private setupContextMenu(mainWindow: BrowserWindow) {
|
||||
contextMenu.contextMenu(mainWindow.webContents)
|
||||
// setup context menu for all webviews like miniapp
|
||||
app.on('web-contents-created', (_, webContents) => {
|
||||
contextMenu.contextMenu(webContents)
|
||||
contextMenu.contextMenu(mainWindow)
|
||||
app.on('browser-window-created', (_, win) => {
|
||||
contextMenu.contextMenu(win)
|
||||
})
|
||||
|
||||
// Dangerous API
|
||||
|
||||
@@ -92,7 +92,6 @@ describe('file', () => {
|
||||
it('should return DOCUMENT for document extensions', () => {
|
||||
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.doc')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)
|
||||
|
||||
@@ -2,26 +2,12 @@ import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isPortable } from '@main/constant'
|
||||
import { isMac } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileType, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function initAppDataDir() {
|
||||
const appDataPath = getAppDataPathFromConfig()
|
||||
if (appDataPath) {
|
||||
app.setPath('userData', appDataPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPortable) {
|
||||
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
|
||||
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 创建文件类型映射表,提高查找效率
|
||||
const fileTypeMap = new Map<string, FileTypes>()
|
||||
|
||||
@@ -37,85 +23,6 @@ function initFileTypeMap() {
|
||||
// 初始化映射表
|
||||
initFileTypeMap()
|
||||
|
||||
export function hasWritePermission(path: string) {
|
||||
try {
|
||||
fs.accessSync(path, fs.constants.W_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getAppDataPathFromConfig() {
|
||||
try {
|
||||
const configPath = path.join(getConfigDir(), 'config.json')
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
|
||||
if (!config.appDataPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
let appDataPath = null
|
||||
// 兼容旧版本
|
||||
if (config.appDataPath && typeof config.appDataPath === 'string') {
|
||||
appDataPath = config.appDataPath
|
||||
// 将旧版本数据迁移到新版本
|
||||
appDataPath && updateAppDataConfig(appDataPath)
|
||||
} else {
|
||||
appDataPath = config.appDataPath.find(
|
||||
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
|
||||
)?.dataPath
|
||||
}
|
||||
|
||||
if (appDataPath && fs.existsSync(appDataPath) && hasWritePermission(appDataPath)) {
|
||||
return appDataPath
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAppDataConfig(appDataPath: string) {
|
||||
const configDir = getConfigDir()
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
|
||||
// config.json
|
||||
// appDataPath: [{ executablePath: string, dataPath: string }]
|
||||
const configPath = path.join(getConfigDir(), 'config.json')
|
||||
if (!fs.existsSync(configPath)) {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
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))
|
||||
}
|
||||
|
||||
export function getFileType(ext: string): FileTypes {
|
||||
ext = ext.toLowerCase()
|
||||
return fileTypeMap.get(ext) || FileTypes.OTHER
|
||||
@@ -181,3 +88,12 @@ export function getCacheDir() {
|
||||
export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
export function setUserDataDir() {
|
||||
if (!isMac) {
|
||||
const dir = path.join(path.dirname(app.getPath('exe')), 'data')
|
||||
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
|
||||
app.setPath('userData', dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
@@ -17,28 +17,15 @@ const api = {
|
||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
||||
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
||||
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
||||
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive),
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
setTestPlan: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTestPlan, isActive),
|
||||
setTestChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetTestChannel, channel),
|
||||
setFeedUrl: (feedUrl: FeedUrl) => ipcRenderer.invoke(IpcChannel.App_SetFeedUrl, feedUrl),
|
||||
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
|
||||
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
|
||||
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
|
||||
getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs),
|
||||
copy: (oldPath: string, newPath: string, occupiedDirs: string[] = []) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath, occupiedDirs),
|
||||
setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason),
|
||||
flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData),
|
||||
isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path),
|
||||
relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
@@ -183,10 +170,6 @@ const api = {
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
|
||||
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
|
||||
},
|
||||
python: {
|
||||
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
|
||||
},
|
||||
shell: {
|
||||
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
|
||||
},
|
||||
@@ -229,9 +212,7 @@ const api = {
|
||||
},
|
||||
webview: {
|
||||
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
||||
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal)
|
||||
},
|
||||
storeSync: {
|
||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||
|
||||
@@ -2,45 +2,42 @@
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
SdkToolCall
|
||||
} from '@renderer/types/sdk'
|
||||
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
|
||||
import { BaseApiClient } from './BaseApiClient'
|
||||
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
|
||||
@@ -42,19 +41,11 @@ export class AihubmixAPIClient extends BaseApiClient {
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
|
||||
const providerExtraHeaders = {
|
||||
...provider,
|
||||
extra_headers: {
|
||||
...provider.extra_headers,
|
||||
'APP-Code': 'MLTG2087'
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化各个client - 现在有类型安全
|
||||
const claudeClient = new AnthropicAPIClient(providerExtraHeaders)
|
||||
const geminiClient = new GeminiAPIClient({ ...providerExtraHeaders, apiHost: 'https://aihubmix.com/gemini' })
|
||||
const openaiClient = new OpenAIResponseAPIClient(providerExtraHeaders)
|
||||
const defaultClient = new OpenAIAPIClient(providerExtraHeaders)
|
||||
const claudeClient = new AnthropicAPIClient(provider)
|
||||
const geminiClient = new GeminiAPIClient({ ...provider, apiHost: 'https://aihubmix.com/gemini' })
|
||||
const openaiClient = new OpenAIResponseAPIClient(provider)
|
||||
const defaultClient = new OpenAIAPIClient(provider)
|
||||
|
||||
this.clients.set('claude', claudeClient)
|
||||
this.clients.set('gemini', geminiClient)
|
||||
@@ -66,13 +57,6 @@ export class AihubmixAPIClient extends BaseApiClient {
|
||||
this.currentClient = this.defaultClient as BaseApiClient
|
||||
}
|
||||
|
||||
override getBaseURL(): string {
|
||||
if (!this.currentClient) {
|
||||
return this.provider.apiHost
|
||||
}
|
||||
return this.currentClient.getBaseURL()
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:确保client是BaseApiClient的实例
|
||||
*/
|
||||
@@ -179,8 +163,8 @@ export class AihubmixAPIClient extends BaseApiClient {
|
||||
return this.currentClient.getRequestTransformer()
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<SdkRawChunk> {
|
||||
return this.currentClient.getResponseChunkTransformer(ctx)
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<SdkRawChunk> {
|
||||
return this.currentClient.getResponseChunkTransformer()
|
||||
}
|
||||
|
||||
convertMcpToolsToSdkTools(mcpTools: MCPTool[]): SdkTool[] {
|
||||
|
||||
@@ -42,8 +42,7 @@ import { defaultTimeout } from '@shared/config/constant'
|
||||
import Logger from 'electron-log/renderer'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
import { ApiClient, RequestTransformer, ResponseChunkTransformer } from './types'
|
||||
import { ApiClient, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from './types'
|
||||
|
||||
/**
|
||||
* Abstract base class for API clients.
|
||||
@@ -96,7 +95,7 @@ export abstract class BaseApiClient<
|
||||
// 在 CoreRequestToSdkParamsMiddleware中使用
|
||||
abstract getRequestTransformer(): RequestTransformer<TSdkParams, TMessageParam>
|
||||
// 在RawSdkChunkToGenericChunkMiddleware中使用
|
||||
abstract getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<TRawChunk>
|
||||
abstract getResponseChunkTransformer(): ResponseChunkTransformer<TRawChunk>
|
||||
|
||||
/**
|
||||
* 工具转换
|
||||
@@ -111,7 +110,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
abstract buildSdkMessages(
|
||||
currentReqMessages: TMessageParam[],
|
||||
output: TRawOutput | string | undefined,
|
||||
output: TRawOutput | string,
|
||||
toolResults: TMessageParam[],
|
||||
toolCalls?: TToolCall[]
|
||||
): TMessageParam[]
|
||||
@@ -130,6 +129,17 @@ export abstract class BaseApiClient<
|
||||
*/
|
||||
abstract extractMessagesFromSdkPayload(sdkPayload: TSdkParams): TMessageParam[]
|
||||
|
||||
/**
|
||||
* 附加原始流监听器
|
||||
*/
|
||||
public attachRawStreamListener<TListener extends RawStreamListener<TRawChunk>>(
|
||||
rawOutput: TRawOutput,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_listener: TListener
|
||||
): TRawOutput {
|
||||
return rawOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用函数
|
||||
**/
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
mcpToolCallResponseToAnthropicMessage,
|
||||
mcpToolsToAnthropicTools
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
|
||||
import { BaseApiClient } from '../BaseApiClient'
|
||||
@@ -90,12 +90,11 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return this.sdkInstance
|
||||
}
|
||||
this.sdkInstance = new Anthropic({
|
||||
apiKey: this.apiKey,
|
||||
apiKey: this.getApiKey(),
|
||||
baseURL: this.getBaseURL(),
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: {
|
||||
'anthropic-beta': 'output-128k-2025-02-19',
|
||||
...this.provider.extra_headers
|
||||
'anthropic-beta': 'output-128k-2025-02-19'
|
||||
}
|
||||
})
|
||||
return this.sdkInstance
|
||||
@@ -126,7 +125,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
|
||||
// @ts-ignore sdk未提供
|
||||
override async getEmbeddingDimensions(): Promise<number> {
|
||||
throw new Error("Anthropic SDK doesn't support getEmbeddingDimensions method.")
|
||||
return 0
|
||||
}
|
||||
|
||||
override getTemperature(assistant: Assistant, model: Model): number | undefined {
|
||||
@@ -192,7 +191,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
const parts: MessageParam['content'] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: await this.getMessageContent(message)
|
||||
text: getMainTextContent(message)
|
||||
}
|
||||
]
|
||||
|
||||
@@ -368,13 +367,12 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
* Anthropic专用的原始流监听器
|
||||
* 处理MessageStream对象的特定事件
|
||||
*/
|
||||
attachRawStreamListener(
|
||||
override attachRawStreamListener(
|
||||
rawOutput: AnthropicSdkRawOutput,
|
||||
listener: RawStreamListener<AnthropicSdkRawChunk>
|
||||
): AnthropicSdkRawOutput {
|
||||
console.log(`[AnthropicApiClient] 附加流监听器到原始输出`)
|
||||
// 专用的Anthropic事件处理
|
||||
const anthropicListener = listener as AnthropicStreamListener
|
||||
|
||||
// 检查是否为MessageStream
|
||||
if (rawOutput instanceof MessageStream) {
|
||||
console.log(`[AnthropicApiClient] 检测到 Anthropic MessageStream,附加专用监听器`)
|
||||
@@ -389,6 +387,9 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
})
|
||||
}
|
||||
|
||||
// 专用的Anthropic事件处理
|
||||
const anthropicListener = listener as AnthropicStreamListener
|
||||
|
||||
if (anthropicListener.onContentBlock) {
|
||||
rawOutput.on('contentBlock', anthropicListener.onContentBlock)
|
||||
}
|
||||
@@ -412,10 +413,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return rawOutput
|
||||
}
|
||||
|
||||
if (anthropicListener.onMessage) {
|
||||
anthropicListener.onMessage(rawOutput)
|
||||
}
|
||||
|
||||
// 对于非MessageStream响应
|
||||
return rawOutput
|
||||
}
|
||||
@@ -521,7 +518,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
switch (rawChunk.type) {
|
||||
case 'message': {
|
||||
let i = 0
|
||||
for (const content of rawChunk.content) {
|
||||
switch (content.type) {
|
||||
case 'text': {
|
||||
@@ -532,8 +528,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'tool_use': {
|
||||
toolCalls[i] = content
|
||||
i++
|
||||
toolCalls[0] = content
|
||||
break
|
||||
}
|
||||
case 'thinking': {
|
||||
@@ -555,22 +550,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
}
|
||||
if (i > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: Object.values(toolCalls)
|
||||
} as MCPToolCreatedChunk)
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: rawChunk.usage.input_tokens || 0,
|
||||
completion_tokens: rawChunk.usage.output_tokens || 0,
|
||||
total_tokens: (rawChunk.usage.input_tokens || 0) + (rawChunk.usage.output_tokens || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'content_block_start': {
|
||||
|
||||
@@ -85,7 +85,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
...rest,
|
||||
config: {
|
||||
...rest.config,
|
||||
abortSignal: options?.signal,
|
||||
abortSignal: options?.abortSignal,
|
||||
httpOptions: {
|
||||
...rest.config?.httpOptions,
|
||||
timeout: options?.timeout
|
||||
@@ -147,12 +147,15 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
|
||||
override async getEmbeddingDimensions(model: Model): Promise<number> {
|
||||
const sdk = await this.getSdkInstance()
|
||||
|
||||
const data = await sdk.models.embedContent({
|
||||
model: model.id,
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
|
||||
})
|
||||
return data.embeddings?.[0]?.values?.length || 0
|
||||
try {
|
||||
const data = await sdk.models.embedContent({
|
||||
model: model.id,
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
|
||||
})
|
||||
return data.embeddings?.[0]?.values?.length || 0
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
override async listModels(): Promise<GeminiModel[]> {
|
||||
@@ -176,10 +179,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
apiVersion: this.getApiVersion(),
|
||||
httpOptions: {
|
||||
baseUrl: this.getBaseURL(),
|
||||
apiVersion: this.getApiVersion(),
|
||||
headers: {
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
apiVersion: this.getApiVersion()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -416,9 +416,8 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
const { min, max } = findTokenLimit(model.id) || { min: 0, max: 0 }
|
||||
// 计算 budgetTokens,确保不低于 min
|
||||
const budget = Math.floor((max - min) * effortRatio + min)
|
||||
const { max } = findTokenLimit(model.id) || { max: 0 }
|
||||
const budget = Math.floor(max * effortRatio)
|
||||
|
||||
return {
|
||||
thinkingConfig: {
|
||||
@@ -467,7 +466,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
systemInstruction = await buildSystemPrompt(assistant.prompt || '', mcpTools, assistant)
|
||||
}
|
||||
|
||||
let messageContents: Content = { role: 'user', parts: [] } // Initialize messageContents
|
||||
let messageContents: Content
|
||||
const history: Content[] = []
|
||||
// 3. 处理用户消息
|
||||
if (typeof messages === 'string') {
|
||||
@@ -476,13 +475,10 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
parts: [{ text: messages }]
|
||||
}
|
||||
} else {
|
||||
const userLastMessage = messages.pop()
|
||||
if (userLastMessage) {
|
||||
messageContents = await this.convertMessageToSdkParam(userLastMessage)
|
||||
for (const message of messages) {
|
||||
history.push(await this.convertMessageToSdkParam(message))
|
||||
}
|
||||
messages.push(userLastMessage)
|
||||
const userLastMessage = messages.pop()!
|
||||
messageContents = await this.convertMessageToSdkParam(userLastMessage)
|
||||
for (const message of messages) {
|
||||
history.push(await this.convertMessageToSdkParam(message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,10 +491,6 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
if (isGemmaModel(model) && assistant.prompt) {
|
||||
const isFirstMessage = history.length === 0
|
||||
if (isFirstMessage && messageContents) {
|
||||
const userMessageText =
|
||||
messageContents.parts && messageContents.parts.length > 0
|
||||
? (messageContents.parts[0] as Part).text || ''
|
||||
: ''
|
||||
const systemMessage = [
|
||||
{
|
||||
text:
|
||||
@@ -506,7 +498,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
systemInstruction +
|
||||
'<end_of_turn>\n' +
|
||||
'<start_of_turn>user\n' +
|
||||
userMessageText +
|
||||
(messageContents?.parts?.[0] as Part).text +
|
||||
'<end_of_turn>'
|
||||
}
|
||||
] as Part[]
|
||||
@@ -523,7 +515,13 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
|
||||
const newMessageContents =
|
||||
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
|
||||
? recursiveSdkMessages[recursiveSdkMessages.length - 1]
|
||||
? {
|
||||
...messageContents,
|
||||
parts: [
|
||||
...(messageContents.parts || []),
|
||||
...(recursiveSdkMessages[recursiveSdkMessages.length - 1].parts || [])
|
||||
]
|
||||
}
|
||||
: messageContents
|
||||
|
||||
const generateContentConfig: GenerateContentConfig = {
|
||||
@@ -557,7 +555,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
|
||||
return () => ({
|
||||
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
let toolCalls: FunctionCall[] = []
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
for (const candidate of chunk.candidates) {
|
||||
if (candidate.content) {
|
||||
@@ -585,8 +583,6 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
]
|
||||
}
|
||||
})
|
||||
} else if (part.functionCall) {
|
||||
toolCalls.push(part.functionCall)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -601,6 +597,9 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
} as LLMWebSearchCompleteChunk)
|
||||
}
|
||||
if (chunk.functionCalls) {
|
||||
toolCalls = toolCalls.concat(chunk.functionCalls)
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
@@ -686,19 +685,16 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
toolCalls: FunctionCall[]
|
||||
): Content[] {
|
||||
const parts: Part[] = []
|
||||
const modelParts: Part[] = []
|
||||
if (output) {
|
||||
modelParts.push({
|
||||
parts.push({
|
||||
text: output
|
||||
})
|
||||
}
|
||||
|
||||
toolCalls.forEach((toolCall) => {
|
||||
modelParts.push({
|
||||
parts.push({
|
||||
functionCall: toolCall
|
||||
})
|
||||
})
|
||||
|
||||
parts.push(
|
||||
...toolResults
|
||||
.map((ts) => ts.parts)
|
||||
@@ -708,21 +704,10 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
|
||||
const userMessage: Content = {
|
||||
role: 'user',
|
||||
parts: []
|
||||
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, userMessage]
|
||||
}
|
||||
|
||||
override estimateMessageTokens(message: GeminiSdkMessageParam): number {
|
||||
@@ -749,20 +734,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
public extractMessagesFromSdkPayload(sdkPayload: GeminiSdkParams): GeminiSdkMessageParam[] {
|
||||
const messageParam: GeminiSdkMessageParam = {
|
||||
role: 'user',
|
||||
parts: []
|
||||
}
|
||||
if (Array.isArray(sdkPayload.message)) {
|
||||
sdkPayload.message.forEach((part) => {
|
||||
if (typeof part === 'string') {
|
||||
messageParam.parts?.push({ text: part })
|
||||
} else if (typeof part === 'object') {
|
||||
messageParam.parts?.push(part)
|
||||
}
|
||||
})
|
||||
}
|
||||
return [...(sdkPayload.history || []), messageParam]
|
||||
return sdkPayload.history || []
|
||||
}
|
||||
|
||||
private async uploadFile(file: FileType): Promise<File> {
|
||||
|
||||
@@ -113,9 +113,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if (!reasoningEffort) {
|
||||
if (model.provider === 'openrouter') {
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
}
|
||||
if (isSupportedThinkingTokenQwenModel(model)) {
|
||||
return { enable_thinking: false }
|
||||
}
|
||||
@@ -125,6 +122,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
// openrouter没有提供一个不推理的选项,先隐藏
|
||||
if (this.provider.id === 'openrouter') {
|
||||
return { reasoning: { max_tokens: 0, exclude: true } }
|
||||
}
|
||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
return { reasoning_effort: 'none' }
|
||||
}
|
||||
@@ -336,14 +337,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
public buildSdkMessages(
|
||||
currentReqMessages: OpenAISdkMessageParam[],
|
||||
output: string | undefined,
|
||||
output: string,
|
||||
toolResults: OpenAISdkMessageParam[],
|
||||
toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[]
|
||||
): OpenAISdkMessageParam[] {
|
||||
if (!output && toolCalls.length === 0) {
|
||||
return [...currentReqMessages, ...toolResults]
|
||||
}
|
||||
|
||||
const assistantMessage: OpenAISdkMessageParam = {
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
@@ -493,7 +490,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 在RawSdkChunkToGenericChunkMiddleware中使用
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAISdkRawChunk> {
|
||||
getResponseChunkTransformer = (): ResponseChunkTransformer<OpenAISdkRawChunk> => {
|
||||
let hasBeenCollectedWebSearch = false
|
||||
const collectWebSearchData = (
|
||||
chunk: OpenAISdkRawChunk,
|
||||
@@ -587,52 +584,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = []
|
||||
let isFinished = false
|
||||
let lastUsageInfo: any = null
|
||||
|
||||
/**
|
||||
* 统一的完成信号发送逻辑
|
||||
* - 有 finish_reason 时
|
||||
* - 无 finish_reason 但是流正常结束时
|
||||
*/
|
||||
const emitCompletionSignals = (controller: TransformStreamDefaultController<GenericChunk>) => {
|
||||
if (isFinished) return
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
|
||||
const usage = lastUsageInfo || {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: { usage }
|
||||
})
|
||||
|
||||
// 防止重复发送
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 持续更新usage信息
|
||||
if (chunk.usage) {
|
||||
lastUsageInfo = {
|
||||
prompt_tokens: chunk.usage.prompt_tokens || 0,
|
||||
completion_tokens: chunk.usage.completion_tokens || 0,
|
||||
total_tokens: (chunk.usage.prompt_tokens || 0) + (chunk.usage.completion_tokens || 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理chunk
|
||||
if ('choices' in chunk && chunk.choices && chunk.choices.length > 0) {
|
||||
const choice = chunk.choices[0]
|
||||
@@ -697,6 +651,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// 处理finish_reason,发送流结束信号
|
||||
if ('finish_reason' in choice && choice.finish_reason) {
|
||||
Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`)
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
const webSearchData = collectWebSearchData(chunk, contentSource, context)
|
||||
if (webSearchData) {
|
||||
controller.enqueue({
|
||||
@@ -704,17 +664,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
llm_web_search: webSearchData
|
||||
})
|
||||
}
|
||||
emitCompletionSignals(controller)
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: chunk.usage?.prompt_tokens || 0,
|
||||
completion_tokens: chunk.usage?.completion_tokens || 0,
|
||||
total_tokens: (chunk.usage?.prompt_tokens || 0) + (chunk.usage?.completion_tokens || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 流正常结束时,检查是否需要发送完成信号
|
||||
flush(controller) {
|
||||
if (isFinished) return
|
||||
|
||||
Logger.debug('[OpenAIApiClient] Stream ended without finish_reason, emitting fallback completion signals')
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,13 +85,16 @@ export abstract class OpenAIBaseClient<
|
||||
|
||||
override async getEmbeddingDimensions(model: Model): Promise<number> {
|
||||
const sdk = await this.getSdkInstance()
|
||||
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
try {
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
override async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||
@@ -135,7 +138,7 @@ export abstract class OpenAIBaseClient<
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
let apiKeyForSdkInstance = this.apiKey
|
||||
let apiKeyForSdkInstance = this.provider.apiKey
|
||||
|
||||
if (this.provider.id === 'copilot') {
|
||||
const defaultHeaders = store.getState().copilot.defaultHeaders
|
||||
@@ -159,7 +162,6 @@ export abstract class OpenAIBaseClient<
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers,
|
||||
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
|
||||
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
|
||||
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
|
||||
import {
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
@@ -39,7 +38,6 @@ import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { isEmpty } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
import { OpenAIAPIClient } from './OpenAIApiClient'
|
||||
@@ -78,11 +76,10 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
apiKey: this.provider.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
...this.defaultHeaders()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -228,29 +225,17 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return
|
||||
}
|
||||
|
||||
private convertResponseToMessageContent(response: OpenAI.Responses.Response): ResponseInput {
|
||||
const content: OpenAI.Responses.ResponseInput = []
|
||||
content.push(...response.output)
|
||||
return content
|
||||
}
|
||||
|
||||
public buildSdkMessages(
|
||||
currentReqMessages: OpenAIResponseSdkMessageParam[],
|
||||
output: OpenAI.Responses.Response | undefined,
|
||||
output: string,
|
||||
toolResults: OpenAIResponseSdkMessageParam[],
|
||||
toolCalls: OpenAIResponseSdkToolCall[]
|
||||
): OpenAIResponseSdkMessageParam[] {
|
||||
if (!output && toolCalls.length === 0) {
|
||||
return [...currentReqMessages, ...toolResults]
|
||||
const assistantMessage: OpenAIResponseSdkMessageParam = {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'input_text', text: output }]
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
return [...currentReqMessages, ...(toolCalls || []), ...(toolResults || [])]
|
||||
}
|
||||
|
||||
const content = this.convertResponseToMessageContent(output)
|
||||
|
||||
const newReqMessages = [...currentReqMessages, ...content, ...(toolResults || [])]
|
||||
const newReqMessages = [...currentReqMessages, assistantMessage, ...(toolCalls || []), ...(toolResults || [])]
|
||||
return newReqMessages
|
||||
}
|
||||
|
||||
@@ -422,18 +407,13 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<OpenAIResponseSdkRawChunk> {
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAIResponseSdkRawChunk> {
|
||||
const toolCalls: OpenAIResponseSdkToolCall[] = []
|
||||
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
|
||||
let hasBeenCollectedToolCalls = false
|
||||
let hasReasoningSummary = false
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 处理chunk
|
||||
if ('output' in chunk) {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = chunk
|
||||
}
|
||||
for (const output of chunk.output) {
|
||||
switch (output.type) {
|
||||
case 'message':
|
||||
@@ -475,22 +455,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
}
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: chunk.usage?.input_tokens || 0,
|
||||
completion_tokens: chunk.usage?.output_tokens || 0,
|
||||
total_tokens: chunk.usage?.total_tokens || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
switch (chunk.type) {
|
||||
case 'response.output_item.added':
|
||||
@@ -498,16 +462,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
outputItems.push(chunk.item)
|
||||
}
|
||||
break
|
||||
case 'response.reasoning_summary_part.added':
|
||||
if (hasReasoningSummary) {
|
||||
const separator = '\n\n'
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: separator
|
||||
})
|
||||
}
|
||||
hasReasoningSummary = true
|
||||
break
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
@@ -548,8 +502,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
if (outputItem.type === 'function_call') {
|
||||
toolCalls.push({
|
||||
...outputItem,
|
||||
arguments: chunk.arguments,
|
||||
status: 'completed'
|
||||
arguments: chunk.arguments
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -565,26 +518,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
})
|
||||
}
|
||||
if (toolCalls.length > 0 && !hasBeenCollectedToolCalls) {
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
hasBeenCollectedToolCalls = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'response.completed': {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = chunk.response
|
||||
}
|
||||
if (toolCalls.length > 0 && !hasBeenCollectedToolCalls) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
hasBeenCollectedToolCalls = true
|
||||
}
|
||||
const completion_tokens = chunk.response.usage?.output_tokens || 0
|
||||
const total_tokens = chunk.response.usage?.total_tokens || 0
|
||||
controller.enqueue({
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@r
|
||||
import { Provider } from '@renderer/types'
|
||||
import {
|
||||
AnthropicSdkRawChunk,
|
||||
OpenAIResponseSdkRawChunk,
|
||||
OpenAIResponseSdkRawOutput,
|
||||
OpenAISdkRawChunk,
|
||||
SdkMessageParam,
|
||||
SdkParams,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { CompletionsParams, GenericChunk } from '../middleware/schemas'
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
|
||||
/**
|
||||
* 原始流监听器接口
|
||||
@@ -36,14 +33,6 @@ export interface OpenAIStreamListener extends RawStreamListener<OpenAISdkRawChun
|
||||
onFinishReason?: (reason: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Response 专用的流监听器
|
||||
*/
|
||||
export interface OpenAIResponseStreamListener<TChunk extends OpenAIResponseSdkRawChunk = OpenAIResponseSdkRawChunk>
|
||||
extends RawStreamListener<TChunk> {
|
||||
onMessage?: (response: OpenAIResponseSdkRawOutput) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic 专用的流监听器
|
||||
*/
|
||||
@@ -112,7 +101,7 @@ export interface ApiClient<
|
||||
// SDK相关方法
|
||||
getSdkInstance(): Promise<TSdkInstance> | TSdkInstance
|
||||
getRequestTransformer(): RequestTransformer<TSdkParams, TMessageParam>
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<TRawChunk>
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<TRawChunk>
|
||||
|
||||
// 原始流监听方法
|
||||
attachRawStreamListener?(rawOutput: TRawOutput, listener: RawStreamListener<TRawChunk>): TRawOutput
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AnthropicAPIClient } from './clients/anthropic/AnthropicAPIClient'
|
||||
import { OpenAIResponseAPIClient } from './clients/openai/OpenAIResponseAPIClient'
|
||||
import { CompletionsMiddlewareBuilder } from './middleware/builder'
|
||||
import { MIDDLEWARE_NAME as AbortHandlerMiddlewareName } from './middleware/common/AbortHandlerMiddleware'
|
||||
import { MIDDLEWARE_NAME as ErrorHandlerMiddlewareName } from './middleware/common/ErrorHandlerMiddleware'
|
||||
import { MIDDLEWARE_NAME as FinalChunkConsumerMiddlewareName } from './middleware/common/FinalChunkConsumerMiddleware'
|
||||
import { applyCompletionsMiddlewares } from './middleware/composer'
|
||||
import { MIDDLEWARE_NAME as McpToolChunkMiddlewareName } from './middleware/core/McpToolChunkMiddleware'
|
||||
@@ -63,7 +62,6 @@ export default class AiProvider {
|
||||
builder.clear()
|
||||
builder
|
||||
.add(MiddlewareRegistry[FinalChunkConsumerMiddlewareName])
|
||||
.add(MiddlewareRegistry[ErrorHandlerMiddlewareName])
|
||||
.add(MiddlewareRegistry[AbortHandlerMiddlewareName])
|
||||
.add(MiddlewareRegistry[ImageGenerationMiddlewareName])
|
||||
} else {
|
||||
@@ -76,7 +74,7 @@ export default class AiProvider {
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient)) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
}
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient)) {
|
||||
builder.remove(RawStreamListenerMiddlewareName)
|
||||
}
|
||||
if (!params.enableWebSearch) {
|
||||
@@ -114,7 +112,7 @@ export default class AiProvider {
|
||||
return dimensions
|
||||
} catch (error) {
|
||||
console.error('Error getting embedding dimensions:', error)
|
||||
throw error
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Chunk } from '@renderer/types/chunk'
|
||||
import { isAbortError } from '@renderer/utils/error'
|
||||
|
||||
import { CompletionsResult } from '../schemas'
|
||||
import { CompletionsContext } from '../types'
|
||||
@@ -25,26 +26,29 @@ export const ErrorHandlerMiddleware =
|
||||
// 尝试执行下一个中间件
|
||||
return await next(ctx, params)
|
||||
} catch (error: any) {
|
||||
console.log('ErrorHandlerMiddleware_error', error)
|
||||
// 1. 使用通用的工具函数将错误解析为标准格式
|
||||
const errorChunk = createErrorChunk(error)
|
||||
// 2. 调用从外部传入的 onError 回调
|
||||
if (params.onError) {
|
||||
params.onError(error)
|
||||
}
|
||||
|
||||
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
|
||||
if (shouldThrow) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
|
||||
const errorStream = new ReadableStream<Chunk>({
|
||||
start(controller) {
|
||||
controller.enqueue(errorChunk)
|
||||
controller.close()
|
||||
let errorStream: ReadableStream<Chunk> | undefined
|
||||
// 有些sdk的abort error 是直接抛出的
|
||||
if (!isAbortError(error)) {
|
||||
// 1. 使用通用的工具函数将错误解析为标准格式
|
||||
const errorChunk = createErrorChunk(error)
|
||||
// 2. 调用从外部传入的 onError 回调
|
||||
if (params.onError) {
|
||||
params.onError(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
|
||||
if (shouldThrow) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
|
||||
errorStream = new ReadableStream<Chunk>({
|
||||
start(controller) {
|
||||
controller.enqueue(errorChunk)
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
rawOutput: undefined,
|
||||
|
||||
@@ -153,7 +153,7 @@ function createToolHandlingTransform(
|
||||
if (toolResult.length > 0) {
|
||||
const output = ctx._internal.toolProcessingState?.output
|
||||
|
||||
const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls)
|
||||
const newParams = buildParamsWithToolResults(ctx, currentParams, output!, toolResult, toolCalls)
|
||||
await executeWithToolHandling(newParams, depth + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -243,7 +243,7 @@ async function executeToolUseResponses(
|
||||
function buildParamsWithToolResults(
|
||||
ctx: CompletionsContext,
|
||||
currentParams: CompletionsParams,
|
||||
output: SdkRawOutput | string | undefined,
|
||||
output: SdkRawOutput | string,
|
||||
toolResults: SdkMessageParam[],
|
||||
toolCalls: SdkToolCall[]
|
||||
): CompletionsParams {
|
||||
@@ -255,10 +255,6 @@ function buildParamsWithToolResults(
|
||||
// 从回复中构建助手消息
|
||||
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
|
||||
|
||||
if (output && ctx._internal.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = undefined
|
||||
}
|
||||
|
||||
// 估算新增消息的 token 消耗并累加到 usage 中
|
||||
if (ctx._internal.observer?.usage && newReqMessages.length > currentReqMessages.length) {
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,8 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 在这里可以监听到从SDK返回的最原始流
|
||||
if (result.rawOutput) {
|
||||
console.log(`[${MIDDLEWARE_NAME}] 检测到原始SDK输出,准备附加监听器`)
|
||||
|
||||
const providerType = ctx.apiClientInstance.provider.type
|
||||
// TODO: 后面下放到AnthropicAPIClient
|
||||
if (providerType === 'anthropic') {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ResponseTransformMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
|
||||
// 获取响应转换器
|
||||
const responseChunkTransformer = apiClient.getResponseChunkTransformer(ctx)
|
||||
const responseChunkTransformer = apiClient.getResponseChunkTransformer?.()
|
||||
if (!responseChunkTransformer) {
|
||||
Logger.warn(`[${MIDDLEWARE_NAME}] No ResponseChunkTransformer available, skipping transformation`)
|
||||
return result
|
||||
|
||||
@@ -25,6 +25,7 @@ export const StreamAdapterMiddleware: CompletionsMiddleware =
|
||||
// 但是这个中间件的职责是流适配,是否在这调用优待商榷
|
||||
// 调用下游中间件
|
||||
const result = await next(ctx, params)
|
||||
|
||||
if (
|
||||
result.rawOutput &&
|
||||
!(result.rawOutput instanceof ReadableStream) &&
|
||||
|
||||
@@ -14,6 +14,8 @@ export const TransformCoreToSdkParamsMiddleware: CompletionsMiddleware =
|
||||
() =>
|
||||
(next) =>
|
||||
async (ctx: CompletionsContext, params: CompletionsParams): Promise<CompletionsResult> => {
|
||||
Logger.debug(`🔄 [${MIDDLEWARE_NAME}] Starting core to SDK params transformation:`, ctx)
|
||||
|
||||
const internal = ctx._internal
|
||||
|
||||
// 🔧 检测递归调用:检查 params 中是否携带了预处理的 SDK 消息
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
|
||||
const { assistant, messages } = params
|
||||
const client = context.apiClientInstance as BaseApiClient<OpenAI>
|
||||
const signal = context._internal?.flowControl?.abortSignal
|
||||
|
||||
if (!assistant.model || !isDedicatedImageGenerationModel(assistant.model) || typeof messages === 'string') {
|
||||
return next(context, params)
|
||||
}
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
<svg width="22" height="22" viewBox="13 -2 25 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="White=False">
|
||||
<g id="if">
|
||||
<path d="M21.2002 3.73454C22.5633 3.73454 23.0666 2.89917 23.0666 1.86812C23.0666 0.837081 22.5623 0.00170898 21.2002 0.00170898C19.838 0.00170898 19.3337 0.837081 19.3337 1.86812C19.3337 2.89917 19.838 3.73454 21.2002 3.73454Z" fill="#0033FF"/>
|
||||
<path d="M27.7336 4.13435V5.33473H24.6668V8.00171H27.7336V14.6687H22.6668V5.33567H15.9998V8.00265H19.7336V14.6696H15.3337V17.3366H35.3337V14.6696H30.6668V8.00265H35.3337V5.33567H30.6668V2.66869H35.3337V0.00170898H31.8671C29.5877 0.00170898 27.7336 1.8559 27.7336 4.13529V4.13435Z" fill="#0033FF"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Dify</title><clipPath id="lobe-icons-dify-fill"><path d="M1 0h10.286c6.627 0 12 5.373 12 12s-5.373 12-12 12H1V0z"></path></clipPath><foreignObject clip-path="url(#lobe-icons-dify-fill)" height="24" style="background:conic-gradient(from 180deg at 50% 50%, #0222C3, #8FB1F4, #FFFFFF)" width="24"></foreignObject></svg>
|
||||
|
Before Width: | Height: | Size: 680 B After Width: | Height: | Size: 480 B |
@@ -58,79 +58,150 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mention-models-dropdown {
|
||||
&.ant-dropdown {
|
||||
background: rgba(var(--color-base-rgb), 0.65) !important;
|
||||
backdrop-filter: blur(35px) saturate(150%) !important;
|
||||
animation-duration: 0.15s !important;
|
||||
}
|
||||
|
||||
/* 移动其他样式到 mention-models-dropdown 类下 */
|
||||
.ant-slide-up-enter .ant-dropdown-menu,
|
||||
.ant-slide-up-appear .ant-dropdown-menu,
|
||||
.ant-slide-up-leave .ant-dropdown-menu,
|
||||
.ant-slide-up-enter-active .ant-dropdown-menu,
|
||||
.ant-slide-up-appear-active .ant-dropdown-menu,
|
||||
.ant-slide-up-leave-active .ant-dropdown-menu {
|
||||
background: rgba(var(--color-base-rgb), 0.65) !important;
|
||||
backdrop-filter: blur(35px) saturate(150%) !important;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu {
|
||||
/* 保持原有的下拉菜单样式,但限定在 mention-models-dropdown 类下 */
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 12px;
|
||||
position: relative;
|
||||
background: rgba(var(--color-base-rgb), 0.65) !important;
|
||||
backdrop-filter: blur(35px) saturate(150%) !important;
|
||||
border: 0.5px solid rgba(var(--color-border-rgb), 0.3);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.15),
|
||||
0 4px 16px rgba(0, 0, 0, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.12),
|
||||
inset 0 0 0 0.5px rgba(255, 255, 255, var(--inner-glow-opacity, 0.1));
|
||||
transform-origin: top;
|
||||
will-change: transform, opacity;
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-bottom: 0;
|
||||
|
||||
&.no-scrollbar {
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
&.has-scrollbar {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
// Scrollbar styles
|
||||
&::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border: 4px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 7px;
|
||||
background-color: var(--color-scrollbar-thumb);
|
||||
min-height: 50px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar-thumb);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:active {
|
||||
background-color: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-group {
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-group-title {
|
||||
padding: 5px 12px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle no-results case margin
|
||||
.no-results {
|
||||
padding: 8px 12px;
|
||||
color: var(--color-text-3);
|
||||
cursor: default;
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 40px;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
padding: 5px 12px;
|
||||
margin: 0 -12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--color-hover-rgb), 0.5);
|
||||
}
|
||||
|
||||
&.ant-dropdown-menu-item-selected {
|
||||
background-color: rgba(var(--color-primary-rgb), 0.12);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-icon {
|
||||
margin-right: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu .ant-dropdown-menu-sub {
|
||||
max-height: 50vh;
|
||||
max-height: 350px;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border: 0.5px solid var(--color-border);
|
||||
}
|
||||
.ant-dropdown {
|
||||
background-color: var(--ant-color-bg-elevated);
|
||||
overflow: hidden;
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
.ant-dropdown-menu {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border: 0.5px solid var(--color-border);
|
||||
}
|
||||
.ant-dropdown-arrow + .ant-dropdown-menu {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
.ant-select-dropdown {
|
||||
border: 0.5px solid var(--color-border);
|
||||
}
|
||||
.ant-dropdown-menu-submenu {
|
||||
background-color: var(--ant-color-bg-elevated);
|
||||
overflow: hidden;
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
}
|
||||
|
||||
.ant-popover {
|
||||
.ant-popover-inner {
|
||||
border: 0.5px solid var(--color-border);
|
||||
.ant-popover-inner-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
.ant-popover-arrow + .ant-popover-content {
|
||||
.ant-popover-inner {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal:not(.ant-modal-confirm) {
|
||||
.ant-modal-confirm-body-has-title {
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
.ant-modal-content {
|
||||
border-radius: 10px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
padding: 0 0 8px 0;
|
||||
.ant-modal-header {
|
||||
padding: 16px 16px 0 16px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ant-modal-body {
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 0 16px;
|
||||
}
|
||||
.ant-modal-footer {
|
||||
padding: 0 16px 8px 16px;
|
||||
}
|
||||
.ant-modal-confirm-btns {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-modal.ant-modal-confirm.ant-modal-confirm-confirm {
|
||||
.ant-modal-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse {
|
||||
@@ -141,14 +212,8 @@
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 0.5px solid var(--color-border) !important;
|
||||
border-top: 1px solid var(--color-border) !important;
|
||||
.ant-color-picker & {
|
||||
border-top: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-slider {
|
||||
.ant-slider-handle::after {
|
||||
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
--color-list-item: #222;
|
||||
--color-list-item-hover: #1e1e1e;
|
||||
|
||||
--modal-background: #111111;
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--color-highlight: rgba(0, 0, 0, 1);
|
||||
--color-background-highlight: rgba(255, 255, 0, 0.9);
|
||||
@@ -66,9 +66,9 @@
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: rgba(255, 255, 255, 0.08);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-background: #111111;
|
||||
--chat-background-user: #28b561;
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 20px;
|
||||
@@ -132,8 +132,8 @@
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: rgba(0, 0, 0, 0.045);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-text-user: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,27 @@ ul {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.bubble:not(.multi-select-mode) {
|
||||
.bubble {
|
||||
background-color: var(--chat-background);
|
||||
#chat-main {
|
||||
background-color: var(--chat-background);
|
||||
}
|
||||
#messages {
|
||||
background-color: var(--chat-background);
|
||||
}
|
||||
#inputbar {
|
||||
margin: -5px 15px 15px 15px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
.system-prompt {
|
||||
background-color: var(--chat-background-assistant);
|
||||
}
|
||||
.message-content-container {
|
||||
margin: 5px 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.block-wrapper {
|
||||
display: flow-root;
|
||||
}
|
||||
@@ -129,35 +149,30 @@ ul {
|
||||
}
|
||||
|
||||
.message-user {
|
||||
.message-header {
|
||||
flex-direction: row-reverse;
|
||||
text-align: right;
|
||||
.message-header-info-wrap {
|
||||
flex-direction: row-reverse;
|
||||
text-align: right;
|
||||
}
|
||||
color: var(--chat-text-user);
|
||||
.message-content-container-user .anticon {
|
||||
color: var(--chat-text-user) !important;
|
||||
}
|
||||
.message-content-container {
|
||||
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;
|
||||
|
||||
.markdown {
|
||||
color: var(--chat-text-user);
|
||||
}
|
||||
}
|
||||
|
||||
.message-assistant {
|
||||
.message-content-container {
|
||||
padding-left: 0;
|
||||
}
|
||||
.MessageFooter {
|
||||
margin-left: 0;
|
||||
.group-grid-container.horizontal,
|
||||
.group-grid-container.grid {
|
||||
.message-content-container-assistant {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.group-message-wrapper {
|
||||
background-color: var(--color-background);
|
||||
.message-content-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.group-menu-bar {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
code {
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -173,17 +188,11 @@ ul {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
|
||||
::highlight(search-matches) {
|
||||
span.highlight {
|
||||
background-color: var(--color-background-highlight);
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
::highlight(current-match) {
|
||||
span.highlight.selected {
|
||||
background-color: var(--color-background-highlight-accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
&::-webkit-resizer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
margin: 20px 0;
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
span {
|
||||
@@ -118,7 +119,7 @@
|
||||
}
|
||||
|
||||
pre {
|
||||
border-radius: 8px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
@@ -156,28 +157,15 @@
|
||||
}
|
||||
|
||||
table {
|
||||
--table-border-radius: 8px;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
width: 100%;
|
||||
border-radius: var(--table-border-radius);
|
||||
overflow: hidden;
|
||||
border-collapse: separate;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
border: 0.5px solid var(--color-border);
|
||||
padding: 0.5em;
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
th {
|
||||
@@ -250,10 +238,6 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
@@ -325,7 +309,7 @@ mjx-container {
|
||||
|
||||
/* CodeMirror 相关样式 */
|
||||
.cm-editor {
|
||||
border-radius: inherit;
|
||||
border-radius: 5px;
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
@@ -333,7 +317,7 @@ mjx-container {
|
||||
|
||||
.cm-scroller {
|
||||
font-family: var(--code-font-family);
|
||||
border-radius: inherit;
|
||||
border-radius: 5px;
|
||||
|
||||
.cm-gutters {
|
||||
line-height: 1.6;
|
||||
|
||||
@@ -5,57 +5,22 @@ html {
|
||||
}
|
||||
|
||||
:root {
|
||||
// Basic Colors
|
||||
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
|
||||
--color-selection-toolbar-hover-bg: #222222;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-error: #f44336;
|
||||
|
||||
--selection-toolbar-color-primary: var(--color-primary);
|
||||
--selection-toolbar-color-error: var(--color-error);
|
||||
|
||||
// Toolbar
|
||||
--selection-toolbar-height: 36px; // default: 36px max: 42px
|
||||
--selection-toolbar-font-size: 14px; // default: 14px
|
||||
|
||||
--selection-toolbar-logo-display: flex; // values: flex | none
|
||||
--selection-toolbar-logo-size: 22px; // default: 22px
|
||||
--selection-toolbar-logo-margin: 0 0 0 5px; // default: 0 0 05px
|
||||
|
||||
// DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING
|
||||
--selection-toolbar-padding: 2px 4px 2px 2px; // default: 2px 4px 2px 2px
|
||||
--selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px
|
||||
// ------------------------------------------------------------
|
||||
|
||||
--selection-toolbar-border-radius: 6px;
|
||||
--selection-toolbar-border: 1px solid rgba(55, 55, 55, 0.5);
|
||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
||||
--selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
|
||||
// Buttons
|
||||
|
||||
--selection-toolbar-button-icon-size: 16px; // default: 16px
|
||||
--selection-toolbar-button-text-margin: 0 0 0 3px; // default: 0 0 0 3px
|
||||
--selection-toolbar-button-margin: 0 2px; // default: 0 2px
|
||||
--selection-toolbar-button-padding: 4px 6px; // default: 4px 6px
|
||||
--selection-toolbar-button-border-radius: 4px; // default: 4px
|
||||
--selection-toolbar-button-border: none; // default: none
|
||||
--selection-toolbar-button-box-shadow: none; // default: none
|
||||
|
||||
--selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9);
|
||||
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-bgcolor: transparent; // default: transparent
|
||||
--selection-toolbar-button-bgcolor-hover: #222222;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5);
|
||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
||||
--selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
|
||||
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
|
||||
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
|
||||
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { getReactStyleFromToken } from '@renderer/utils/shiki'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
@@ -18,20 +18,19 @@ interface CodePreviewProps {
|
||||
/**
|
||||
* Shiki 流式代码高亮组件
|
||||
*
|
||||
* - 通过 shiki tokenizer 处理流式响应,高性能
|
||||
* - 进入视口后触发高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
* - 通过 shiki tokenizer 处理流式响应
|
||||
* - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过
|
||||
*/
|
||||
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
|
||||
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
||||
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
|
||||
const [isInViewport, setIsInViewport] = useState(false)
|
||||
const codeContainerRef = useRef<HTMLDivElement>(null)
|
||||
const processingRef = useRef(false)
|
||||
const latestRequestedContentRef = useRef<string | null>(null)
|
||||
const codeContentRef = useRef<HTMLDivElement>(null)
|
||||
const prevCodeLengthRef = useRef(0)
|
||||
const safeCodeStringRef = useRef(children)
|
||||
const highlightQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
const shikiThemeRef = useRef(activeShikiTheme)
|
||||
|
||||
@@ -46,7 +45,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = codeContainerRef.current?.scrollHeight
|
||||
const scrollHeight = codeContentRef.current?.scrollHeight
|
||||
return codeCollapsible && (scrollHeight ?? 0) > 350
|
||||
},
|
||||
onClick: () => setIsExpanded((prev) => !prev)
|
||||
@@ -78,63 +77,81 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
setIsUnwrapped(!codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
// 处理尾部空白字符
|
||||
const safeCodeString = useMemo(() => {
|
||||
return typeof children === 'string' ? children.trimEnd() : ''
|
||||
}, [children])
|
||||
|
||||
const highlightCode = useCallback(async () => {
|
||||
const currentContent = typeof children === 'string' ? children.trimEnd() : ''
|
||||
if (!safeCodeString) return
|
||||
|
||||
// 记录最新要处理的内容,为了保证最终状态正确
|
||||
latestRequestedContentRef.current = currentContent
|
||||
if (prevCodeLengthRef.current === safeCodeString.length) return
|
||||
|
||||
// 如果正在处理,先跳出,等到完成后会检查是否有新内容
|
||||
if (processingRef.current) return
|
||||
// 捕获当前状态
|
||||
const startPos = prevCodeLengthRef.current
|
||||
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 = ''
|
||||
|
||||
try {
|
||||
// 循环处理,确保会处理最新内容
|
||||
while (latestRequestedContentRef.current !== null) {
|
||||
const contentToProcess = latestRequestedContentRef.current
|
||||
latestRequestedContentRef.current = null // 标记开始处理
|
||||
const result = await highlightCodeChunk(safeCodeString, language, callerId)
|
||||
setTokenLines(result.lines)
|
||||
|
||||
// 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮
|
||||
const result = await highlightStreamingCode(contentToProcess, language, callerId)
|
||||
prevCodeLengthRef.current = safeCodeString.length
|
||||
safeCodeStringRef.current = safeCodeString
|
||||
|
||||
// 如有结果,更新 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]
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
processingRef.current = false
|
||||
}
|
||||
}, [highlightStreamingCode, language, callerId, children])
|
||||
|
||||
// 跳过 race condition,延迟到后续任务
|
||||
if (prevCodeLengthRef.current !== startPos) {
|
||||
return
|
||||
}
|
||||
|
||||
const incrementalCode = safeCodeString.slice(startPos, endPos)
|
||||
const result = await highlightCodeChunk(incrementalCode, language, callerId)
|
||||
setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines])
|
||||
prevCodeLengthRef.current = endPos
|
||||
safeCodeStringRef.current = safeCodeString
|
||||
})
|
||||
}, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString])
|
||||
|
||||
// 主题变化时强制重新高亮
|
||||
useEffect(() => {
|
||||
if (shikiThemeRef.current !== activeShikiTheme) {
|
||||
prevCodeLengthRef.current++
|
||||
shikiThemeRef.current = activeShikiTheme
|
||||
cleanupTokenizers(callerId)
|
||||
setTokenLines([])
|
||||
}
|
||||
}, [activeShikiTheme, callerId, cleanupTokenizers])
|
||||
}, [activeShikiTheme])
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
return () => cleanupTokenizers(callerId)
|
||||
}, [callerId, cleanupTokenizers])
|
||||
|
||||
// 视口检测逻辑,进入视口后触发第一次代码高亮
|
||||
// 触发代码高亮
|
||||
// - 进入视口后触发第一次高亮
|
||||
// - 内容变化后触发之后的高亮
|
||||
useEffect(() => {
|
||||
const codeElement = codeContainerRef.current
|
||||
let isMounted = true
|
||||
|
||||
if (prevCodeLengthRef.current > 0) {
|
||||
setTimeout(highlightCode, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const codeElement = codeContentRef.current
|
||||
if (!codeElement) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].intersectionRatio > 0) {
|
||||
setIsInViewport(true)
|
||||
if (entries[0].intersectionRatio > 0 && isMounted) {
|
||||
setTimeout(highlightCode, 0)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
@@ -144,35 +161,21 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
)
|
||||
|
||||
observer.observe(codeElement)
|
||||
return () => observer.disconnect()
|
||||
}, []) // 只执行一次
|
||||
|
||||
// 触发代码高亮
|
||||
useEffect(() => {
|
||||
if (!isInViewport) return
|
||||
return () => {
|
||||
isMounted = false
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [highlightCode])
|
||||
|
||||
setTimeout(highlightCode, 0)
|
||||
}, [isInViewport, highlightCode])
|
||||
|
||||
const lastDigitsRef = useRef(1)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = codeContainerRef.current
|
||||
if (!container || !codeShowLineNumbers) return
|
||||
|
||||
const digits = Math.max(tokenLines.length.toString().length, 1)
|
||||
if (digits === lastDigitsRef.current) return
|
||||
|
||||
const gutterWidth = digits * 0.6
|
||||
container.style.setProperty('--gutter-width', `${gutterWidth}rem`)
|
||||
lastDigitsRef.current = digits
|
||||
}, [codeShowLineNumbers, tokenLines.length])
|
||||
|
||||
const hasHighlightedCode = tokenLines.length > 0
|
||||
const hasHighlightedCode = useMemo(() => {
|
||||
return tokenLines.length > 0
|
||||
}, [tokenLines.length])
|
||||
|
||||
return (
|
||||
<ContentContainer
|
||||
ref={codeContainerRef}
|
||||
ref={codeContentRef}
|
||||
$lineNumbers={codeShowLineNumbers}
|
||||
$wrap={codeWrappable && !isUnwrapped}
|
||||
$fadeIn={hasHighlightedCode}
|
||||
style={{
|
||||
@@ -180,7 +183,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
|
||||
}}>
|
||||
{hasHighlightedCode ? (
|
||||
<ShikiTokensRenderer language={language} tokenLines={tokenLines} showLineNumbers={codeShowLineNumbers} />
|
||||
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
|
||||
) : (
|
||||
<CodePlaceholder>{children}</CodePlaceholder>
|
||||
)}
|
||||
@@ -188,103 +191,97 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
interface ShikiTokensRendererProps {
|
||||
language: string
|
||||
tokenLines: ThemedToken[][]
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Shiki 高亮后的 tokens
|
||||
*
|
||||
* 独立出来,方便将来做 virtual list
|
||||
*/
|
||||
const ShikiTokensRenderer: React.FC<ShikiTokensRendererProps> = memo(({ language, tokenLines, showLineNumbers }) => {
|
||||
const { getShikiPreProperties } = useCodeStyle()
|
||||
const rendererRef = useRef<HTMLPreElement>(null)
|
||||
const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo(
|
||||
({ language, tokenLines }) => {
|
||||
const { getShikiPreProperties } = useCodeStyle()
|
||||
const rendererRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
// 设置 pre 标签属性
|
||||
useLayoutEffect(() => {
|
||||
getShikiPreProperties(language).then((properties) => {
|
||||
const pre = rendererRef.current
|
||||
if (pre) {
|
||||
pre.className = properties.class
|
||||
pre.style.cssText = properties.style
|
||||
pre.tabIndex = properties.tabindex
|
||||
}
|
||||
})
|
||||
}, [language, getShikiPreProperties])
|
||||
// 设置 pre 标签属性
|
||||
useEffect(() => {
|
||||
getShikiPreProperties(language).then((properties) => {
|
||||
const pre = rendererRef.current
|
||||
if (pre) {
|
||||
pre.className = properties.class
|
||||
pre.style.cssText = properties.style
|
||||
pre.tabIndex = properties.tabindex
|
||||
}
|
||||
})
|
||||
}, [language, getShikiPreProperties])
|
||||
|
||||
return (
|
||||
<pre className="shiki" ref={rendererRef}>
|
||||
<code>
|
||||
{tokenLines.map((lineTokens, lineIndex) => (
|
||||
<span key={`line-${lineIndex}`} className="line">
|
||||
{showLineNumbers && <span className="line-number">{lineIndex + 1}</span>}
|
||||
<span className="line-content">
|
||||
return (
|
||||
<pre className="shiki" ref={rendererRef}>
|
||||
<code>
|
||||
{tokenLines.map((lineTokens, lineIndex) => (
|
||||
<span key={`line-${lineIndex}`} className="line">
|
||||
{lineTokens.map((token, tokenIndex) => (
|
||||
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
})
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const ContentContainer = styled.div<{
|
||||
$lineNumbers: boolean
|
||||
$wrap: boolean
|
||||
$fadeIn: boolean
|
||||
}>`
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border-radius: inherit;
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: 5px;
|
||||
margin-top: 0;
|
||||
|
||||
/* gutter 宽度默认值 */
|
||||
--gutter-width: 0.6rem;
|
||||
|
||||
.shiki {
|
||||
padding: 1em;
|
||||
border-radius: inherit;
|
||||
|
||||
code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
display: block;
|
||||
min-height: 1.3rem;
|
||||
padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')};
|
||||
|
||||
.line-number {
|
||||
width: var(--gutter-width);
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
margin-right: 1rem;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
|
||||
* {
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
}
|
||||
* {
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$lineNumbers &&
|
||||
`
|
||||
code {
|
||||
counter-reset: step;
|
||||
counter-increment: step 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
code .line::before {
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
width: 1rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
}
|
||||
`}
|
||||
|
||||
@keyframes contentFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -294,7 +291,7 @@ const ContentContainer = styled.div<{
|
||||
}
|
||||
}
|
||||
|
||||
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.1s ease-in forwards' : 'none')};
|
||||
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')};
|
||||
`
|
||||
|
||||
const CodePlaceholder = styled.div`
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/compon
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { getExtensionByLanguage, isValidPlantUML } from '@renderer/utils/markdown'
|
||||
import { isValidPlantUML } from '@renderer/utils/markdown'
|
||||
import dayjs from 'dayjs'
|
||||
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -67,21 +67,23 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
|
||||
}, [children, t])
|
||||
|
||||
const handleDownloadSource = useCallback(async () => {
|
||||
const handleDownloadSource = useCallback(() => {
|
||||
let fileName = ''
|
||||
|
||||
// 尝试提取 HTML 标题
|
||||
// 尝试提取标题
|
||||
if (language === 'html' && children.includes('</html>')) {
|
||||
fileName = extractTitle(children) || ''
|
||||
const title = extractTitle(children)
|
||||
if (title) {
|
||||
fileName = `${title}.html`
|
||||
}
|
||||
}
|
||||
|
||||
// 默认使用日期格式命名
|
||||
if (!fileName) {
|
||||
fileName = `${dayjs().format('YYYYMMDDHHmm')}`
|
||||
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
|
||||
}
|
||||
|
||||
const ext = await getExtensionByLanguage(language)
|
||||
window.api.file.save(`${fileName}${ext}`, children)
|
||||
window.api.file.save(fileName, children)
|
||||
}, [children, language])
|
||||
|
||||
const handleRunScript = useCallback(() => {
|
||||
@@ -273,7 +275,6 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
font-weight: bold;
|
||||
padding: 0 10px;
|
||||
border-top-left-radius: 8px;
|
||||
@@ -289,10 +290,6 @@ const SplitViewWrapper = styled.div`
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodeBlockView)
|
||||
|
||||
@@ -227,10 +227,10 @@ const CodeEditor = ({
|
||||
...customBasicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
marginTop: 0,
|
||||
borderRadius: 'inherit',
|
||||
...style
|
||||
border: '0.5px solid transparent',
|
||||
marginTop: 0
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -3,10 +3,13 @@ import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||
import { Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const HIGHLIGHT_CLASS = 'highlight'
|
||||
const HIGHLIGHT_SELECT_CLASS = 'selected'
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode
|
||||
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
|
||||
@@ -15,14 +18,19 @@ interface Props {
|
||||
*
|
||||
* 返回`true`表示该`node`会被搜索
|
||||
*/
|
||||
filter: NodeFilter
|
||||
filter: (node: Node) => boolean
|
||||
includeUser?: boolean
|
||||
onIncludeUserChange?: (value: boolean) => void
|
||||
}
|
||||
|
||||
enum SearchCompletedState {
|
||||
NotSearched,
|
||||
Searched
|
||||
FirstSearched
|
||||
}
|
||||
|
||||
enum SearchTargetIndex {
|
||||
Next,
|
||||
Prev
|
||||
}
|
||||
|
||||
export interface ContentSearchRef {
|
||||
@@ -39,20 +47,60 @@ export interface ContentSearchRef {
|
||||
focus(): void
|
||||
}
|
||||
|
||||
interface MatchInfo {
|
||||
index: number
|
||||
length: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const escapeRegExp = (string: string): string => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
||||
}
|
||||
|
||||
const findRangesInTarget = (
|
||||
target: HTMLElement,
|
||||
filter: NodeFilter,
|
||||
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
|
||||
if (!elementList || elementList.length === 0) {
|
||||
return null
|
||||
}
|
||||
let closestElementIndex: number | null = null
|
||||
let minVerticalDistance = Infinity
|
||||
const windowCenterY = window.innerHeight / 2
|
||||
for (let i = 0; i < elementList.length; i++) {
|
||||
const element = elementList[i]
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue
|
||||
}
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||
continue
|
||||
}
|
||||
const elementCenterY = rect.top + rect.height / 2
|
||||
const verticalDistance = Math.abs(elementCenterY - windowCenterY)
|
||||
if (verticalDistance < minVerticalDistance) {
|
||||
minVerticalDistance = verticalDistance
|
||||
closestElementIndex = i
|
||||
}
|
||||
}
|
||||
return closestElementIndex
|
||||
}
|
||||
|
||||
const highlightText = (
|
||||
textNode: Node,
|
||||
searchText: string,
|
||||
highlightClass: string,
|
||||
isCaseSensitive: boolean,
|
||||
isWholeWord: boolean
|
||||
): Range[] => {
|
||||
CSS.highlights.clear()
|
||||
const ranges: Range[] = []
|
||||
): HTMLSpanElement[] | null => {
|
||||
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
|
||||
if (textNodeParentNode) {
|
||||
if (textNodeParentNode.classList.contains(highlightClass)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const textContent = textNode.textContent
|
||||
const escapedSearchText = escapeRegExp(searchText)
|
||||
|
||||
// 检查搜索文本是否仅包含拉丁字母
|
||||
@@ -61,66 +109,89 @@ const findRangesInTarget = (
|
||||
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
|
||||
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
|
||||
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
|
||||
const searchRegex = new RegExp(regexPattern, regexFlags)
|
||||
const treeWalker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, filter)
|
||||
const allTextNodes: { node: Node; startOffset: number }[] = []
|
||||
let fullText = ''
|
||||
const regex = new RegExp(regexPattern, regexFlags)
|
||||
|
||||
// 1. 拼接所有文本节点内容
|
||||
while (treeWalker.nextNode()) {
|
||||
allTextNodes.push({
|
||||
node: treeWalker.currentNode,
|
||||
startOffset: fullText.length
|
||||
})
|
||||
fullText += treeWalker.currentNode.nodeValue
|
||||
}
|
||||
|
||||
// 2.在完整文本中查找匹配项
|
||||
let match: RegExpExecArray | null = null
|
||||
while ((match = searchRegex.exec(fullText))) {
|
||||
const matchStart = match.index
|
||||
const matchEnd = matchStart + match[0].length
|
||||
|
||||
// 3. 将匹配项的索引映射回DOM Range
|
||||
let startNode: Node | null = null
|
||||
let endNode: Node | null = null
|
||||
let startOffset = 0
|
||||
let endOffset = 0
|
||||
|
||||
// 找到起始节点和偏移
|
||||
for (const nodeInfo of allTextNodes) {
|
||||
if (
|
||||
matchStart >= nodeInfo.startOffset &&
|
||||
matchStart < nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
|
||||
) {
|
||||
startNode = nodeInfo.node
|
||||
startOffset = matchStart - nodeInfo.startOffset
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 找到结束节点和偏移
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 如果起始和结束节点都找到了,则创建一个 Range
|
||||
if (startNode && endNode) {
|
||||
const range = new Range()
|
||||
range.setStart(startNode, startOffset)
|
||||
range.setEnd(endNode, endOffset)
|
||||
ranges.push(range)
|
||||
let match
|
||||
const matches: MatchInfo[] = []
|
||||
while ((match = regex.exec(textContent)) !== null) {
|
||||
if (typeof match.index === 'number' && typeof match[0] === 'string') {
|
||||
matches.push({ index: match.index, length: match[0].length, text: match[0] })
|
||||
} else {
|
||||
console.error('Unexpected match format:', match)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parentNode = textNode.parentNode
|
||||
if (!parentNode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
let currentIndex = 0
|
||||
const highlightTextSet = new Set<HTMLSpanElement>()
|
||||
|
||||
matches.forEach(({ index, length, text }) => {
|
||||
if (index > currentIndex) {
|
||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
|
||||
}
|
||||
const highlightSpan = document.createElement('span')
|
||||
highlightSpan.className = highlightClass
|
||||
highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
|
||||
fragment.appendChild(highlightSpan)
|
||||
highlightTextSet.add(highlightSpan)
|
||||
currentIndex = index + length
|
||||
})
|
||||
|
||||
if (currentIndex < textContent.length) {
|
||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
|
||||
}
|
||||
|
||||
parentNode.replaceChild(fragment, textNode)
|
||||
return [...highlightTextSet]
|
||||
}
|
||||
|
||||
const mergeAdjacentTextNodes = (node: HTMLElement) => {
|
||||
const children = Array.from(node.childNodes)
|
||||
const groups: Array<Node | { text: string; nodes: Node[] }> = []
|
||||
let currentTextGroup: { text: string; nodes: Node[] } | null = null
|
||||
|
||||
for (const child of children) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (currentTextGroup === null) {
|
||||
currentTextGroup = {
|
||||
text: child.textContent ?? '',
|
||||
nodes: [child]
|
||||
}
|
||||
} else {
|
||||
currentTextGroup.text += child.textContent
|
||||
currentTextGroup.nodes.push(child)
|
||||
}
|
||||
} else {
|
||||
if (currentTextGroup !== null) {
|
||||
groups.push(currentTextGroup!)
|
||||
currentTextGroup = null
|
||||
}
|
||||
groups.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTextGroup !== null) {
|
||||
groups.push(currentTextGroup)
|
||||
}
|
||||
|
||||
const newChildren = groups.map((group) => {
|
||||
if (group instanceof Node) {
|
||||
return group
|
||||
} else {
|
||||
return document.createTextNode(group.text)
|
||||
}
|
||||
})
|
||||
|
||||
node.replaceChildren(...newChildren)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @eslint-react/no-forward-ref
|
||||
@@ -135,178 +206,328 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
})()
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [searchResultIndex, setSearchResultIndex] = useState(0)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [enableContentSearch, setEnableContentSearch] = useState(false)
|
||||
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
|
||||
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||
const [isWholeWord, setIsWholeWord] = useState(false)
|
||||
const [allRanges, setAllRanges] = useState<Range[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [shouldScroll, setShouldScroll] = useState(false)
|
||||
const highlightTextSet = useState(new Set<Node>())[0]
|
||||
const prevSearchText = useRef('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resetSearch = useCallback(() => {
|
||||
CSS.highlights.clear()
|
||||
setAllRanges([])
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}, [])
|
||||
|
||||
const locateByIndex = useCallback(
|
||||
(shouldScroll = true) => {
|
||||
// 清理旧的高亮
|
||||
CSS.highlights.clear()
|
||||
|
||||
if (allRanges.length > 0) {
|
||||
// 1. 创建并注册所有匹配项的高亮
|
||||
const allMatchesHighlight = new Highlight(...allRanges)
|
||||
CSS.highlights.set('search-matches', allMatchesHighlight)
|
||||
|
||||
// 2. 如果有当前项,为其创建并注册一个特殊的高亮
|
||||
if (currentIndex !== -1 && allRanges[currentIndex]) {
|
||||
const currentMatchRange = allRanges[currentIndex]
|
||||
const currentMatchHighlight = new Highlight(currentMatchRange)
|
||||
CSS.highlights.set('current-match', currentMatchHighlight)
|
||||
|
||||
// 3. 将当前项滚动到视图中
|
||||
// 获取第一个文本节点的父元素来进行滚动
|
||||
const parentElement = currentMatchRange.startContainer.parentElement
|
||||
const locateByIndex = (index: number, shouldScroll = true) => {
|
||||
if (target) {
|
||||
const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
|
||||
highlightTextNodes.sort((a, b) => {
|
||||
const { top: aTop } = a.getBoundingClientRect()
|
||||
const { top: bTop } = b.getBoundingClientRect()
|
||||
return aTop - bTop
|
||||
})
|
||||
for (const node of highlightTextNodes) {
|
||||
node.classList.remove(HIGHLIGHT_SELECT_CLASS)
|
||||
}
|
||||
setSearchResultIndex(index)
|
||||
if (highlightTextNodes.length > 0) {
|
||||
const highlightTextNode = highlightTextNodes[index] ?? null
|
||||
if (highlightTextNode) {
|
||||
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
|
||||
if (shouldScroll) {
|
||||
parentElement?.scrollIntoView({
|
||||
highlightTextNode.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
block: 'center'
|
||||
// inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[allRanges, currentIndex]
|
||||
)
|
||||
|
||||
const search = useCallback(() => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
setSearchCompleted(SearchCompletedState.Searched)
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||
setAllRanges(ranges)
|
||||
setCurrentIndex(0)
|
||||
}
|
||||
}, [target, filter, isCaseSensitive, isWholeWord])
|
||||
}
|
||||
|
||||
const implementation = useMemo(
|
||||
() => ({
|
||||
disable: () => {
|
||||
setEnableContentSearch(false)
|
||||
CSS.highlights.clear()
|
||||
},
|
||||
enable: (initialText?: string) => {
|
||||
setEnableContentSearch(true)
|
||||
if (searchInputRef.current) {
|
||||
const inputEl = searchInputRef.current
|
||||
if (initialText && initialText.trim().length > 0) {
|
||||
inputEl.value = initialText
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
search()
|
||||
CSS.highlights.clear()
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
})
|
||||
const restoreHighlight = () => {
|
||||
const highlightTextParentNodeSet = new Set<HTMLElement>()
|
||||
// Make a copy because the set might be modified during iteration indirectly
|
||||
const nodesToRestore = [...highlightTextSet]
|
||||
for (const highlightTextNode of nodesToRestore) {
|
||||
if (highlightTextNode.textContent) {
|
||||
const textNode = document.createTextNode(highlightTextNode.textContent)
|
||||
const node = highlightTextNode as HTMLElement
|
||||
if (node.parentNode) {
|
||||
highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
|
||||
node.replaceWith(textNode) // This removes the node from the DOM
|
||||
}
|
||||
}
|
||||
}
|
||||
highlightTextSet.clear() // Clear the original set after processing
|
||||
for (const parentNode of highlightTextParentNodeSet) {
|
||||
mergeAdjacentTextNodes(parentNode)
|
||||
}
|
||||
// highlightTextSet.clear() // Already cleared
|
||||
}
|
||||
|
||||
const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
restoreHighlight()
|
||||
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
|
||||
let textNode: Node | null
|
||||
const textNodeSet: Set<Node> = new Set()
|
||||
while ((textNode = iter.nextNode())) {
|
||||
if (filter(textNode)) {
|
||||
textNodeSet.add(textNode)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightTextSetTemp = new Set<HTMLSpanElement>()
|
||||
for (const node of textNodeSet) {
|
||||
const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
|
||||
if (list) {
|
||||
list.forEach((node) => highlightTextSetTemp.add(node))
|
||||
}
|
||||
}
|
||||
const highlightTextList = [...highlightTextSetTemp]
|
||||
setTotalCount(highlightTextList.length)
|
||||
highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
|
||||
const changeIndex = () => {
|
||||
let index: number
|
||||
switch (searchTargetIndex) {
|
||||
case SearchTargetIndex.Next:
|
||||
{
|
||||
index = (searchResultIndex + 1) % highlightTextList.length
|
||||
}
|
||||
break
|
||||
case SearchTargetIndex.Prev:
|
||||
{
|
||||
index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
|
||||
}
|
||||
break
|
||||
default: {
|
||||
index = searchResultIndex
|
||||
}
|
||||
}
|
||||
},
|
||||
searchNext: () => {
|
||||
if (allRanges.length > 0) {
|
||||
setCurrentIndex((prev) => (prev < allRanges.length - 1 ? prev + 1 : 0))
|
||||
}
|
||||
},
|
||||
searchPrev: () => {
|
||||
if (allRanges.length > 0) {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allRanges.length - 1))
|
||||
}
|
||||
},
|
||||
resetSearchState: () => {
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
},
|
||||
search: () => {
|
||||
search()
|
||||
locateByIndex(true)
|
||||
},
|
||||
silentSearch: () => {
|
||||
search()
|
||||
locateByIndex(false)
|
||||
},
|
||||
focus: () => {
|
||||
searchInputRef.current?.focus()
|
||||
return Math.max(index, 0)
|
||||
}
|
||||
}),
|
||||
[allRanges.length, locateByIndex, search]
|
||||
)
|
||||
|
||||
const _searchHandlerDebounce = useMemo(() => debounce(implementation.search, 300), [implementation.search])
|
||||
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
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
const searchHandler = useCallback(() => {
|
||||
_searchHandlerDebounce()
|
||||
}, [_searchHandlerDebounce])
|
||||
|
||||
const userInputHandler = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value.trim()
|
||||
if (value.length === 0) {
|
||||
resetSearch()
|
||||
if (targetIndex === null) {
|
||||
return null
|
||||
} else {
|
||||
searchHandler()
|
||||
}
|
||||
prevSearchText.current = value
|
||||
},
|
||||
[searchHandler, resetSearch]
|
||||
)
|
||||
|
||||
const keyDownHandler = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const value = (event.target as HTMLInputElement).value.trim()
|
||||
if (value.length === 0) {
|
||||
resetSearch()
|
||||
return
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
implementation.searchPrev()
|
||||
const totalCount = highlightTextSet.size
|
||||
if (targetIndex >= totalCount) {
|
||||
return totalCount - 1
|
||||
} else {
|
||||
implementation.searchNext()
|
||||
return targetIndex
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const _searchHandlerDebounce = debounce(() => {
|
||||
implementation.search()
|
||||
}, 300)
|
||||
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
|
||||
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value.trim()
|
||||
if (value.length === 0) {
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
} else {
|
||||
// 用户输入时允许滚动
|
||||
setShouldScroll(true)
|
||||
searchHandler()
|
||||
}
|
||||
prevSearchText.current = value
|
||||
}
|
||||
|
||||
const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { code, key, shiftKey } = event
|
||||
if (key === 'Process') {
|
||||
return
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'Enter':
|
||||
{
|
||||
if (shiftKey) {
|
||||
implementation.searchPrev()
|
||||
} else {
|
||||
implementation.searchNext()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
{
|
||||
implementation.disable()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||
|
||||
const userOutlinedButtonOnClick = () => {
|
||||
if (onIncludeUserChange) {
|
||||
onIncludeUserChange(!includeUser)
|
||||
}
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const implementation = {
|
||||
disable() {
|
||||
setEnableContentSearch(false)
|
||||
restoreHighlight()
|
||||
setShouldScroll(false)
|
||||
},
|
||||
enable(initialText?: string) {
|
||||
setEnableContentSearch(true)
|
||||
setShouldScroll(false) // Default to false, search itself might set it to true
|
||||
if (searchInputRef.current) {
|
||||
const inputEl = searchInputRef.current
|
||||
if (initialText && initialText.trim().length > 0) {
|
||||
inputEl.value = initialText
|
||||
// Trigger search after setting initial text
|
||||
// Need to make sure search() uses the new value
|
||||
// and also to focus and select
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
setShouldScroll(true)
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex, true) // Ensure scrolling
|
||||
} else {
|
||||
// If search returns null (e.g., empty input or no matches with initial text), clear state
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
})
|
||||
// Only search if there's existing text and no new initialText
|
||||
if (inputEl.value.trim()) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
setSearchResultIndex(targetIndex)
|
||||
// locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
implementation.disable()
|
||||
}
|
||||
},
|
||||
[implementation, resetSearch]
|
||||
)
|
||||
searchNext() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search(SearchTargetIndex.Next)
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
searchPrev() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search(SearchTargetIndex.Prev)
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
resetSearchState() {
|
||||
if (enableContentSearch) {
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
// Maybe also reset index? Depends on desired behavior
|
||||
// setSearchResultIndex(0);
|
||||
}
|
||||
},
|
||||
search() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex, shouldScroll)
|
||||
} else {
|
||||
// If search returns null (e.g., empty input), clear state
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}
|
||||
}
|
||||
},
|
||||
silentSearch() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
// 只更新索引,不触发滚动
|
||||
locateByIndex(targetIndex, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
focus() {
|
||||
searchInputFocus()
|
||||
}
|
||||
}
|
||||
|
||||
const searchInputFocus = useCallback(() => {
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||
}, [])
|
||||
|
||||
const userOutlinedButtonOnClick = useCallback(() => {
|
||||
onIncludeUserChange?.(!includeUser)
|
||||
searchInputFocus()
|
||||
}, [includeUser, onIncludeUserChange, searchInputFocus])
|
||||
|
||||
useImperativeHandle(ref, () => implementation, [implementation])
|
||||
|
||||
useEffect(() => {
|
||||
locateByIndex()
|
||||
}, [currentIndex, locateByIndex])
|
||||
useImperativeHandle(ref, () => ({
|
||||
disable() {
|
||||
implementation.disable()
|
||||
},
|
||||
enable(initialText?: string) {
|
||||
implementation.enable(initialText)
|
||||
},
|
||||
searchNext() {
|
||||
implementation.searchNext()
|
||||
},
|
||||
searchPrev() {
|
||||
implementation.searchPrev()
|
||||
},
|
||||
search() {
|
||||
implementation.search()
|
||||
},
|
||||
silentSearch() {
|
||||
implementation.silentSearch()
|
||||
},
|
||||
focus() {
|
||||
implementation.focus()
|
||||
}
|
||||
}))
|
||||
|
||||
// Re-run search when options change and search is active
|
||||
useEffect(() => {
|
||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||
search()
|
||||
implementation.search()
|
||||
}
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency
|
||||
|
||||
const prevButtonOnClick = () => {
|
||||
implementation.searchPrev()
|
||||
@@ -368,11 +589,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
<Separator></Separator>
|
||||
<SearchResults>
|
||||
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
||||
allRanges.length > 0 ? (
|
||||
totalCount > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<NoResults>{t('common.no_results')}</NoResults>
|
||||
@@ -382,10 +603,10 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
)}
|
||||
</SearchResults>
|
||||
<ToolBar>
|
||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
|
||||
<ChevronUp size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
|
||||
<ChevronDown size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={closeButtonOnClick}>
|
||||
|
||||
@@ -1,59 +1,87 @@
|
||||
import { Dropdown } from 'antd'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ContextMenuProps {
|
||||
children: React.ReactNode
|
||||
onContextMenu?: (e: React.MouseEvent) => void
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu, style }) => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedText, setSelectedText] = useState<string | undefined>(undefined)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
const contextMenuItems = useMemo(() => {
|
||||
if (!selectedText) return []
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
onContextMenu?.(e)
|
||||
},
|
||||
[onContextMenu]
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => {
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
window.api?.quoteToMainWindow(selectedText)
|
||||
}
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
setContextMenuPosition(null)
|
||||
}
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取右键菜单项
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => {
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
window.api?.quoteToMainWindow(selectedText)
|
||||
}
|
||||
}
|
||||
]
|
||||
}, [selectedText, t])
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
const selectedText = window.getSelection()?.toString()
|
||||
setSelectedText(selectedText)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Dropdown onOpenChange={onOpenChange} menu={{ items: contextMenuItems }} trigger={['contextMenu']}>
|
||||
<ContextContainer onContextMenu={handleContextMenu} className="context-menu-container" style={style}>
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
</Dropdown>
|
||||
)}
|
||||
{children}
|
||||
</Dropdown>
|
||||
</ContextContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextContainer = styled.div``
|
||||
|
||||
export default ContextMenu
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Collapse } from 'antd'
|
||||
import { merge } from 'lodash'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { FC, memo, useMemo, useState } from 'react'
|
||||
|
||||
interface CustomCollapseProps {
|
||||
@@ -79,14 +78,6 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
destroyInactivePanel={destroyInactivePanel}
|
||||
collapsible={collapsible}
|
||||
onChange={setActiveKeys}
|
||||
expandIcon={({ isActive }) => (
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color="var(--color-text-3)"
|
||||
strokeWidth={1.5}
|
||||
style={{ transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
)}
|
||||
items={[
|
||||
{
|
||||
styles: collapseItemStyles,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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
|
||||
@@ -41,10 +41,11 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
||||
return (
|
||||
<EditorContainer style={{ height }}>
|
||||
<InputArea value={inputValue} onChange={handleChange} placeholder={placeholder} autoFocus={autoFocus} />
|
||||
<PreviewArea className="markdown">
|
||||
<PreviewArea>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkCjkFriendly, remarkMath]}
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex]}>
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex]}
|
||||
className="markdown">
|
||||
{inputValue || t('settings.provider.notes.markdown_editor_default_value')}
|
||||
</ReactMarkdown>
|
||||
</PreviewArea>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
PushpinOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
@@ -303,7 +303,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
<Spacer />
|
||||
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
|
||||
<ButtonsGroup className={isWindows || isLinux ? 'windows' : ''}>
|
||||
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleGoBack(appInfo.id)}>
|
||||
<ArrowLeftOutlined />
|
||||
@@ -452,7 +452,7 @@ const ButtonsGroup = styled.div`
|
||||
gap: 5px;
|
||||
-webkit-app-region: no-drag;
|
||||
&.windows {
|
||||
margin-right: ${isWin ? '130px' : isLinux ? '100px' : 0};
|
||||
margin-right: ${isWindows ? '130px' : isLinux ? '100px' : 0};
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 50px;
|
||||
padding: 0 3px;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
@@ -22,7 +21,6 @@ const WebviewContainer = memo(
|
||||
onNavigateCallback: (appid: string, url: string) => void
|
||||
}) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
const { enableSpellCheck } = useSettings()
|
||||
|
||||
const setRef = (appid: string) => {
|
||||
onSetRefCallback(appid, null)
|
||||
@@ -48,14 +46,6 @@ const WebviewContainer = memo(
|
||||
onNavigateCallback(appid, event.url)
|
||||
}
|
||||
|
||||
const handleDomReady = () => {
|
||||
const webviewId = webviewRef.current?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||
}
|
||||
}
|
||||
|
||||
webviewRef.current.addEventListener('dom-ready', handleDomReady)
|
||||
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
|
||||
|
||||
@@ -65,7 +55,6 @@ const WebviewContainer = memo(
|
||||
return () => {
|
||||
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
|
||||
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
|
||||
}
|
||||
// because the appid and url are enough, no need to add onLoadedCallback
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -35,38 +35,17 @@ const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
|
||||
<SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount>
|
||||
<ActionButtons>
|
||||
<Tooltip title={t('common.save')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
color="default"
|
||||
variant="text"
|
||||
icon={<Save size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={() => handleAction('save')}
|
||||
/>
|
||||
<ActionButton icon={<Save size={16} />} disabled={isActionDisabled} onClick={() => handleAction('save')} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.copy')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
color="default"
|
||||
variant="text"
|
||||
icon={<Copy size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={() => handleAction('copy')}
|
||||
/>
|
||||
<ActionButton icon={<Copy size={16} />} disabled={isActionDisabled} onClick={() => handleAction('copy')} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
color="danger"
|
||||
variant="text"
|
||||
danger
|
||||
icon={<Trash size={16} />}
|
||||
onClick={() => handleAction('delete')}
|
||||
/>
|
||||
<ActionButton danger icon={<Trash size={16} />} onClick={() => handleAction('delete')} />
|
||||
</Tooltip>
|
||||
</ActionButtons>
|
||||
<Tooltip title={t('chat.navigation.close')}>
|
||||
<Button shape="circle" color="default" variant="text" icon={<X size={16} />} onClick={handleClose} />
|
||||
<ActionButton icon={<X size={16} />} onClick={handleClose} />
|
||||
</Tooltip>
|
||||
</ActionBar>
|
||||
</Container>
|
||||
@@ -74,38 +53,45 @@ const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
padding: 36px 20px;
|
||||
background-color: var(--color-background);
|
||||
border-top: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
const ActionBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--color-background);
|
||||
padding: 4px 4px;
|
||||
border-radius: 99px;
|
||||
box-shadow: 0px 2px 8px 0px rgb(128 128 128 / 20%);
|
||||
border: 0.5px solid var(--color-border);
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 50%;
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
const SelectionCount = styled.div`
|
||||
margin-right: 15px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
padding-left: 8px;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
export default MultiSelectActionPopup
|
||||
|
||||
@@ -32,23 +32,16 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
title={null}
|
||||
width={700}
|
||||
width="920px"
|
||||
transitionName="animation-move-down"
|
||||
styles={{
|
||||
content: {
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 16
|
||||
border: `1px solid var(--color-frame-border)`
|
||||
},
|
||||
body: {
|
||||
height: '80vh',
|
||||
maxHeight: 'inherit',
|
||||
padding: 0
|
||||
}
|
||||
body: { height: '85vh' }
|
||||
}}
|
||||
centered
|
||||
closable={false}
|
||||
footer={null}>
|
||||
<HistoryPage />
|
||||
</Modal>
|
||||
|
||||
@@ -388,11 +388,8 @@ const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 16
|
||||
},
|
||||
body: {
|
||||
maxHeight: 'inherit',
|
||||
padding: 0
|
||||
paddingBottom: 20,
|
||||
border: '1px solid var(--color-border)'
|
||||
}
|
||||
}}
|
||||
closeIcon={null}
|
||||
|
||||
@@ -48,17 +48,17 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
|
||||
}, [throttledInternalScrollHandler, clearScrollingTimeout])
|
||||
|
||||
return (
|
||||
<ScrollBarContainer
|
||||
<Container
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
$isScrolling={isScrolling}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
</ScrollBarContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const ScrollBarContainer = styled.div<{ $isScrolling: boolean }>`
|
||||
const Container = styled.div<{ $isScrolling: boolean }>`
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { Dropdown, DropdownProps } from 'antd'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
interface SelectorOption<V = string | number> {
|
||||
label: string | ReactNode
|
||||
value: V
|
||||
type?: 'group'
|
||||
options?: SelectorOption<V>[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface BaseSelectorProps<V = string | number> {
|
||||
options: SelectorOption<V>[]
|
||||
placeholder?: string
|
||||
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
|
||||
/** 字体大小 */
|
||||
size?: number
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface SingleSelectorProps<V> extends BaseSelectorProps<V> {
|
||||
multiple?: false
|
||||
value?: V
|
||||
onChange: (value: V) => void
|
||||
}
|
||||
|
||||
interface MultipleSelectorProps<V> extends BaseSelectorProps<V> {
|
||||
multiple: true
|
||||
value?: V[]
|
||||
onChange: (value: V[]) => void
|
||||
}
|
||||
|
||||
type SelectorProps<V> = SingleSelectorProps<V> | MultipleSelectorProps<V>
|
||||
|
||||
const Selector = <V extends string | number>({
|
||||
options,
|
||||
value,
|
||||
onChange = () => {},
|
||||
placement = 'bottomRight',
|
||||
size = 13,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
multiple = false
|
||||
}: SelectorProps<V>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 1)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectedValues = useMemo(() => {
|
||||
if (multiple) {
|
||||
return (value as V[]) || []
|
||||
}
|
||||
return value !== undefined ? [value as V] : []
|
||||
}, [value, multiple])
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (selectedValues.length > 0) {
|
||||
const findLabels = (opts: SelectorOption<V>[]): (string | ReactNode)[] => {
|
||||
const labels: (string | ReactNode)[] = []
|
||||
for (const opt of opts) {
|
||||
if (selectedValues.some((v) => v == opt.value)) {
|
||||
labels.push(opt.label)
|
||||
}
|
||||
if (opt.options) {
|
||||
labels.push(...findLabels(opt.options))
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
const labels = findLabels(options)
|
||||
if (labels.length === 0) return placeholder
|
||||
if (labels.length === 1) return labels[0]
|
||||
return t('common.selectedItems', { count: labels.length })
|
||||
}
|
||||
return placeholder
|
||||
}, [selectedValues, placeholder, options, t])
|
||||
|
||||
const items = useMemo(() => {
|
||||
const mapOption = (option: SelectorOption<V>) => ({
|
||||
key: option.value,
|
||||
label: option.label,
|
||||
extra: <CheckIcon>{selectedValues.some((v) => v == option.value) && <Check size={14} />}</CheckIcon>,
|
||||
disabled: option.disabled,
|
||||
type: option.type || (option.options ? 'group' : undefined),
|
||||
children: option.options?.map(mapOption)
|
||||
})
|
||||
|
||||
return options.map(mapOption)
|
||||
}, [options, selectedValues])
|
||||
|
||||
function onClick(e: { key: string }) {
|
||||
if (disabled) return
|
||||
|
||||
const newValue = e.key as V
|
||||
if (multiple) {
|
||||
const newValues = selectedValues.includes(newValue)
|
||||
? selectedValues.filter((v) => v !== newValue)
|
||||
: [...selectedValues, newValue]
|
||||
;(onChange as MultipleSelectorProps<V>['onChange'])(newValues)
|
||||
} else {
|
||||
;(onChange as SingleSelectorProps<V>['onChange'])(newValue)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange: DropdownProps['onOpenChange'] = (nextOpen, info) => {
|
||||
if (disabled) return
|
||||
|
||||
if (info.source === 'trigger' || nextOpen) {
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="selector-dropdown"
|
||||
menu={{ items, onClick }}
|
||||
trigger={['click']}
|
||||
placement={placement}
|
||||
open={open && !disabled}
|
||||
onOpenChange={handleOpenChange}>
|
||||
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
|
||||
{label}
|
||||
<LabelIcon size={size + 3} />
|
||||
</Label>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
const LabelIcon = styled(ChevronsUpDown)`
|
||||
border-radius: 4px;
|
||||
padding: 2px 0;
|
||||
background-color: var(--color-background-soft);
|
||||
transition: background-color 0.2s;
|
||||
`
|
||||
|
||||
const Label = styled.div<{ $size: number; $open: boolean; $disabled: boolean; $isPlaceholder: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 99px;
|
||||
padding: 3px 2px 3px 10px;
|
||||
font-size: ${({ $size }) => $size}px;
|
||||
line-height: 1;
|
||||
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
|
||||
opacity: ${({ $disabled }) => ($disabled ? 0.6 : 1)};
|
||||
color: ${({ $isPlaceholder }) => ($isPlaceholder ? 'var(--color-text-2)' : 'inherit')};
|
||||
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
&:hover {
|
||||
${({ $disabled }) =>
|
||||
!$disabled &&
|
||||
css`
|
||||
background-color: var(--color-background-mute);
|
||||
${LabelIcon} {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
`}
|
||||
}
|
||||
${({ $open, $disabled }) =>
|
||||
$open &&
|
||||
!$disabled &&
|
||||
css`
|
||||
background-color: var(--color-background-mute);
|
||||
${LabelIcon} {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
const CheckIcon = styled.div`
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
`
|
||||
|
||||
export default Selector
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@@ -17,6 +18,7 @@ const spinnerVariants = {
|
||||
}
|
||||
|
||||
export default function Spinner({ text }: Props) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Searching
|
||||
variants={spinnerVariants}
|
||||
@@ -29,7 +31,7 @@ export default function Spinner({ text }: Props) {
|
||||
ease: 'easeInOut'
|
||||
}}>
|
||||
<Search size={16} style={{ color: 'unset' }} />
|
||||
<span>{text}</span>
|
||||
<span>{t(text)}</span>
|
||||
</Searching>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { backupToWebdav } from '@renderer/services/BackupService'
|
||||
import { Input, Modal } from 'antd'
|
||||
import { backupToWebdav, restoreFromWebdav } 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
|
||||
}
|
||||
|
||||
interface WebdavModalProps {
|
||||
isModalVisible: boolean
|
||||
handleBackup: () => void
|
||||
@@ -80,3 +87,156 @@ export function WebdavBackupModal({
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface WebdavRestoreModalProps {
|
||||
isRestoreModalVisible: boolean
|
||||
handleRestore: () => void
|
||||
handleCancel: () => void
|
||||
restoring: boolean
|
||||
selectedFile: string | null
|
||||
setSelectedFile: (value: string | null) => void
|
||||
loadingFiles: boolean
|
||||
backupFiles: BackupFile[]
|
||||
}
|
||||
|
||||
interface UseWebdavRestoreModalProps {
|
||||
webdavHost: string | undefined
|
||||
webdavUser: string | undefined
|
||||
webdavPass: string | undefined
|
||||
webdavPath: string | undefined
|
||||
restoreMethod?: typeof restoreFromWebdav
|
||||
}
|
||||
|
||||
export function useWebdavRestoreModal({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath,
|
||||
restoreMethod
|
||||
}: UseWebdavRestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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 showRestoreModal = useCallback(async () => {
|
||||
if (!webdavHost) {
|
||||
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsRestoreModalVisible(true)
|
||||
setLoadingFiles(true)
|
||||
try {
|
||||
const files = await window.api.backup.listWebdavFiles({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
})
|
||||
setBackupFiles(files)
|
||||
} catch (error: any) {
|
||||
window.message.error({ content: error.message, key: 'list-files-error' })
|
||||
} finally {
|
||||
setLoadingFiles(false)
|
||||
}
|
||||
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!selectedFile || !webdavHost) {
|
||||
window.message.error({
|
||||
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
|
||||
key: 'restore-error'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.webdav.restore.confirm.title'),
|
||||
content: t('settings.data.webdav.restore.confirm.content'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setRestoring(true)
|
||||
try {
|
||||
await (restoreMethod ?? restoreFromWebdav)(selectedFile)
|
||||
setIsRestoreModalVisible(false)
|
||||
} catch (error: any) {
|
||||
window.message.error({ content: error.message, key: 'restore-error' })
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [selectedFile, webdavHost, t, restoreMethod])
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsRestoreModalVisible(false)
|
||||
}
|
||||
|
||||
return {
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles,
|
||||
showRestoreModal
|
||||
}
|
||||
}
|
||||
|
||||
export function WebdavRestoreModal({
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles
|
||||
}: WebdavRestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.webdav.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.webdav.restore.modal.select.placeholder')}
|
||||
value={selectedFile}
|
||||
onChange={setSelectedFile}
|
||||
options={backupFiles.map(formatFileOption)}
|
||||
loading={loadingFiles}
|
||||
showSearch
|
||||
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
{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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
@@ -78,7 +78,7 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
@@ -91,5 +91,5 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
`
|
||||
|
||||
@@ -3,11 +3,10 @@ export const DEFAULT_CONTEXTCOUNT = 5
|
||||
export const DEFAULT_MAX_TOKENS = 4096
|
||||
export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6
|
||||
export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0
|
||||
export const DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT = 1
|
||||
|
||||
export const platform = window.electron?.process?.platform
|
||||
export const isMac = platform === 'darwin'
|
||||
export const isWin = platform === 'win32' || platform === 'win64'
|
||||
export const isWindows = platform === 'win32' || platform === 'win64'
|
||||
export const isLinux = platform === 'linux'
|
||||
|
||||
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'
|
||||
|
||||
@@ -145,7 +145,7 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
|
||||
import NomicLogo from '@renderer/assets/images/providers/nomic.png'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { getLowerBaseModelName } from '@renderer/utils'
|
||||
import { getBaseModelName } from '@renderer/utils'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts'
|
||||
@@ -184,7 +184,7 @@ const visionAllowedModels = [
|
||||
'deepseek-vl(?:[\\w-]+)?',
|
||||
'kimi-latest',
|
||||
'gemma-3(?:-[\\w-]+)',
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?'
|
||||
'doubao-1.6-seed(?:-[\\w-]+)'
|
||||
]
|
||||
|
||||
const visionExcludedModels = [
|
||||
@@ -238,8 +238,7 @@ export const FUNCTION_CALLING_MODELS = [
|
||||
'glm-4(?:-[\\w-]+)?',
|
||||
'learnlm(?:-[\\w-]+)?',
|
||||
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
|
||||
'grok-3(?:-[\\w-]+)?',
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?'
|
||||
'grok-3(?:-[\\w-]+)?'
|
||||
]
|
||||
|
||||
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||
@@ -273,10 +272,6 @@ export function isFunctionCallingModel(model: Model): boolean {
|
||||
return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id)
|
||||
}
|
||||
|
||||
if (model.provider === 'doubao') {
|
||||
return FUNCTION_CALLING_REGEX.test(model.id) || FUNCTION_CALLING_REGEX.test(model.name)
|
||||
}
|
||||
|
||||
if (['deepseek', 'anthropic'].includes(model.provider)) {
|
||||
return true
|
||||
}
|
||||
@@ -1356,6 +1351,12 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'DeepSeek-V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3-250324',
|
||||
provider: 'doubao',
|
||||
name: 'DeepSeek-V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'doubao-pro-32k-241215',
|
||||
provider: 'doubao',
|
||||
@@ -2287,8 +2288,6 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
|
||||
]
|
||||
|
||||
export const SUPPORTED_DISABLE_GENERATION_MODELS = [
|
||||
'gemini-2.0-flash-exp-image-generation',
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
@@ -2308,7 +2307,21 @@ export const GENERATE_IMAGE_MODELS = [
|
||||
...SUPPORTED_DISABLE_GENERATION_MODELS
|
||||
]
|
||||
|
||||
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
|
||||
export const GEMINI_SEARCH_MODELS = [
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-lite',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-001',
|
||||
'gemini-2.0-pro-exp-02-05',
|
||||
'gemini-2.0-pro-exp',
|
||||
'gemini-2.5-pro-exp',
|
||||
'gemini-2.5-pro-exp-03-25',
|
||||
'gemini-2.5-pro-preview',
|
||||
'gemini-2.5-pro-preview-03-25',
|
||||
'gemini-2.5-pro-preview-05-06',
|
||||
'gemini-2.5-flash-preview',
|
||||
'gemini-2.5-flash-preview-04-17'
|
||||
]
|
||||
|
||||
export const OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini']
|
||||
|
||||
@@ -2352,7 +2365,7 @@ export function isVisionModel(model: Model): boolean {
|
||||
// }
|
||||
|
||||
if (model.provider === 'doubao') {
|
||||
return VISION_REGEX.test(model.name) || VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
|
||||
return VISION_REGEX.test(model.name) || model.type?.includes('vision') || false
|
||||
}
|
||||
|
||||
return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
|
||||
@@ -2473,10 +2486,6 @@ export function isGeminiReasoningModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.id.startsWith('gemini') && model.id.includes('thinking')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (model.id.includes('gemini-2.5')) {
|
||||
return true
|
||||
}
|
||||
@@ -2507,16 +2516,14 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseName = getLowerBaseModelName(model.id, '/')
|
||||
const baseName = getBaseModelName(model.id, '/').toLowerCase()
|
||||
|
||||
return (
|
||||
baseName.startsWith('qwen3') ||
|
||||
[
|
||||
'qwen-plus',
|
||||
'qwen-plus-latest',
|
||||
'qwen-plus-0428',
|
||||
'qwen-plus-2025-04-28',
|
||||
'qwen-turbo',
|
||||
'qwen-turbo-latest',
|
||||
'qwen-turbo-0428',
|
||||
'qwen-turbo-2025-04-28'
|
||||
@@ -2529,7 +2536,7 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return DOUBAO_THINKING_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_MODEL_REGEX.test(model.name)
|
||||
return DOUBAO_THINKING_MODEL_REGEX.test(model.id)
|
||||
}
|
||||
|
||||
export function isClaudeReasoningModel(model?: Model): boolean {
|
||||
@@ -2551,10 +2558,6 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isEmbeddingModel(model)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.provider === 'doubao') {
|
||||
return (
|
||||
REASONING_REGEX.test(model.name) ||
|
||||
@@ -2622,7 +2625,7 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseName = getLowerBaseModelName(model.id, '/')
|
||||
const baseName = getBaseModelName(model.id, '/').toLowerCase()
|
||||
|
||||
// 不管哪个供应商都判断了
|
||||
if (model.id.includes('claude')) {
|
||||
@@ -2651,13 +2654,13 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
if (provider?.type === 'openai') {
|
||||
if (GEMINI_SEARCH_REGEX.test(baseName) || isOpenAIWebSearchModel(model)) {
|
||||
if (GEMINI_SEARCH_MODELS.includes(baseName) || isOpenAIWebSearchModel(model)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.id === 'gemini' || provider?.type === 'gemini') {
|
||||
return GEMINI_SEARCH_REGEX.test(baseName)
|
||||
return GEMINI_SEARCH_MODELS.includes(baseName)
|
||||
}
|
||||
|
||||
if (provider.id === 'hunyuan') {
|
||||
@@ -2696,7 +2699,7 @@ export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return isOpenAIWebSearchChatCompletionOnlyModel(model) || model.id.includes('sonar')
|
||||
return isOpenAIWebSearchModel(model) || model.id.includes('sonar')
|
||||
}
|
||||
|
||||
export function isGenerateImageModel(model: Model): boolean {
|
||||
@@ -2716,7 +2719,7 @@ export function isGenerateImageModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseName = getLowerBaseModelName(model.id, '/')
|
||||
const baseName = getBaseModelName(model.id, '/').toLowerCase()
|
||||
if (GENERATE_IMAGE_MODELS.includes(baseName)) {
|
||||
return true
|
||||
}
|
||||
@@ -2728,7 +2731,7 @@ export function isSupportedDisableGenerationModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(getLowerBaseModelName(model.id))
|
||||
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(model.id)
|
||||
}
|
||||
|
||||
export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record<string, any> {
|
||||
@@ -2834,7 +2837,6 @@ export function groupQwenModels(models: Model[]): Record<string, Model[]> {
|
||||
|
||||
export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
|
||||
// Gemini models
|
||||
'gemini-2\\.5-flash-lite.*$': { min: 512, max: 24576 },
|
||||
'gemini-.*-flash.*$': { min: 0, max: 24576 },
|
||||
'gemini-.*-pro.*$': { min: 128, max: 32768 },
|
||||
|
||||
@@ -2861,14 +2863,13 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } |
|
||||
|
||||
// Doubao 支持思考模式的模型正则
|
||||
export const DOUBAO_THINKING_MODEL_REGEX =
|
||||
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i
|
||||
/doubao-(?:1(\.|-5)-thinking-vision-pro|1(\.|-)5-thinking-pro-m|seed-1\.6|seed-1\.6-flash)(?:-[\\w-]+)?/i
|
||||
|
||||
// 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX =
|
||||
/doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-[\w-]+)*/i
|
||||
// 支持 auto 的 Doubao 模型
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(?:1-5-thinking-pro-m|seed-1.6)(?:-[\\w-]+)?/i
|
||||
|
||||
export function isDoubaoThinkingAutoModel(model: Model): boolean {
|
||||
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name)
|
||||
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id)
|
||||
}
|
||||
|
||||
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
|
||||
|
||||
@@ -38,20 +38,7 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
boxShadowSecondary: 'none',
|
||||
defaultShadow: 'none',
|
||||
dangerShadow: '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)'
|
||||
primaryShadow: 'none'
|
||||
},
|
||||
Collapse: {
|
||||
headerBg: 'transparent'
|
||||
@@ -63,47 +50,13 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
fontFamily: 'var(--code-font-family)'
|
||||
},
|
||||
Segmented: {
|
||||
itemActiveBg: 'var(--color-background-soft)',
|
||||
itemHoverBg: 'var(--color-background-soft)',
|
||||
trackBg: 'rgba(153,153,153,0.15)'
|
||||
},
|
||||
Switch: {
|
||||
colorTextQuaternary: 'rgba(153,153,153,0.20)',
|
||||
trackMinWidth: 40,
|
||||
handleSize: 19,
|
||||
trackMinWidthSM: 28,
|
||||
trackHeightSM: 17,
|
||||
handleSizeSM: 14,
|
||||
trackPadding: 1.5
|
||||
},
|
||||
Dropdown: {
|
||||
controlPaddingHorizontal: 8,
|
||||
borderRadiusLG: 10,
|
||||
borderRadiusSM: 8
|
||||
},
|
||||
Popover: {
|
||||
borderRadiusLG: 10
|
||||
},
|
||||
Slider: {
|
||||
handleLineWidth: 1.5,
|
||||
handleSize: 15,
|
||||
handleSizeHover: 15,
|
||||
dotSize: 7,
|
||||
railSize: 5,
|
||||
colorBgElevated: '#ffffff'
|
||||
},
|
||||
Modal: {
|
||||
colorBgElevated: 'var(--modal-background)'
|
||||
},
|
||||
Divider: {
|
||||
colorSplit: 'rgba(128,128,128,0.15)'
|
||||
itemActiveBg: 'var(--color-background-mute)',
|
||||
itemHoverBg: 'var(--color-background-mute)'
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: colorPrimary,
|
||||
fontFamily: 'var(--font-family)',
|
||||
colorBgMask: _theme === 'dark' ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.8)',
|
||||
motionDurationMid: '100ms'
|
||||
fontFamily: 'var(--font-family)'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createContext, type PropsWithChildren, use, useCallback, useEffect, use
|
||||
|
||||
interface CodeStyleContextType {
|
||||
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
highlightStreamingCode: (code: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
cleanupTokenizers: (callerId: string) => void
|
||||
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
|
||||
highlightCode: (code: string, language: string) => Promise<string>
|
||||
@@ -23,7 +22,6 @@ interface CodeStyleContextType {
|
||||
|
||||
const defaultCodeStyleContext: CodeStyleContextType = {
|
||||
highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
|
||||
highlightStreamingCode: async () => ({ lines: [], recall: 0 }),
|
||||
cleanupTokenizers: () => {},
|
||||
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
|
||||
highlightCode: async () => '',
|
||||
@@ -116,15 +114,6 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
shikiStreamService.cleanupTokenizers(callerId)
|
||||
}, [])
|
||||
|
||||
// 高亮流式输出的代码
|
||||
const highlightStreamingCode = useCallback(
|
||||
async (fullContent: string, language: string, callerId: string) => {
|
||||
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
return shikiStreamService.highlightStreamingCode(fullContent, normalizedLang, activeShikiTheme, callerId)
|
||||
},
|
||||
[activeShikiTheme, languageMap]
|
||||
)
|
||||
|
||||
// 获取 Shiki pre 标签属性
|
||||
const getShikiPreProperties = useCallback(
|
||||
async (language: string) => {
|
||||
@@ -159,7 +148,6 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
highlightCodeChunk,
|
||||
highlightStreamingCode,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
@@ -171,7 +159,6 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
}),
|
||||
[
|
||||
highlightCodeChunk,
|
||||
highlightStreamingCode,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
|
||||
@@ -30,14 +30,6 @@ export function useAppInit() {
|
||||
console.timeEnd('init')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getDataPathFromArgs().then((dataPath) => {
|
||||
if (dataPath) {
|
||||
window.navigate('/settings/data', { replace: true })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useUpdateHandler()
|
||||
useFullScreenNotice()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isWin } from '@renderer/config/constant'
|
||||
import { isWindows } from '@renderer/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -8,7 +8,7 @@ export function useFullScreenNotice() {
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, isFullscreen) => {
|
||||
if (isWin && isFullscreen) {
|
||||
if (isWindows && isFullscreen) {
|
||||
window.message.info({
|
||||
content: t('common.fullscreen'),
|
||||
duration: 3,
|
||||
|
||||
@@ -169,8 +169,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
processingStatus: 'pending',
|
||||
processingProgress: 0,
|
||||
processingError: '',
|
||||
uniqueId: undefined,
|
||||
updated_at: Date.now()
|
||||
uniqueId: undefined
|
||||
})
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SendMessageShortcut,
|
||||
setAssistantIconType,
|
||||
setAutoCheckUpdate as _setAutoCheckUpdate,
|
||||
setEarlyAccess as _setEarlyAccess,
|
||||
setLaunchOnBoot,
|
||||
setLaunchToTray,
|
||||
setPinTopicsToTop,
|
||||
@@ -11,8 +12,6 @@ import {
|
||||
setShowTokens,
|
||||
setSidebarIcons,
|
||||
setTargetLanguage,
|
||||
setTestChannel as _setTestChannel,
|
||||
setTestPlan as _setTestPlan,
|
||||
setTheme,
|
||||
SettingsState,
|
||||
setTopicPosition,
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
setWindowStyle
|
||||
} from '@renderer/store/settings'
|
||||
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
|
||||
export function useSettings() {
|
||||
const settings = useAppSelector((state) => state.settings)
|
||||
@@ -61,14 +60,9 @@ export function useSettings() {
|
||||
window.api.setAutoUpdate(isAutoUpdate)
|
||||
},
|
||||
|
||||
setTestPlan(isTestPlan: boolean) {
|
||||
dispatch(_setTestPlan(isTestPlan))
|
||||
window.api.setTestPlan(isTestPlan)
|
||||
},
|
||||
|
||||
setTestChannel(channel: UpgradeChannel) {
|
||||
dispatch(_setTestChannel(channel))
|
||||
window.api.setTestChannel(channel)
|
||||
setEarlyAccess(isEarlyAccess: boolean) {
|
||||
dispatch(_setEarlyAccess(isEarlyAccess))
|
||||
window.api.setFeedUrl(isEarlyAccess ? FeedUrl.EARLY_ACCESS : FeedUrl.PRODUCTION)
|
||||
},
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { orderBy } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
@@ -72,7 +72,7 @@ export function useShortcutDisplay(key: string) {
|
||||
case 'ctrl':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
case 'command':
|
||||
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
|
||||
return isMac ? '⌘' : isWindows ? 'Win' : 'Super'
|
||||
case 'alt':
|
||||
return isMac ? '⌥' : 'Alt'
|
||||
case 'shift':
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { RootState, useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setTagsOrder, updateTagCollapse } from '@renderer/store/assistants'
|
||||
import { setTagsOrder, updateAssistants } from '@renderer/store/assistants'
|
||||
import { flatMap, groupBy, uniq } from 'lodash'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -12,8 +12,6 @@ const selectAssistantsState = (state: RootState) => state.assistants
|
||||
// 记忆化 tagsOrder 选择器(自动处理默认值)--- 这是一个选择器,用于从 store 中获取 tagsOrder 的值。因为之前的tagsOrder是后面新加的,不这样做会报错,所以这里需要处理一下默认值
|
||||
const selectTagsOrder = createSelector([selectAssistantsState], (assistants) => assistants.tagsOrder ?? [])
|
||||
|
||||
const selectCollapsedTags = createSelector([selectAssistantsState], (assistants) => assistants.collapsedTags ?? {})
|
||||
|
||||
// 定义useTags的返回类型,包含所有标签和获取特定标签的助手函数
|
||||
// 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性
|
||||
// 但是为了方便管理,增加了一个获取特定标签的助手函数
|
||||
@@ -22,7 +20,6 @@ export const useTags = () => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const savedTagsOrder = useAppSelector(selectTagsOrder)
|
||||
const collapsedTags = useAppSelector(selectCollapsedTags)
|
||||
|
||||
// 计算所有标签
|
||||
const allTags = useMemo(() => {
|
||||
@@ -41,6 +38,28 @@ export const useTags = () => {
|
||||
[assistants]
|
||||
)
|
||||
|
||||
const updateTagsOrder = useCallback(
|
||||
(newOrder: string[]) => {
|
||||
dispatch(setTagsOrder(newOrder))
|
||||
updateAssistants(
|
||||
assistants.map((assistant) => {
|
||||
if (!assistant.tags || assistant.tags.length === 0) {
|
||||
return assistant
|
||||
}
|
||||
const newTags = [...assistant.tags]
|
||||
newTags.sort((a, b) => {
|
||||
return newOrder.indexOf(a) - newOrder.indexOf(b)
|
||||
})
|
||||
return {
|
||||
...assistant,
|
||||
tags: newTags
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[assistants, dispatch]
|
||||
)
|
||||
|
||||
const getGroupedAssistants = useMemo(() => {
|
||||
// 按标签分组,处理多标签的情况
|
||||
const assistantsByTags = flatMap(assistants, (assistant) => {
|
||||
@@ -81,26 +100,10 @@ export const useTags = () => {
|
||||
return grouped
|
||||
}, [assistants, t, savedTagsOrder])
|
||||
|
||||
const updateTagsOrder = useCallback(
|
||||
(newOrder: string[]) => {
|
||||
dispatch(setTagsOrder(newOrder))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
const toggleTagCollapse = useCallback(
|
||||
(tag: string) => {
|
||||
dispatch(updateTagCollapse(tag))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
return {
|
||||
allTags,
|
||||
getAssistantsByTag,
|
||||
getGroupedAssistants,
|
||||
updateTagsOrder,
|
||||
collapsedTags,
|
||||
toggleTagCollapse
|
||||
updateTagsOrder
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addSubscribeSource as _addSubscribeSource,
|
||||
type CompressionConfig,
|
||||
removeSubscribeSource as _removeSubscribeSource,
|
||||
setCompressionConfig,
|
||||
setDefaultProvider as _setDefaultProvider,
|
||||
setSubscribeSources as _setSubscribeSources,
|
||||
updateCompressionConfig,
|
||||
updateSubscribeBlacklist as _updateSubscribeBlacklist,
|
||||
updateWebSearchProvider,
|
||||
updateWebSearchProviders
|
||||
@@ -93,14 +90,3 @@ export const useBlacklist = () => {
|
||||
setSubscribeSources
|
||||
}
|
||||
}
|
||||
|
||||
export const useWebSearchSettings = () => {
|
||||
const state = useAppSelector((state) => state.websearch)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
...state,
|
||||
setCompressionConfig: (config: CompressionConfig) => dispatch(setCompressionConfig(config)),
|
||||
updateCompressionConfig: (config: Partial<CompressionConfig>) => dispatch(updateCompressionConfig(config))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "Clear Context {{Command}}",
|
||||
"input.new_topic": "New Topic {{Command}}",
|
||||
"input.pause": "Pause",
|
||||
"input.placeholder": "Type your message here, press {{key}} to send...",
|
||||
"input.placeholder": "Type your message here...",
|
||||
"input.send": "Send",
|
||||
"input.settings": "Settings",
|
||||
"input.topics": " Topics ",
|
||||
@@ -412,7 +412,6 @@
|
||||
"search": "Search",
|
||||
"select": "Select",
|
||||
"selectedMessages": "Selected {{count}} messages",
|
||||
"selectedItems": "Selected {{count}} items",
|
||||
"success": "Success",
|
||||
"topics": "Topics",
|
||||
"warning": "Warning",
|
||||
@@ -703,13 +702,6 @@
|
||||
"success.siyuan.export": "Successfully exported to Siyuan Note",
|
||||
"warn.yuque.exporting": "Exporting to Yuque, please do not request export repeatedly!",
|
||||
"warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!",
|
||||
"websearch": {
|
||||
"rag": "Executing RAG...",
|
||||
"rag_complete": "Keeping {{countAfter}} out of {{countBefore}} results...",
|
||||
"rag_failed": "RAG failed, returning empty results...",
|
||||
"cutoff": "Truncating search content...",
|
||||
"fetch_complete": "Completed {{count}} searches..."
|
||||
},
|
||||
"download.success": "Download successfully",
|
||||
"download.failed": "Download failed"
|
||||
},
|
||||
@@ -783,7 +775,6 @@
|
||||
"dimensions": "Dimensions {{dimensions}}",
|
||||
"edit": "Edit Model",
|
||||
"embedding": "Embedding",
|
||||
"embedding_dimensions": "Embedding Dimensions",
|
||||
"embedding_model": "Embedding Model",
|
||||
"embedding_model_tooltip": "Add in Settings->Model Provider->Manage",
|
||||
"function_calling": "Function Calling",
|
||||
@@ -796,18 +787,6 @@
|
||||
"string": "Text"
|
||||
},
|
||||
"pinned": "Pinned",
|
||||
"price": {
|
||||
"cost": "Cost",
|
||||
"currency": "Currency",
|
||||
"custom": "Custom",
|
||||
"custom_currency": "Custom Currency",
|
||||
"custom_currency_placeholder": "Enter Custom Currency",
|
||||
"input": "Input Price",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "Output Price",
|
||||
"price": "Price"
|
||||
},
|
||||
"reasoning": "Reasoning",
|
||||
"rerank_model": "Reranker",
|
||||
"rerank_model_support_provider": "Currently, the reranker model only supports some providers ({{provider}})",
|
||||
"rerank_model_not_support_provider": "Currently, the reranker model does not support this provider ({{provider}})",
|
||||
@@ -873,7 +852,7 @@
|
||||
"paint_course": "tutorial",
|
||||
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap",
|
||||
"prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts",
|
||||
"proxy_required": "Open the proxy and enable \"TUN mode\" to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
|
||||
"proxy_required": "Open the proxy and enable “TUN mode” to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
|
||||
"image_file_required": "Please upload an image first",
|
||||
"image_file_retry": "Please re-upload an image first",
|
||||
"image_placeholder": "No image available",
|
||||
@@ -1094,28 +1073,6 @@
|
||||
"assistant.title": "Default Assistant",
|
||||
"data": {
|
||||
"app_data": "App Data",
|
||||
"app_data.select": "Modify Directory",
|
||||
"app_data.select_title": "Change App Data Directory",
|
||||
"app_data.restart_notice": "The app may need to restart multiple times to apply the changes",
|
||||
"app_data.copy_data_option": "Copy data, will automatically restart after copying the original directory data to the new directory",
|
||||
"app_data.copy_time_notice": "Copying data may take a while, do not force quit app",
|
||||
"app_data.path_changed_without_copy": "Path changed successfully",
|
||||
"app_data.copying_warning": "Data copying, do not force quit app, the app will restart after copied",
|
||||
"app_data.copying": "Copying data to new location...",
|
||||
"app_data.copy_success": "Successfully copied data to new location",
|
||||
"app_data.copy_failed": "Failed to copy data",
|
||||
"app_data.select_success": "Data directory changed, the app will restart to apply changes",
|
||||
"app_data.select_error": "Failed to change data directory",
|
||||
"app_data.migration_title": "Data Migration",
|
||||
"app_data.original_path": "Original Path",
|
||||
"app_data.new_path": "New 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.stop_quit_app_reason": "The app is currently migrating data and cannot be exited",
|
||||
"app_data.select_not_empty_dir": "New path is not empty",
|
||||
"app_data.select_not_empty_dir_content": "New path is not empty, it will overwrite the data in the new path, there is a risk of data loss and copy failure, continue?",
|
||||
"app_data.select_error_same_path": "New path is the same as the old path, please select another path",
|
||||
"app_data.select_error_in_app_path": "New path is the same as the application installation path, please select another path",
|
||||
"app_knowledge": "Knowledge Base Files",
|
||||
"app_knowledge.button.delete": "Delete File",
|
||||
"app_knowledge.remove_all": "Remove Knowledge Base Files",
|
||||
@@ -1148,8 +1105,7 @@
|
||||
"obsidian": "Export to Obsidian",
|
||||
"siyuan": "Export to SiYuan Note",
|
||||
"joplin": "Export to Joplin",
|
||||
"docx": "Export as Word",
|
||||
"plain_text": "Copy as Plain Text"
|
||||
"docx": "Export as Word"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@@ -1176,7 +1132,7 @@
|
||||
"markdown_export.select": "Select",
|
||||
"markdown_export.title": "Markdown Export",
|
||||
"markdown_export.show_model_name.title": "Use Model Name on Export",
|
||||
"markdown_export.show_model_name.help": "When enabled, the model name will be displayed when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
|
||||
"markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
|
||||
"markdown_export.show_model_provider.title": "Show Model Provider",
|
||||
"markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown",
|
||||
"minute_interval_one": "{{count}} minute",
|
||||
@@ -1240,6 +1196,8 @@
|
||||
"restore.confirm.content": "Restoring from WebDAV will overwrite current data. Do you want to continue?",
|
||||
"restore.confirm.title": "Confirm Restore",
|
||||
"restore.content": "Restore from WebDAV will overwrite the current data, continue?",
|
||||
"restore.modal.select.placeholder": "Please select a backup file to restore",
|
||||
"restore.modal.title": "Restore from WebDAV",
|
||||
"restore.title": "Restore from WebDAV",
|
||||
"syncError": "Backup Error",
|
||||
"syncStatus": "Backup Status",
|
||||
@@ -1392,14 +1350,8 @@
|
||||
"general.emoji_picker": "Emoji Picker",
|
||||
"general.image_upload": "Image Upload",
|
||||
"general.auto_check_update.title": "Auto Update",
|
||||
"general.test_plan.title": "Test Plan",
|
||||
"general.test_plan.tooltip": "Participate in the test plan to experience the latest features faster, but also brings more risks, please backup your data in advance",
|
||||
"general.test_plan.beta_version": "Beta Version (Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "Features may change at any time, bugs are more, upgrade quickly",
|
||||
"general.test_plan.rc_version": "Preview Version (RC)",
|
||||
"general.test_plan.rc_version_tooltip": "Close to stable version, features are basically stable, bugs are few",
|
||||
"general.test_plan.version_options": "Version Options",
|
||||
"general.test_plan.version_channel_not_match": "Preview and test version switching will take effect after the next stable version is released",
|
||||
"general.early_access.title": "Early Access",
|
||||
"general.early_access.tooltip": "Enable to use the latest version from GitHub, which may be slower. Please backup your data in advance.",
|
||||
"general.reset.button": "Reset",
|
||||
"general.reset.title": "Data Reset",
|
||||
"general.restore.button": "Restore",
|
||||
@@ -1407,8 +1359,6 @@
|
||||
"general.user_name": "User Name",
|
||||
"general.user_name.placeholder": "Enter your name",
|
||||
"general.view_webdav_settings": "View WebDAV settings",
|
||||
"general.spell_check": "Spell Check",
|
||||
"general.spell_check.languages": "Use spell check for",
|
||||
"input.auto_translate_with_space": "Quickly translate with 3 spaces",
|
||||
"input.show_translate_confirm": "Show translation confirmation dialog",
|
||||
"input.target_language": "Target language",
|
||||
@@ -1485,8 +1435,7 @@
|
||||
"version": "Version"
|
||||
},
|
||||
"errors": {
|
||||
"32000": "MCP server failed to start, please check the parameters according to the tutorial",
|
||||
"toolNotFound": "Tool {{name}} not found"
|
||||
"32000": "MCP server failed to start, please check the parameters according to the tutorial"
|
||||
},
|
||||
"serverPlural": "servers",
|
||||
"serverSingular": "server",
|
||||
@@ -1534,7 +1483,6 @@
|
||||
"registry": "Package Registry",
|
||||
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
|
||||
"registryDefault": "Default",
|
||||
"customRegistryPlaceholder": "Enter private registry URL, e.g.: https://npm.company.com",
|
||||
"not_support": "Model not supported",
|
||||
"user": "User",
|
||||
"system": "System",
|
||||
@@ -1852,33 +1800,8 @@
|
||||
"overwrite_tooltip": "Force use search service instead of LLM",
|
||||
"apikey": "API key",
|
||||
"free": "Free",
|
||||
"compression": {
|
||||
"title": "Search Result Compression",
|
||||
"method": "Compression Method",
|
||||
"method.none": "None",
|
||||
"method.cutoff": "Cutoff",
|
||||
"cutoff.limit": "Cutoff Limit",
|
||||
"cutoff.limit.placeholder": "Enter length",
|
||||
"cutoff.limit.tooltip": "Limit the content length of search results, content exceeding the limit will be truncated (e.g., 2000 characters)",
|
||||
"cutoff.unit.char": "Char",
|
||||
"cutoff.unit.token": "Token",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "Document Count",
|
||||
"rag.document_count.default": "Default",
|
||||
"rag.document_count.tooltip": "Expected number of documents to extract from each search result, the actual total number of extracted documents is this value multiplied by the number of search results.",
|
||||
"rag.embedding_dimensions.auto_get": "Auto Get Dimensions",
|
||||
"rag.embedding_dimensions.placeholder": "Leave empty",
|
||||
"rag.embedding_dimensions.tooltip": "If left blank, the dimensions parameter will not be passed",
|
||||
"info": {
|
||||
"dimensions_auto_success": "Dimensions auto-obtained successfully, dimensions: {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "Please select an embedding model first",
|
||||
"dimensions_auto_failed": "Failed to auto-obtain dimensions",
|
||||
"provider_not_found": "Provider not found",
|
||||
"rag_failed": "RAG failed"
|
||||
}
|
||||
}
|
||||
"content_limit": "Content length limit",
|
||||
"content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated."
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "Quick Phrases",
|
||||
@@ -1962,8 +1885,7 @@
|
||||
"model_desc": "Model used for translation service",
|
||||
"bidirectional": "Bidirectional Translation Settings",
|
||||
"bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported",
|
||||
"scroll_sync": "Scroll Sync Settings",
|
||||
"preview": "Markdown Preview"
|
||||
"scroll_sync": "Scroll Sync Settings"
|
||||
},
|
||||
"title": "Translation",
|
||||
"tooltip.newline": "Newline",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "コンテキストをクリア {{Command}}",
|
||||
"input.new_topic": "新しいトピック {{Command}}",
|
||||
"input.pause": "一時停止",
|
||||
"input.placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||
"input.placeholder": "ここにメッセージを入力...",
|
||||
"input.send": "送信",
|
||||
"input.settings": "設定",
|
||||
"input.topics": " トピック ",
|
||||
@@ -412,7 +412,6 @@
|
||||
"search": "検索",
|
||||
"select": "選択",
|
||||
"selectedMessages": "{{count}}件のメッセージを選択しました",
|
||||
"selectedItems": "{{count}}件の項目を選択しました",
|
||||
"success": "成功",
|
||||
"topics": "トピック",
|
||||
"warning": "警告",
|
||||
@@ -702,13 +701,6 @@
|
||||
"warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!",
|
||||
"warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!",
|
||||
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません",
|
||||
"websearch": {
|
||||
"rag": "RAGを実行中...",
|
||||
"rag_complete": "{{countBefore}}個の結果から{{countAfter}}個を保持...",
|
||||
"rag_failed": "RAGが失敗しました。空の結果を返します...",
|
||||
"cutoff": "検索内容を切り詰めています...",
|
||||
"fetch_complete": "{{count}}回の検索を完了しました..."
|
||||
},
|
||||
"download.success": "ダウンロードに成功しました",
|
||||
"download.failed": "ダウンロードに失敗しました",
|
||||
"error.fetchTopicName": "トピック名の取得に失敗しました"
|
||||
@@ -783,7 +775,6 @@
|
||||
"dimensions": "{{dimensions}} 次元",
|
||||
"edit": "モデルを編集",
|
||||
"embedding": "埋め込み",
|
||||
"embedding_dimensions": "埋め込み次元",
|
||||
"embedding_model": "埋め込み模型",
|
||||
"embedding_model_tooltip": "設定->モデルサービス->管理で追加",
|
||||
"function_calling": "関数呼び出し",
|
||||
@@ -813,19 +804,7 @@
|
||||
"vision": "画像",
|
||||
"websearch": "ウェブ検索"
|
||||
},
|
||||
"rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。",
|
||||
"price": {
|
||||
"cost": "コスト",
|
||||
"currency": "通貨",
|
||||
"custom": "カスタム",
|
||||
"custom_currency": "カスタム通貨",
|
||||
"custom_currency_placeholder": "カスタム通貨を入力してください",
|
||||
"input": "入力価格",
|
||||
"million_tokens": "百万トークン",
|
||||
"output": "出力価格",
|
||||
"price": "価格"
|
||||
},
|
||||
"reasoning": "思考"
|
||||
"rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "ダイアログを展開",
|
||||
@@ -1092,29 +1071,7 @@
|
||||
"assistant.title": "デフォルトアシスタント",
|
||||
"data": {
|
||||
"app_data": "アプリデータ",
|
||||
"app_data.select": "ディレクトリを変更",
|
||||
"app_data.select_title": "アプリデータディレクトリの変更",
|
||||
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります。",
|
||||
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます。",
|
||||
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください。",
|
||||
"app_data.path_changed_without_copy": "パスが変更されました。",
|
||||
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください。コピーが完了すると、アプリが自動的に再起動します。",
|
||||
"app_data.copying": "新しい場所にデータをコピーしています...",
|
||||
"app_data.copy_success": "データを新しい場所に正常にコピーしました",
|
||||
"app_data.copy_failed": "データのコピーに失敗しました",
|
||||
"app_data.select_success": "データディレクトリが変更されました。変更を適用するためにアプリが再起動します",
|
||||
"app_data.select_error": "データディレクトリの変更に失敗しました",
|
||||
"app_data.migration_title": "データ移行",
|
||||
"app_data.original_path": "元のパス",
|
||||
"app_data.new_path": "新しいパス",
|
||||
"app_data.select_error_root_path": "新しいパスはルートパスにできません",
|
||||
"app_data.select_error_write_permission": "新しいパスに書き込み権限がありません",
|
||||
"app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません",
|
||||
"app_data.select_not_empty_dir": "新しいパスは空ではありません",
|
||||
"app_data.select_not_empty_dir_content": "新しいパスは空ではありません。新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?",
|
||||
"app_data.select_error_same_path": "新しいパスは元のパスと同じです。別のパスを選択してください",
|
||||
"app_data.select_error_in_app_path": "新しいパスはアプリのインストールパスと同じです。別のパスを選択してください",
|
||||
"app_knowledge": "知識ベースファイル",
|
||||
"app_knowledge": "ナレッジベースファイル",
|
||||
"app_knowledge.button.delete": "ファイルを削除",
|
||||
"app_knowledge.remove_all": "ナレッジベースファイルを削除",
|
||||
"app_knowledge.remove_all_confirm": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?",
|
||||
@@ -1146,8 +1103,7 @@
|
||||
"obsidian": "Obsidianにエクスポート",
|
||||
"siyuan": "思源ノートにエクスポート",
|
||||
"joplin": "Joplinにエクスポート",
|
||||
"docx": "Wordとしてエクスポート",
|
||||
"plain_text": "プレーンテキストとしてコピー"
|
||||
"docx": "Wordとしてエクスポート"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@@ -1174,7 +1130,7 @@
|
||||
"markdown_export.select": "選択",
|
||||
"markdown_export.title": "Markdown エクスポート",
|
||||
"markdown_export.show_model_name.title": "エクスポート時にモデル名を使用",
|
||||
"markdown_export.show_model_name.help": "有効にすると、Markdownエクスポート時にモデル名を表示します。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
|
||||
"markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
|
||||
"markdown_export.show_model_provider.title": "モデルプロバイダーを表示",
|
||||
"markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダー(例:OpenAI、Geminiなど)を表示します。",
|
||||
"minute_interval_one": "{{count}} 分",
|
||||
@@ -1220,6 +1176,8 @@
|
||||
"restore.confirm.content": "WebDAV から復元すると現在のデータが上書きされます。続行しますか?",
|
||||
"restore.confirm.title": "復元を確認",
|
||||
"restore.content": "WebDAVから復元すると現在のデータが上書きされます。続行しますか?",
|
||||
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
|
||||
"restore.modal.title": "WebDAV から復元",
|
||||
"restore.title": "WebDAVから復元",
|
||||
"syncError": "バックアップエラー",
|
||||
"syncStatus": "バックアップ状態",
|
||||
@@ -1396,8 +1354,6 @@
|
||||
"general.user_name": "ユーザー名",
|
||||
"general.user_name.placeholder": "ユーザー名を入力",
|
||||
"general.view_webdav_settings": "WebDAV設定を表示",
|
||||
"general.spell_check": "スペルチェック",
|
||||
"general.spell_check.languages": "スペルチェック言語",
|
||||
"input.auto_translate_with_space": "スペースを3回押して翻訳",
|
||||
"input.target_language": "目標言語",
|
||||
"input.target_language.chinese": "簡体字中国語",
|
||||
@@ -1481,8 +1437,7 @@
|
||||
"updateSuccess": "サーバーが正常に更新されました",
|
||||
"url": "URL",
|
||||
"errors": {
|
||||
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください",
|
||||
"toolNotFound": "ツール {{name}} が見つかりません"
|
||||
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
|
||||
},
|
||||
"editMcpJson": "MCP 設定を編集",
|
||||
"installHelp": "インストールヘルプを取得",
|
||||
@@ -1522,7 +1477,6 @@
|
||||
"registry": "パッケージ管理レジストリ",
|
||||
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
|
||||
"registryDefault": "デフォルト",
|
||||
"customRegistryPlaceholder": "プライベート倉庫のアドレスを入力してください(例:https://npm.company.com)",
|
||||
"not_support": "モデルはサポートされていません",
|
||||
"user": "ユーザー",
|
||||
"system": "システム",
|
||||
@@ -1834,43 +1788,12 @@
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
|
||||
"apikey": "API キー",
|
||||
"free": "無料",
|
||||
"compression": {
|
||||
"title": "検索結果の圧縮",
|
||||
"method": "圧縮方法",
|
||||
"method.none": "圧縮しない",
|
||||
"method.cutoff": "切り捨て",
|
||||
"cutoff.limit": "切り捨て長",
|
||||
"cutoff.limit.placeholder": "長さを入力",
|
||||
"cutoff.limit.tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます(例:2000文字)",
|
||||
"cutoff.unit.char": "文字",
|
||||
"cutoff.unit.token": "トークン",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "文書数",
|
||||
"rag.document_count.default": "デフォルト",
|
||||
"rag.document_count.tooltip": "単一の検索結果から抽出する文書数。実際に抽出される文書数は、この値に検索結果数を乗じたものです。",
|
||||
"rag.embedding_dimensions.auto_get": "次元を自動取得",
|
||||
"rag.embedding_dimensions.placeholder": "次元を設定しない",
|
||||
"rag.embedding_dimensions.tooltip": "空の場合、dimensions パラメーターは渡されません",
|
||||
"info": {
|
||||
"dimensions_auto_success": "次元が自動取得されました。次元: {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "まず埋め込みモデルを選択してください",
|
||||
"dimensions_auto_failed": "次元の自動取得に失敗しました",
|
||||
"provider_not_found": "プロバイダーが見つかりません",
|
||||
"rag_failed": "RAG に失敗しました"
|
||||
}
|
||||
}
|
||||
"content_limit": "内容の長さ制限",
|
||||
"content_limit_tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます。"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.test_plan.title": "テストプラン",
|
||||
"general.test_plan.tooltip": "テストプランに参加すると、最新の機能をより早く体験できますが、同時により多くのリスクが伴います。データを事前にバックアップしてください。",
|
||||
"general.test_plan.beta_version": "ベータ版(Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "機能が変更される可能性があります。バグが多く、迅速にアップグレードされます。",
|
||||
"general.test_plan.rc_version": "プレビュー版(RC)",
|
||||
"general.test_plan.rc_version_tooltip": "安定版に近い機能ですが、バグが少なく、迅速にアップグレードされます。",
|
||||
"general.test_plan.version_options": "バージョンオプション",
|
||||
"general.test_plan.version_channel_not_match": "プレビュー版とテスト版の切り替えは、次の正式版リリース時に有効になります。",
|
||||
"general.early_access.title": "早期アクセス",
|
||||
"general.early_access.tooltip": "有効にすると、GitHub の最新バージョンを使用します。ダウンロード速度が遅く、不安定な場合があります。データを事前にバックアップしてください。",
|
||||
"quickPhrase": {
|
||||
"title": "クイックフレーズ",
|
||||
"add": "フレーズを追加",
|
||||
@@ -1961,8 +1884,7 @@
|
||||
"model_desc": "翻訳サービスで使用されるモデル",
|
||||
"bidirectional": "双方向翻訳設定",
|
||||
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
|
||||
"scroll_sync": "スクロール同期設定",
|
||||
"preview": "Markdown プレビュー"
|
||||
"scroll_sync": "スクロール同期設定"
|
||||
},
|
||||
"title": "翻訳",
|
||||
"tooltip.newline": "改行",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "Очистить контекст {{Command}}",
|
||||
"input.new_topic": "Новый топик {{Command}}",
|
||||
"input.pause": "Остановить",
|
||||
"input.placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
|
||||
"input.placeholder": "Введите ваше сообщение здесь...",
|
||||
"input.send": "Отправить",
|
||||
"input.settings": "Настройки",
|
||||
"input.topics": " Топики ",
|
||||
@@ -412,7 +412,6 @@
|
||||
"search": "Поиск",
|
||||
"select": "Выбрать",
|
||||
"selectedMessages": "Выбрано {{count}} сообщений",
|
||||
"selectedItems": "Выбрано {{count}} элементов",
|
||||
"success": "Успешно",
|
||||
"topics": "Топики",
|
||||
"warning": "Предупреждение",
|
||||
@@ -702,13 +701,6 @@
|
||||
"success.siyuan.export": "Успешный экспорт в Siyuan",
|
||||
"warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!",
|
||||
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!",
|
||||
"websearch": {
|
||||
"rag": "Выполнение RAG...",
|
||||
"rag_complete": "Сохранено {{countAfter}} из {{countBefore}} результатов...",
|
||||
"rag_failed": "RAG не удалось, возвращается пустой результат...",
|
||||
"cutoff": "Обрезка содержимого поиска...",
|
||||
"fetch_complete": "Завершено {{count}} поисков..."
|
||||
},
|
||||
"download.success": "Скачано успешно",
|
||||
"download.failed": "Скачивание не удалось",
|
||||
"error.fetchTopicName": "Не удалось назвать топик"
|
||||
@@ -783,7 +775,6 @@
|
||||
"dimensions": "{{dimensions}} мер",
|
||||
"edit": "Редактировать модель",
|
||||
"embedding": "Встраиваемые",
|
||||
"embedding_dimensions": "Встраиваемые размерности",
|
||||
"embedding_model": "Встраиваемые модели",
|
||||
"embedding_model_tooltip": "Добавьте в настройки->модель сервиса->управление",
|
||||
"function_calling": "Вызов функции",
|
||||
@@ -813,19 +804,7 @@
|
||||
"vision": "Визуальные",
|
||||
"websearch": "Веб-поисковые"
|
||||
},
|
||||
"rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})",
|
||||
"price": {
|
||||
"cost": "Стоимость",
|
||||
"currency": "Валюта",
|
||||
"custom": "Пользовательский",
|
||||
"custom_currency": "Пользовательская валюта",
|
||||
"custom_currency_placeholder": "Введите пользовательскую валюту",
|
||||
"input": "Цена ввода",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "Цена вывода",
|
||||
"price": "Цена"
|
||||
},
|
||||
"reasoning": "Рассуждение"
|
||||
"rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "Развернуть диалоговое окно",
|
||||
@@ -983,8 +962,7 @@
|
||||
"per_image": "за изображение",
|
||||
"per_images": "за изображения",
|
||||
"required_field": "Обязательное поле",
|
||||
"uploaded_input": "Загруженный ввод",
|
||||
"prompt_placeholder_en": "[to be translated]:Enter your image description, currently Imagen only supports English prompts"
|
||||
"uploaded_input": "Загруженный ввод"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Объясните мне этот концепт",
|
||||
@@ -1092,29 +1070,7 @@
|
||||
"assistant.title": "Ассистент по умолчанию",
|
||||
"data": {
|
||||
"app_data": "Данные приложения",
|
||||
"app_data.select": "Изменить директорию",
|
||||
"app_data.select_title": "Изменить директорию данных приложения",
|
||||
"app_data.restart_notice": "Для применения изменений может потребоваться несколько перезапусков приложения",
|
||||
"app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию",
|
||||
"app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы",
|
||||
"app_data.path_changed_without_copy": "Путь изменен успешно",
|
||||
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение, приложение будет перезапущено после копирования",
|
||||
"app_data.copying": "Копирование данных в новое место...",
|
||||
"app_data.copy_success": "Данные успешно скопированы в новое место",
|
||||
"app_data.copy_failed": "Не удалось скопировать данные",
|
||||
"app_data.select_success": "Директория данных изменена, приложение будет перезапущено для применения изменений",
|
||||
"app_data.select_error": "Не удалось изменить директорию данных",
|
||||
"app_data.migration_title": "Миграция данных",
|
||||
"app_data.original_path": "Исходный путь",
|
||||
"app_data.new_path": "Новый путь",
|
||||
"app_data.select_error_root_path": "Новый путь не может быть корневым",
|
||||
"app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись",
|
||||
"app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто",
|
||||
"app_data.select_not_empty_dir": "Новый путь не пуст",
|
||||
"app_data.select_not_empty_dir_content": "Новый путь не пуст, он перезапишет данные в новом пути, есть риск потери данных и ошибки копирования, продолжить?",
|
||||
"app_data.select_error_in_app_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь",
|
||||
"app_data.select_error_same_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь",
|
||||
"app_knowledge": "Файлы базы знаний",
|
||||
"app_knowledge": "База знаний",
|
||||
"app_knowledge.button.delete": "Удалить файл",
|
||||
"app_knowledge.remove_all": "Удалить файлы базы знаний",
|
||||
"app_knowledge.remove_all_confirm": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?",
|
||||
@@ -1146,8 +1102,7 @@
|
||||
"obsidian": "Экспорт в Obsidian",
|
||||
"siyuan": "Экспорт в SiYuan Note",
|
||||
"joplin": "Экспорт в Joplin",
|
||||
"docx": "Экспорт в Word",
|
||||
"plain_text": "Копировать как чистый текст"
|
||||
"docx": "Экспорт в Word"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@@ -1174,7 +1129,7 @@
|
||||
"markdown_export.select": "Выбрать",
|
||||
"markdown_export.title": "Экспорт в Markdown",
|
||||
"markdown_export.show_model_name.title": "Использовать имя модели при экспорте",
|
||||
"markdown_export.show_model_name.help": "Если включено, при экспорте в Markdown будет отображаться имя модели. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
|
||||
"markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
|
||||
"markdown_export.show_model_provider.title": "Показать поставщика модели",
|
||||
"markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown",
|
||||
"minute_interval_one": "{{count}} минута",
|
||||
@@ -1238,6 +1193,8 @@
|
||||
"restore.confirm.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
|
||||
"restore.confirm.title": "Подтверждение восстановления",
|
||||
"restore.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
|
||||
"restore.modal.select.placeholder": "Выберите файл резервной копии для восстановления",
|
||||
"restore.modal.title": "Восстановление с WebDAV",
|
||||
"restore.title": "Восстановление с WebDAV",
|
||||
"syncError": "Ошибка резервного копирования",
|
||||
"syncStatus": "Статус резервного копирования",
|
||||
@@ -1396,8 +1353,6 @@
|
||||
"general.user_name": "Имя пользователя",
|
||||
"general.user_name.placeholder": "Введите ваше имя",
|
||||
"general.view_webdav_settings": "Просмотр настроек WebDAV",
|
||||
"general.spell_check": "Проверка орфографии",
|
||||
"general.spell_check.languages": "Языки проверки орфографии",
|
||||
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
|
||||
"input.target_language": "Целевой язык",
|
||||
"input.target_language.chinese": "Китайский упрощенный",
|
||||
@@ -1473,8 +1428,7 @@
|
||||
"version": "Версия"
|
||||
},
|
||||
"errors": {
|
||||
"32000": "MCP сервер не запущен, пожалуйста, проверьте параметры",
|
||||
"toolNotFound": "Инструмент {{name}} не найден"
|
||||
"32000": "MCP сервер не запущен, пожалуйста, проверьте параметры"
|
||||
},
|
||||
"serverPlural": "серверы",
|
||||
"serverSingular": "сервер",
|
||||
@@ -1522,7 +1476,6 @@
|
||||
"registry": "Реестр пакетов",
|
||||
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
|
||||
"registryDefault": "По умолчанию",
|
||||
"customRegistryPlaceholder": "Введите адрес частного склада, например: https://npm.company.com",
|
||||
"not_support": "Модель не поддерживается",
|
||||
"user": "Пользователь",
|
||||
"system": "Система",
|
||||
@@ -1834,43 +1787,12 @@
|
||||
"overwrite_tooltip": "Использовать провайдера поиска вместо LLM",
|
||||
"apikey": "API ключ",
|
||||
"free": "Бесплатно",
|
||||
"compression": {
|
||||
"title": "Сжатие результатов поиска",
|
||||
"method": "Метод сжатия",
|
||||
"method.none": "Не сжимать",
|
||||
"method.cutoff": "Обрезка",
|
||||
"cutoff.limit": "Лимит обрезки",
|
||||
"cutoff.limit.placeholder": "Введите длину",
|
||||
"cutoff.limit.tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан (например, 2000 символов)",
|
||||
"cutoff.unit.char": "Символы",
|
||||
"cutoff.unit.token": "Токены",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "Количество документов",
|
||||
"rag.document_count.default": "По умолчанию",
|
||||
"rag.document_count.tooltip": "Ожидаемое количество документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных документов равно этому значению, умноженному на количество результатов поиска.",
|
||||
"rag.embedding_dimensions.auto_get": "Автоматически получить размерности",
|
||||
"rag.embedding_dimensions.placeholder": "Не устанавливать размерности",
|
||||
"rag.embedding_dimensions.tooltip": "Если оставить пустым, параметр dimensions не будет передан",
|
||||
"info": {
|
||||
"dimensions_auto_success": "Размерности успешно получены, размерности: {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "Пожалуйста, сначала выберите модель встраивания",
|
||||
"dimensions_auto_failed": "Не удалось получить размерности",
|
||||
"provider_not_found": "Поставщик не найден",
|
||||
"rag_failed": "RAG не удалось"
|
||||
}
|
||||
}
|
||||
"content_limit": "Ограничение длины текста",
|
||||
"content_limit_tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан."
|
||||
},
|
||||
"general.auto_check_update.title": "Автоматическое обновление",
|
||||
"general.test_plan.title": "Тестовый план",
|
||||
"general.test_plan.tooltip": "Участвовать в тестовом плане, чтобы быстрее получать новые функции, но при этом возникает больше рисков, пожалуйста, сделайте резервную копию данных заранее",
|
||||
"general.test_plan.beta_version": "Тестовая версия (Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "Функции могут меняться в любое время, ошибки больше, обновление происходит быстрее",
|
||||
"general.test_plan.rc_version": "Предварительная версия (RC)",
|
||||
"general.test_plan.rc_version_tooltip": "Похожа на стабильную версию, функции стабильны, ошибки меньше, обновление происходит быстрее",
|
||||
"general.test_plan.version_options": "Варианты версии",
|
||||
"general.test_plan.version_channel_not_match": "Предварительная и тестовая версия будут доступны после выхода следующей стабильной версии",
|
||||
"general.early_access.title": "Ранний доступ",
|
||||
"general.early_access.tooltip": "Включить для использования последней версии из GitHub, что может быть медленнее и нестабильно. Пожалуйста, сделайте резервную копию данных заранее.",
|
||||
"quickPhrase": {
|
||||
"title": "Быстрые фразы",
|
||||
"add": "Добавить фразу",
|
||||
@@ -1961,8 +1883,7 @@
|
||||
"model_desc": "Модель, используемая для службы перевода",
|
||||
"bidirectional": "Настройки двунаправленного перевода",
|
||||
"scroll_sync": "Настройки синхронизации прокрутки",
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот.",
|
||||
"preview": "Markdown предпросмотр"
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
|
||||
},
|
||||
"title": "Перевод",
|
||||
"tooltip.newline": "Перевести",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "清除上下文 {{Command}}",
|
||||
"input.new_topic": "新话题 {{Command}}",
|
||||
"input.pause": "暂停",
|
||||
"input.placeholder": "在这里输入消息,按 {{key}} 发送...",
|
||||
"input.placeholder": "在这里输入消息...",
|
||||
"input.translating": "翻译中...",
|
||||
"input.send": "发送",
|
||||
"input.settings": "设置",
|
||||
@@ -412,7 +412,6 @@
|
||||
"search": "搜索",
|
||||
"select": "选择",
|
||||
"selectedMessages": "选中 {{count}} 条消息",
|
||||
"selectedItems": "已选择 {{count}} 项",
|
||||
"success": "成功",
|
||||
"topics": "话题",
|
||||
"warning": "警告",
|
||||
@@ -703,13 +702,6 @@
|
||||
"success.siyuan.export": "导出到思源笔记成功",
|
||||
"warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!",
|
||||
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!",
|
||||
"websearch": {
|
||||
"rag": "正在执行 RAG...",
|
||||
"rag_complete": "保留 {{countBefore}} 个结果中的 {{countAfter}} 个...",
|
||||
"rag_failed": "RAG 失败,返回空结果...",
|
||||
"cutoff": "正在截断搜索内容...",
|
||||
"fetch_complete": "已完成 {{count}} 次搜索..."
|
||||
},
|
||||
"download.success": "下载成功",
|
||||
"download.failed": "下载失败"
|
||||
},
|
||||
@@ -783,7 +775,6 @@
|
||||
"dimensions": "{{dimensions}} 维",
|
||||
"edit": "编辑模型",
|
||||
"embedding": "嵌入",
|
||||
"embedding_dimensions": "嵌入维度",
|
||||
"embedding_model": "嵌入模型",
|
||||
"embedding_model_tooltip": "在设置->模型服务中点击管理按钮添加",
|
||||
"function_calling": "函数调用",
|
||||
@@ -796,18 +787,6 @@
|
||||
"string": "文本"
|
||||
},
|
||||
"pinned": "已固定",
|
||||
"price": {
|
||||
"cost": "花费",
|
||||
"currency": "币种",
|
||||
"custom": "自定义",
|
||||
"custom_currency": "自定义币种",
|
||||
"custom_currency_placeholder": "请输入自定义币种",
|
||||
"input": "输入价格",
|
||||
"million_tokens": "百万 Token",
|
||||
"output": "输出价格",
|
||||
"price": "价格"
|
||||
},
|
||||
"reasoning": "推理",
|
||||
"rerank_model": "重排模型",
|
||||
"rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})",
|
||||
"rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})",
|
||||
@@ -872,8 +851,8 @@
|
||||
"learn_more": "了解更多",
|
||||
"paint_course": "教程",
|
||||
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
|
||||
"prompt_placeholder_en": "输入\"英文\"图片描述,目前 Imagen 仅支持英文提示词",
|
||||
"proxy_required": "打开代理并开启\"TUN模式\"查看生成图片或复制到浏览器打开,后续会支持国内直连",
|
||||
"prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词",
|
||||
"proxy_required": "打开代理并开启”TUN模式“查看生成图片或复制到浏览器打开,后续会支持国内直连",
|
||||
"image_file_required": "请先上传图片",
|
||||
"image_file_retry": "请重新上传图片",
|
||||
"image_placeholder": "暂无图片",
|
||||
@@ -969,7 +948,7 @@
|
||||
"magic_prompt_option_tip": "智能优化放大提示词"
|
||||
},
|
||||
"text_desc_required": "请先输入图片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免\"版权词\"和\"敏感词\"哦。",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"req_error_token": "请检查令牌有效性",
|
||||
"req_error_no_balance": "请检查令牌有效性",
|
||||
"image_handle_required": "请先上传图片",
|
||||
@@ -1094,28 +1073,6 @@
|
||||
"assistant.title": "默认助手",
|
||||
"data": {
|
||||
"app_data": "应用数据",
|
||||
"app_data.select": "修改目录",
|
||||
"app_data.select_title": "更改应用数据目录",
|
||||
"app_data.restart_notice": "应用可能会重启多次以应用更改",
|
||||
"app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录",
|
||||
"app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用",
|
||||
"app_data.path_changed_without_copy": "路径已更改成功",
|
||||
"app_data.copying_warning": "数据复制中,不要强制退出app, 复制完成后会自动重启应用",
|
||||
"app_data.copying": "正在将数据复制到新位置...",
|
||||
"app_data.copy_success": "已成功复制数据到新位置",
|
||||
"app_data.copy_failed": "复制数据失败",
|
||||
"app_data.select_success": "数据目录已更改,应用将重启以应用更改",
|
||||
"app_data.select_error": "更改数据目录失败",
|
||||
"app_data.migration_title": "数据迁移",
|
||||
"app_data.original_path": "原始路径",
|
||||
"app_data.new_path": "新路径",
|
||||
"app_data.select_error_root_path": "新路径不能是根路径",
|
||||
"app_data.select_error_write_permission": "新路径没有写入权限",
|
||||
"app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出",
|
||||
"app_data.select_not_empty_dir": "新路径不为空",
|
||||
"app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据, 有数据丢失和复制失败的风险,是否继续?",
|
||||
"app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径",
|
||||
"app_data.select_error_in_app_path": "新路径与应用安装路径相同,请选择其他路径",
|
||||
"app_knowledge": "知识库文件",
|
||||
"app_knowledge.button.delete": "删除文件",
|
||||
"app_knowledge.remove_all": "删除知识库文件",
|
||||
@@ -1148,8 +1105,7 @@
|
||||
"obsidian": "导出到Obsidian",
|
||||
"siyuan": "导出到思源笔记",
|
||||
"joplin": "导出到Joplin",
|
||||
"docx": "导出为Word",
|
||||
"plain_text": "复制为纯文本"
|
||||
"docx": "导出为Word"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@@ -1176,7 +1132,7 @@
|
||||
"markdown_export.select": "选择",
|
||||
"markdown_export.title": "Markdown 导出",
|
||||
"markdown_export.show_model_name.title": "导出时使用模型名称",
|
||||
"markdown_export.show_model_name.help": "开启后,导出Markdown时会显示模型名称。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。",
|
||||
"markdown_export.show_model_name.help": "开启后,使用话题命名模型为导出的消息创建标题。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。",
|
||||
"markdown_export.show_model_provider.title": "显示模型供应商",
|
||||
"markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商,如OpenAI、Gemini等",
|
||||
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
|
||||
@@ -1242,6 +1198,8 @@
|
||||
"restore.confirm.content": "从 WebDAV 恢复将会覆盖当前数据,是否继续?",
|
||||
"restore.confirm.title": "确认恢复",
|
||||
"restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
|
||||
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
|
||||
"restore.modal.title": "从 WebDAV 恢复",
|
||||
"restore.title": "从 WebDAV 恢复",
|
||||
"syncError": "备份错误",
|
||||
"syncStatus": "备份状态",
|
||||
@@ -1392,24 +1350,16 @@
|
||||
"general.emoji_picker": "表情选择器",
|
||||
"general.image_upload": "图片上传",
|
||||
"general.auto_check_update.title": "自动更新",
|
||||
"general.test_plan.title": "测试计划",
|
||||
"general.test_plan.tooltip": "参与测试计划,可以更快体验到最新功能,但同时也会带来更多风险,务必提前做好备份",
|
||||
"general.test_plan.beta_version": "测试版(Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "功能可能随时变化,bug较多,升级较快",
|
||||
"general.test_plan.rc_version": "预览版(RC)",
|
||||
"general.test_plan.rc_version_tooltip": "接近正式版,功能基本稳定,bug较少",
|
||||
"general.test_plan.version_options": "版本选择",
|
||||
"general.test_plan.version_channel_not_match": "预览版和测试版的切换将在下一个正式版发布时生效",
|
||||
"general.early_access.title": "抢先体验",
|
||||
"general.early_access.tooltip": "开启后,将使用 GitHub 的最新版本,下载速度可能较慢,请务必提前备份数据",
|
||||
"general.reset.button": "重置",
|
||||
"general.reset.title": "重置数据",
|
||||
"general.restore.button": "恢复",
|
||||
"general.title": "常规设置",
|
||||
"general.user_name": "用户名",
|
||||
"general.user_name.placeholder": "输入您的姓名",
|
||||
"general.user_name.placeholder": "请输入用户名",
|
||||
"general.view_webdav_settings": "查看 WebDAV 设置",
|
||||
"general.spell_check": "拼写检查",
|
||||
"general.spell_check.languages": "拼写检查语言",
|
||||
"input.auto_translate_with_space": "3个空格快速翻译",
|
||||
"input.auto_translate_with_space": "快速敲击3次空格翻译",
|
||||
"input.show_translate_confirm": "显示翻译确认对话框",
|
||||
"input.target_language": "目标语言",
|
||||
"input.target_language.chinese": "简体中文",
|
||||
@@ -1485,8 +1435,7 @@
|
||||
"version": "版本"
|
||||
},
|
||||
"errors": {
|
||||
"32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整",
|
||||
"toolNotFound": "未找到工具 {{name}}"
|
||||
"32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整"
|
||||
},
|
||||
"serverPlural": "服务器",
|
||||
"serverSingular": "服务器",
|
||||
@@ -1534,7 +1483,6 @@
|
||||
"registry": "包管理源",
|
||||
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题",
|
||||
"registryDefault": "默认",
|
||||
"customRegistryPlaceholder": "请输入私有仓库地址,如: https://npm.company.com",
|
||||
"not_support": "模型不支持",
|
||||
"user": "用户",
|
||||
"system": "系统",
|
||||
@@ -1852,33 +1800,8 @@
|
||||
"title": "网络搜索",
|
||||
"apikey": "API 密钥",
|
||||
"free": "免费",
|
||||
"compression": {
|
||||
"title": "搜索结果压缩",
|
||||
"method": "压缩方法",
|
||||
"method.none": "不压缩",
|
||||
"method.cutoff": "截断",
|
||||
"cutoff.limit": "截断长度",
|
||||
"cutoff.limit.placeholder": "输入长度",
|
||||
"cutoff.limit.tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断(例如 2000 字符)",
|
||||
"cutoff.unit.char": "字符",
|
||||
"cutoff.unit.token": "Token",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "文档数量",
|
||||
"rag.document_count.default": "默认",
|
||||
"rag.document_count.tooltip": "预期从单个搜索结果中提取的文档数量,实际提取的总数量是这个值乘以搜索结果数量。",
|
||||
"rag.embedding_dimensions.auto_get": "自动获取维度",
|
||||
"rag.embedding_dimensions.placeholder": "不设置维度",
|
||||
"rag.embedding_dimensions.tooltip": "留空则不传递 dimensions 参数",
|
||||
"info": {
|
||||
"dimensions_auto_success": "维度自动获取成功,维度为 {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "请先选择嵌入模型",
|
||||
"dimensions_auto_failed": "维度自动获取失败",
|
||||
"provider_not_found": "未找到服务商",
|
||||
"rag_failed": "RAG 失败"
|
||||
}
|
||||
}
|
||||
"content_limit": "内容长度限制",
|
||||
"content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "快捷短语",
|
||||
@@ -1964,8 +1887,7 @@
|
||||
"model_desc": "翻译服务使用的模型",
|
||||
"bidirectional": "双向翻译设置",
|
||||
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
|
||||
"scroll_sync": "滚动同步设置",
|
||||
"preview": "Markdown 预览"
|
||||
"scroll_sync": "滚动同步设置"
|
||||
},
|
||||
"title": "翻译",
|
||||
"tooltip.newline": "换行",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "清除上下文 {{Command}}",
|
||||
"input.new_topic": "新話題 {{Command}}",
|
||||
"input.pause": "暫停",
|
||||
"input.placeholder": "在此輸入您的訊息,按 {{key}} 傳送...",
|
||||
"input.placeholder": "在此輸入您的訊息...",
|
||||
"input.send": "傳送",
|
||||
"input.settings": "設定",
|
||||
"input.topics": " 話題 ",
|
||||
@@ -412,7 +412,6 @@
|
||||
"search": "搜尋",
|
||||
"select": "選擇",
|
||||
"selectedMessages": "選中 {{count}} 條訊息",
|
||||
"selectedItems": "已選擇 {{count}} 項",
|
||||
"success": "成功",
|
||||
"topics": "話題",
|
||||
"warning": "警告",
|
||||
@@ -703,13 +702,6 @@
|
||||
"success.siyuan.export": "導出到思源筆記成功",
|
||||
"warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!",
|
||||
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!",
|
||||
"websearch": {
|
||||
"rag": "正在執行 RAG...",
|
||||
"rag_complete": "保留 {{countBefore}} 個結果中的 {{countAfter}} 個...",
|
||||
"rag_failed": "RAG 失敗,返回空結果...",
|
||||
"cutoff": "正在截斷搜尋內容...",
|
||||
"fetch_complete": "已完成 {{count}} 次搜尋..."
|
||||
},
|
||||
"download.success": "下載成功",
|
||||
"download.failed": "下載失敗"
|
||||
},
|
||||
@@ -783,7 +775,6 @@
|
||||
"dimensions": "{{dimensions}} 維",
|
||||
"edit": "編輯模型",
|
||||
"embedding": "嵌入",
|
||||
"embedding_dimensions": "嵌入維度",
|
||||
"embedding_model": "嵌入模型",
|
||||
"embedding_model_tooltip": "在設定->模型服務中點選管理按鈕新增",
|
||||
"function_calling": "函數調用",
|
||||
@@ -813,19 +804,7 @@
|
||||
"vision": "視覺",
|
||||
"websearch": "網路搜尋"
|
||||
},
|
||||
"rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})",
|
||||
"price": {
|
||||
"cost": "花費",
|
||||
"currency": "幣種",
|
||||
"custom": "自訂",
|
||||
"custom_currency": "自訂幣種",
|
||||
"custom_currency_placeholder": "請輸入自訂幣種",
|
||||
"input": "輸入價格",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "輸出價格",
|
||||
"price": "價格"
|
||||
},
|
||||
"reasoning": "推理"
|
||||
"rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "伸縮對話框",
|
||||
@@ -1093,29 +1072,7 @@
|
||||
"assistant.icon.type.none": "不顯示",
|
||||
"assistant.title": "預設助手",
|
||||
"data": {
|
||||
"app_data": "應用數據",
|
||||
"app_data.select": "修改目錄",
|
||||
"app_data.select_title": "變更應用數據目錄",
|
||||
"app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效",
|
||||
"app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄",
|
||||
"app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用",
|
||||
"app_data.path_changed_without_copy": "路徑已變更成功",
|
||||
"app_data.copying_warning": "數據複製中,不要強制退出應用, 複製完成後會自動重啟應用",
|
||||
"app_data.copying": "正在複製數據到新位置...",
|
||||
"app_data.copy_success": "成功複製數據到新位置",
|
||||
"app_data.copy_failed": "複製數據失敗",
|
||||
"app_data.select_success": "數據目錄已變更,應用將重啟以應用變更",
|
||||
"app_data.select_error": "變更數據目錄失敗",
|
||||
"app_data.migration_title": "數據遷移",
|
||||
"app_data.original_path": "原始路徑",
|
||||
"app_data.new_path": "新路徑",
|
||||
"app_data.select_error_root_path": "新路徑不能是根路徑",
|
||||
"app_data.select_error_write_permission": "新路徑沒有寫入權限",
|
||||
"app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出",
|
||||
"app_data.select_not_empty_dir": "新路徑不為空",
|
||||
"app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失和複製失敗的風險,是否繼續?",
|
||||
"app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑",
|
||||
"app_data.select_error_in_app_path": "新路徑與應用安裝路徑相同,請選擇其他路徑",
|
||||
"app_data": "應用程式資料",
|
||||
"app_knowledge": "知識庫文件",
|
||||
"app_knowledge.button.delete": "刪除檔案",
|
||||
"app_knowledge.remove_all": "刪除知識庫檔案",
|
||||
@@ -1148,8 +1105,7 @@
|
||||
"obsidian": "匯出到Obsidian",
|
||||
"siyuan": "匯出到思源筆記",
|
||||
"joplin": "匯出到Joplin",
|
||||
"docx": "匯出為Word",
|
||||
"plain_text": "複製為純文本"
|
||||
"docx": "匯出為Word"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@@ -1176,7 +1132,7 @@
|
||||
"markdown_export.select": "選擇",
|
||||
"markdown_export.title": "Markdown 匯出",
|
||||
"markdown_export.show_model_name.title": "匯出時使用模型名稱",
|
||||
"markdown_export.show_model_name.help": "啟用後,匯出Markdown時會顯示模型名稱。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。",
|
||||
"markdown_export.show_model_name.help": "啟用後,將以主題命名模型為匯出的訊息建立標題。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。",
|
||||
"markdown_export.show_model_provider.title": "顯示模型供應商",
|
||||
"markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商,如OpenAI、Gemini等",
|
||||
"minute_interval_one": "{{count}} 分鐘",
|
||||
@@ -1240,6 +1196,8 @@
|
||||
"restore.confirm.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?",
|
||||
"restore.confirm.title": "復元確認",
|
||||
"restore.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?",
|
||||
"restore.modal.select.placeholder": "請選擇要恢復的備份文件",
|
||||
"restore.modal.title": "從 WebDAV 恢復",
|
||||
"restore.title": "從 WebDAV 恢復",
|
||||
"syncError": "備份錯誤",
|
||||
"syncStatus": "備份狀態",
|
||||
@@ -1398,8 +1356,6 @@
|
||||
"general.user_name": "使用者名稱",
|
||||
"general.user_name.placeholder": "輸入您的名稱",
|
||||
"general.view_webdav_settings": "檢視 WebDAV 設定",
|
||||
"general.spell_check": "拼寫檢查",
|
||||
"general.spell_check.languages": "拼寫檢查語言",
|
||||
"input.auto_translate_with_space": "快速敲擊 3 次空格翻譯",
|
||||
"input.show_translate_confirm": "顯示翻譯確認對話框",
|
||||
"input.target_language": "目標語言",
|
||||
@@ -1476,8 +1432,7 @@
|
||||
"version": "版本"
|
||||
},
|
||||
"errors": {
|
||||
"32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整",
|
||||
"toolNotFound": "未找到工具 {{name}}"
|
||||
"32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整"
|
||||
},
|
||||
"serverPlural": "伺服器",
|
||||
"serverSingular": "伺服器",
|
||||
@@ -1525,7 +1480,6 @@
|
||||
"registry": "套件管理源",
|
||||
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題",
|
||||
"registryDefault": "預設",
|
||||
"customRegistryPlaceholder": "請輸入私有倉庫位址,如: https://npm.company.com",
|
||||
"not_support": "不支援此模型",
|
||||
"user": "用戶",
|
||||
"system": "系統",
|
||||
@@ -1837,43 +1791,12 @@
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
|
||||
"apikey": "API 金鑰",
|
||||
"free": "免費",
|
||||
"compression": {
|
||||
"title": "搜尋結果壓縮",
|
||||
"method": "壓縮方法",
|
||||
"method.none": "不壓縮",
|
||||
"method.cutoff": "截斷",
|
||||
"cutoff.limit": "截斷長度",
|
||||
"cutoff.limit.placeholder": "輸入長度",
|
||||
"cutoff.limit.tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字符)",
|
||||
"cutoff.unit.char": "字符",
|
||||
"cutoff.unit.token": "Token",
|
||||
"method.rag": "RAG",
|
||||
"rag.document_count": "文檔數量",
|
||||
"rag.document_count.default": "預設",
|
||||
"rag.document_count.tooltip": "預期從單個搜尋結果中提取的文檔數量,實際提取的總數量是這個值乘以搜尋結果數量。",
|
||||
"rag.embedding_dimensions.auto_get": "自動獲取維度",
|
||||
"rag.embedding_dimensions.placeholder": "不設置維度",
|
||||
"rag.embedding_dimensions.tooltip": "留空則不傳遞 dimensions 參數",
|
||||
"info": {
|
||||
"dimensions_auto_success": "維度自動獲取成功,維度為 {{dimensions}}"
|
||||
},
|
||||
"error": {
|
||||
"embedding_model_required": "請先選擇嵌入模型",
|
||||
"dimensions_auto_failed": "維度自動獲取失敗",
|
||||
"provider_not_found": "未找到服務商",
|
||||
"rag_failed": "RAG 失敗"
|
||||
}
|
||||
}
|
||||
"content_limit": "內容長度限制",
|
||||
"content_limit_tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.test_plan.title": "測試計畫",
|
||||
"general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據",
|
||||
"general.test_plan.beta_version": "測試版本(Beta)",
|
||||
"general.test_plan.beta_version_tooltip": "功能可能會隨時變化,錯誤較多,升級較快",
|
||||
"general.test_plan.rc_version": "預覽版本(RC)",
|
||||
"general.test_plan.rc_version_tooltip": "相對穩定,請務必提前備份數據",
|
||||
"general.test_plan.version_options": "版本選項",
|
||||
"general.test_plan.version_channel_not_match": "預覽版和測試版的切換將在下一個正式版發布時生效",
|
||||
"general.early_access.title": "搶先體驗",
|
||||
"general.early_access.tooltip": "開啟後,將使用 GitHub 的最新版本,下載速度可能較慢,請務必提前備份數據",
|
||||
"quickPhrase": {
|
||||
"title": "快捷短語",
|
||||
"add": "新增短語",
|
||||
@@ -1961,8 +1884,7 @@
|
||||
"model_desc": "翻譯服務使用的模型",
|
||||
"bidirectional": "雙向翻譯設定",
|
||||
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
|
||||
"scroll_sync": "滾動同步設定",
|
||||
"preview": "Markdown 預覽"
|
||||
"scroll_sync": "滾動同步設定"
|
||||
},
|
||||
"title": "翻譯",
|
||||
"tooltip.newline": "換行",
|
||||
|
||||
@@ -75,8 +75,8 @@ const AgentsPage: FC = () => {
|
||||
{agent.description && <AgentDescription>{agent.description}</AgentDescription>}
|
||||
|
||||
{agent.prompt && (
|
||||
<AgentPrompt className="markdown">
|
||||
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
|
||||
<AgentPrompt>
|
||||
<ReactMarkdown className="markdown">{agent.prompt}</ReactMarkdown>{' '}
|
||||
</AgentPrompt>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Agent, KnowledgeBase } from '@renderer/types'
|
||||
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
||||
import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import stringWidth from 'string-width'
|
||||
@@ -151,7 +150,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
maskClosable={false}
|
||||
afterClose={onClose}
|
||||
okText={t('agents.add.title')}
|
||||
width={600}
|
||||
width={800}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Form
|
||||
@@ -213,7 +212,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { groupTranslations } from '@renderer/pages/agents/agentGroupTranslations'
|
||||
import { DynamicIcon, IconName } from 'lucide-react/dynamic'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
groupName: string
|
||||
@@ -10,25 +8,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export const AgentGroupIcon: FC<Props> = ({ groupName, size = 20, strokeWidth = 1.2 }) => {
|
||||
const { i18n } = useTranslation()
|
||||
const currentLanguage = i18n.language as keyof (typeof groupTranslations)[string]
|
||||
|
||||
const findOriginalKey = (name: string): string => {
|
||||
if (groupTranslations[name]) {
|
||||
return name
|
||||
}
|
||||
|
||||
for (const key in groupTranslations) {
|
||||
if (groupTranslations[key][currentLanguage] === name) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
const originalKey = findOriginalKey(groupName)
|
||||
|
||||
const iconMap: { [key: string]: IconName } = {
|
||||
我的: 'user-check',
|
||||
精选: 'star',
|
||||
@@ -67,5 +46,5 @@ export const AgentGroupIcon: FC<Props> = ({ groupName, size = 20, strokeWidth =
|
||||
搜索: 'search'
|
||||
} as const
|
||||
|
||||
return <DynamicIcon name={iconMap[originalKey] || 'bot-message-square'} size={size} strokeWidth={strokeWidth} />
|
||||
return <DynamicIcon name={iconMap[groupName] || 'bot-message-square'} size={size} strokeWidth={strokeWidth} />
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Button, Flex, Form, Input, Modal, Radio } from 'antd'
|
||||
import { Button, Form, Input, Modal, Radio, Space } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -98,14 +98,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
title={t('agents.import.title')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
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>
|
||||
}
|
||||
footer={null}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Form form={form} onFinish={onFinish} layout="vertical">
|
||||
@@ -127,6 +120,15 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
<Button onClick={() => form.submit()}>{t('agents.import.select_file')}</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button type="primary" onClick={() => form.submit()} loading={loading}>
|
||||
{t('agents.import.button')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import store from '@renderer/store'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
let _agents: Agent[] = []
|
||||
|
||||
@@ -23,8 +22,6 @@ export function useSystemAgents() {
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const { resourcesPath } = useRuntime()
|
||||
const { agentssubscribeUrl } = store.getState().settings
|
||||
const { i18n } = useTranslation()
|
||||
const currentLanguage = i18n.language
|
||||
|
||||
useEffect(() => {
|
||||
const loadAgents = async () => {
|
||||
@@ -47,21 +44,9 @@ export function useSystemAgents() {
|
||||
}
|
||||
|
||||
// 如果没有远程配置或获取失败,加载本地代理
|
||||
if (resourcesPath) {
|
||||
try {
|
||||
let fileName = 'agents.json'
|
||||
if (currentLanguage === 'zh-CN') {
|
||||
fileName = 'agents-zh.json'
|
||||
} else {
|
||||
fileName = 'agents-en.json'
|
||||
}
|
||||
|
||||
const localAgentsData = await window.api.fs.read(`${resourcesPath}/data/${fileName}`, 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
} catch (error) {
|
||||
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
}
|
||||
if (resourcesPath && _agents.length === 0) {
|
||||
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
}
|
||||
|
||||
setAgents(_agents)
|
||||
@@ -73,7 +58,7 @@ export function useSystemAgents() {
|
||||
}
|
||||
|
||||
loadAgents()
|
||||
}, [defaultAgent, resourcesPath, agentssubscribeUrl, currentLanguage])
|
||||
}, [defaultAgent, resourcesPath, agentssubscribeUrl])
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { handleDelete } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
@@ -50,24 +48,6 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
<ImageInfo>
|
||||
<div>{formatFileSize(file.size)}</div>
|
||||
</ImageInfo>
|
||||
<DeleteButton
|
||||
title={t('files.delete.title')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.modal.confirm({
|
||||
title: t('files.delete.title'),
|
||||
content: t('files.delete.content'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
handleDelete(file.id, t)
|
||||
},
|
||||
icon: <ExclamationCircleOutlined style={{ color: 'red' }} />
|
||||
})
|
||||
}}>
|
||||
<DeleteOutlined />
|
||||
</DeleteButton>
|
||||
</ImageWrapper>
|
||||
</Col>
|
||||
))}
|
||||
@@ -179,26 +159,4 @@ const ImageInfo = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const DeleteButton = styled.div`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(FileList)
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import db from '@renderer/databases'
|
||||
import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import store from '@renderer/store'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Empty, Flex, Popconfirm } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -31,6 +34,34 @@ const FilesPage: FC = () => {
|
||||
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
|
||||
const tempFilesSort = (files: FileType[]) => {
|
||||
return files.sort((a, b) => {
|
||||
const aIsTemp = a.origin_name.startsWith('temp_file')
|
||||
const bIsTemp = b.origin_name.startsWith('temp_file')
|
||||
if (aIsTemp && !bIsTemp) return 1
|
||||
if (!aIsTemp && bIsTemp) return -1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
const sortFiles = (files: FileType[]) => {
|
||||
return [...files].sort((a, b) => {
|
||||
let comparison = 0
|
||||
switch (sortField) {
|
||||
case 'created_at':
|
||||
comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix()
|
||||
break
|
||||
case 'size':
|
||||
comparison = a.size - b.size
|
||||
break
|
||||
case 'name':
|
||||
comparison = a.origin_name.localeCompare(b.origin_name)
|
||||
break
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
|
||||
const files = useLiveQuery<FileType[]>(() => {
|
||||
if (fileType === 'all') {
|
||||
return db.files.orderBy('count').toArray().then(tempFilesSort)
|
||||
@@ -38,7 +69,106 @@ const FilesPage: FC = () => {
|
||||
return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
|
||||
}, [fileType])
|
||||
|
||||
const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : []
|
||||
const sortedFiles = files ? sortFiles(files) : []
|
||||
|
||||
const handleDelete = async (fileId: string) => {
|
||||
const file = await FileManager.getFile(fileId)
|
||||
if (!file) return
|
||||
|
||||
const paintings = await store.getState().paintings.paintings
|
||||
const paintingsFiles = paintings.flatMap((p) => p.files)
|
||||
|
||||
if (paintingsFiles.some((p) => p.id === fileId)) {
|
||||
window.modal.warning({ content: t('files.delete.paintings.warning'), centered: true })
|
||||
return
|
||||
}
|
||||
if (file) {
|
||||
await FileManager.deleteFile(fileId, true)
|
||||
}
|
||||
|
||||
const relatedBlocks = await db.message_blocks.where('file.id').equals(fileId).toArray()
|
||||
|
||||
const blockIdsToDelete = relatedBlocks.map((block) => block.id)
|
||||
|
||||
const blocksByMessageId: Record<string, string[]> = {}
|
||||
for (const block of relatedBlocks) {
|
||||
if (!blocksByMessageId[block.messageId]) {
|
||||
blocksByMessageId[block.messageId] = []
|
||||
}
|
||||
blocksByMessageId[block.messageId].push(block.id)
|
||||
}
|
||||
|
||||
try {
|
||||
const affectedMessageIds = [...new Set(relatedBlocks.map((b) => b.messageId))]
|
||||
|
||||
if (affectedMessageIds.length === 0 && blockIdsToDelete.length > 0) {
|
||||
// This case should ideally not happen if relatedBlocks were found,
|
||||
// but handle it just in case: only delete blocks.
|
||||
await db.message_blocks.bulkDelete(blockIdsToDelete)
|
||||
Logger.log(
|
||||
`Deleted ${blockIdsToDelete.length} blocks related to file ${fileId}. No associated messages found (unexpected).`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await db.transaction('rw', db.topics, db.message_blocks, async () => {
|
||||
// Fetch all topics (potential performance bottleneck if many topics)
|
||||
const allTopics = await db.topics.toArray()
|
||||
const topicsToUpdate: Record<string, { messages: Message[] }> = {} // Store updates keyed by topicId
|
||||
|
||||
for (const topic of allTopics) {
|
||||
let topicModified = false
|
||||
// Ensure topic.messages exists and is an array before mapping
|
||||
const currentMessages = Array.isArray(topic.messages) ? topic.messages : []
|
||||
const updatedMessages = currentMessages.map((message) => {
|
||||
// Check if this message is affected
|
||||
if (affectedMessageIds.includes(message.id)) {
|
||||
// Ensure message.blocks exists and is an array
|
||||
const currentBlocks = Array.isArray(message.blocks) ? message.blocks : []
|
||||
const originalBlockCount = currentBlocks.length
|
||||
// Filter out the blocks marked for deletion
|
||||
const newBlocks = currentBlocks.filter((blockId) => !blockIdsToDelete.includes(blockId))
|
||||
if (newBlocks.length < originalBlockCount) {
|
||||
topicModified = true
|
||||
return { ...message, blocks: newBlocks } // Return updated message
|
||||
}
|
||||
}
|
||||
return message // Return original message
|
||||
})
|
||||
|
||||
if (topicModified) {
|
||||
// Store the update for this topic
|
||||
topicsToUpdate[topic.id] = { messages: updatedMessages }
|
||||
}
|
||||
}
|
||||
|
||||
// Apply updates to topics
|
||||
const updatePromises = Object.entries(topicsToUpdate).map(([topicId, updateData]) =>
|
||||
db.topics.update(topicId, updateData)
|
||||
)
|
||||
await Promise.all(updatePromises)
|
||||
|
||||
// Finally, delete the MessageBlocks
|
||||
await db.message_blocks.bulkDelete(blockIdsToDelete)
|
||||
})
|
||||
|
||||
Logger.log(`Deleted ${blockIdsToDelete.length} blocks and updated relevant topic messages for file ${fileId}.`)
|
||||
} catch (error) {
|
||||
Logger.error(`Error updating topics or deleting blocks for file ${fileId}:`, error)
|
||||
window.modal.error({ content: t('files.delete.db_error'), centered: true }) // 提示数据库操作失败
|
||||
// Consider whether to attempt to restore the physical file (usually difficult)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRename = async (fileId: string) => {
|
||||
const file = await FileManager.getFile(fileId)
|
||||
if (file) {
|
||||
const newName = await TextEditPopup.show({ text: file.origin_name })
|
||||
if (newName) {
|
||||
FileManager.updateFile({ ...file, origin_name: newName })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataSource = sortedFiles?.map((file) => {
|
||||
return {
|
||||
@@ -59,7 +189,7 @@ const FilesPage: FC = () => {
|
||||
description={t('files.delete.content')}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleDelete(file.id, t)}
|
||||
onConfirm={() => handleDelete(file.id)}
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
@@ -180,6 +310,7 @@ const SideNav = styled.div`
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-primary);
|
||||
border: 0.5px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { ArrowLeftOutlined, EnterOutlined } from '@ant-design/icons'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { Divider, Input, InputRef } from 'antd'
|
||||
import { Input, InputRef } from 'antd'
|
||||
import { last } from 'lodash'
|
||||
import { ChevronLeft, CornerDownLeft, Search } from 'lucide-react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -73,35 +73,26 @@ const TopicsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<HStack style={{ padding: '0 12px', marginTop: 8 }}>
|
||||
<Input
|
||||
prefix={
|
||||
stack.length > 1 ? (
|
||||
<SearchIcon className="back-icon" onClick={goBack}>
|
||||
<ChevronLeft size={16} />
|
||||
</SearchIcon>
|
||||
) : (
|
||||
<SearchIcon>
|
||||
<Search size={15} />
|
||||
</SearchIcon>
|
||||
)
|
||||
}
|
||||
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
|
||||
ref={inputRef}
|
||||
<Header>
|
||||
{stack.length > 1 && (
|
||||
<HeaderLeft>
|
||||
<MenuIcon onClick={goBack}>
|
||||
<ArrowLeftOutlined />
|
||||
</MenuIcon>
|
||||
</HeaderLeft>
|
||||
)}
|
||||
<SearchInput
|
||||
placeholder={t('history.search.placeholder')}
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value.trimStart())}
|
||||
allowClear
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
style={{ paddingLeft: 0 }}
|
||||
variant="borderless"
|
||||
size="middle"
|
||||
allowClear
|
||||
ref={inputRef}
|
||||
onChange={(e) => setSearch(e.target.value.trimStart())}
|
||||
suffix={search.length >= 2 ? <EnterOutlined /> : <Search size={16} />}
|
||||
onPressEnter={onSearch}
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
|
||||
</Header>
|
||||
<TopicsHistory
|
||||
keywords={search}
|
||||
onClick={onTopicClick as any}
|
||||
@@ -127,23 +118,50 @@ const Container = styled.div`
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const SearchIcon = styled.div`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background-color: var(--color-background-mute);
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom: 0.5px solid var(--color-frame-border);
|
||||
`
|
||||
|
||||
const HeaderLeft = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 15px;
|
||||
`
|
||||
|
||||
const MenuIcon = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
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);
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
border-radius: 50%;
|
||||
&:hover {
|
||||
background-color: var(--color-background);
|
||||
.anticon {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SearchInput = styled(Input)`
|
||||
border-radius: 30px;
|
||||
width: 800px;
|
||||
height: 36px;
|
||||
`
|
||||
|
||||
export default TopicsPage
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ArrowRightOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
|
||||
import { locateToMessage } from '@renderer/services/MessagesService'
|
||||
@@ -8,7 +10,6 @@ import { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { Button } from 'antd'
|
||||
import { Forward } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -19,6 +20,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
const SearchMessage: FC<Props> = ({ message, ...props }) => {
|
||||
const navigate = NavigationService.navigate!
|
||||
const { messageStyle } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
const [topic, setTopic] = useState<Topic | null>(null)
|
||||
|
||||
@@ -41,18 +43,18 @@ const SearchMessage: FC<Props> = ({ message, ...props }) => {
|
||||
|
||||
return (
|
||||
<MessageEditingProvider>
|
||||
<MessagesContainer {...props}>
|
||||
<ContainerWrapper>
|
||||
<MessagesContainer {...props} className={messageStyle}>
|
||||
<ContainerWrapper style={{ paddingTop: 20, paddingBottom: 20, position: 'relative' }}>
|
||||
<MessageItem message={message} topic={topic} hideMenuBar={true} />
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 16, top: 16 }}
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 10 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
icon={<Forward size={16} />}
|
||||
icon={<ArrowRightOutlined />}
|
||||
/>
|
||||
<HStack mt="10px" justifyContent="center">
|
||||
<Button onClick={() => locateToMessage(navigate, message)} icon={<Forward size={16} />}>
|
||||
<Button onClick={() => locateToMessage(navigate, message)} icon={<ArrowRightOutlined />}>
|
||||
{t('history.locate.message')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@@ -72,11 +74,12 @@ const MessagesContainer = styled.div`
|
||||
`
|
||||
|
||||
const ContainerWrapper = styled.div`
|
||||
width: 100%;
|
||||
width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
.message {
|
||||
padding: 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default SearchMessage
|
||||
|
||||
@@ -151,8 +151,7 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const ContainerWrapper = styled.div`
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user