Merge branch 'main' into feat/sidebar-ui

This commit is contained in:
suyao 2025-07-02 03:04:43 +08:00
commit 0609b93a14
No known key found for this signature in database
206 changed files with 11982 additions and 2848 deletions

View File

@ -1,7 +1,8 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
},
"search.exclude": {
"**/dist/**": true,

View File

@ -0,0 +1,69 @@
diff --git a/es/dropdown/dropdown.js b/es/dropdown/dropdown.js
index 986877a762b9ad0aca596a8552732cd12d2eaabb..1f18aa2ea745e68950e4cee16d4d655f5c835fd5 100644
--- a/es/dropdown/dropdown.js
+++ b/es/dropdown/dropdown.js
@@ -2,7 +2,7 @@
import * as React from 'react';
import LeftOutlined from "@ant-design/icons/es/icons/LeftOutlined";
-import RightOutlined from "@ant-design/icons/es/icons/RightOutlined";
+import { ChevronRight } from 'lucide-react';
import classNames from 'classnames';
import RcDropdown from 'rc-dropdown';
import useEvent from "rc-util/es/hooks/useEvent";
@@ -158,8 +158,10 @@ const Dropdown = props => {
className: `${prefixCls}-menu-submenu-arrow`
}, direction === 'rtl' ? (/*#__PURE__*/React.createElement(LeftOutlined, {
className: `${prefixCls}-menu-submenu-arrow-icon`
- })) : (/*#__PURE__*/React.createElement(RightOutlined, {
- className: `${prefixCls}-menu-submenu-arrow-icon`
+ })) : (/*#__PURE__*/React.createElement(ChevronRight, {
+ size: 16,
+ strokeWidth: 1.8,
+ className: `${prefixCls}-menu-submenu-arrow-icon lucide-custom`
}))),
mode: "vertical",
selectable: false,
diff --git a/es/dropdown/style/index.js b/es/dropdown/style/index.js
index 768c01783002c6901c85a73061ff6b3e776a60ce..39b1b95a56cdc9fb586a193c3adad5141f5cf213 100644
--- a/es/dropdown/style/index.js
+++ b/es/dropdown/style/index.js
@@ -240,7 +240,8 @@ const genBaseStyle = token => {
marginInlineEnd: '0 !important',
color: token.colorTextDescription,
fontSize: fontSizeIcon,
- fontStyle: 'normal'
+ fontStyle: 'normal',
+ marginTop: 3,
}
}
}),
diff --git a/es/select/useIcons.js b/es/select/useIcons.js
index 959115be936ef8901548af2658c5dcfdc5852723..c812edd52123eb0faf4638b1154fcfa1b05b513b 100644
--- a/es/select/useIcons.js
+++ b/es/select/useIcons.js
@@ -4,10 +4,10 @@ import * as React from 'react';
import CheckOutlined from "@ant-design/icons/es/icons/CheckOutlined";
import CloseCircleFilled from "@ant-design/icons/es/icons/CloseCircleFilled";
import CloseOutlined from "@ant-design/icons/es/icons/CloseOutlined";
-import DownOutlined from "@ant-design/icons/es/icons/DownOutlined";
import LoadingOutlined from "@ant-design/icons/es/icons/LoadingOutlined";
import SearchOutlined from "@ant-design/icons/es/icons/SearchOutlined";
import { devUseWarning } from '../_util/warning';
+import { ChevronDown } from 'lucide-react';
export default function useIcons(_ref) {
let {
suffixIcon,
@@ -56,8 +56,10 @@ export default function useIcons(_ref) {
className: iconCls
}));
}
- return getSuffixIconNode(/*#__PURE__*/React.createElement(DownOutlined, {
- className: iconCls
+ return getSuffixIconNode(/*#__PURE__*/React.createElement(ChevronDown, {
+ size: 16,
+ strokeWidth: 1.8,
+ className: `${iconCls} lucide-custom`
}));
};
}

105
README.md
View File

@ -1,3 +1,33 @@
<div align="right" >
<details>
<summary >🌐 Language</summary>
<div>
<div align="right">
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=en">English</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-CN">简体中文</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-TW">繁體中文</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ja">日本語</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ko">한국어</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=hi">हिन्दी</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=th">ไทย</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fr">Français</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=de">Deutsch</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=es">Español</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=it">Itapano</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ru">Русский</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pt">Português</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=nl">Nederlands</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pl">Polski</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ar">العربية</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fa">فارسی</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=tr">Türkçe</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=vi">Tiếng Việt</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=id">Bahasa Indonesia</a></p>
</div>
</div>
</details>
</div>
<h1 align="center">
<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>
@ -167,6 +197,78 @@ For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIB
Thank you for your support and contributions!
# 🔧 Developer Co-creation Program
We are launching the Cherry Studio Developer Co-creation Program to foster a healthy and positive-feedback loop within the open-source ecosystem. We believe that great software is built collaboratively, and every merged pull request breathes new life into the project.
We sincerely invite you to join our ranks of contributors and shape the future of Cherry Studio with us.
## Contributor Rewards Program
To give back to our core contributors and create a virtuous cycle, we have established the following long-term incentive plan.
**The inaugural tracking period for this program will be Q3 2025 (July, August, September). Rewards for this cycle will be distributed on October 1st.**
Within any tracking period (e.g., July 1st to September 30th for the first cycle), any developer who contributes more than **30 meaningful commits** to any of Cherry Studio's open-source projects on GitHub is eligible for the following benefits:
- **Cursor Subscription Sponsorship**: Receive a **$70 USD** credit or reimbursement for your [Cursor](https://cursor.sh/) subscription, making AI your most efficient coding partner.
- **Unlimited Model Access**: Get **unlimited** API calls for the **DeepSeek** and **Qwen** models.
- **Cutting-Edge Tech Access**: Enjoy occasional perks, including API access to models like **Claude**, **Gemini**, and **OpenAI**, keeping you at the forefront of technology.
## Growing Together & Future Plans
A vibrant community is the driving force behind any sustainable open-source project. As Cherry Studio grows, so will our rewards program. We are committed to continuously aligning our benefits with the best-in-class tools and resources in the industry. This ensures our core contributors receive meaningful support, creating a positive cycle where developers, the community, and the project grow together.
**Moving forward, the project will also embrace an increasingly open stance to give back to the entire open-source community.**
## How to Get Started?
We look forward to your first Pull Request!
You can start by exploring our repositories, picking up a `good first issue`, or proposing your own enhancements. Every commit is a testament to the spirit of open source.
Thank you for your interest and contributions.
Let's build together.
# 🏢 Enterprise Edition
Building on the Community Edition, we are proud to introduce **Cherry Studio Enterprise Edition**—a privately deployable AI productivity and management platform designed for modern teams and enterprises.
The Enterprise Edition addresses core challenges in team collaboration by centralizing the management of AI resources, knowledge, and data. It empowers organizations to enhance efficiency, foster innovation, and ensure compliance, all while maintaining 100% control over their data in a secure environment.
## Core Advantages
- **Unified Model Management**: Centrally integrate and manage various cloud-based LLMs (e.g., OpenAI, Anthropic, Google Gemini) and locally deployed private models. Employees can use them out-of-the-box without individual configuration.
- **Enterprise-Grade Knowledge Base**: Build, manage, and share team-wide knowledge bases. Ensure knowledge is retained and consistent, enabling team members to interact with AI based on unified and accurate information.
- **Fine-Grained Access Control**: Easily manage employee accounts and assign role-based permissions for different models, knowledge bases, and features through a unified admin backend.
- **Fully Private Deployment**: Deploy the entire backend service on your on-premises servers or private cloud, ensuring your data remains 100% private and under your control to meet the strictest security and compliance standards.
- **Reliable Backend Services**: Provides stable API services, enterprise-grade data backup and recovery mechanisms to ensure business continuity.
## ✨ Online Demo
> 🚧 **Public Beta Notice**
>
> The Enterprise Edition is currently in its early public beta stage, and we are actively iterating and optimizing its features. We are aware that it may not be perfectly stable yet. If you encounter any issues or have valuable suggestions during your trial, we would be very grateful if you could contact us via email to provide feedback.
**🔗 [Cherry Studio Enterprise](https://www.cherry-ai.com/enterprise)**
## Version Comparison
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ part. released to cust. |
| **Cost** | Free for Personal Use / Commercial License | Buyout / Subscription Fee |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
## Get the Enterprise Edition
We believe the Enterprise Edition will become your team's AI productivity engine. If you are interested in Cherry Studio Enterprise Edition and would like to learn more, request a quote, or schedule a demo, please contact us.
- **For Business Inquiries & Purchasing**:
**📧 [bd@cherry-ai.com](mailto:bd@cherry-ai.com)**
# 🔗 Related Projects
- [one-api](https://github.com/songquanpeng/one-api):LLM API management and distribution system, supporting mainstream models like OpenAI, Azure, and Anthropic. Features unified API interface, suitable for key management and secondary distribution.
@ -185,6 +287,7 @@ Thank you for your support and contributions!
[![Star History Chart](https://api.star-history.com/svg?repos=CherryHQ/cherry-studio&type=Timeline)](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
<!-- Links & Images -->
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
@ -195,6 +298,7 @@ Thank you for your support and contributions!
[telegram-link]: https://t.me/CherryStudioAI
<!-- Links & Images -->
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
@ -205,6 +309,7 @@ Thank you for your support and contributions!
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
<!-- Links & Images -->
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
[license-link]: https://www.gnu.org/licenses/agpl-3.0
[commercial-shield]: https://img.shields.io/badge/License-Contact-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue

View File

@ -90,6 +90,7 @@ linux:
artifactName: ${productName}-${version}-${arch}.${ext}
target:
- target: AppImage
- target: deb
maintainer: electronjs.org
category: Utility
desktop:
@ -107,11 +108,10 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
划词助手:支持文本选择快捷键、开关快捷键、思考块支持和引用功能
复制功能新增纯文本复制去除Markdown格式符号
知识库支持设置向量维度修复Ollama分数错误和维度编辑问题
多语言:增加模型名称多语言提示和翻译源语言手动选择
文件管理:修复主题/消息删除时文件未清理问题,优化文件选择流程
模型修复Gemini模型推理预算、Voyage AI嵌入问题和DeepSeek翻译模型更新
图像功能统一图片查看器支持Base64图片渲染修复图片预览相关问题
UI实现标签折叠/拖拽排序,修复气泡溢出,增加引文索引显示
界面优化:优化多处界面样式,气泡样式改版,自动调整代码预览边栏宽度
知识库:修复知识库引用不显示问题,修复部分嵌入模型适配问题
备份与恢复:修复超过 2GB 大文件无法恢复问题
文件处理:添加 .doc 文件支持
划词助手:支持自定义 CSS 样式
MCP基于 Pyodide 实现 Python MCP 服务
其他错误修复和优化

View File

@ -68,12 +68,16 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: ['pyodide']
exclude: ['pyodide'],
esbuildOptions: {
target: 'esnext' // for dev
}
},
worker: {
format: 'es'
},
build: {
target: 'esnext', // for build
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),

View File

@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.4.2",
"version": "1.4.7",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@ -62,7 +62,9 @@
"@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",
"opendal": "0.47.11",
"os-proxy-config": "^1.1.2",
"selection-hook": "^0.9.23",
"turndown": "7.2.0"
@ -123,6 +125,7 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-window": "^1",
"@types/tinycolor2": "^1",
"@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.23.12",
"@uiw/codemirror-themes-all": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
@ -132,12 +135,13 @@
"@vitest/ui": "^3.1.4",
"@vitest/web-worker": "^3.1.4",
"@xyflow/react": "^12.4.4",
"antd": "^5.22.5",
"antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"color": "^5.0.0",
"country-flag-emoji-polyfill": "0.1.8",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
@ -177,7 +181,6 @@
"mermaid": "^11.6.0",
"mime": "^4.0.4",
"motion": "^12.10.5",
"node-stream-zip": "^1.15.0",
"npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1",
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
@ -191,7 +194,7 @@
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^9.0.1",
"react-markdown": "^10.1.0",
"react-redux": "^9.1.2",
"react-router": "^7.6.2",
"react-router-dom": "^7.6.2",
@ -200,10 +203,10 @@
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.0.0",
"rehype-mathjax": "^7.1.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0",
"remark-cjk-friendly": "^1.2.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0",
@ -213,12 +216,13 @@
"styled-components": "^6.1.11",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
"tokenx": "^0.4.1",
"tokenx": "^1.1.0",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"vite": "6.2.6",
"vitest": "^3.1.4",
"webdav": "^5.8.0",
"word-extractor": "^1.0.4",
"zipread": "^1.3.3"
},
"resolutions": {

View File

@ -3,6 +3,8 @@ export enum IpcChannel {
App_ClearCache = 'app:clear-cache',
App_SetLaunchOnBoot = 'app:set-launch-on-boot',
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_Reload = 'app:reload',
@ -13,13 +15,17 @@ export enum IpcChannel {
App_SetTrayOnClose = 'app:set-tray-on-close',
App_SetTheme = 'app:set-theme',
App_SetAutoUpdate = 'app:set-auto-update',
App_SetFeedUrl = 'app:set-feed-url',
App_SetTestPlan = 'app:set-test-plan',
App_SetTestChannel = 'app:set-test-channel',
App_HandleZoomFactor = 'app:handle-zoom-factor',
App_Select = 'app:select',
App_HasWritePermission = 'app:has-write-permission',
App_Copy = 'app:copy',
App_SetStopQuitApp = 'app:set-stop-quit-app',
App_SetAppDataPath = 'app:set-app-data-path',
App_GetDataPathFromArgs = 'app:get-data-path-from-args',
App_FlushAppData = 'app:flush-app-data',
App_IsNotEmptyDir = 'app:is-not-empty-dir',
App_RelaunchApp = 'app:relaunch-app',
App_IsBinaryExist = 'app:is-binary-exist',
App_GetBinaryPath = 'app:get-binary-path',
@ -32,6 +38,7 @@ export enum IpcChannel {
Notification_OnClick = 'notification:on-click',
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
// Open
Open_Path = 'open:path',
@ -64,6 +71,9 @@ export enum IpcChannel {
Mcp_ServersUpdated = 'mcp:servers-updated',
Mcp_CheckConnectivity = 'mcp:check-connectivity',
// Python
Python_Execute = 'python:execute',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@ -143,6 +153,11 @@ export enum IpcChannel {
Backup_CheckConnection = 'backup:checkConnection',
Backup_CreateDirectory = 'backup:createDirectory',
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
Backup_BackupToS3 = 'backup:backupToS3',
Backup_RestoreFromS3 = 'backup:restoreFromS3',
Backup_ListS3Files = 'backup:listS3Files',
Backup_DeleteS3File = 'backup:deleteS3File',
Backup_CheckS3Connection = 'backup:checkS3Connection',
// zip
Zip_Compress = 'zip:compress',

View File

@ -1,7 +1,7 @@
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const documentExts = ['.pdf', '.doc', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const thirdPartyApplicationExts = ['.draftsExport']
export const bookExts = ['.epub']
const textExtsByCategory = new Map([
@ -406,6 +406,16 @@ export const defaultLanguage = 'en-US'
export enum FeedUrl {
PRODUCTION = 'https://releases.cherry-ai.com',
EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download',
PRERELEASE_LOWEST = 'https://github.com/CherryHQ/cherry-studio/releases/download/v1.4.0'
}
export const defaultTimeout = 5 * 1000 * 60
export enum UpgradeChannel {
LATEST = 'latest', // 最新稳定版本
RC = 'rc', // 公测版本
BETA = 'beta' // 预览版本
}
export const defaultTimeout = 10 * 1000 * 60
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -2,34 +2,33 @@ const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const tar = require('tar')
const AdmZip = require('adm-zip')
const StreamZip = require('node-stream-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading uv binaries
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
const DEFAULT_UV_VERSION = '0.6.14'
const DEFAULT_UV_VERSION = '0.7.13'
// Mapping of platform+arch to binary package name
const UV_PACKAGES = {
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
'darwin-arm64': 'uv-aarch64-apple-darwin.zip',
'darwin-x64': 'uv-x86_64-apple-darwin.zip',
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
'win32-ia32': 'uv-i686-pc-windows-msvc.zip',
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz',
'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz',
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz',
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz',
'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz',
'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz',
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip',
'linux-ia32': 'uv-i686-unknown-linux-gnu.zip',
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip',
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip',
'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip',
'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip',
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip',
// MUSL variants
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz',
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz',
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz',
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz',
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip',
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip',
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip',
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip',
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip'
}
/**
@ -66,46 +65,35 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
console.log(`Extracting ${packageName} to ${binDir}...`)
// 根据文件扩展名选择解压方法
if (packageName.endsWith('.zip')) {
// 使用 adm-zip 处理 zip 文件
const zip = new AdmZip(tempFilename)
zip.extractAllTo(binDir, true)
fs.unlinkSync(tempFilename)
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
return true
} else {
// tar.gz 文件的处理保持不变
await tar.x({
file: tempFilename,
cwd: tempdir,
z: true
})
const zip = new StreamZip.async({ file: tempFilename })
// Move files using Node.js fs
const sourceDir = path.join(tempdir, packageName.split('.')[0])
const files = fs.readdirSync(sourceDir)
for (const file of files) {
const sourcePath = path.join(sourceDir, file)
const destPath = path.join(binDir, file)
fs.copyFileSync(sourcePath, destPath)
fs.unlinkSync(sourcePath)
// Get all entries in the zip file
const entries = await zip.entries()
// Set executable permissions for non-Windows platforms
// Extract files directly to binDir, flattening the directory structure
for (const entry of Object.values(entries)) {
if (!entry.isDirectory) {
// Get just the filename without path
const filename = path.basename(entry.name)
const outputPath = path.join(binDir, filename)
console.log(`Extracting ${entry.name} -> ${filename}`)
await zip.extract(entry.name, outputPath)
// Make executable files executable on Unix-like systems
if (platform !== 'win32') {
try {
fs.chmodSync(destPath, '755')
} catch (error) {
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
fs.chmodSync(outputPath, 0o755)
} catch (chmodError) {
console.error(`Warning: Failed to set executable permissions on ${filename}`)
return false
}
}
console.log(`Extracted ${entry.name} -> ${outputPath}`)
}
// Clean up
fs.unlinkSync(tempFilename)
fs.rmSync(sourceDir, { recursive: true })
}
await zip.close()
fs.unlinkSync(tempFilename)
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
return true
} catch (error) {

33
src/main/bootstrap.ts Normal file
View File

@ -0,0 +1,33 @@
import { occupiedDirs } from '@shared/config/constant'
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { initAppDataDir } from './utils/file'
app.isPackaged && initAppDataDir()
// 在主进程中复制 appData 中某些一直被占用的文件
// 在renderer进程还没有启动时主进程可以复制这些文件到新的appData中
function copyOccupiedDirsInMainProcess() {
const newAppDataPath = process.argv
.slice(1)
.find((arg) => arg.startsWith('--new-data-path='))
?.split('--new-data-path=')[1]
if (!newAppDataPath) {
return
}
if (process.platform === 'win32') {
const appDataPath = app.getPath('userData')
occupiedDirs.forEach((dir) => {
const dirPath = path.join(appDataPath, dir)
const newDirPath = path.join(newAppDataPath, dir)
if (fs.existsSync(dirPath)) {
fs.cpSync(dirPath, newDirPath, { recursive: true })
}
})
}
}
copyOccupiedDirsInMainProcess()

View File

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

View File

@ -1,13 +1,14 @@
import fs from 'node:fs'
import { arch } from 'node:os'
import path from 'node:path'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom'
import { FeedUrl } from '@shared/config/constant'
import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, dialog, ipcMain, session, shell } from 'electron'
import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron'
import log from 'electron-log'
import { Notification } from 'src/renderer/src/types/notification'
@ -25,6 +26,7 @@ import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
@ -35,7 +37,7 @@ import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateConfig } from './utils/file'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
@ -48,6 +50,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
const notificationService = new NotificationService(mainWindow)
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
@ -58,7 +63,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path,
arch: arch(),
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env,
installPath: path.dirname(app.getPath('exe'))
}))
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
@ -86,6 +92,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setLanguage(language)
})
// spell check
ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => {
// disable spell check for all webviews
const webviews = webContents.getAllWebContents()
webviews.forEach((webview) => {
webview.session.setSpellCheckerEnabled(isEnable)
})
})
// spell check languages
ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => {
if (languages.length === 0) {
return
}
const windows = BrowserWindow.getAllWindows()
windows.forEach((window) => {
window.webContents.session.setSpellCheckerLanguages(languages)
})
configManager.set('spellCheckLanguages', languages)
})
// launch on boot
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
@ -116,8 +143,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setAutoUpdate(isActive)
})
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => {
appUpdater.setFeedUrl(feedUrl)
ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => {
log.info('set test plan', isActive)
if (isActive !== configManager.getTestPlan()) {
appUpdater.cancelDownload()
configManager.setTestPlan(isActive)
}
})
ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => {
log.info('set test channel', channel)
if (channel !== configManager.getTestChannel()) {
appUpdater.cancelDownload()
configManager.setTestChannel(channel)
}
})
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
@ -219,14 +258,46 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// Set app data path
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
updateConfig(filePath)
updateAppDataConfig(filePath)
app.setPath('userData', filePath)
})
ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => {
return process.argv
.slice(1)
.find((arg) => arg.startsWith('--new-data-path='))
?.split('--new-data-path=')[1]
})
ipcMain.handle(IpcChannel.App_FlushAppData, () => {
BrowserWindow.getAllWindows().forEach((w) => {
w.webContents.session.flushStorageData()
w.webContents.session.cookies.flushStore()
w.webContents.session.closeAllConnections()
})
session.defaultSession.flushStorageData()
session.defaultSession.cookies.flushStore()
session.defaultSession.closeAllConnections()
})
ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => {
return fs.readdirSync(path).length > 0
})
// Copy user data to new location
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => {
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string, occupiedDirs: string[] = []) => {
try {
await fs.promises.cp(oldPath, newPath, { recursive: true })
await fs.promises.cp(oldPath, newPath, {
recursive: true,
filter: (src) => {
if (occupiedDirs.some((dir) => src.startsWith(path.resolve(dir)))) {
return false
}
return true
}
})
return { success: true }
} catch (error: any) {
log.error('Failed to copy user data:', error)
@ -235,8 +306,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// Relaunch app
ipcMain.handle(IpcChannel.App_RelaunchApp, () => {
app.relaunch()
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
app.relaunch(options)
app.exit(0)
})
@ -274,6 +345,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File)
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection)
// file
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
@ -378,6 +454,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
// Register Python execution handler
ipcMain.handle(
IpcChannel.Python_Execute,
async (_, script: string, context?: Record<string, any>, timeout?: number) => {
return await pythonService.executeScript(script, context, timeout)
}
)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
@ -423,6 +507,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return
webview.session.setSpellCheckerEnabled(isEnable)
})
// store sync
storeSyncService.registerIpcHandler()

View File

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

View File

@ -0,0 +1,44 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import md5 from 'md5'
export class NoteLoader extends BaseLoader<{ type: 'NoteLoader' }> {
private readonly text: string
private readonly sourceUrl?: string
constructor({
text,
sourceUrl,
chunkSize,
chunkOverlap
}: {
text: string
sourceUrl?: string
chunkSize?: number
chunkOverlap?: number
}) {
super(`NoteLoader_${md5(text + (sourceUrl || ''))}`, { text, sourceUrl }, chunkSize ?? 2000, chunkOverlap ?? 0)
this.text = text
this.sourceUrl = sourceUrl
}
override async *getUnfilteredChunks() {
const chunker = new RecursiveCharacterTextSplitter({
chunkSize: this.chunkSize,
chunkOverlap: this.chunkOverlap
})
const chunks = await chunker.splitText(cleanString(this.text))
for (const chunk of chunks) {
yield {
pageContent: chunk,
metadata: {
type: 'NoteLoader' as const,
source: this.sourceUrl || 'note'
}
}
}
}
}

View File

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

View File

@ -0,0 +1,113 @@
import { pythonService } from '@main/services/PythonService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import Logger from 'electron-log'
/**
* Python MCP Server for executing Python code using Pyodide
*/
class PythonServer {
public server: Server
constructor() {
this.server = new Server(
{
name: 'python-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
)
this.setupRequestHandlers()
}
private setupRequestHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'python_execute',
description: `Execute Python code using Pyodide in a sandboxed environment. Supports most Python standard library and scientific packages.
The code will be executed with Python 3.12.
Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start
with a comment of the form:
# /// script
# dependencies = ['pydantic']
# ///
print('python code here')`,
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'The Python code to execute'
},
context: {
type: 'object',
description: 'Optional context variables to pass to the Python execution environment',
additionalProperties: true
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds (default: 60000)',
default: 60000
}
},
required: ['code']
}
}
]
}
})
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (name !== 'python_execute') {
throw new McpError(ErrorCode.MethodNotFound, `Tool ${name} not found`)
}
try {
const {
code,
context = {},
timeout = 60000
} = args as {
code: string
context?: Record<string, any>
timeout?: number
}
if (!code || typeof code !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'Code parameter is required and must be a string')
}
Logger.info('Executing Python code via Pyodide')
const result = await pythonService.executeScript(code, context, timeout)
return {
content: [
{
type: 'text',
text: result
}
]
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error('Python execution error:', errorMessage)
throw new McpError(ErrorCode.InternalError, `Python execution failed: ${errorMessage}`)
}
})
}
}
export default PythonServer

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import { isWin } from '@main/constant'
import { locales } from '@main/utils/locales'
import { FeedUrl } from '@shared/config/constant'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { UpdateInfo } from 'builder-util-runtime'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater } from 'electron-updater'
import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import icon from '../../../build/icon.png?asset'
@ -14,6 +14,8 @@ import { configManager } from './ConfigManager'
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
constructor(mainWindow: BrowserWindow) {
logger.transports.file.level = 'info'
@ -22,9 +24,7 @@ export default class AppUpdater {
autoUpdater.forceDevUpdateConfig = !app.isPackaged
autoUpdater.autoDownload = configManager.getAutoUpdate()
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.setFeedURL(configManager.getFeedUrl())
// 检测下载错误
autoUpdater.on('error', (error) => {
// 简单记录错误信息和时间戳
logger.error('更新异常', {
@ -64,6 +64,35 @@ export default class AppUpdater {
this.autoUpdater = autoUpdater
}
private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) {
try {
logger.info('get pre release version from github', channel)
const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Accept-Language': 'en-US,en;q=0.9'
}
})
const data = (await responses.json()) as GithubReleaseInfo[]
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
return item.prerelease && item.tag_name.includes(`-${channel}.`)
})
logger.info('release info', release)
if (!release) {
return null
}
logger.info('release info', release.tag_name)
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
} catch (error) {
logger.error('Failed to get latest not draft version from github:', error)
return null
}
}
private async _getIpCountry() {
try {
// add timeout using AbortController
@ -93,9 +122,72 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = isActive
}
public setFeedUrl(feedUrl: FeedUrl) {
autoUpdater.setFeedURL(feedUrl)
configManager.setFeedUrl(feedUrl)
private _getChannelByVersion(version: string) {
if (version.includes(`-${UpgradeChannel.BETA}.`)) {
return UpgradeChannel.BETA
}
if (version.includes(`-${UpgradeChannel.RC}.`)) {
return UpgradeChannel.RC
}
return UpgradeChannel.LATEST
}
private _getTestChannel() {
const currentChannel = this._getChannelByVersion(app.getVersion())
const savedChannel = configManager.getTestChannel()
if (currentChannel === UpgradeChannel.LATEST) {
return savedChannel || UpgradeChannel.RC
}
if (savedChannel === currentChannel) {
return savedChannel
}
// if the upgrade channel is not equal to the current channel, use the latest channel
return UpgradeChannel.LATEST
}
private async _setFeedUrl() {
const testPlan = configManager.getTestPlan()
if (testPlan) {
const channel = this._getTestChannel()
if (channel === UpgradeChannel.LATEST) {
this.autoUpdater.channel = UpgradeChannel.LATEST
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
return
}
const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel)
if (preReleaseUrl) {
this.autoUpdater.setFeedURL(preReleaseUrl)
this.autoUpdater.channel = channel
return
}
// if no prerelease url, use lowest prerelease version to avoid error
this.autoUpdater.setFeedURL(FeedUrl.PRERELEASE_LOWEST)
this.autoUpdater.channel = UpgradeChannel.LATEST
return
}
this.autoUpdater.channel = UpgradeChannel.LATEST
this.autoUpdater.setFeedURL(FeedUrl.PRODUCTION)
const ipCountry = await this._getIpCountry()
logger.info('ipCountry', ipCountry)
if (ipCountry.toLowerCase() !== 'cn') {
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
}
}
public cancelDownload() {
this.cancellationToken.cancel()
this.cancellationToken = new CancellationToken()
if (this.autoUpdater.autoDownload) {
this.updateCheckResult?.cancellationToken?.cancel()
}
}
public async checkForUpdates() {
@ -106,23 +198,26 @@ export default class AppUpdater {
}
}
const ipCountry = await this._getIpCountry()
logger.info('ipCountry', ipCountry)
if (ipCountry !== 'CN') {
this.autoUpdater.setFeedURL(FeedUrl.EARLY_ACCESS)
}
await this._setFeedUrl()
// disable downgrade after change the channel
this.autoUpdater.allowDowngrade = false
// github and gitcode don't support multiple range download
this.autoUpdater.disableDifferentialDownload = true
try {
const update = await this.autoUpdater.checkForUpdates()
if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
this.updateCheckResult = await this.autoUpdater.checkForUpdates()
if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
// 如果 autoDownload 为 false则需要再调用下面的函数触发下
// do not use await, because it will block the return of this function
this.autoUpdater.downloadUpdate()
logger.info('downloadUpdate manual by check for updates', this.cancellationToken)
this.autoUpdater.downloadUpdate(this.cancellationToken)
}
return {
currentVersion: this.autoUpdater.currentVersion,
updateInfo: update?.updateInfo
updateInfo: this.updateCheckResult?.updateInfo
}
} catch (error) {
logger.error('Failed to check for update:', error)
@ -178,7 +273,11 @@ export default class AppUpdater {
return releaseNotes.map((note) => note.note).join('\n')
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null

View File

@ -1,5 +1,6 @@
import { IpcChannel } from '@shared/IpcChannel'
import { WebDavConfig } from '@types'
import { S3Config } from '@types'
import archiver from 'archiver'
import { exec } from 'child_process'
import { app } from 'electron'
@ -10,6 +11,7 @@ import * as path from 'path'
import { CreateDirectoryOptions, FileStat } from 'webdav'
import { getDataPath } from '../utils'
import S3Storage from './RemoteStorage'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@ -25,6 +27,11 @@ class BackupManager {
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this)
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
this.backupToS3 = this.backupToS3.bind(this)
this.restoreFromS3 = this.restoreFromS3.bind(this)
this.listS3Files = this.listS3Files.bind(this)
this.deleteS3File = this.deleteS3File.bind(this)
this.checkS3Connection = this.checkS3Connection.bind(this)
}
private async setWritableRecursive(dirPath: string): Promise<void> {
@ -85,7 +92,11 @@ class BackupManager {
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
Logger.log('[BackupManager] backup progress', processData)
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
if (logStages.includes(processData.stage) || processData.progress === 100) {
Logger.log('[BackupManager] backup progress', processData)
}
}
try {
@ -147,18 +158,23 @@ class BackupManager {
let totalBytes = 0
let processedBytes = 0
// 首先计算总文件数和总大小
// 首先计算总文件数和总大小,但不记录详细日志
const calculateTotals = async (dirPath: string) => {
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
await calculateTotals(fullPath)
} else {
totalEntries++
const stats = await fs.stat(fullPath)
totalBytes += stats.size
try {
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
await calculateTotals(fullPath)
} else {
totalEntries++
const stats = await fs.stat(fullPath)
totalBytes += stats.size
}
}
} catch (error) {
// 仅在出错时记录日志
Logger.error('[BackupManager] Error calculating totals:', error)
}
}
@ -230,7 +246,11 @@ class BackupManager {
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
Logger.log('[BackupManager] restore progress', processData)
// 只在关键阶段记录日志
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
if (logStages.includes(processData.stage) || processData.progress === 100) {
Logger.log('[BackupManager] restore progress', processData)
}
}
try {
@ -382,21 +402,54 @@ class BackupManager {
destination: string,
onProgress: (size: number) => void
): Promise<void> {
const items = await fs.readdir(source, { withFileTypes: true })
// 先统计总文件数
let totalFiles = 0
let processedFiles = 0
let lastProgressReported = 0
for (const item of items) {
const sourcePath = path.join(source, item.name)
const destPath = path.join(destination, item.name)
// 计算总文件数
const countFiles = async (dir: string): Promise<number> => {
let count = 0
const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) {
if (item.isDirectory()) {
count += await countFiles(path.join(dir, item.name))
} else {
count++
}
}
return count
}
if (item.isDirectory()) {
await fs.ensureDir(destPath)
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
} else {
const stats = await fs.stat(sourcePath)
await fs.copy(sourcePath, destPath)
onProgress(stats.size)
totalFiles = await countFiles(source)
// 复制文件并更新进度
const copyDir = async (src: string, dest: string): Promise<void> => {
const items = await fs.readdir(src, { withFileTypes: true })
for (const item of items) {
const sourcePath = path.join(src, item.name)
const destPath = path.join(dest, item.name)
if (item.isDirectory()) {
await fs.ensureDir(destPath)
await copyDir(sourcePath, destPath)
} else {
const stats = await fs.stat(sourcePath)
await fs.copy(sourcePath, destPath)
processedFiles++
// 只在进度变化超过5%时报告进度
const currentProgress = Math.floor((processedFiles / totalFiles) * 100)
if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) {
lastProgressReported = currentProgress
onProgress(stats.size)
}
}
}
}
await copyDir(source, destination)
}
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
@ -423,6 +476,141 @@ class BackupManager {
throw new Error(error.message || 'Failed to delete backup file')
}
}
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
// 获取设备名
const os = require('os')
const deviceName = os.hostname ? os.hostname() : 'device'
const timestamp = new Date()
.toISOString()
.replace(/[-:T.Z]/g, '')
.slice(0, 14)
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
// 不记录详细日志,只记录开始和结束
Logger.log(`[BackupManager] Starting S3 backup to ${filename}`)
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
try {
const fileBuffer = await fs.promises.readFile(backupedFilePath)
const result = await s3Client.putFileContents(filename, fileBuffer)
await fs.remove(backupedFilePath)
Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`)
return result
} catch (error) {
Logger.error(`[BackupManager] S3 backup failed:`, error)
await fs.remove(backupedFilePath)
throw error
}
}
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
// 只记录开始和结束或错误
Logger.log(`[BackupManager] Starting restore from S3: ${filename}`)
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
try {
const retrievedFile = await s3Client.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true })
}
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(backupedFilePath)
writeStream.write(retrievedFile as Buffer)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`)
return await this.restore(_, backupedFilePath)
} catch (error: any) {
Logger.error('[BackupManager] Failed to restore from S3:', error)
throw new Error(error.message || 'Failed to restore backup file')
}
}
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
try {
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
const entries = await s3Client.instance?.list('/')
const files: Array<{ fileName: string; modifiedTime: string; size: number }> = []
if (entries) {
for await (const entry of entries) {
const path = entry.path()
if (path.endsWith('.zip')) {
const meta = await s3Client.instance!.stat(path)
if (meta.isFile()) {
files.push({
fileName: path.replace(/^\/+/, ''),
modifiedTime: meta.lastModified || '',
size: Number(meta.contentLength || 0n)
})
}
}
}
}
return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
} catch (error: any) {
Logger.error('Failed to list S3 files:', error)
throw new Error(error.message || 'Failed to list backup files')
}
}
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
try {
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
return await s3Client.deleteFile(fileName)
} catch (error: any) {
Logger.error('Failed to delete S3 file:', error)
throw new Error(error.message || 'Failed to delete backup file')
}
}
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
return await s3Client.checkConnection()
}
}
export default BackupManager

View File

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

View File

@ -4,18 +4,29 @@ import { locales } from '../utils/locales'
import { configManager } from './ConfigManager'
class ContextMenu {
public contextMenu(w: Electron.BrowserWindow) {
w.webContents.on('context-menu', (_event, properties) => {
public contextMenu(w: Electron.WebContents) {
w.on('context-menu', (_event, properties) => {
const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties)
const filtered = template.filter((item) => item.visible !== false)
if (filtered.length > 0) {
const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)])
let template = [...filtered, ...this.createInspectMenuItems(w)]
const dictionarySuggestions = this.createDictionarySuggestions(properties, w)
if (dictionarySuggestions.length > 0) {
template = [
...dictionarySuggestions,
{ type: 'separator' },
this.createSpellCheckMenuItem(properties, w),
{ type: 'separator' },
...template
]
}
const menu = Menu.buildFromTemplate(template)
menu.popup()
}
})
}
private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] {
private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] {
const locale = locales[configManager.getLanguage()]
const { common } = locale.translation
const template: MenuItemConstructorOptions[] = [
@ -23,7 +34,7 @@ class ContextMenu {
id: 'inspect',
label: common.inspect,
click: () => {
w.webContents.toggleDevTools()
w.toggleDevTools()
},
enabled: true
}
@ -72,6 +83,53 @@ class ContextMenu {
return template
}
private createSpellCheckMenuItem(
properties: Electron.ContextMenuParams,
w: Electron.WebContents
): MenuItemConstructorOptions {
const hasText = properties.selectionText.length > 0
return {
id: 'learnSpelling',
label: '&Learn Spelling',
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
click: () => {
w.session.addWordToSpellCheckerDictionary(properties.misspelledWord)
}
}
}
private createDictionarySuggestions(
properties: Electron.ContextMenuParams,
w: Electron.WebContents
): MenuItemConstructorOptions[] {
const hasText = properties.selectionText.length > 0
if (!hasText || !properties.misspelledWord) {
return []
}
if (properties.dictionarySuggestions.length === 0) {
return [
{
id: 'dictionarySuggestions',
label: 'No Guesses Found',
visible: true,
enabled: false
}
]
}
return properties.dictionarySuggestions.map((suggestion) => ({
id: 'dictionarySuggestions',
label: suggestion,
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
click: (menuItem: Electron.MenuItem) => {
w.replaceMisspelling(menuItem.label)
}
}))
}
}
export const contextMenu = new ContextMenu()

View File

@ -19,6 +19,7 @@ import { getDocument } from 'officeparser/pdfjs-dist-build/pdf.js'
import * as path from 'path'
import { chdir } from 'process'
import { v4 as uuidv4 } from 'uuid'
import WordExtractor from 'word-extractor'
class FileStorage {
private storageDir = getFilesDir()
@ -220,10 +221,20 @@ class FileStorage {
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
const filePath = path.join(this.storageDir, id)
if (documentExts.includes(path.extname(filePath))) {
const fileExtension = path.extname(filePath)
if (documentExts.includes(fileExtension)) {
const originalCwd = process.cwd()
try {
chdir(this.tempDir)
if (fileExtension === '.doc') {
const extractor = new WordExtractor()
const extracted = await extractor.extract(filePath)
chdir(originalCwd)
return extracted.getBody()
}
const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd)
return data
@ -352,7 +363,7 @@ class FileStorage {
public open = async (
_: Electron.IpcMainInvokeEvent,
options: OpenDialogOptions
): Promise<{ fileName: string; filePath: string; content: Buffer } | null> => {
): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => {
try {
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
title: '打开文件',
@ -364,8 +375,16 @@ class FileStorage {
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const fileName = filePath.split('/').pop() || ''
const content = await readFile(filePath)
return { fileName, filePath, content }
const stats = await fs.promises.stat(filePath)
// If the file is less than 2GB, read the content
if (stats.size < 2 * 1024 * 1024 * 1024) {
const content = await readFile(filePath)
return { fileName, filePath, content, size: stats.size }
}
// For large files, only return file information, do not read content
return { fileName, filePath, size: stats.size }
}
return null

View File

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

View File

@ -0,0 +1,102 @@
import { randomUUID } from 'node:crypto'
import { BrowserWindow, ipcMain } from 'electron'
interface PythonExecutionRequest {
id: string
script: string
context: Record<string, any>
timeout: number
}
interface PythonExecutionResponse {
id: string
result?: string
error?: string
}
/**
* Service for executing Python code by communicating with the PyodideService in the renderer process
*/
export class PythonService {
private static instance: PythonService | null = null
private mainWindow: BrowserWindow | null = null
private pendingRequests = new Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>()
private constructor() {
// Private constructor for singleton pattern
this.setupIpcHandlers()
}
public static getInstance(): PythonService {
if (!PythonService.instance) {
PythonService.instance = new PythonService()
}
return PythonService.instance
}
private setupIpcHandlers() {
// Handle responses from renderer
ipcMain.on('python-execution-response', (_, response: PythonExecutionResponse) => {
const request = this.pendingRequests.get(response.id)
if (request) {
this.pendingRequests.delete(response.id)
if (response.error) {
request.reject(new Error(response.error))
} else {
request.resolve(response.result || '')
}
}
})
}
public setMainWindow(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow
}
/**
* Execute Python code by sending request to renderer PyodideService
*/
public async executeScript(
script: string,
context: Record<string, any> = {},
timeout: number = 60000
): Promise<string> {
if (!this.mainWindow) {
throw new Error('Main window not set in PythonService')
}
return new Promise((resolve, reject) => {
const requestId = randomUUID()
// Store the request
this.pendingRequests.set(requestId, { resolve, reject })
// Set up timeout
const timeoutId = setTimeout(() => {
this.pendingRequests.delete(requestId)
reject(new Error('Python execution timed out'))
}, timeout + 5000) // Add 5s buffer for IPC communication
// Update resolve/reject to clear timeout
const originalResolve = resolve
const originalReject = reject
this.pendingRequests.set(requestId, {
resolve: (value: string) => {
clearTimeout(timeoutId)
originalResolve(value)
},
reject: (error: Error) => {
clearTimeout(timeoutId)
originalReject(error)
}
})
// Send request to renderer
const request: PythonExecutionRequest = { id: requestId, script, context, timeout }
this.mainWindow?.webContents.send('python-execution-request', request)
})
}
}
export const pythonService = PythonService.getInstance()

View File

@ -1,57 +1,83 @@
// import Logger from 'electron-log'
// import { Operator } from 'opendal'
import Logger from 'electron-log'
import type { Operator as OperatorType } from 'opendal'
const { Operator } = require('opendal')
// export default class RemoteStorage {
// public instance: Operator | undefined
export default class S3Storage {
public instance: OperatorType | undefined
// /**
// *
// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
// *
// * For example, use minio as remote storage:
// *
// * ```typescript
// * const storage = new RemoteStorage('s3', {
// * endpoint: 'http://localhost:9000',
// * region: 'us-east-1',
// * bucket: 'testbucket',
// * access_key_id: 'user',
// * secret_access_key: 'password',
// * root: '/path/to/basepath',
// * })
// * ```
// */
// constructor(scheme: string, options?: Record<string, string> | undefined | null) {
// this.instance = new Operator(scheme, options)
/**
*
* @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
* @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
*
* For example, use minio as remote storage:
*
* ```typescript
* const storage = new S3Storage('s3', {
* endpoint: 'http://localhost:9000',
* region: 'us-east-1',
* bucket: 'testbucket',
* access_key_id: 'user',
* secret_access_key: 'password',
* root: '/path/to/basepath',
* })
* ```
*/
constructor(scheme: string, options?: Record<string, string> | undefined | null) {
this.instance = new Operator(scheme, options)
// this.putFileContents = this.putFileContents.bind(this)
// this.getFileContents = this.getFileContents.bind(this)
// }
this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this)
}
// public putFileContents = async (filename: string, data: string | Buffer) => {
// if (!this.instance) {
// return new Error('RemoteStorage client not initialized')
// }
public putFileContents = async (filename: string, data: string | Buffer) => {
if (!this.instance) {
return new Error('RemoteStorage client not initialized')
}
// try {
// return await this.instance.write(filename, data)
// } catch (error) {
// Logger.error('[RemoteStorage] Error putting file contents:', error)
// throw error
// }
// }
try {
return await this.instance.write(filename, data)
} catch (error) {
Logger.error('[RemoteStorage] Error putting file contents:', error)
throw error
}
}
// public getFileContents = async (filename: string) => {
// if (!this.instance) {
// throw new Error('RemoteStorage client not initialized')
// }
public getFileContents = async (filename: string) => {
if (!this.instance) {
throw new Error('RemoteStorage client not initialized')
}
// try {
// return await this.instance.read(filename)
// } catch (error) {
// Logger.error('[RemoteStorage] Error getting file contents:', error)
// throw error
// }
// }
// }
try {
return await this.instance.read(filename)
} catch (error) {
Logger.error('[RemoteStorage] Error getting file contents:', error)
throw error
}
}
public deleteFile = async (filename: string) => {
if (!this.instance) {
throw new Error('RemoteStorage client not initialized')
}
try {
return await this.instance.delete(filename)
} catch (error) {
Logger.error('[RemoteStorage] Error deleting file:', error)
throw error
}
}
public checkConnection = async () => {
if (!this.instance) {
throw new Error('RemoteStorage client not initialized')
}
try {
// 检查根目录是否可访问
return await this.instance.stat('/')
} catch (error) {
Logger.error('[RemoteStorage] Error checking connection:', error)
throw error
}
}
}

View File

@ -72,8 +72,7 @@ export class WindowService {
webSecurity: false,
webviewTag: true,
allowRunningInsecureContent: true,
zoomFactor: configManager.getZoomFactor(),
backgroundThrottling: false
zoomFactor: configManager.getZoomFactor()
}
})
@ -96,6 +95,7 @@ export class WindowService {
this.setupMaximize(mainWindow, mainWindowState.isMaximized)
this.setupContextMenu(mainWindow)
this.setupSpellCheck(mainWindow)
this.setupWindowEvents(mainWindow)
this.setupWebContentsHandlers(mainWindow)
this.setupWindowLifecycleEvents(mainWindow)
@ -103,6 +103,18 @@ export class WindowService {
this.loadMainWindowContent(mainWindow)
}
private setupSpellCheck(mainWindow: BrowserWindow) {
const enableSpellCheck = configManager.get('enableSpellCheck', false)
if (enableSpellCheck) {
try {
const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[]
spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages)
} catch (error) {
Logger.error('Failed to set spell check languages:', error as Error)
}
}
}
private setupMainWindowMonitor(mainWindow: BrowserWindow) {
mainWindow.webContents.on('render-process-gone', (_, details) => {
Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`)
@ -131,9 +143,10 @@ export class WindowService {
}
private setupContextMenu(mainWindow: BrowserWindow) {
contextMenu.contextMenu(mainWindow)
app.on('browser-window-created', (_, win) => {
contextMenu.contextMenu(win)
contextMenu.contextMenu(mainWindow.webContents)
// setup context menu for all webviews like miniapp
app.on('web-contents-created', (_, webContents) => {
contextMenu.contextMenu(webContents)
})
// Dangerous API
@ -444,8 +457,7 @@ export class WindowService {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true,
backgroundThrottling: false
webviewTag: true
}
})

View File

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

View File

@ -8,6 +8,20 @@ import { FileType, FileTypes } from '@types'
import { app } from 'electron'
import { v4 as uuidv4 } from 'uuid'
export function initAppDataDir() {
const appDataPath = getAppDataPathFromConfig()
if (appDataPath) {
app.setPath('userData', appDataPath)
return
}
if (isPortable) {
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
return
}
}
// 创建文件类型映射表,提高查找效率
const fileTypeMap = new Map<string, FileTypes>()
@ -35,46 +49,70 @@ export function hasWritePermission(path: string) {
function getAppDataPathFromConfig() {
try {
const configPath = path.join(getConfigDir(), 'config.json')
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.appDataPath && fs.existsSync(config.appDataPath) && hasWritePermission(config.appDataPath)) {
return config.appDataPath
}
if (!fs.existsSync(configPath)) {
return null
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (!config.appDataPath) {
return null
}
let appDataPath = null
// 兼容旧版本
if (config.appDataPath && typeof config.appDataPath === 'string') {
appDataPath = config.appDataPath
// 将旧版本数据迁移到新版本
appDataPath && updateAppDataConfig(appDataPath)
} else {
appDataPath = config.appDataPath.find(
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
)?.dataPath
}
if (appDataPath && fs.existsSync(appDataPath) && hasWritePermission(appDataPath)) {
return appDataPath
}
return null
} catch (error) {
return null
}
return null
}
export function initAppDataDir() {
const appDataPath = getAppDataPathFromConfig()
if (appDataPath) {
app.setPath('userData', appDataPath)
return
}
if (isPortable) {
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
return
}
}
export function updateConfig(appDataPath: string) {
export function updateAppDataConfig(appDataPath: string) {
const configDir = getConfigDir()
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true })
}
// config.json
// appDataPath: [{ executablePath: string, dataPath: string }]
const configPath = path.join(getConfigDir(), 'config.json')
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify({ appDataPath }, null, 2))
fs.writeFileSync(
configPath,
JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2)
)
return
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
config.appDataPath = appDataPath
if (!config.appDataPath || (config.appDataPath && typeof config.appDataPath !== 'object')) {
config.appDataPath = []
}
const existingPath = config.appDataPath.find(
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
)
if (existingPath) {
existingPath.dataPath = appDataPath
} else {
config.appDataPath.push({ executablePath: app.getPath('exe'), dataPath: appDataPath })
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
}

View File

@ -1,8 +1,17 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { FeedUrl } from '@shared/config/constant'
import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
import {
FileType,
KnowledgeBaseParams,
KnowledgeItem,
MCPServer,
S3Config,
Shortcut,
ThemeMode,
WebDavConfig
} from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
@ -17,11 +26,14 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive),
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
setFeedUrl: (feedUrl: FeedUrl) => ipcRenderer.invoke(IpcChannel.App_SetFeedUrl, feedUrl),
setTestPlan: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTestPlan, isActive),
setTestChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetTestChannel, channel),
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
handleZoomFactor: (delta: number, reset: boolean = false) =>
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
@ -29,9 +41,13 @@ const api = {
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath),
getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs),
copy: (oldPath: string, newPath: string, occupiedDirs: string[] = []) =>
ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath, occupiedDirs),
setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason),
relaunchApp: () => ipcRenderer.invoke(IpcChannel.App_RelaunchApp),
flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData),
isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path),
relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
@ -64,7 +80,13 @@ const api = {
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config),
restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config),
listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config),
deleteS3File: (fileName: string, s3Config: S3Config) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config),
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
@ -176,6 +198,10 @@ const api = {
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
},
python: {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
},
shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
},
@ -218,7 +244,9 @@ const api = {
},
webview: {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal)
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),

View File

@ -2,42 +2,45 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Selection Toolbar</title>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Selection Toolbar</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
<style>
html {
margin: 0;
}
<div id="root"></div>
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
<style>
html {
margin: 0 !important;
background-color: transparent !important;
background-image: none !important;
body {
margin: 0;
padding: 0;
overflow: hidden;
width: 100vw;
height: 100vh;
}
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
body {
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
width: 100vw !important;
height: 100vh !important;
#root {
margin: 0;
padding: 0;
width: max-content !important;
height: fit-content !important;
}
</style>
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#root {
margin: 0 !important;
padding: 0 !important;
width: max-content !important;
height: fit-content !important;
}
</style>
</body>
</html>

View File

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

View File

@ -7,6 +7,7 @@ import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { VertexAPIClient } from './gemini/VertexAPIClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
/**
* Factory for creating ApiClient instances based on provider configuration
@ -31,6 +32,11 @@ export class ApiClientFactory {
instance = new AihubmixAPIClient(provider) as BaseApiClient
return instance
}
if (provider.id === 'ppio') {
console.log(`[ApiClientFactory] Creating PPIOAPIClient for provider: ${provider.id}`)
instance = new PPIOAPIClient(provider) as BaseApiClient
return instance
}
// 然后检查标准的provider type
switch (provider.type) {

View File

@ -37,7 +37,7 @@ import {
} from '@renderer/types/sdk'
import { isJSON, parseJSON } from '@renderer/utils'
import { addAbortController, removeAbortController } from '@renderer/utils/abortController'
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { findFileBlocks, getContentWithTools, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { defaultTimeout } from '@shared/config/constant'
import Logger from 'electron-log/renderer'
import { isEmpty } from 'lodash'
@ -209,7 +209,7 @@ export abstract class BaseApiClient<
}
public async getMessageContent(message: Message): Promise<string> {
const content = getMainTextContent(message)
const content = getContentWithTools(message)
if (isEmpty(content)) {
return ''
}

View File

@ -52,7 +52,7 @@ import {
TextDeltaChunk,
ThinkingDeltaChunk
} from '@renderer/types/chunk'
import type { Message } from '@renderer/types/newMessage'
import { type Message } from '@renderer/types/newMessage'
import {
AnthropicSdkMessageParam,
AnthropicSdkParams,
@ -66,7 +66,7 @@ import {
mcpToolCallResponseToAnthropicMessage,
mcpToolsToAnthropicTools
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { BaseApiClient } from '../BaseApiClient'
@ -90,11 +90,12 @@ export class AnthropicAPIClient extends BaseApiClient<
return this.sdkInstance
}
this.sdkInstance = new Anthropic({
apiKey: this.getApiKey(),
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
dangerouslyAllowBrowser: true,
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19'
'anthropic-beta': 'output-128k-2025-02-19',
...this.provider.extra_headers
}
})
return this.sdkInstance
@ -191,7 +192,7 @@ export class AnthropicAPIClient extends BaseApiClient<
const parts: MessageParam['content'] = [
{
type: 'text',
text: getMainTextContent(message)
text: await this.getMessageContent(message)
}
]
@ -492,7 +493,8 @@ export class AnthropicAPIClient extends BaseApiClient<
system: systemMessage ? [systemMessage] : undefined,
thinking: this.getBudgetToken(assistant, model),
tools: tools.length > 0 ? tools : undefined,
...this.getCustomParameters(assistant)
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
}
const finalParams: MessageCreateParams = streamOutput

View File

@ -22,8 +22,8 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
import {
findTokenLimit,
GEMINI_FLASH_MODEL_REGEX,
isGeminiReasoningModel,
isGemmaModel,
isSupportedThinkingTokenGeminiModel,
isVisionModel
} from '@renderer/config/models'
import { CacheService } from '@renderer/services/CacheService'
@ -60,7 +60,7 @@ import {
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { MB } from '@shared/config/constant'
import { defaultTimeout, MB } from '@shared/config/constant'
import { BaseApiClient } from '../BaseApiClient'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
@ -85,7 +85,7 @@ export class GeminiAPIClient extends BaseApiClient<
...rest,
config: {
...rest.config,
abortSignal: options?.abortSignal,
abortSignal: options?.signal,
httpOptions: {
...rest.config?.httpOptions,
timeout: options?.timeout
@ -118,7 +118,7 @@ export class GeminiAPIClient extends BaseApiClient<
aspectRatio: imageSize,
abortSignal: signal,
httpOptions: {
timeout: 5 * 60 * 1000
timeout: defaultTimeout
}
}
const response = await sdk.models.generateImages({
@ -176,7 +176,10 @@ export class GeminiAPIClient extends BaseApiClient<
apiVersion: this.getApiVersion(),
httpOptions: {
baseUrl: this.getBaseURL(),
apiVersion: this.getApiVersion()
apiVersion: this.getApiVersion(),
headers: {
...this.provider.extra_headers
}
}
})
@ -240,6 +243,7 @@ export class GeminiAPIClient extends BaseApiClient<
private async convertMessageToSdkParam(message: Message): Promise<Content> {
const role = message.role === 'user' ? 'user' : 'model'
const parts: Part[] = [{ text: await this.getMessageContent(message) }]
// Add any generated images from previous responses
const imageBlocks = findImageBlocks(message)
for (const imageBlock of imageBlocks) {
@ -390,29 +394,29 @@ export class GeminiAPIClient extends BaseApiClient<
* @returns The reasoning effort
*/
private getBudgetToken(assistant: Assistant, model: Model) {
if (isGeminiReasoningModel(model)) {
if (isSupportedThinkingTokenGeminiModel(model)) {
const reasoningEffort = assistant?.settings?.reasoning_effort
// 如果thinking_budget是undefined不思考
if (reasoningEffort === undefined) {
return {
thinkingConfig: {
includeThoughts: false,
...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinkingBudget: 0 } : {})
} as ThinkingConfig
}
return GEMINI_FLASH_MODEL_REGEX.test(model.id)
? {
thinkingConfig: {
thinkingBudget: 0
}
}
: {}
}
const effortRatio = EFFORT_RATIO[reasoningEffort]
if (effortRatio > 1) {
if (reasoningEffort === 'auto') {
return {
thinkingConfig: {
includeThoughts: true
includeThoughts: true,
thinkingBudget: -1
}
}
}
const effortRatio = EFFORT_RATIO[reasoningEffort]
const { min, max } = findTokenLimit(model.id) || { min: 0, max: 0 }
// 计算 budgetTokens确保不低于 min
const budget = Math.floor((max - min) * effortRatio + min)
@ -479,6 +483,7 @@ export class GeminiAPIClient extends BaseApiClient<
for (const message of messages) {
history.push(await this.convertMessageToSdkParam(message))
}
messages.push(userLastMessage)
}
}
@ -531,7 +536,8 @@ export class GeminiAPIClient extends BaseApiClient<
tools: tools,
...(enableGenerateImage ? this.getGenerateImageParameter() : {}),
...this.getBudgetToken(assistant, model),
...this.getCustomParameters(assistant)
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
}
const param: GeminiSdkParams = {
@ -682,16 +688,19 @@ export class GeminiAPIClient extends BaseApiClient<
toolCalls: FunctionCall[]
): Content[] {
const parts: Part[] = []
const modelParts: Part[] = []
if (output) {
parts.push({
modelParts.push({
text: output
})
}
toolCalls.forEach((toolCall) => {
parts.push({
modelParts.push({
functionCall: toolCall
})
})
parts.push(
...toolResults
.map((ts) => ts.parts)
@ -699,10 +708,22 @@ export class GeminiAPIClient extends BaseApiClient<
.filter((p) => p !== undefined)
)
const lastMessage = currentReqMessages[currentReqMessages.length - 1]
if (lastMessage) {
lastMessage.parts?.push(...parts)
const userMessage: Content = {
role: 'user',
parts: []
}
if (modelParts.length > 0) {
currentReqMessages.push({
role: 'model',
parts: modelParts
})
}
if (parts.length > 0) {
userMessage.parts?.push(...parts)
currentReqMessages.push(userMessage)
}
return currentReqMessages
}
@ -743,7 +764,7 @@ export class GeminiAPIClient extends BaseApiClient<
}
})
}
return [messageParam, ...(sdkPayload.history || [])]
return [...(sdkPayload.history || []), messageParam]
}
private async uploadFile(file: FileType): Promise<File> {

View File

@ -113,6 +113,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
if (!reasoningEffort) {
if (model.provider === 'openrouter') {
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
}
if (isSupportedThinkingTokenQwenModel(model)) {
return { enable_thinking: false }
}
@ -122,12 +128,16 @@ 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' }
return {
extra_body: {
google: {
thinking_config: {
thinking_budget: 0
}
}
}
}
}
return {}
}
@ -170,12 +180,37 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
// OpenAI models
if (isSupportedReasoningEffortOpenAIModel(model) || isSupportedThinkingTokenGeminiModel(model)) {
if (isSupportedReasoningEffortOpenAIModel(model)) {
return {
reasoning_effort: reasoningEffort
}
}
if (isSupportedThinkingTokenGeminiModel(model)) {
if (reasoningEffort === 'auto') {
return {
extra_body: {
google: {
thinking_config: {
thinking_budget: -1,
include_thoughts: true
}
}
}
}
}
return {
extra_body: {
google: {
thinking_config: {
thinking_budget: budgetTokens,
include_thoughts: true
}
}
}
}
}
// Claude models
if (isSupportedThinkingTokenClaudeModel(model)) {
const maxTokens = assistant.settings?.maxTokens
@ -472,7 +507,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
...this.getProviderSpecificParameters(assistant, model),
...this.getReasoningEffort(assistant, model),
...getOpenAIWebSearchParams(model, enableWebSearch),
...this.getCustomParameters(assistant)
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
}
// Create the appropriate parameters object based on whether streaming is enabled
@ -640,9 +676,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
if (!choice) return
// 对于流式响应使用delta对于非流式响应使用message
const contentSource: OpenAISdkRawContentSource | null =
'delta' in choice ? choice.delta : 'message' in choice ? choice.message : null
// 对于流式响应,使用 delta对于非流式响应使用 message。
// 然而某些 OpenAI 兼容平台在非流式请求时会错误地返回一个空对象的 delta 字段。
// 如果 delta 为空对象,应当忽略它并回退到 message避免造成内容缺失。
let contentSource: OpenAISdkRawContentSource | null = null
if ('delta' in choice && choice.delta && Object.keys(choice.delta).length > 0) {
contentSource = choice.delta
} else if ('message' in choice) {
contentSource = choice.message
}
if (!contentSource) return

View File

@ -135,7 +135,7 @@ export abstract class OpenAIBaseClient<
return this.sdkInstance
}
let apiKeyForSdkInstance = this.provider.apiKey
let apiKeyForSdkInstance = this.apiKey
if (this.provider.id === 'copilot') {
const defaultHeaders = store.getState().copilot.defaultHeaders
@ -159,6 +159,7 @@ export abstract class OpenAIBaseClient<
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
}

View File

@ -78,10 +78,11 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return new OpenAI({
dangerouslyAllowBrowser: true,
apiKey: this.provider.apiKey,
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders()
...this.defaultHeaders(),
...this.provider.extra_headers
}
})
}
@ -385,10 +386,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
})
}
const toolChoices: OpenAI.Responses.ToolChoiceTypes = {
type: 'web_search_preview'
}
tools = tools.concat(extraTools)
const commonParams = {
model: model.id,
@ -401,10 +398,10 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
max_output_tokens: maxTokens,
stream: streamOutput,
tools: !isEmpty(tools) ? tools : undefined,
tool_choice: enableWebSearch ? toolChoices : undefined,
service_tier: this.getServiceTier(model),
...(this.getReasoningEffort(assistant, model) as OpenAI.Reasoning),
...this.getCustomParameters(assistant)
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
}
const sdkParams: OpenAIResponseSdkParams = streamOutput
? {
@ -425,6 +422,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
const toolCalls: OpenAIResponseSdkToolCall[] = []
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
let hasBeenCollectedToolCalls = false
let hasReasoningSummary = false
return () => ({
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 处理chunk
@ -496,6 +494,16 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
outputItems.push(chunk.item)
}
break
case 'response.reasoning_summary_part.added':
if (hasReasoningSummary) {
const separator = '\n\n'
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: separator
})
}
hasReasoningSummary = true
break
case 'response.reasoning_summary_text.delta':
controller.enqueue({
type: ChunkType.THINKING_DELTA,

View File

@ -0,0 +1,65 @@
import { isSupportedModel } from '@renderer/config/models'
import { Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
export class PPIOAPIClient extends OpenAIAPIClient {
constructor(provider: Provider) {
super(provider)
}
override async listModels(): Promise<OpenAI.Models.Model[]> {
try {
const sdk = await this.getSdkInstance()
// PPIO requires three separate requests to get all model types
const [chatModelsResponse, embeddingModelsResponse, rerankerModelsResponse] = await Promise.all([
// Chat/completion models
sdk.request({
method: 'get',
path: '/models'
}),
// Embedding models
sdk.request({
method: 'get',
path: '/models?model_type=embedding'
}),
// Reranker models
sdk.request({
method: 'get',
path: '/models?model_type=reranker'
})
])
// Extract models from all responses
// @ts-ignore - PPIO response structure may not be typed
const allModels = [
...((chatModelsResponse as any)?.data || []),
...((embeddingModelsResponse as any)?.data || []),
...((rerankerModelsResponse as any)?.data || [])
]
// Process and standardize model data
const processedModels = allModels.map((model: any) => ({
id: model.id || model.name,
description: model.description || model.display_name || model.summary,
object: 'model' as const,
owned_by: model.owned_by || model.publisher || model.organization || 'ppio',
created: model.created || Date.now()
}))
// Clean up model IDs and filter supported models
processedModels.forEach((model) => {
if (model.id) {
model.id = model.id.trim()
}
})
return processedModels.filter(isSupportedModel)
} catch (error) {
console.error('Error listing PPIO models:', error)
return []
}
}
}

View File

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

View File

@ -1,7 +1,9 @@
import { BaseApiClient } from '@renderer/aiCore/clients/BaseApiClient'
import { isDedicatedImageGenerationModel } from '@renderer/config/models'
import FileManager from '@renderer/services/FileManager'
import { ChunkType } from '@renderer/types/chunk'
import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { defaultTimeout } from '@shared/config/constant'
import OpenAI from 'openai'
import { toFile } from 'openai/uploads'
@ -46,7 +48,7 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
const userImages = await Promise.all(
userImageBlocks.map(async (block) => {
if (!block.file) return null
const binaryData: Uint8Array = await window.api.file.binaryImage(block.file.id)
const binaryData: Uint8Array = await FileManager.readBinaryImage(block.file)
const mimeType = `${block.file.type}/${block.file.ext.slice(1)}`
return await toFile(new Blob([binaryData]), block.file.origin_name || 'image.png', { type: mimeType })
})
@ -73,8 +75,7 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
const startTime = Date.now()
let response: OpenAI.Images.ImagesResponse
const options = { signal, timeout: 300_000 }
const options = { signal, timeout: defaultTimeout }
if (imageFiles.length > 0) {
response = await sdk.images.edit(

View File

@ -11,11 +11,13 @@ export const MIDDLEWARE_NAME = 'ThinkingTagExtractionMiddleware'
// 不同模型的思考标签配置
const reasoningTags: TagConfig[] = [
{ openingTag: '<think>', closingTag: '</think>', separator: '\n' },
{ openingTag: '<thought>', closingTag: '</thought>', separator: '\n' },
{ openingTag: '###Thinking', closingTag: '###Response', separator: '\n' }
]
const getAppropriateTag = (model?: Model): TagConfig => {
if (model?.id?.includes('qwen3')) return reasoningTags[0]
if (model?.id?.includes('gemini-2.5')) return reasoningTags[1]
// 可以在这里添加更多模型特定的标签配置
return reasoningTags[0] // 默认使用 <think> 标签
}

View File

@ -0,0 +1,13 @@
@font-face {
font-family: 'Twemoji Country Flags';
unicode-range:
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
src: url('TwemojiCountryFlags.woff2') format('woff2');
font-display: swap;
}
/* 国旗字体样式类 */
.country-flag-font {
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
}

View File

@ -1,12 +1,20 @@
:root {
--font-family:
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-family-serif:
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
}
:root {
--font-family:
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-family-serif:
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
}
// Windows系统专用字体配置
body[os='windows'] {
--font-family:
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

View File

@ -8,6 +8,7 @@
@use './animation.scss';
@import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css';
@import '../fonts/country-flag-fonts/flag.css';
*,
*::before,
@ -112,15 +113,7 @@ ul {
word-wrap: break-word;
}
.bubble {
.system-prompt {
background-color: var(--chat-background-assistant);
}
.message-content-container {
margin: 5px 0;
border-radius: 8px;
}
.bubble:not(.multi-select-mode) {
.block-wrapper {
display: flow-root;
}
@ -138,27 +131,35 @@ ul {
}
.message-user {
.message-header {
flex-direction: row-reverse;
text-align: right;
.message-header-info-wrap {
flex-direction: row-reverse;
text-align: right;
}
}
.message-content-container {
margin: 5px 0;
border-radius: 8px 0 8px 8px;
padding: 10px 15px 0 15px;
border-radius: 10px 0 10px 10px;
padding: 10px 16px 10px 16px;
background-color: var(--chat-background-user);
align-self: self-end;
}
.MessageFooter {
margin-top: 2px;
align-self: self-end;
}
}
.group-grid-container.horizontal,
.group-grid-container.grid {
.message-content-container-assistant {
padding: 0;
}
}
.group-message-wrapper {
background-color: var(--color-background);
.message-assistant {
.message-content-container {
width: 100%;
padding-left: 0;
}
.MessageFooter {
margin-left: 0;
}
}
.group-menu-bar {
background-color: var(--color-background);
}
code {
color: var(--color-text);
}
@ -170,16 +171,16 @@ ul {
}
}
.lucide {
.lucide:not(.lucide-custom) {
color: var(--color-icon);
}
span.highlight {
::highlight(search-matches) {
background-color: var(--color-background-highlight);
color: var(--color-highlight);
}
span.highlight.selected {
::highlight(current-match) {
background-color: var(--color-background-highlight-accent);
}

View File

@ -98,7 +98,6 @@
border: none;
border-top: 0.5px solid var(--color-border);
margin: 20px 0;
background-color: var(--color-border);
}
span {
@ -251,6 +250,10 @@
text-decoration: underline;
}
}
> *:last-child {
margin-bottom: 0 !important;
}
}
.footnotes {
@ -330,7 +333,7 @@ mjx-container {
.cm-scroller {
font-family: var(--code-font-family);
border-radius: 5px;
border-radius: inherit;
.cm-gutters {
line-height: 1.6;

View File

@ -5,22 +5,57 @@ html {
}
:root {
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
--color-selection-toolbar-hover-bg: #222222;
// Basic Colors
--color-primary: #00b96b;
--color-error: #f44336;
--selection-toolbar-color-primary: var(--color-primary);
--selection-toolbar-color-error: var(--color-error);
// Toolbar
--selection-toolbar-height: 36px; // default: 36px max: 42px
--selection-toolbar-font-size: 14px; // default: 14px
--selection-toolbar-logo-display: flex; // values: flex | none
--selection-toolbar-logo-size: 22px; // default: 22px
--selection-toolbar-logo-margin: 0 0 0 5px; // default: 0 0 05px
// DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING
--selection-toolbar-padding: 2px 4px 2px 2px; // default: 2px 4px 2px 2px
--selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px
// ------------------------------------------------------------
--selection-toolbar-border-radius: 6px;
--selection-toolbar-border: 1px solid rgba(55, 55, 55, 0.5);
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
--selection-toolbar-background: rgba(20, 20, 20, 0.95);
// Buttons
--selection-toolbar-button-icon-size: 16px; // default: 16px
--selection-toolbar-button-text-margin: 0 0 0 3px; // default: 0 0 0 3px
--selection-toolbar-button-margin: 0 2px; // default: 0 2px
--selection-toolbar-button-padding: 4px 6px; // default: 4px 6px
--selection-toolbar-button-border-radius: 4px; // default: 4px
--selection-toolbar-button-border: none; // default: none
--selection-toolbar-button-box-shadow: none; // default: none
--selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9);
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-bgcolor: transparent; // default: transparent
--selection-toolbar-button-bgcolor-hover: #222222;
}
[theme-mode='light'] {
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
--selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5);
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
--selection-toolbar-background: rgba(245, 245, 245, 0.95);
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
}

View File

@ -44,6 +44,7 @@ const StyledEmojiAvatar = styled.div<{ $size: number; $fontSize: number }>`
height: ${(props) => props.$size}px;
font-size: ${(props) => props.$fontSize}px;
transition: opacity 0.3s ease;
&:hover {
opacity: 0.8;
}

View File

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

View File

@ -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 { isValidPlantUML } from '@renderer/utils/markdown'
import { getExtensionByLanguage, 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,23 +67,21 @@ 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(() => {
const handleDownloadSource = useCallback(async () => {
let fileName = ''
// 尝试提取标题
// 尝试提取 HTML 标题
if (language === 'html' && children.includes('</html>')) {
const title = extractTitle(children)
if (title) {
fileName = `${title}.html`
}
fileName = extractTitle(children) || ''
}
// 默认使用日期格式命名
if (!fileName) {
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
fileName = `${dayjs().format('YYYYMMDDHHmm')}`
}
window.api.file.save(fileName, children)
const ext = await getExtensionByLanguage(language)
window.api.file.save(`${fileName}${ext}`, children)
}, [children, language])
const handleRunScript = useCallback(() => {
@ -275,6 +273,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
align-items: center;
color: var(--color-text);
font-size: 14px;
line-height: 1;
font-weight: bold;
padding: 0 10px;
border-top-left-radius: 8px;
@ -294,6 +293,10 @@ const SplitViewWrapper = styled.div`
&:not(:has(+ .html-artifacts)) {
border-radius: 0 0 8px 8px;
}
&:not(:has(+ [class*='Container'])) {
border-radius: 0 0 8px 8px;
}
`
export default memo(CodeBlockView)

View File

@ -10,8 +10,7 @@ import {
Text as UnWrapIcon,
WrapText as WrapIcon
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLanguageExtensions } from './hook'
@ -227,10 +226,10 @@ const CodeEditor = ({
...customBasicSetup // override basicSetup
}}
style={{
...style,
fontSize: `${fontSize - 1}px`,
marginTop: 0,
borderRadius: 'inherit'
borderRadius: 'inherit',
...style
}}
/>
)

View File

@ -3,13 +3,10 @@ import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import { Tooltip } from 'antd'
import { debounce } from 'lodash'
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const HIGHLIGHT_CLASS = 'highlight'
const HIGHLIGHT_SELECT_CLASS = 'selected'
interface Props {
children?: React.ReactNode
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
@ -18,19 +15,14 @@ interface Props {
*
* `true``node`
*/
filter: (node: Node) => boolean
filter: NodeFilter
includeUser?: boolean
onIncludeUserChange?: (value: boolean) => void
}
enum SearchCompletedState {
NotSearched,
FirstSearched
}
enum SearchTargetIndex {
Next,
Prev
Searched
}
export interface ContentSearchRef {
@ -47,60 +39,20 @@ export interface ContentSearchRef {
focus(): void
}
interface MatchInfo {
index: number
length: number
text: string
}
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
}
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
if (!elementList || elementList.length === 0) {
return null
}
let closestElementIndex: number | null = null
let minVerticalDistance = Infinity
const windowCenterY = window.innerHeight / 2
for (let i = 0; i < elementList.length; i++) {
const element = elementList[i]
if (!(element instanceof HTMLElement)) {
continue
}
const rect = element.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > window.innerHeight) {
continue
}
const elementCenterY = rect.top + rect.height / 2
const verticalDistance = Math.abs(elementCenterY - windowCenterY)
if (verticalDistance < minVerticalDistance) {
minVerticalDistance = verticalDistance
closestElementIndex = i
}
}
return closestElementIndex
}
const highlightText = (
textNode: Node,
const findRangesInTarget = (
target: HTMLElement,
filter: NodeFilter,
searchText: string,
highlightClass: string,
isCaseSensitive: boolean,
isWholeWord: boolean
): HTMLSpanElement[] | null => {
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
if (textNodeParentNode) {
if (textNodeParentNode.classList.contains(highlightClass)) {
return null
}
}
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
return null
}
): Range[] => {
CSS.highlights.clear()
const ranges: Range[] = []
const textContent = textNode.textContent
const escapedSearchText = escapeRegExp(searchText)
// 检查搜索文本是否仅包含拉丁字母
@ -109,89 +61,66 @@ const highlightText = (
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
const regex = new RegExp(regexPattern, regexFlags)
const searchRegex = new RegExp(regexPattern, regexFlags)
const treeWalker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, filter)
const allTextNodes: { node: Node; startOffset: number }[] = []
let fullText = ''
let match
const matches: MatchInfo[] = []
while ((match = regex.exec(textContent)) !== null) {
if (typeof match.index === 'number' && typeof match[0] === 'string') {
matches.push({ index: match.index, length: match[0].length, text: match[0] })
} else {
console.error('Unexpected match format:', match)
}
// 1. 拼接所有文本节点内容
while (treeWalker.nextNode()) {
allTextNodes.push({
node: treeWalker.currentNode,
startOffset: fullText.length
})
fullText += treeWalker.currentNode.nodeValue
}
if (matches.length === 0) {
return null
}
// 2.在完整文本中查找匹配项
let match: RegExpExecArray | null = null
while ((match = searchRegex.exec(fullText))) {
const matchStart = match.index
const matchEnd = matchStart + match[0].length
const parentNode = textNode.parentNode
if (!parentNode) {
return null
}
// 3. 将匹配项的索引映射回DOM Range
let startNode: Node | null = null
let endNode: Node | null = null
let startOffset = 0
let endOffset = 0
const fragment = document.createDocumentFragment()
let currentIndex = 0
const highlightTextSet = new Set<HTMLSpanElement>()
matches.forEach(({ index, length, text }) => {
if (index > currentIndex) {
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
}
const highlightSpan = document.createElement('span')
highlightSpan.className = highlightClass
highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
fragment.appendChild(highlightSpan)
highlightTextSet.add(highlightSpan)
currentIndex = index + length
})
if (currentIndex < textContent.length) {
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
}
parentNode.replaceChild(fragment, textNode)
return [...highlightTextSet]
}
const mergeAdjacentTextNodes = (node: HTMLElement) => {
const children = Array.from(node.childNodes)
const groups: Array<Node | { text: string; nodes: Node[] }> = []
let currentTextGroup: { text: string; nodes: Node[] } | null = null
for (const child of children) {
if (child.nodeType === Node.TEXT_NODE) {
if (currentTextGroup === null) {
currentTextGroup = {
text: child.textContent ?? '',
nodes: [child]
}
} else {
currentTextGroup.text += child.textContent
currentTextGroup.nodes.push(child)
// 找到起始节点和偏移
for (const nodeInfo of allTextNodes) {
if (
matchStart >= nodeInfo.startOffset &&
matchStart < nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
) {
startNode = nodeInfo.node
startOffset = matchStart - nodeInfo.startOffset
break
}
} else {
if (currentTextGroup !== null) {
groups.push(currentTextGroup!)
currentTextGroup = null
}
// 找到结束节点和偏移
for (const nodeInfo of allTextNodes) {
if (
matchEnd > nodeInfo.startOffset &&
matchEnd <= nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
) {
endNode = nodeInfo.node
endOffset = matchEnd - nodeInfo.startOffset
break
}
groups.push(child)
}
// 如果起始和结束节点都找到了,则创建一个 Range
if (startNode && endNode) {
const range = new Range()
range.setStart(startNode, startOffset)
range.setEnd(endNode, endOffset)
ranges.push(range)
}
}
if (currentTextGroup !== null) {
groups.push(currentTextGroup)
}
const newChildren = groups.map((group) => {
if (group instanceof Node) {
return group
} else {
return document.createTextNode(group.text)
}
})
node.replaceChildren(...newChildren)
return ranges
}
// eslint-disable-next-line @eslint-react/no-forward-ref
@ -206,328 +135,178 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
})()
const containerRef = React.useRef<HTMLDivElement>(null)
const searchInputRef = React.useRef<HTMLInputElement>(null)
const [searchResultIndex, setSearchResultIndex] = useState(0)
const [totalCount, setTotalCount] = useState(0)
const [enableContentSearch, setEnableContentSearch] = useState(false)
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
const [isWholeWord, setIsWholeWord] = useState(false)
const [shouldScroll, setShouldScroll] = useState(false)
const highlightTextSet = useState(new Set<Node>())[0]
const [allRanges, setAllRanges] = useState<Range[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const prevSearchText = useRef('')
const { t } = useTranslation()
const locateByIndex = (index: number, shouldScroll = true) => {
if (target) {
const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
highlightTextNodes.sort((a, b) => {
const { top: aTop } = a.getBoundingClientRect()
const { top: bTop } = b.getBoundingClientRect()
return aTop - bTop
})
for (const node of highlightTextNodes) {
node.classList.remove(HIGHLIGHT_SELECT_CLASS)
}
setSearchResultIndex(index)
if (highlightTextNodes.length > 0) {
const highlightTextNode = highlightTextNodes[index] ?? null
if (highlightTextNode) {
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
const resetSearch = useCallback(() => {
CSS.highlights.clear()
setAllRanges([])
setSearchCompleted(SearchCompletedState.NotSearched)
}, [])
const locateByIndex = useCallback(
(shouldScroll = true) => {
// 清理旧的高亮
CSS.highlights.clear()
if (allRanges.length > 0) {
// 1. 创建并注册所有匹配项的高亮
const allMatchesHighlight = new Highlight(...allRanges)
CSS.highlights.set('search-matches', allMatchesHighlight)
// 2. 如果有当前项,为其创建并注册一个特殊的高亮
if (currentIndex !== -1 && allRanges[currentIndex]) {
const currentMatchRange = allRanges[currentIndex]
const currentMatchHighlight = new Highlight(currentMatchRange)
CSS.highlights.set('current-match', currentMatchHighlight)
// 3. 将当前项滚动到视图中
// 获取第一个文本节点的父元素来进行滚动
const parentElement = currentMatchRange.startContainer.parentElement
if (shouldScroll) {
highlightTextNode.scrollIntoView({
parentElement?.scrollIntoView({
behavior: 'smooth',
block: 'center'
// inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码
block: 'center',
inline: 'nearest'
})
}
}
}
}
}
},
[allRanges, currentIndex]
)
const restoreHighlight = () => {
const highlightTextParentNodeSet = new Set<HTMLElement>()
// Make a copy because the set might be modified during iteration indirectly
const nodesToRestore = [...highlightTextSet]
for (const highlightTextNode of nodesToRestore) {
if (highlightTextNode.textContent) {
const textNode = document.createTextNode(highlightTextNode.textContent)
const node = highlightTextNode as HTMLElement
if (node.parentNode) {
highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
node.replaceWith(textNode) // This removes the node from the DOM
}
}
}
highlightTextSet.clear() // Clear the original set after processing
for (const parentNode of highlightTextParentNodeSet) {
mergeAdjacentTextNodes(parentNode)
}
// highlightTextSet.clear() // Already cleared
}
const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
const search = useCallback(() => {
const searchText = searchInputRef.current?.value.trim() ?? null
setSearchCompleted(SearchCompletedState.Searched)
if (target && searchText !== null && searchText !== '') {
restoreHighlight()
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
let textNode: Node | null
const textNodeSet: Set<Node> = new Set()
while ((textNode = iter.nextNode())) {
if (filter(textNode)) {
textNodeSet.add(textNode)
}
}
const highlightTextSetTemp = new Set<HTMLSpanElement>()
for (const node of textNodeSet) {
const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
if (list) {
list.forEach((node) => highlightTextSetTemp.add(node))
}
}
const highlightTextList = [...highlightTextSetTemp]
setTotalCount(highlightTextList.length)
highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
const changeIndex = () => {
let index: number
switch (searchTargetIndex) {
case SearchTargetIndex.Next:
{
index = (searchResultIndex + 1) % highlightTextList.length
}
break
case SearchTargetIndex.Prev:
{
index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
}
break
default: {
index = searchResultIndex
}
}
return Math.max(index, 0)
}
const targetIndex = (() => {
switch (searchCompleted) {
case SearchCompletedState.NotSearched: {
setSearchCompleted(SearchCompletedState.FirstSearched)
const index = findWindowVerticalCenterElementIndex(highlightTextList)
if (index !== null) {
setSearchResultIndex(index)
return index
} else {
setSearchResultIndex(0)
return 0
}
}
case SearchCompletedState.FirstSearched: {
return changeIndex()
}
default: {
return null
}
}
})()
if (targetIndex === null) {
return null
} else {
const totalCount = highlightTextSet.size
if (targetIndex >= totalCount) {
return totalCount - 1
} else {
return targetIndex
}
}
} else {
return null
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
setAllRanges(ranges)
setCurrentIndex(0)
}
}
}, [target, filter, isCaseSensitive, isWholeWord])
const _searchHandlerDebounce = debounce(() => {
implementation.search()
}, 300)
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim()
if (value.length === 0) {
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
} else {
// 用户输入时允许滚动
setShouldScroll(true)
searchHandler()
}
prevSearchText.current = value
}
const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
const { code, key, shiftKey } = event
if (key === 'Process') {
return
}
switch (code) {
case 'Enter':
{
if (shiftKey) {
implementation.searchPrev()
} else {
implementation.searchNext()
}
event.preventDefault()
}
break
case 'Escape':
{
implementation.disable()
}
break
}
}
const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
const userOutlinedButtonOnClick = () => {
if (onIncludeUserChange) {
onIncludeUserChange(!includeUser)
}
searchInputFocus()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const implementation = {
disable() {
setEnableContentSearch(false)
restoreHighlight()
setShouldScroll(false)
},
enable(initialText?: string) {
setEnableContentSearch(true)
setShouldScroll(false) // Default to false, search itself might set it to true
if (searchInputRef.current) {
const inputEl = searchInputRef.current
if (initialText && initialText.trim().length > 0) {
inputEl.value = initialText
// Trigger search after setting initial text
// Need to make sure search() uses the new value
// and also to focus and select
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
setShouldScroll(true)
const targetIndex = search()
if (targetIndex !== null) {
locateByIndex(targetIndex, true) // Ensure scrolling
} else {
// If search returns null (e.g., empty input or no matches with initial text), clear state
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
const implementation = useMemo(
() => ({
disable: () => {
setEnableContentSearch(false)
CSS.highlights.clear()
},
enable: (initialText?: string) => {
setEnableContentSearch(true)
if (searchInputRef.current) {
const inputEl = searchInputRef.current
if (initialText && initialText.trim().length > 0) {
inputEl.value = initialText
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
search()
CSS.highlights.clear()
setSearchCompleted(SearchCompletedState.NotSearched)
}
})
} else {
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
})
// Only search if there's existing text and no new initialText
if (inputEl.value.trim()) {
const targetIndex = search()
if (targetIndex !== null) {
setSearchResultIndex(targetIndex)
// locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
}
})
} else {
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
})
}
}
}
},
searchNext() {
if (enableContentSearch) {
const targetIndex = search(SearchTargetIndex.Next)
if (targetIndex !== null) {
locateByIndex(targetIndex)
},
searchNext: () => {
if (allRanges.length > 0) {
setCurrentIndex((prev) => (prev < allRanges.length - 1 ? prev + 1 : 0))
}
}
},
searchPrev() {
if (enableContentSearch) {
const targetIndex = search(SearchTargetIndex.Prev)
if (targetIndex !== null) {
locateByIndex(targetIndex)
},
searchPrev: () => {
if (allRanges.length > 0) {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allRanges.length - 1))
}
}
},
resetSearchState() {
if (enableContentSearch) {
},
resetSearchState: () => {
setSearchCompleted(SearchCompletedState.NotSearched)
// Maybe also reset index? Depends on desired behavior
// setSearchResultIndex(0);
},
search: () => {
search()
locateByIndex(true)
},
silentSearch: () => {
search()
locateByIndex(false)
},
focus: () => {
searchInputRef.current?.focus()
}
}),
[allRanges.length, locateByIndex, search]
)
const _searchHandlerDebounce = useMemo(() => debounce(implementation.search, 300), [implementation.search])
const searchHandler = useCallback(() => {
_searchHandlerDebounce()
}, [_searchHandlerDebounce])
const userInputHandler = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim()
if (value.length === 0) {
resetSearch()
} else {
searchHandler()
}
prevSearchText.current = value
},
search() {
if (enableContentSearch) {
const targetIndex = search()
if (targetIndex !== null) {
locateByIndex(targetIndex, shouldScroll)
[searchHandler, resetSearch]
)
const keyDownHandler = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
const value = (event.target as HTMLInputElement).value.trim()
if (value.length === 0) {
resetSearch()
return
}
if (event.shiftKey) {
implementation.searchPrev()
} else {
// If search returns null (e.g., empty input), clear state
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
implementation.searchNext()
}
} else if (event.key === 'Escape') {
implementation.disable()
}
},
silentSearch() {
if (enableContentSearch) {
const targetIndex = search()
if (targetIndex !== null) {
// 只更新索引,不触发滚动
locateByIndex(targetIndex, false)
}
}
},
focus() {
searchInputFocus()
}
}
[implementation, resetSearch]
)
useImperativeHandle(ref, () => ({
disable() {
implementation.disable()
},
enable(initialText?: string) {
implementation.enable(initialText)
},
searchNext() {
implementation.searchNext()
},
searchPrev() {
implementation.searchPrev()
},
search() {
implementation.search()
},
silentSearch() {
implementation.silentSearch()
},
focus() {
implementation.focus()
}
}))
const searchInputFocus = useCallback(() => {
requestAnimationFrame(() => searchInputRef.current?.focus())
}, [])
const userOutlinedButtonOnClick = useCallback(() => {
onIncludeUserChange?.(!includeUser)
searchInputFocus()
}, [includeUser, onIncludeUserChange, searchInputFocus])
useImperativeHandle(ref, () => implementation, [implementation])
useEffect(() => {
locateByIndex()
}, [currentIndex, locateByIndex])
// Re-run search when options change and search is active
useEffect(() => {
if (enableContentSearch && searchInputRef.current?.value.trim()) {
implementation.search()
search()
}
}, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
const prevButtonOnClick = () => {
implementation.searchPrev()
@ -592,11 +371,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
<Separator></Separator>
<SearchResults>
{searchCompleted !== SearchCompletedState.NotSearched ? (
totalCount > 0 ? (
allRanges.length > 0 ? (
<>
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
</>
) : (
<NoResults>{t('common.no_results')}</NoResults>
@ -606,10 +385,10 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
)}
</SearchResults>
<ToolBar>
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
<ChevronUp size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
<ChevronDown size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={closeButtonOnClick}>

View File

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

View File

@ -4,26 +4,28 @@ import styled from 'styled-components'
interface EmojiIconProps {
emoji: string
className?: string
size?: number
fontSize?: number
}
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className }) => {
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className, size = 26, fontSize = 15 }) => {
return (
<Container className={className}>
<Container className={className} $size={size} $fontSize={fontSize}>
<EmojiBackground>{emoji || '⭐️'}</EmojiBackground>
{emoji}
</Container>
)
}
const Container = styled.div`
width: 26px;
height: 26px;
border-radius: 13px;
const Container = styled.div<{ $size: number; $fontSize: number }>`
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: ${({ $size }) => $size / 2}px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 15px;
font-size: ${({ $fontSize }) => $fontSize}px;
position: relative;
overflow: hidden;
margin-right: 3px;

View File

@ -1,4 +1,6 @@
import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url'
import { useTheme } from '@renderer/context/ThemeProvider'
import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'
import { FC, useEffect, useRef } from 'react'
interface Props {
@ -9,6 +11,10 @@ const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
const { theme } = useTheme()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2)
}, [])
useEffect(() => {
if (ref.current) {
ref.current.addEventListener('emoji-click', (event: any) => {

View File

@ -41,11 +41,10 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
return (
<EditorContainer style={{ height }}>
<InputArea value={inputValue} onChange={handleChange} placeholder={placeholder} autoFocus={autoFocus} />
<PreviewArea>
<PreviewArea className="markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkCjkFriendly, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
className="markdown">
rehypePlugins={[rehypeRaw, rehypeKatex]}>
{inputValue || t('settings.provider.notes.markdown_editor_default_value')}
</ReactMarkdown>
</PreviewArea>

View File

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

View File

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

View File

@ -156,6 +156,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
padding: 0,
overflow: 'hidden',
paddingBottom: 20
},
body: {
padding: 0
}
}}
closeIcon={null}

View File

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

View File

@ -34,13 +34,15 @@ const ITEM_HEIGHT = 36
interface PopupParams {
model?: Model
modelFilter?: (model: Model) => boolean
}
interface Props extends PopupParams {
resolve: (value: Model | undefined) => void
modelFilter?: (model: Model) => boolean
}
const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const { t } = useTranslation()
const { providers } = useProviders()
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
@ -156,7 +158,10 @@ const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
// 添加置顶模型分组(仅在无搜索文本时)
if (searchText.length === 0 && pinnedModels.length > 0) {
const pinnedItems = providers.flatMap((p) =>
p.models.filter((m) => pinnedModels.includes(getModelUniqId(m))).map((m) => createModelItem(m, p, true))
p.models
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.filter(modelFilter ? modelFilter : () => true)
.map((m) => createModelItem(m, p, true))
)
if (pinnedItems.length > 0) {
@ -174,9 +179,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
// 添加常规模型分组
providers.forEach((p) => {
const filteredModels = getFilteredModels(p).filter(
(m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m))
)
const filteredModels = getFilteredModels(p)
.filter((m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m)))
.filter(modelFilter ? modelFilter : () => true)
if (filteredModels.length === 0) return
@ -199,7 +204,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
firstGroupRef.current = null
}
return items
}, [providers, getFilteredModels, pinnedModels, searchText, t, createModelItem])
}, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels])
// 获取可选择的模型项(过滤掉分组标题)
const modelItems = useMemo(() => {

View File

@ -0,0 +1,298 @@
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import { restoreFromS3 } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Button, Modal, Table, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
interface S3Config {
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key: string
root?: string
}
interface S3BackupManagerProps {
visible: boolean
onClose: () => void
s3Config: {
endpoint?: string
region?: string
bucket?: string
access_key_id?: string
secret_access_key?: string
root?: string
}
restoreMethod?: (fileName: string) => Promise<void>
}
export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) {
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [loading, setLoading] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
const [deleting, setDeleting] = useState(false)
const [restoring, setRestoring] = useState(false)
const [pagination, setPagination] = useState({
current: 1,
pageSize: 5,
total: 0
})
const { t } = useTranslation()
const { endpoint, region, bucket, access_key_id, secret_access_key, root } = s3Config
const fetchBackupFiles = useCallback(async () => {
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error(t('settings.data.s3.manager.config.incomplete'))
return
}
setLoading(true)
try {
const files = await window.api.backup.listS3Files({
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
} as S3Config)
setBackupFiles(files)
setPagination((prev) => ({
...prev,
total: files.length
}))
} catch (error: any) {
window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message }))
} finally {
setLoading(false)
}
}, [endpoint, region, bucket, access_key_id, secret_access_key, root, t])
useEffect(() => {
if (visible) {
fetchBackupFiles()
setSelectedRowKeys([])
setPagination((prev) => ({
...prev,
current: 1
}))
}
}, [visible, fetchBackupFiles])
const handleTableChange = (pagination: any) => {
setPagination(pagination)
}
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
window.message.warning(t('settings.data.s3.manager.select.warning'))
return
}
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error(t('settings.data.s3.manager.config.incomplete'))
return
}
window.modal.confirm({
title: t('settings.data.s3.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.s3.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
okText: t('settings.data.s3.manager.delete.confirm.title'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
// 依次删除选中的文件
for (const key of selectedRowKeys) {
await window.api.backup.deleteS3File(key.toString(), {
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
} as S3Config)
}
window.message.success(
t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length })
)
setSelectedRowKeys([])
await fetchBackupFiles()
} catch (error: any) {
window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
} finally {
setDeleting(false)
}
}
})
}
const handleDeleteSingle = async (fileName: string) => {
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error(t('settings.data.s3.manager.config.incomplete'))
return
}
window.modal.confirm({
title: t('settings.data.s3.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.s3.manager.delete.confirm.single', { fileName }),
okText: t('settings.data.s3.manager.delete.confirm.title'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
await window.api.backup.deleteS3File(fileName, {
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
} as S3Config)
window.message.success(t('settings.data.s3.manager.delete.success.single'))
await fetchBackupFiles()
} catch (error: any) {
window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
} finally {
setDeleting(false)
}
}
})
}
const handleRestore = async (fileName: string) => {
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error(t('settings.data.s3.manager.config.incomplete'))
return
}
window.modal.confirm({
title: t('settings.data.s3.restore.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.s3.restore.confirm.content'),
okText: t('settings.data.s3.restore.confirm.ok'),
cancelText: t('settings.data.s3.restore.confirm.cancel'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await (restoreMethod || restoreFromS3)(fileName)
window.message.success(t('settings.data.s3.restore.success'))
onClose() // 关闭模态框
} catch (error: any) {
window.message.error(t('settings.data.s3.restore.error', { message: error.message }))
} finally {
setRestoring(false)
}
}
})
}
const columns = [
{
title: t('settings.data.s3.manager.columns.fileName'),
dataIndex: 'fileName',
key: 'fileName',
ellipsis: {
showTitle: false
},
render: (fileName: string) => (
<Tooltip placement="topLeft" title={fileName}>
{fileName}
</Tooltip>
)
},
{
title: t('settings.data.s3.manager.columns.modifiedTime'),
dataIndex: 'modifiedTime',
key: 'modifiedTime',
width: 180,
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
},
{
title: t('settings.data.s3.manager.columns.size'),
dataIndex: 'size',
key: 'size',
width: 120,
render: (size: number) => formatFileSize(size)
},
{
title: t('settings.data.s3.manager.columns.actions'),
key: 'action',
width: 160,
render: (_: any, record: BackupFile) => (
<>
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
{t('settings.data.s3.manager.restore')}
</Button>
<Button
type="link"
danger
onClick={() => handleDeleteSingle(record.fileName)}
disabled={deleting || restoring}>
{t('settings.data.s3.manager.delete')}
</Button>
</>
)
}
]
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[]) => {
setSelectedRowKeys(selectedRowKeys)
}
}
return (
<Modal
title={t('settings.data.s3.manager.title')}
open={visible}
onCancel={onClose}
width={800}
centered
transitionName="animation-move-down"
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
{t('settings.data.s3.manager.refresh')}
</Button>,
<Button
key="delete"
danger
icon={<DeleteOutlined />}
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}
loading={deleting}>
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
</Button>,
<Button key="close" onClick={onClose}>
{t('settings.data.s3.manager.close')}
</Button>
]}>
<Table
rowKey="fileName"
columns={columns}
dataSource={backupFiles}
rowSelection={rowSelection}
pagination={pagination}
loading={loading}
onChange={handleTableChange}
size="middle"
/>
</Modal>
)
}

View File

@ -0,0 +1,258 @@
import { backupToS3, handleData } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Input, Modal, Select, Spin } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
export function useS3BackupModal() {
const [customFileName, setCustomFileName] = useState('')
const [isModalVisible, setIsModalVisible] = useState(false)
const [backuping, setBackuping] = useState(false)
const handleBackup = async () => {
setBackuping(true)
try {
await backupToS3({ customFileName, showMessage: true })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
const handleCancel = () => {
setIsModalVisible(false)
}
const showBackupModal = useCallback(async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const hostname = await window.api.system.getHostname()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}, [])
return {
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName,
showBackupModal
}
}
type S3BackupModalProps = {
isModalVisible: boolean
handleBackup: () => Promise<void>
handleCancel: () => void
backuping: boolean
customFileName: string
setCustomFileName: (value: string) => void
}
export function S3BackupModal({
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName
}: S3BackupModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.s3.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}
transitionName="animation-move-down"
centered>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.s3.backup.modal.filename.placeholder')}
/>
</Modal>
)
}
interface UseS3RestoreModalProps {
endpoint: string | undefined
region: string | undefined
bucket: string | undefined
access_key_id: string | undefined
secret_access_key: string | undefined
root?: string | undefined
}
export function useS3RestoreModal({
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
}: UseS3RestoreModalProps) {
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [restoring, setRestoring] = useState(false)
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [loadingFiles, setLoadingFiles] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const { t } = useTranslation()
const showRestoreModal = useCallback(async () => {
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listS3Files({
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({
content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }),
key: 'list-files-error'
})
} finally {
setLoadingFiles(false)
}
}, [endpoint, region, bucket, access_key_id, secret_access_key, root, t])
const handleRestore = useCallback(async () => {
if (!selectedFile || !endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error({
content: !selectedFile
? t('settings.data.s3.restore.file.required')
: t('settings.data.s3.restore.config.incomplete'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.s3.restore.confirm.title'),
content: t('settings.data.s3.restore.confirm.content'),
okText: t('settings.data.s3.restore.confirm.ok'),
cancelText: t('settings.data.s3.restore.confirm.cancel'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
const data = await window.api.backup.restoreFromS3({
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root,
fileName: selectedFile
})
await handleData(JSON.parse(data))
window.message.success(t('settings.data.s3.restore.success'))
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({
content: t('settings.data.s3.restore.error', { message: error.message }),
key: 'restore-error'
})
} finally {
setRestoring(false)
}
}
})
}, [selectedFile, endpoint, region, bucket, access_key_id, secret_access_key, root, t])
const handleCancel = () => {
setIsRestoreModalVisible(false)
}
return {
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles,
showRestoreModal
}
}
type S3RestoreModalProps = ReturnType<typeof useS3RestoreModal>
export function S3RestoreModal({
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles
}: S3RestoreModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.s3.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={handleCancel}
okButtonProps={{ loading: restoring }}
width={600}
transitionName="animation-move-down"
centered>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.s3.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) =>
typeof option?.label === 'string' ? option.label.toLowerCase().includes(input.toLowerCase()) : false
}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
)
}
function formatFileOption(file: BackupFile) {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = formatFileSize(file.size)
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}

View File

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

View File

@ -1,6 +1,7 @@
import { Dropdown } from 'antd'
import { Dropdown, DropdownProps } from 'antd'
import { Check, ChevronsUpDown } from 'lucide-react'
import { ReactNode, useMemo, useState } from '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> {
@ -11,18 +12,30 @@ interface SelectorOption<V = string | number> {
disabled?: boolean
}
interface SelectorProps<V = string | number> {
interface BaseSelectorProps<V = string | number> {
options: SelectorOption<V>[]
value?: 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,
@ -30,45 +43,83 @@ const Selector = <V extends string | number>({
placement = 'bottomRight',
size = 13,
placeholder,
disabled = false
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 (value !== undefined && value !== null) {
const findLabel = (opts: SelectorOption<V>[]): string | ReactNode | undefined => {
if (selectedValues.length > 0) {
const findLabels = (opts: SelectorOption<V>[]): (string | ReactNode)[] => {
const labels: (string | ReactNode)[] = []
for (const opt of opts) {
if (opt.value === value) {
return opt.label
if (selectedValues.some((v) => v == opt.value)) {
labels.push(opt.label)
}
if (opt.options) {
const found = findLabel(opt.options)
if (found) return found
labels.push(...findLabels(opt.options))
}
}
return undefined
return labels
}
return findLabel(options) || placeholder
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
}, [options, value, placeholder])
}, [selectedValues, placeholder, options, t])
const items = useMemo(() => {
const mapOption = (option: SelectorOption<V>) => ({
key: option.value,
label: option.label,
extra: <CheckIcon>{option.value === value && <Check size={14} />}</CheckIcon>,
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, value])
}, [options, selectedValues])
function onClick(e: { key: string }) {
if (!disabled) {
onChange(e.key as V)
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)
}
}
@ -76,11 +127,11 @@ const Selector = <V extends string | number>({
<Dropdown
overlayClassName="selector-dropdown"
menu={{ items, onClick }}
trigger={disabled ? [] : ['click']}
trigger={['click']}
placement={placement}
open={open && !disabled}
onOpenChange={disabled ? undefined : setOpen}>
<Label $size={size} $open={open} $disabled={disabled}>
onOpenChange={handleOpenChange}>
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
{label}
<LabelIcon size={size + 3} />
</Label>
@ -95,7 +146,7 @@ const LabelIcon = styled(ChevronsUpDown)`
transition: background-color 0.2s;
`
const Label = styled.div<{ $size: number; $open: boolean; $disabled: boolean }>`
const Label = styled.div<{ $size: number; $open: boolean; $disabled: boolean; $isPlaceholder: boolean }>`
display: flex;
align-items: center;
gap: 4px;
@ -105,6 +156,8 @@ const Label = styled.div<{ $size: number; $open: boolean; $disabled: boolean }>`
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;

View File

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

View File

@ -1,9 +1,7 @@
import { isLinux, isWindows } from '@renderer/config/constant'
import { isLinux, isWin } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { Button } from 'antd'
import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import styled, { keyframes } from 'styled-components'
import type { FC, HTMLAttributes, PropsWithChildren } from 'react'
import styled from 'styled-components'
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
@ -49,7 +47,7 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
display: flex;
align-items: center;
padding: 0 12px;
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '135px' : isLinux ? '120px' : '12px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
justify-content: flex-end;
`
@ -82,15 +80,15 @@ const NavbarCenterContainer = styled.div`
color: var(--color-text-1);
`
const rotateAnimation = keyframes`
from {
transform: rotate(-180deg);
}
to {
transform: rotate(0);
}
`
// const rotateAnimation = keyframes`
// from {
// transform: rotate(-180deg);
// }
// to {
// transform: rotate(0);
// }
// `
const AnimatedButton = styled(Button)`
animation: ${rotateAnimation} 0.4s ease-out;
`
// const AnimatedButton = styled(Button)`
// animation: ${rotateAnimation} 0.4s ease-out;
// `

View File

@ -3,10 +3,11 @@ 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 isWindows = platform === 'win32' || platform === 'win64'
export const isWin = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux'
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'

View File

@ -145,7 +145,7 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
import NomicLogo from '@renderer/assets/images/providers/nomic.png'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { Model } from '@renderer/types'
import { getBaseModelName } from '@renderer/utils'
import { getLowerBaseModelName } from '@renderer/utils'
import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts'
@ -184,7 +184,7 @@ const visionAllowedModels = [
'deepseek-vl(?:[\\w-]+)?',
'kimi-latest',
'gemma-3(?:-[\\w-]+)',
'doubao-seed-1[.-]6(?:-[\\w-]+)'
'doubao-seed-1[.-]6(?:-[\\w-]+)?'
]
const visionExcludedModels = [
@ -273,6 +273,10 @@ export function isFunctionCallingModel(model: Model): boolean {
return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id)
}
if (model.provider === 'doubao' || model.id.includes('doubao')) {
return FUNCTION_CALLING_REGEX.test(model.id) || FUNCTION_CALLING_REGEX.test(model.name)
}
if (['deepseek', 'anthropic'].includes(model.provider)) {
return true
}
@ -763,6 +767,30 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
}
],
ppio: [
{
id: 'deepseek/deepseek-r1-0528',
provider: 'ppio',
name: 'DeepSeek R1-0528',
group: 'deepseek'
},
{
id: 'deepseek/deepseek-v3-0324',
provider: 'ppio',
name: 'DeepSeek V3-0324',
group: 'deepseek'
},
{
id: 'deepseek/deepseek-r1-turbo',
provider: 'ppio',
name: 'DeepSeek R1 Turbo',
group: 'deepseek'
},
{
id: 'deepseek/deepseek-v3-turbo',
provider: 'ppio',
name: 'DeepSeek V3 Turbo',
group: 'deepseek'
},
{
id: 'deepseek/deepseek-r1/community',
name: 'DeepSeek: DeepSeek R1 (Community)',
@ -776,52 +804,58 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'deepseek'
},
{
id: 'deepseek/deepseek-r1',
id: 'minimaxai/minimax-m1-80k',
provider: 'ppio',
name: 'DeepSeek R1',
group: 'deepseek'
name: 'MiniMax M1-80K',
group: 'minimaxai'
},
{
id: 'deepseek/deepseek-v3',
id: 'qwen/qwen3-235b-a22b-fp8',
provider: 'ppio',
name: 'DeepSeek V3',
group: 'deepseek'
},
{
id: 'qwen/qwen-2.5-72b-instruct',
provider: 'ppio',
name: 'Qwen2.5-72B-Instruct',
name: 'Qwen3 235B',
group: 'qwen'
},
{
id: 'qwen/qwen2.5-32b-instruct',
id: 'qwen/qwen3-32b-fp8',
provider: 'ppio',
name: 'Qwen2.5-32B-Instruct',
name: 'Qwen3 32B',
group: 'qwen'
},
{
id: 'meta-llama/llama-3.1-70b-instruct',
id: 'qwen/qwen3-30b-a3b-fp8',
provider: 'ppio',
name: 'Llama-3.1-70B-Instruct',
group: 'meta-llama'
name: 'Qwen3 30B',
group: 'qwen'
},
{
id: 'meta-llama/llama-3.1-8b-instruct',
id: 'qwen/qwen2.5-vl-72b-instruct',
provider: 'ppio',
name: 'Llama-3.1-8B-Instruct',
group: 'meta-llama'
name: 'Qwen2.5 VL 72B',
group: 'qwen'
},
{
id: '01-ai/yi-1.5-34b-chat',
id: 'qwen/qwen3-embedding-8b',
provider: 'ppio',
name: 'Yi-1.5-34B-Chat',
group: '01-ai'
name: 'Qwen3 Embedding 8B',
group: 'qwen'
},
{
id: '01-ai/yi-1.5-9b-chat',
id: 'qwen/qwen3-reranker-8b',
provider: 'ppio',
name: 'Yi-1.5-9B-Chat',
group: '01-ai'
name: 'Qwen3 Reranker 8B',
group: 'qwen'
},
{
id: 'thudm/glm-z1-32b-0414',
provider: 'ppio',
name: 'GLM-Z1 32B',
group: 'thudm'
},
{
id: 'thudm/glm-z1-9b-0414',
provider: 'ppio',
name: 'GLM-Z1 9B',
group: 'thudm'
}
],
alayanew: [],
@ -1352,12 +1386,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'deepseek-v3-250324',
provider: 'doubao',
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'doubao-pro-32k-241215',
provider: 'doubao',
@ -2329,7 +2357,7 @@ export function isEmbeddingModel(model: Model): boolean {
return false
}
if (model.provider === 'doubao') {
if (model.provider === 'doubao' || model.id.includes('doubao')) {
return EMBEDDING_REGEX.test(model.name)
}
@ -2353,7 +2381,7 @@ export function isVisionModel(model: Model): boolean {
// return false
// }
if (model.provider === 'doubao') {
if (model.provider === 'doubao' || model.id.includes('doubao')) {
return VISION_REGEX.test(model.name) || VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
}
@ -2424,7 +2452,9 @@ export function isOpenAIWebSearchModel(model: Model): boolean {
model.id.includes('gpt-4o-search-preview') ||
model.id.includes('gpt-4o-mini-search-preview') ||
(model.id.includes('gpt-4.1') && !model.id.includes('gpt-4.1-nano')) ||
(model.id.includes('gpt-4o') && !model.id.includes('gpt-4o-image'))
(model.id.includes('gpt-4o') && !model.id.includes('gpt-4o-image')) ||
model.id.includes('o3') ||
model.id.includes('o4')
)
}
@ -2475,14 +2505,20 @@ export function isGeminiReasoningModel(model?: Model): boolean {
return false
}
if (model.id.includes('gemini-2.5')) {
if (model.id.startsWith('gemini') && model.id.includes('thinking')) {
return true
}
if (isSupportedThinkingTokenGeminiModel(model)) {
return true
}
return false
}
export const isSupportedThinkingTokenGeminiModel = isGeminiReasoningModel
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
return model.id.includes('gemini-2.5')
}
export function isQwenReasoningModel(model?: Model): boolean {
if (!model) {
@ -2505,14 +2541,16 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
return false
}
const baseName = getBaseModelName(model.id, '/').toLowerCase()
const baseName = getLowerBaseModelName(model.id, '/')
return (
baseName.startsWith('qwen3') ||
[
'qwen-plus',
'qwen-plus-latest',
'qwen-plus-0428',
'qwen-plus-2025-04-28',
'qwen-turbo',
'qwen-turbo-latest',
'qwen-turbo-0428',
'qwen-turbo-2025-04-28'
@ -2525,7 +2563,7 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean {
return false
}
return DOUBAO_THINKING_MODEL_REGEX.test(model.id)
return DOUBAO_THINKING_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_MODEL_REGEX.test(model.name)
}
export function isClaudeReasoningModel(model?: Model): boolean {
@ -2547,8 +2585,13 @@ export function isReasoningModel(model?: Model): boolean {
return false
}
if (model.provider === 'doubao') {
if (isEmbeddingModel(model)) {
return false
}
if (model.provider === 'doubao' || model.id.includes('doubao')) {
return (
REASONING_REGEX.test(model.id) ||
REASONING_REGEX.test(model.name) ||
model.type?.includes('reasoning') ||
isSupportedThinkingTokenDoubaoModel(model) ||
@ -2614,7 +2657,7 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
const baseName = getBaseModelName(model.id, '/').toLowerCase()
const baseName = getLowerBaseModelName(model.id, '/')
// 不管哪个供应商都判断了
if (model.id.includes('claude')) {
@ -2688,7 +2731,7 @@ export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
return false
}
return isOpenAIWebSearchModel(model) || model.id.includes('sonar')
return isOpenAIWebSearchChatCompletionOnlyModel(model) || model.id.includes('sonar')
}
export function isGenerateImageModel(model: Model): boolean {
@ -2708,7 +2751,7 @@ export function isGenerateImageModel(model: Model): boolean {
return false
}
const baseName = getBaseModelName(model.id, '/').toLowerCase()
const baseName = getLowerBaseModelName(model.id, '/')
if (GENERATE_IMAGE_MODELS.includes(baseName)) {
return true
}
@ -2720,7 +2763,7 @@ export function isSupportedDisableGenerationModel(model: Model): boolean {
return false
}
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(model.id)
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(getLowerBaseModelName(model.id))
}
export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record<string, any> {
@ -2853,13 +2896,23 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } |
// Doubao 支持思考模式的模型正则
export const DOUBAO_THINKING_MODEL_REGEX =
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?)(?:-[\w-]+)?/i
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i
// 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(1-5-thinking-pro-m|seed-1\.6|seed-1-6-[\w-]+)(?:-[\w-]+)*/i
export const DOUBAO_THINKING_AUTO_MODEL_REGEX =
/doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-[\w-]+)*/i
export function isDoubaoThinkingAutoModel(model: Model): boolean {
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id)
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name)
}
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
// 模型集合功能测试
export const isVisionModels = (models: Model[]) => {
return models.every((model) => isVisionModel(model))
}
export const isGenerateImageModels = (models: Model[]) => {
return models.every((model) => isGenerateImageModel(model))
}

View File

@ -9,116 +9,116 @@ export interface TranslateLanguageOption {
export const TranslateLanguageOptions: TranslateLanguageOption[] = [
{
value: 'english',
value: 'English',
langCode: 'en-us',
label: i18n.t('languages.english'),
emoji: '🇬🇧'
},
{
value: 'chinese',
value: 'Chinese (Simplified)',
langCode: 'zh-cn',
label: i18n.t('languages.chinese'),
emoji: '🇨🇳'
},
{
value: 'chinese-traditional',
value: 'Chinese (Traditional)',
langCode: 'zh-tw',
label: i18n.t('languages.chinese-traditional'),
emoji: '🇭🇰'
},
{
value: 'japanese',
value: 'Japanese',
langCode: 'ja-jp',
label: i18n.t('languages.japanese'),
emoji: '🇯🇵'
},
{
value: 'korean',
value: 'Korean',
langCode: 'ko-kr',
label: i18n.t('languages.korean'),
emoji: '🇰🇷'
},
{
value: 'french',
value: 'French',
langCode: 'fr-fr',
label: i18n.t('languages.french'),
emoji: '🇫🇷'
},
{
value: 'german',
value: 'German',
langCode: 'de-de',
label: i18n.t('languages.german'),
emoji: '🇩🇪'
},
{
value: 'italian',
value: 'Italian',
langCode: 'it-it',
label: i18n.t('languages.italian'),
emoji: '🇮🇹'
},
{
value: 'spanish',
value: 'Spanish',
langCode: 'es-es',
label: i18n.t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'portuguese',
value: 'Portuguese',
langCode: 'pt-pt',
label: i18n.t('languages.portuguese'),
emoji: '🇵🇹'
},
{
value: 'russian',
value: 'Russian',
langCode: 'ru-ru',
label: i18n.t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'polish',
value: 'Polish',
langCode: 'pl-pl',
label: i18n.t('languages.polish'),
emoji: '🇵🇱'
},
{
value: 'arabic',
value: 'Arabic',
langCode: 'ar-ar',
label: i18n.t('languages.arabic'),
emoji: '🇸🇦'
},
{
value: 'turkish',
value: 'Turkish',
langCode: 'tr-tr',
label: i18n.t('languages.turkish'),
emoji: '🇹🇷'
},
{
value: 'thai',
value: 'Thai',
langCode: 'th-th',
label: i18n.t('languages.thai'),
emoji: '🇹🇭'
},
{
value: 'vietnamese',
value: 'Vietnamese',
langCode: 'vi-vn',
label: i18n.t('languages.vietnamese'),
emoji: '🇻🇳'
},
{
value: 'indonesian',
value: 'Indonesian',
langCode: 'id-id',
label: i18n.t('languages.indonesian'),
emoji: '🇮🇩'
},
{
value: 'urdu',
value: 'Urdu',
langCode: 'ur-pk',
label: i18n.t('languages.urdu'),
emoji: '🇵🇰'
},
{
value: 'malay',
value: 'Malay',
langCode: 'ms-my',
label: i18n.t('languages.malay'),
emoji: '🇲🇾'
@ -129,7 +129,7 @@ export const translateLanguageOptions = (): typeof TranslateLanguageOptions => {
return TranslateLanguageOptions.map((option) => {
return {
value: option.value,
label: i18n.t(`languages.${option.value}`),
label: option.label,
emoji: option.emoji
}
})

View File

@ -79,7 +79,8 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
Dropdown: {
controlPaddingHorizontal: 8,
borderRadiusLG: 10,
borderRadiusSM: 8
borderRadiusSM: 8,
paddingXS: 4
},
Popover: {
borderRadiusLG: 10

View File

@ -10,6 +10,7 @@ import { createContext, type PropsWithChildren, use, useCallback, useEffect, use
interface CodeStyleContextType {
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
highlightStreamingCode: (code: string, language: string, callerId: string) => Promise<HighlightChunkResult>
cleanupTokenizers: (callerId: string) => void
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
highlightCode: (code: string, language: string) => Promise<string>
@ -22,6 +23,7 @@ interface CodeStyleContextType {
const defaultCodeStyleContext: CodeStyleContextType = {
highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
highlightStreamingCode: async () => ({ lines: [], recall: 0 }),
cleanupTokenizers: () => {},
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
highlightCode: async () => '',
@ -114,6 +116,15 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
shikiStreamService.cleanupTokenizers(callerId)
}, [])
// 高亮流式输出的代码
const highlightStreamingCode = useCallback(
async (fullContent: string, language: string, callerId: string) => {
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
return shikiStreamService.highlightStreamingCode(fullContent, normalizedLang, activeShikiTheme, callerId)
},
[activeShikiTheme, languageMap]
)
// 获取 Shiki pre 标签属性
const getShikiPreProperties = useCallback(
async (language: string) => {
@ -148,6 +159,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
const contextValue = useMemo(
() => ({
highlightCodeChunk,
highlightStreamingCode,
cleanupTokenizers,
getShikiPreProperties,
highlightCode,
@ -159,6 +171,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
}),
[
highlightCodeChunk,
highlightStreamingCode,
cleanupTokenizers,
getShikiPreProperties,
highlightCode,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,12 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addSubscribeSource as _addSubscribeSource,
type CompressionConfig,
removeSubscribeSource as _removeSubscribeSource,
setCompressionConfig,
setDefaultProvider as _setDefaultProvider,
setSubscribeSources as _setSubscribeSources,
updateCompressionConfig,
updateSubscribeBlacklist as _updateSubscribeBlacklist,
updateWebSearchProvider,
updateWebSearchProviders
@ -90,3 +93,14 @@ export const useBlacklist = () => {
setSubscribeSources
}
}
export const useWebSearchSettings = () => {
const state = useAppSelector((state) => state.websearch)
const dispatch = useAppDispatch()
return {
...state,
setCompressionConfig: (config: CompressionConfig) => dispatch(setCompressionConfig(config)),
updateCompressionConfig: (config: Partial<CompressionConfig>) => dispatch(updateCompressionConfig(config))
}
}

View File

@ -424,6 +424,7 @@
"search": "Search",
"select": "Select",
"selectedMessages": "Selected {{count}} messages",
"selectedItems": "Selected {{count}} items",
"success": "Success",
"topics": "Topics",
"warning": "Warning",
@ -718,6 +719,13 @@
"success.siyuan.export": "Successfully exported to Siyuan Note",
"warn.yuque.exporting": "Exporting to Yuque, please do not request export repeatedly!",
"warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!",
"websearch": {
"rag": "Executing RAG...",
"rag_complete": "Keeping {{countAfter}} out of {{countBefore}} results...",
"rag_failed": "RAG failed, returning empty results...",
"cutoff": "Truncating search content...",
"fetch_complete": "Completed {{count}} searches..."
},
"download.success": "Download successfully",
"download.failed": "Download failed"
},
@ -791,6 +799,7 @@
"dimensions": "Dimensions {{dimensions}}",
"edit": "Edit Model",
"embedding": "Embedding",
"embedding_dimensions": "Embedding Dimensions",
"embedding_model": "Embedding Model",
"embedding_model_tooltip": "Add in Settings->Model Provider->Manage",
"function_calling": "Function Calling",
@ -880,7 +889,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",
@ -1103,11 +1112,11 @@
"app_data": "App Data",
"app_data.select": "Modify Directory",
"app_data.select_title": "Change App Data Directory",
"app_data.restart_notice": "The app will need to restart to apply the changes",
"app_data.copy_data_option": "Copy data from original directory to new directory",
"app_data.restart_notice": "The app may need to restart multiple times to apply the changes",
"app_data.copy_data_option": "Copy data, will automatically restart after copying the original directory data to the new directory",
"app_data.copy_time_notice": "Copying data may take a while, do not force quit app",
"app_data.path_changed_without_copy": "Path changed successfully, but data not copied",
"app_data.copying_warning": "Data copying, do not force quit app",
"app_data.path_changed_without_copy": "Path changed successfully",
"app_data.copying_warning": "Data copying, do not force quit app, the app will restart after copied",
"app_data.copying": "Copying data to new location...",
"app_data.copy_success": "Successfully copied data to new location",
"app_data.copy_failed": "Failed to copy data",
@ -1119,6 +1128,10 @@
"app_data.select_error_root_path": "New path cannot be the root path",
"app_data.select_error_write_permission": "New path does not have write permission",
"app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited",
"app_data.select_not_empty_dir": "New path is not empty",
"app_data.select_not_empty_dir_content": "New path is not empty, it will overwrite the data in the new path, there is a risk of data loss and copy failure, continue?",
"app_data.select_error_same_path": "New path is the same as the old path, please select another path",
"app_data.select_error_in_app_path": "New path is the same as the application installation path, please select another path",
"app_knowledge": "Knowledge Base Files",
"app_knowledge.button.delete": "Delete File",
"app_knowledge.remove_all": "Remove Knowledge Base Files",
@ -1179,7 +1192,7 @@
"markdown_export.select": "Select",
"markdown_export.title": "Markdown Export",
"markdown_export.show_model_name.title": "Use Model Name on Export",
"markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
"markdown_export.show_model_name.help": "When enabled, the model name will be displayed when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
"markdown_export.show_model_provider.title": "Show Model Provider",
"markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown",
"minute_interval_one": "{{count}} minute",
@ -1251,6 +1264,70 @@
"maxBackups": "Maximum Backups",
"maxBackups.unlimited": "Unlimited"
},
"s3": {
"title": "S3 Compatible Storage",
"title.help": "Object storage services compatible with AWS S3 API, such as AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, etc.",
"endpoint": "API Endpoint",
"endpoint.placeholder": "https://s3.example.com",
"region": "Region",
"region.placeholder": "Region, e.g: us-east-1",
"bucket": "Bucket",
"bucket.placeholder": "Bucket, e.g: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "Backup Directory (Optional)",
"root.placeholder": "e.g: /cherry-studio",
"backup.operation": "Backup Operation",
"backup.button": "Backup Now",
"backup.manager.button": "Manage Backups",
"backup.modal.title": "S3 Backup",
"backup.modal.filename.placeholder": "Please enter backup filename",
"backup.success": "S3 backup successful",
"backup.error": "S3 backup failed: {{message}}",
"autoSync": "Auto Sync",
"autoSync.off": "Off",
"autoSync.minute": "Every {{count}} minute",
"autoSync.hour": "Every {{count}} hour",
"maxBackups": "Maximum Backups",
"maxBackups.unlimited": "Unlimited",
"skipBackupFile": "Lightweight Backup",
"skipBackupFile.help": "When enabled, file data will be skipped during backup, only configuration information will be backed up, significantly reducing backup file size",
"syncStatus": "Sync Status",
"syncStatus.noSync": "Not synced",
"syncStatus.error": "Sync error: {{message}}",
"syncStatus.lastSync": "Last sync: {{time}}",
"manager.title": "S3 Backup File Manager",
"manager.refresh": "Refresh",
"manager.delete.selected": "Delete Selected ({{count}})",
"manager.close": "Close",
"manager.columns.fileName": "File Name",
"manager.columns.modifiedTime": "Modified Time",
"manager.columns.size": "File Size",
"manager.columns.actions": "Actions",
"manager.restore": "Restore",
"manager.delete": "Delete",
"manager.config.incomplete": "Please fill in complete S3 configuration",
"manager.files.fetch.error": "Failed to fetch backup file list: {{message}}",
"manager.delete.confirm.title": "Confirm Delete",
"manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.",
"manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.",
"manager.delete.success.multiple": "Successfully deleted {{count}} backup files",
"manager.delete.success.single": "Backup file deleted successfully",
"manager.delete.error": "Failed to delete backup file: {{message}}",
"manager.select.warning": "Please select backup files to delete",
"restore.modal.title": "S3 Data Restore",
"restore.modal.select.placeholder": "Please select backup file to restore",
"restore.confirm.title": "Confirm Restore Data",
"restore.confirm.content": "Restoring data will overwrite all current data. This action cannot be undone. Are you sure you want to continue?",
"restore.confirm.ok": "Confirm Restore",
"restore.confirm.cancel": "Cancel",
"restore.success": "Data restore successful",
"restore.error": "Data restore failed: {{message}}",
"restore.config.incomplete": "Please fill in complete S3 configuration",
"restore.file.required": "Please select backup file to restore"
},
"yuque": {
"check": {
"button": "Check",
@ -1395,8 +1472,14 @@
"general.emoji_picker": "Emoji Picker",
"general.image_upload": "Image Upload",
"general.auto_check_update.title": "Auto Update",
"general.early_access.title": "Early Access",
"general.early_access.tooltip": "Enable to use the latest version from GitHub, which may be slower. Please backup your data in advance.",
"general.test_plan.title": "Test Plan",
"general.test_plan.tooltip": "Participate in the test plan to experience the latest features faster, but also brings more risks, please backup your data in advance",
"general.test_plan.beta_version": "Beta Version (Beta)",
"general.test_plan.beta_version_tooltip": "Features may change at any time, bugs are more, upgrade quickly",
"general.test_plan.rc_version": "Preview Version (RC)",
"general.test_plan.rc_version_tooltip": "Close to stable version, features are basically stable, bugs are few",
"general.test_plan.version_options": "Version Options",
"general.test_plan.version_channel_not_match": "Preview and test version switching will take effect after the next stable version is released",
"general.reset.button": "Reset",
"general.reset.title": "Data Reset",
"general.restore.button": "Restore",
@ -1404,6 +1487,8 @@
"general.user_name": "User Name",
"general.user_name.placeholder": "Enter your name",
"general.view_webdav_settings": "View WebDAV settings",
"general.spell_check": "Spell Check",
"general.spell_check.languages": "Use spell check for",
"input.auto_translate_with_space": "Quickly translate with 3 spaces",
"input.show_translate_confirm": "Show translation confirmation dialog",
"input.target_language": "Target language",
@ -1480,7 +1565,8 @@
"version": "Version"
},
"errors": {
"32000": "MCP server failed to start, please check the parameters according to the tutorial"
"32000": "MCP server failed to start, please check the parameters according to the tutorial",
"toolNotFound": "Tool {{name}} not found"
},
"serverPlural": "servers",
"serverSingular": "server",
@ -1528,6 +1614,7 @@
"registry": "Package Registry",
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
"registryDefault": "Default",
"customRegistryPlaceholder": "Enter private registry URL, e.g.: https://npm.company.com",
"not_support": "Model not supported",
"user": "User",
"system": "System",
@ -1845,8 +1932,33 @@
"overwrite_tooltip": "Force use search service instead of LLM",
"apikey": "API key",
"free": "Free",
"content_limit": "Content length limit",
"content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated."
"compression": {
"title": "Search Result Compression",
"method": "Compression Method",
"method.none": "None",
"method.cutoff": "Cutoff",
"cutoff.limit": "Cutoff Limit",
"cutoff.limit.placeholder": "Enter length",
"cutoff.limit.tooltip": "Limit the content length of search results, content exceeding the limit will be truncated (e.g., 2000 characters)",
"cutoff.unit.char": "Char",
"cutoff.unit.token": "Token",
"method.rag": "RAG",
"rag.document_count": "Document Count",
"rag.document_count.default": "Default",
"rag.document_count.tooltip": "Expected number of documents to extract from each search result, the actual total number of extracted documents is this value multiplied by the number of search results.",
"rag.embedding_dimensions.auto_get": "Auto Get Dimensions",
"rag.embedding_dimensions.placeholder": "Leave empty",
"rag.embedding_dimensions.tooltip": "If left blank, the dimensions parameter will not be passed",
"info": {
"dimensions_auto_success": "Dimensions auto-obtained successfully, dimensions: {{dimensions}}"
},
"error": {
"embedding_model_required": "Please select an embedding model first",
"dimensions_auto_failed": "Failed to auto-obtain dimensions",
"provider_not_found": "Provider not found",
"rag_failed": "RAG failed"
}
}
},
"quickPhrase": {
"title": "Quick Phrases",

View File

@ -424,6 +424,7 @@
"search": "検索",
"select": "選択",
"selectedMessages": "{{count}}件のメッセージを選択しました",
"selectedItems": "{{count}}件の項目を選択しました",
"success": "成功",
"topics": "トピック",
"warning": "警告",
@ -717,6 +718,13 @@
"warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!",
"warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!",
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません",
"websearch": {
"rag": "RAGを実行中...",
"rag_complete": "{{countBefore}}個の結果から{{countAfter}}個を保持...",
"rag_failed": "RAGが失敗しました。空の結果を返します...",
"cutoff": "検索内容を切り詰めています...",
"fetch_complete": "{{count}}回の検索を完了しました..."
},
"download.success": "ダウンロードに成功しました",
"download.failed": "ダウンロードに失敗しました",
"error.fetchTopicName": "トピック名の取得に失敗しました"
@ -791,6 +799,7 @@
"dimensions": "{{dimensions}} 次元",
"edit": "モデルを編集",
"embedding": "埋め込み",
"embedding_dimensions": "埋め込み次元",
"embedding_model": "埋め込み模型",
"embedding_model_tooltip": "設定->モデルサービス->管理で追加",
"function_calling": "関数呼び出し",
@ -1101,11 +1110,11 @@
"app_data": "アプリデータ",
"app_data.select": "ディレクトリを変更",
"app_data.select_title": "アプリデータディレクトリの変更",
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります",
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます",
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください",
"app_data.path_changed_without_copy": "パスが変更されましたが、データがコピーされていません",
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください",
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります",
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます",
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください",
"app_data.path_changed_without_copy": "パスが変更されました",
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください。コピーが完了すると、アプリが自動的に再起動します。",
"app_data.copying": "新しい場所にデータをコピーしています...",
"app_data.copy_success": "データを新しい場所に正常にコピーしました",
"app_data.copy_failed": "データのコピーに失敗しました",
@ -1117,6 +1126,10 @@
"app_data.select_error_root_path": "新しいパスはルートパスにできません",
"app_data.select_error_write_permission": "新しいパスに書き込み権限がありません",
"app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません",
"app_data.select_not_empty_dir": "新しいパスは空ではありません",
"app_data.select_not_empty_dir_content": "新しいパスは空ではありません。新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?",
"app_data.select_error_same_path": "新しいパスは元のパスと同じです。別のパスを選択してください",
"app_data.select_error_in_app_path": "新しいパスはアプリのインストールパスと同じです。別のパスを選択してください",
"app_knowledge": "知識ベースファイル",
"app_knowledge.button.delete": "ファイルを削除",
"app_knowledge.remove_all": "ナレッジベースファイルを削除",
@ -1177,7 +1190,7 @@
"markdown_export.select": "選択",
"markdown_export.title": "Markdown エクスポート",
"markdown_export.show_model_name.title": "エクスポート時にモデル名を使用",
"markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
"markdown_export.show_model_name.help": "有効にすると、Markdownエクスポート時にモデル名を表示します。注意この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
"markdown_export.show_model_provider.title": "モデルプロバイダーを表示",
"markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダーOpenAI、Geminiなどを表示します。",
"minute_interval_one": "{{count}} 分",
@ -1231,6 +1244,70 @@
"maxBackups": "最大バックアップ数",
"maxBackups.unlimited": "無制限"
},
"s3": {
"title": "S3互換ストレージ",
"title.help": "AWS S3 APIと互換性のあるオブジェクトストレージサービスAWS S3、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COSなど",
"endpoint": "APIエンドポイント",
"endpoint.placeholder": "https://s3.example.com",
"region": "リージョン",
"region.placeholder": "Region、例: us-east-1",
"bucket": "バケット",
"bucket.placeholder": "Bucket、例: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "バックアップディレクトリ(オプション)",
"root.placeholder": "例:/cherry-studio",
"backup.operation": "バックアップ操作",
"backup.button": "今すぐバックアップ",
"backup.manager.button": "バックアップ管理",
"backup.modal.title": "S3バックアップ",
"backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
"backup.success": "S3バックアップ成功",
"backup.error": "S3バックアップ失敗: {{message}}",
"autoSync": "自動同期",
"autoSync.off": "オフ",
"autoSync.minute": "{{count}}分毎",
"autoSync.hour": "{{count}}時間毎",
"maxBackups": "最大バックアップ数",
"maxBackups.unlimited": "無制限",
"skipBackupFile": "軽量バックアップ",
"skipBackupFile.help": "有効にすると、バックアップ時にファイルデータがスキップされ、設定情報のみがバックアップされ、バックアップファイルのサイズが大幅に削減されます。",
"syncStatus": "同期ステータス",
"syncStatus.noSync": "未同期",
"syncStatus.error": "同期エラー: {{message}}",
"syncStatus.lastSync": "最終同期: {{time}}",
"manager.title": "S3バックアップファイルマネージャー",
"manager.refresh": "更新",
"manager.delete.selected": "選択項目を削除 ({{count}})",
"manager.close": "閉じる",
"manager.columns.fileName": "ファイル名",
"manager.columns.modifiedTime": "変更日時",
"manager.columns.size": "ファイルサイズ",
"manager.columns.actions": "操作",
"manager.restore": "復元",
"manager.delete": "削除",
"manager.config.incomplete": "完全なS3設定情報を入力してください",
"manager.files.fetch.error": "バックアップファイルリストの取得に失敗しました: {{message}}",
"manager.delete.confirm.title": "削除の確認",
"manager.delete.confirm.multiple": "選択した{{count}}個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。",
"manager.delete.confirm.single": "バックアップファイル「{{fileName}}」を削除してもよろしいですか?この操作は元に戻せません。",
"manager.delete.success.multiple": "{{count}}個のバックアップファイルを正常に削除しました",
"manager.delete.success.single": "バックアップファイルの削除に成功しました",
"manager.delete.error": "バックアップファイルの削除に失敗しました: {{message}}",
"manager.select.warning": "削除するバックアップファイルを選択してください",
"restore.modal.title": "S3データ復元",
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
"restore.confirm.title": "データ復元の確認",
"restore.confirm.content": "データを復元すると、現在のすべてのデータが上書きされます。この操作は元に戻せません。続行してもよろしいですか?",
"restore.confirm.ok": "復元を確認",
"restore.confirm.cancel": "キャンセル",
"restore.success": "データの復元に成功しました",
"restore.error": "データの復元に失敗しました: {{message}}",
"restore.config.incomplete": "完全なS3設定情報を入力してください",
"restore.file.required": "復元するバックアップファイルを選択してください"
},
"yuque": {
"check": {
"button": "接続確認",
@ -1399,6 +1476,8 @@
"general.user_name": "ユーザー名",
"general.user_name.placeholder": "ユーザー名を入力",
"general.view_webdav_settings": "WebDAV設定を表示",
"general.spell_check": "スペルチェック",
"general.spell_check.languages": "スペルチェック言語",
"input.auto_translate_with_space": "スペースを3回押して翻訳",
"input.target_language": "目標言語",
"input.target_language.chinese": "簡体字中国語",
@ -1482,7 +1561,8 @@
"updateSuccess": "サーバーが正常に更新されました",
"url": "URL",
"errors": {
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください",
"toolNotFound": "ツール {{name}} が見つかりません"
},
"editMcpJson": "MCP 設定を編集",
"installHelp": "インストールヘルプを取得",
@ -1522,6 +1602,7 @@
"registry": "パッケージ管理レジストリ",
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
"registryDefault": "デフォルト",
"customRegistryPlaceholder": "プライベート倉庫のアドレスを入力してくださいhttps://npm.company.com",
"not_support": "モデルはサポートされていません",
"user": "ユーザー",
"system": "システム",
@ -1833,12 +1914,43 @@
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
"apikey": "API キー",
"free": "無料",
"content_limit": "内容の長さ制限",
"content_limit_tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます。"
"compression": {
"title": "検索結果の圧縮",
"method": "圧縮方法",
"method.none": "圧縮しない",
"method.cutoff": "切り捨て",
"cutoff.limit": "切り捨て長",
"cutoff.limit.placeholder": "長さを入力",
"cutoff.limit.tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます2000文字",
"cutoff.unit.char": "文字",
"cutoff.unit.token": "トークン",
"method.rag": "RAG",
"rag.document_count": "文書数",
"rag.document_count.default": "デフォルト",
"rag.document_count.tooltip": "単一の検索結果から抽出する文書数。実際に抽出される文書数は、この値に検索結果数を乗じたものです。",
"rag.embedding_dimensions.auto_get": "次元を自動取得",
"rag.embedding_dimensions.placeholder": "次元を設定しない",
"rag.embedding_dimensions.tooltip": "空の場合、dimensions パラメーターは渡されません",
"info": {
"dimensions_auto_success": "次元が自動取得されました。次元: {{dimensions}}"
},
"error": {
"embedding_model_required": "まず埋め込みモデルを選択してください",
"dimensions_auto_failed": "次元の自動取得に失敗しました",
"provider_not_found": "プロバイダーが見つかりません",
"rag_failed": "RAG に失敗しました"
}
}
},
"general.auto_check_update.title": "自動更新",
"general.early_access.title": "早期アクセス",
"general.early_access.tooltip": "有効にすると、GitHub の最新バージョンを使用します。ダウンロード速度が遅く、不安定な場合があります。データを事前にバックアップしてください。",
"general.test_plan.title": "テストプラン",
"general.test_plan.tooltip": "テストプランに参加すると、最新の機能をより早く体験できますが、同時により多くのリスクが伴います。データを事前にバックアップしてください。",
"general.test_plan.beta_version": "ベータ版(Beta)",
"general.test_plan.beta_version_tooltip": "機能が変更される可能性があります。バグが多く、迅速にアップグレードされます。",
"general.test_plan.rc_version": "プレビュー版(RC)",
"general.test_plan.rc_version_tooltip": "安定版に近い機能ですが、バグが少なく、迅速にアップグレードされます。",
"general.test_plan.version_options": "バージョンオプション",
"general.test_plan.version_channel_not_match": "プレビュー版とテスト版の切り替えは、次の正式版リリース時に有効になります。",
"quickPhrase": {
"title": "クイックフレーズ",
"add": "フレーズを追加",

View File

@ -424,6 +424,7 @@
"search": "Поиск",
"select": "Выбрать",
"selectedMessages": "Выбрано {{count}} сообщений",
"selectedItems": "Выбрано {{count}} элементов",
"success": "Успешно",
"topics": "Топики",
"warning": "Предупреждение",
@ -717,6 +718,13 @@
"success.siyuan.export": "Успешный экспорт в Siyuan",
"warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!",
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!",
"websearch": {
"rag": "Выполнение RAG...",
"rag_complete": "Сохранено {{countAfter}} из {{countBefore}} результатов...",
"rag_failed": "RAG не удалось, возвращается пустой результат...",
"cutoff": "Обрезка содержимого поиска...",
"fetch_complete": "Завершено {{count}} поисков..."
},
"download.success": "Скачано успешно",
"download.failed": "Скачивание не удалось",
"error.fetchTopicName": "Не удалось назвать топик"
@ -791,6 +799,7 @@
"dimensions": "{{dimensions}} мер",
"edit": "Редактировать модель",
"embedding": "Встраиваемые",
"embedding_dimensions": "Встраиваемые размерности",
"embedding_model": "Встраиваемые модели",
"embedding_model_tooltip": "Добавьте в настройки->модель сервиса->управление",
"function_calling": "Вызов функции",
@ -1101,11 +1110,11 @@
"app_data": "Данные приложения",
"app_data.select": "Изменить директорию",
"app_data.select_title": "Изменить директорию данных приложения",
"app_data.restart_notice": "Для применения изменений потребуется перезапуск приложения",
"app_data.copy_data_option": "Копировать данные из исходной директории в новую директорию",
"app_data.restart_notice": "Для применения изменений может потребоваться несколько перезапусков приложения",
"app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию",
"app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы",
"app_data.path_changed_without_copy": "Путь изменен успешно, но данные не скопированы",
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение",
"app_data.path_changed_without_copy": "Путь изменен успешно",
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение, приложение будет перезапущено после копирования",
"app_data.copying": "Копирование данных в новое место...",
"app_data.copy_success": "Данные успешно скопированы в новое место",
"app_data.copy_failed": "Не удалось скопировать данные",
@ -1117,6 +1126,10 @@
"app_data.select_error_root_path": "Новый путь не может быть корневым",
"app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись",
"app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто",
"app_data.select_not_empty_dir": "Новый путь не пуст",
"app_data.select_not_empty_dir_content": "Новый путь не пуст, он перезапишет данные в новом пути, есть риск потери данных и ошибки копирования, продолжить?",
"app_data.select_error_in_app_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь",
"app_data.select_error_same_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь",
"app_knowledge": "Файлы базы знаний",
"app_knowledge.button.delete": "Удалить файл",
"app_knowledge.remove_all": "Удалить файлы базы знаний",
@ -1177,7 +1190,7 @@
"markdown_export.select": "Выбрать",
"markdown_export.title": "Экспорт в Markdown",
"markdown_export.show_model_name.title": "Использовать имя модели при экспорте",
"markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
"markdown_export.show_model_name.help": "Если включено, при экспорте в Markdown будет отображаться имя модели. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
"markdown_export.show_model_provider.title": "Показать поставщика модели",
"markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown",
"minute_interval_one": "{{count}} минута",
@ -1249,6 +1262,70 @@
"maxBackups": "Максимальное количество резервных копий",
"maxBackups.unlimited": "Без ограничений"
},
"s3": {
"title": "S3-совместимое хранилище",
"title.help": "Сервисы объектного хранения, совместимые с AWS S3 API, такие как AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS и т.д.",
"endpoint": "Конечная точка API",
"endpoint.placeholder": "https://s3.example.com",
"region": "Регион",
"region.placeholder": "Регион, например: us-east-1",
"bucket": "Корзина",
"bucket.placeholder": "Корзина, например: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "Каталог резервных копий (необязательно)",
"root.placeholder": "например: /cherry-studio",
"backup.operation": "Операция резервного копирования",
"backup.button": "Создать резервную копию сейчас",
"backup.manager.button": "Управление резервными копиями",
"backup.modal.title": "Резервное копирование S3",
"backup.modal.filename.placeholder": "Пожалуйста, введите имя файла резервной копии",
"backup.success": "Резервное копирование S3 успешно",
"backup.error": "Ошибка резервного копирования S3: {{message}}",
"autoSync": "Автосинхронизация",
"autoSync.off": "Выкл.",
"autoSync.minute": "Каждые {{count}} мин.",
"autoSync.hour": "Каждые {{count}} ч.",
"maxBackups": "Макс. резервных копий",
"maxBackups.unlimited": "Неограниченно",
"skipBackupFile": "Облегченное резервное копирование",
"skipBackupFile.help": "Если включено, данные файлов будут пропущены во время резервного копирования, будет скопирована только информация о конфигурации, что значительно уменьшит размер файла резервной копии.",
"syncStatus": "Статус синхронизации",
"syncStatus.noSync": "Не синхронизировано",
"syncStatus.error": "Ошибка синхронизации: {{message}}",
"syncStatus.lastSync": "Последняя синхронизация: {{time}}",
"manager.title": "Менеджер файлов резервных копий S3",
"manager.refresh": "Обновить",
"manager.delete.selected": "Удалить выбранные ({{count}})",
"manager.close": "Закрыть",
"manager.columns.fileName": "Имя файла",
"manager.columns.modifiedTime": "Время изменения",
"manager.columns.size": "Размер файла",
"manager.columns.actions": "Действия",
"manager.restore": "Восстановить",
"manager.delete": "Удалить",
"manager.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
"manager.files.fetch.error": "Не удалось получить список файлов резервных копий: {{message}}",
"manager.delete.confirm.title": "Подтвердить удаление",
"manager.delete.confirm.multiple": "Вы уверены, что хотите удалить {{count}} выбранных файлов резервных копий? Это действие нельзя отменить.",
"manager.delete.confirm.single": "Вы уверены, что хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.",
"manager.delete.success.multiple": "Успешно удалено {{count}} файлов резервных копий",
"manager.delete.success.single": "Файл резервной копии успешно удален",
"manager.delete.error": "Не удалось удалить файл резервной копии: {{message}}",
"manager.select.warning": "Пожалуйста, выберите файлы резервных копий для удаления",
"restore.modal.title": "Восстановление данных S3",
"restore.modal.select.placeholder": "Пожалуйста, выберите файл резервной копии для восстановления",
"restore.confirm.title": "Подтвердить восстановление данных",
"restore.confirm.content": "Восстановление данных перезапишет все текущие данные. Это действие нельзя отменить. Вы уверены, что хотите продолжить?",
"restore.confirm.ok": "Подтвердить восстановление",
"restore.confirm.cancel": "Отмена",
"restore.success": "Восстановление данных успешно",
"restore.error": "Ошибка восстановления данных: {{message}}",
"restore.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
"restore.file.required": "Пожалуйста, выберите файл резервной копии для восстановления"
},
"yuque": {
"check": {
"button": "Проверить",
@ -1399,6 +1476,8 @@
"general.user_name": "Имя пользователя",
"general.user_name.placeholder": "Введите ваше имя",
"general.view_webdav_settings": "Просмотр настроек WebDAV",
"general.spell_check": "Проверка орфографии",
"general.spell_check.languages": "Языки проверки орфографии",
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
"input.target_language": "Целевой язык",
"input.target_language.chinese": "Китайский упрощенный",
@ -1474,7 +1553,8 @@
"version": "Версия"
},
"errors": {
"32000": "MCP сервер не запущен, пожалуйста, проверьте параметры"
"32000": "MCP сервер не запущен, пожалуйста, проверьте параметры",
"toolNotFound": "Инструмент {{name}} не найден"
},
"serverPlural": "серверы",
"serverSingular": "сервер",
@ -1522,6 +1602,7 @@
"registry": "Реестр пакетов",
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
"registryDefault": "По умолчанию",
"customRegistryPlaceholder": "Введите адрес частного склада, например: https://npm.company.com",
"not_support": "Модель не поддерживается",
"user": "Пользователь",
"system": "Система",
@ -1833,12 +1914,43 @@
"overwrite_tooltip": "Использовать провайдера поиска вместо LLM",
"apikey": "API ключ",
"free": "Бесплатно",
"content_limit": "Ограничение длины текста",
"content_limit_tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан."
"compression": {
"title": "Сжатие результатов поиска",
"method": "Метод сжатия",
"method.none": "Не сжимать",
"method.cutoff": "Обрезка",
"cutoff.limit": "Лимит обрезки",
"cutoff.limit.placeholder": "Введите длину",
"cutoff.limit.tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан (например, 2000 символов)",
"cutoff.unit.char": "Символы",
"cutoff.unit.token": "Токены",
"method.rag": "RAG",
"rag.document_count": "Количество документов",
"rag.document_count.default": "По умолчанию",
"rag.document_count.tooltip": "Ожидаемое количество документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных документов равно этому значению, умноженному на количество результатов поиска.",
"rag.embedding_dimensions.auto_get": "Автоматически получить размерности",
"rag.embedding_dimensions.placeholder": "Не устанавливать размерности",
"rag.embedding_dimensions.tooltip": "Если оставить пустым, параметр dimensions не будет передан",
"info": {
"dimensions_auto_success": "Размерности успешно получены, размерности: {{dimensions}}"
},
"error": {
"embedding_model_required": "Пожалуйста, сначала выберите модель встраивания",
"dimensions_auto_failed": "Не удалось получить размерности",
"provider_not_found": "Поставщик не найден",
"rag_failed": "RAG не удалось"
}
}
},
"general.auto_check_update.title": "Автоматическое обновление",
"general.early_access.title": "Ранний доступ",
"general.early_access.tooltip": "Включить для использования последней версии из GitHub, что может быть медленнее и нестабильно. Пожалуйста, сделайте резервную копию данных заранее.",
"general.test_plan.title": "Тестовый план",
"general.test_plan.tooltip": "Участвовать в тестовом плане, чтобы быстрее получать новые функции, но при этом возникает больше рисков, пожалуйста, сделайте резервную копию данных заранее",
"general.test_plan.beta_version": "Тестовая версия (Beta)",
"general.test_plan.beta_version_tooltip": "Функции могут меняться в любое время, ошибки больше, обновление происходит быстрее",
"general.test_plan.rc_version": "Предварительная версия (RC)",
"general.test_plan.rc_version_tooltip": "Похожа на стабильную версию, функции стабильны, ошибки меньше, обновление происходит быстрее",
"general.test_plan.version_options": "Варианты версии",
"general.test_plan.version_channel_not_match": "Предварительная и тестовая версия будут доступны после выхода следующей стабильной версии",
"quickPhrase": {
"title": "Быстрые фразы",
"add": "Добавить фразу",

View File

@ -22,7 +22,7 @@
"add.prompt.placeholder": "输入提示词",
"add.prompt.variables.tip": {
"title": "可用的变量",
"content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名"
"content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU 架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名"
},
"add.title": "创建智能体",
"import": {
@ -106,7 +106,7 @@
"titleLabel": "标题",
"titlePlaceholder": "输入标题",
"contentLabel": "内容",
"contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如\n帮我规划从${from}到${to}的路线,然后发送到${email}"
"contentPlaceholder": "请输入短语内容,支持使用变量,然后按 Tab 键可以快速定位到变量进行修改。比如:\n帮我规划从 ${from} ${to} 的路线,然后发送到 ${email}"
},
"list": {
"showByList": "列表展示",
@ -130,7 +130,7 @@
"get_key": "获取",
"get_key_success": "自动获取密钥成功",
"login": "登录",
"oauth_button": "使用{{provider}}登录"
"oauth_button": "使用 {{provider}} 登录"
},
"backup": {
"confirm": "确定要备份数据吗?",
@ -181,11 +181,11 @@
},
"input.auto_resize": "自动调整高度",
"input.clear": "清空消息 {{Command}}",
"input.clear.content": "确定要清除当前会话所有消息吗?",
"input.clear.content": "确定要清除当前会话所有消息吗",
"input.clear.title": "清空消息",
"input.collapse": "收起",
"input.context_count.tip": "上下文数 / 最大上下文数",
"input.estimated_tokens.tip": "预估 token 数",
"input.estimated_tokens.tip": "预估 Token 数",
"input.expand": "展开",
"input.file_not_supported": "模型不支持此文件类型",
"input.file_error": "文件处理出错",
@ -201,13 +201,13 @@
"input.settings": "设置",
"input.thinking": "思考",
"input.thinking.mode.default": "默认",
"input.thinking.mode.default.tip": "模型会自动确定思考的 token 数",
"input.thinking.mode.default.tip": "模型会自动确定思考的 Token 数",
"input.thinking.mode.custom": "自定义",
"input.thinking.mode.custom.tip": "模型最多可以思考的 token 数。需要考虑模型的上下文限制,否则会报错",
"input.thinking.mode.tokens.tip": "设置思考的 token 数",
"input.thinking.budget_exceeds_max": "思考预算超过最大 token 数",
"input.topics": " 话题 ",
"input.translate": "翻译成{{target_language}}",
"input.thinking.mode.custom.tip": "模型最多可以思考的 Token 数。需要考虑模型的上下文限制,否则会报错",
"input.thinking.mode.tokens.tip": "设置思考的 Token 数",
"input.thinking.budget_exceeds_max": "思考预算超过最大 Token 数",
"input.topics": "话题",
"input.translate": "翻译成 {{target_language}}",
"input.upload": "上传图片或文档",
"input.upload.upload_from_local": "上传本地文件...",
"input.upload.document": "上传文档(模型不支持图片)",
@ -270,12 +270,12 @@
"settings.code_cache_threshold": "缓存阈值",
"settings.code_cache_threshold.tip": "允许缓存的最小代码长度(千字符),超过阈值的代码块才会被缓存",
"settings.context_count": "上下文数",
"settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10",
"settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 Token 越多。普通聊天建议 5-10",
"settings.max": "不限",
"settings.max_tokens": "最大 TOKEN 数",
"settings.max_tokens": "最大 Token 数",
"settings.max_tokens.confirm": "最大 Token 数",
"settings.max_tokens.confirm_content": "设置单次交互所用的最大 Token 数, 会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错",
"settings.max_tokens.tip": "单次交互所用的最大 Token 数, 会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错",
"settings.max_tokens.confirm_content": "设置单次交互所用的最大 Token 数会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错",
"settings.max_tokens.tip": "单次交互所用的最大 Token 数会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错",
"settings.reset": "重置",
"settings.set_as_default": "应用到默认助手",
"settings.show_line_numbers": "代码显示行号",
@ -310,8 +310,8 @@
"topics.export.obsidian_btn": "确定",
"topics.export.obsidian_created": "创建时间",
"topics.export.obsidian_created_placeholder": "请选择创建时间",
"topics.export.obsidian_export_failed": "导出到Obsidian失败",
"topics.export.obsidian_export_success": "导出到Obsidian成功",
"topics.export.obsidian_export_failed": "导出到 Obsidian 失败",
"topics.export.obsidian_export_success": "导出到 Obsidian 成功",
"topics.export.obsidian_operate": "处理方式",
"topics.export.obsidian_operate_append": "追加",
"topics.export.obsidian_operate_new_or_overwrite": "新建(如果存在就覆盖)",
@ -324,9 +324,9 @@
"topics.export.obsidian_title": "标题",
"topics.export.obsidian_title_placeholder": "请输入标题",
"topics.export.obsidian_title_required": "标题不能为空",
"topics.export.obsidian_no_vaults": "未找到Obsidian保管库",
"topics.export.obsidian_no_vaults": "未找到 Obsidian 保管库",
"topics.export.obsidian_loading": "加载中...",
"topics.export.obsidian_fetch_error": "获取Obsidian保管库失败",
"topics.export.obsidian_fetch_error": "获取 Obsidian 保管库失败",
"topics.export.obsidian_fetch_folders_error": "获取文件夹结构失败",
"topics.export.obsidian_no_vault_selected": "请先选择一个保管库",
"topics.export.obsidian_select_vault_first": "请先选择保管库",
@ -341,7 +341,7 @@
"topics.pinned": "固定话题",
"topics.prompt": "话题提示词",
"topics.prompt.edit.title": "编辑话题提示词",
"topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词",
"topics.prompt.tips": "话题提示词针对当前话题提供额外的补充提示词",
"topics.title": "话题",
"topics.unpinned": "取消固定",
"translate": "翻译",
@ -424,6 +424,7 @@
"search": "搜索",
"select": "选择",
"selectedMessages": "选中 {{count}} 条消息",
"selectedItems": "已选择 {{count}} 项",
"success": "成功",
"topics": "话题",
"warning": "警告",
@ -499,7 +500,7 @@
"batch_delete": "批量删除"
},
"gpustack": {
"keep_alive_time.description": "模型在内存中保持的时间默认5分钟)",
"keep_alive_time.description": "模型在内存中保持的时间默认5 分钟)",
"keep_alive_time.placeholder": "分钟",
"keep_alive_time.title": "保持活跃时间",
"title": "GPUStack"
@ -509,7 +510,7 @@
"locate.message": "定位到消息",
"search.messages": "搜索所有消息",
"search.placeholder": "搜索话题或消息...",
"search.topics.empty": "没有找到相关话题, 点击回车键搜索所有消息",
"search.topics.empty": "没有找到相关话题点击回车键搜索所有消息",
"title": "话题搜索"
},
"knowledge": {
@ -532,12 +533,12 @@
"chunk_size_tooltip": "将文档切割分段,每段的大小,不能超过模型上下文限制",
"clear_selection": "清除选择",
"delete": "删除",
"delete_confirm": "确定要删除此知识库吗?",
"delete_confirm": "确定要删除此知识库吗",
"dimensions": "嵌入维度",
"dimensions_size_tooltip": "嵌入维度大小,数值越大,嵌入维度越大,但消耗的 Token 也越多",
"dimensions_set_right": "⚠️ 请确保模型支持所设置的嵌入维度大小",
"dimensions_default": "模型将使用默认嵌入维度",
"dimensions_size_placeholder": " 嵌入维度大小,如 1024",
"dimensions_size_placeholder": "嵌入维度大小,如 1024",
"dimensions_auto_set": "自动设置嵌入维度",
"dimensions_error_invalid": "请输入嵌入维度大小",
"dimensions_size_too_large": "嵌入维度不能超过模型上下文限制({{max_context}}",
@ -578,15 +579,15 @@
"status_processing": "处理中",
"threshold": "匹配度阈值",
"threshold_placeholder": "未设置",
"threshold_too_large_or_small": "阈值不能大于1或小于0",
"threshold_too_large_or_small": "阈值不能大于 1 或小于 0",
"threshold_tooltip": "用于衡量用户问题与知识库内容之间的相关性0-1",
"title": "知识库",
"topN": "返回结果数量",
"topN_too_large_or_small": "返回结果数量不能大于30或小于1",
"topN_too_large_or_small": "返回结果数量不能大于 30 或小于 1",
"topN_placeholder": "未设置",
"topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多",
"url_added": "网址已添加",
"url_placeholder": "请输入网址, 多个网址用回车分隔",
"url_placeholder": "请输入网址多个网址用回车分隔",
"urls": "网址"
},
"languages": {
@ -611,7 +612,7 @@
"malay": "马来文"
},
"lmstudio": {
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟)",
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5 分钟)",
"keep_alive_time.placeholder": "分钟",
"keep_alive_time.title": "保持活跃时间",
"title": "LM Studio"
@ -633,13 +634,13 @@
"backup.start.success": "开始备份",
"backup.success": "备份成功",
"chat.completion.paused": "会话已停止",
"citation": "{{count}}个引用内容",
"citation": "{{count}} 个引用内容",
"citations": "引用内容",
"copied": "已复制",
"copy.failed": "复制失败",
"copy.success": "复制成功",
"delete.confirm.title": "删除确认",
"delete.confirm.content": "确认删除选中的{{count}}条消息吗?",
"delete.confirm.content": "确认删除选中的 {{count}} 条消息吗?",
"delete.failed": "删除失败",
"delete.success": "删除成功",
"empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇",
@ -660,8 +661,8 @@
"error.joplin.no_config": "未配置 Joplin 授权令牌 或 URL",
"error.invalid.nutstore": "无效的坚果云设置",
"error.invalid.nutstore_token": "无效的坚果云 Token",
"error.markdown.export.preconf": "导出Markdown文件到预先设定的路径失败",
"error.markdown.export.specified": "导出Markdown文件失败",
"error.markdown.export.preconf": "导出 Markdown 文件到预先设定的路径失败",
"error.markdown.export.specified": "导出 Markdown 文件失败",
"error.notion.export": "导出 Notion 错误,请检查连接状态并对照文档检查配置",
"error.notion.no_api_key": "未配置 Notion API Key 或 Notion Database ID",
"error.yuque.export": "导出语雀错误,请检查连接状态并对照文档检查配置",
@ -669,11 +670,11 @@
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答",
"group.delete.title": "删除分组消息",
"ignore.knowledge.base": "联网模式开启,忽略知识库",
"loading.notion.exporting_progress": "正在导出到Notion ...",
"loading.notion.preparing": "正在准备导出到Notion...",
"loading.notion.exporting_progress": "正在导出到 Notion ...",
"loading.notion.preparing": "正在准备导出到 Notion...",
"mention.title": "切换模型回答",
"message.code_style": "代码风格",
"message.delete.content": "确定要删除此消息吗?",
"message.delete.content": "确定要删除此消息吗",
"message.delete.title": "删除消息",
"message.multi_model_style": "多模型回答样式",
"message.multi_model_style.fold": "标签模式",
@ -711,13 +712,20 @@
"upgrade.success.button": "重启",
"upgrade.success.content": "重启用以完成升级",
"upgrade.success.title": "升级成功",
"warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!",
"warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出",
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试",
"error.siyuan.export": "导出思源笔记失败,请检查连接状态并对照文档检查配置",
"error.siyuan.no_config": "未配置思源笔记API地址或令牌",
"error.siyuan.no_config": "未配置思源笔记 API 地址或令牌",
"success.siyuan.export": "导出到思源笔记成功",
"warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!",
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!",
"warn.yuque.exporting": "正在导出语雀,请勿重复请求导出!",
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!",
"websearch": {
"rag": "正在执行 RAG...",
"rag_complete": "保留 {{countBefore}} 个结果中的 {{countAfter}} 个...",
"rag_failed": "RAG 失败,返回空结果...",
"cutoff": "正在截断搜索内容...",
"fetch_complete": "已完成 {{count}} 次搜索..."
},
"download.success": "下载成功",
"download.failed": "下载失败"
},
@ -730,7 +738,7 @@
"minimize": "最小化小程序",
"devtools": "开发者工具",
"openExternal": "在浏览器中打开",
"rightclick_copyurl": "右键复制URL",
"rightclick_copyurl": "右键复制 URL",
"open_link_external_on": "当前:在浏览器中打开链接",
"open_link_external_off": "当前:使用默认窗口打开链接"
},
@ -791,8 +799,9 @@
"dimensions": "{{dimensions}} 维",
"edit": "编辑模型",
"embedding": "嵌入",
"embedding_dimensions": "嵌入维度",
"embedding_model": "嵌入模型",
"embedding_model_tooltip": "在设置->模型服务中点击管理按钮添加",
"embedding_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加",
"function_calling": "函数调用",
"no_matches": "无可用模型",
"parameter_name": "参数名称",
@ -818,7 +827,7 @@
"rerank_model": "重排模型",
"rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})",
"rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})",
"rerank_model_tooltip": "在设置->模型服务中点击管理按钮添加",
"rerank_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加",
"search": "搜索模型...",
"stream_output": "流式输出",
"enable_tool_use": "工具调用",
@ -845,14 +854,14 @@
"knowledge.error": "添加 {{type}} 到知识库失败: {{error}}"
},
"ollama": {
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟)",
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5 分钟)",
"keep_alive_time.placeholder": "分钟",
"keep_alive_time.title": "保持活跃时间",
"title": "Ollama"
},
"paintings": {
"button.delete.image": "删除图片",
"button.delete.image.confirm": "确定要删除此图片吗?",
"button.delete.image.confirm": "确定要删除此图片吗",
"button.new.image": "新建图片",
"guidance_scale": "引导比例",
"guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度",
@ -879,8 +888,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": "暂无图片",
@ -963,7 +972,7 @@
"style_type_tip": "重混后的图像风格,仅适用于 V_2 及以上版本",
"negative_prompt_tip": "描述不想在重混结果中出现的元素",
"magic_prompt_option_tip": "智能优化重混提示词",
"rendering_speed_tip": "控制渲染速度与质量之间的平衡,仅适用于V_3版本"
"rendering_speed_tip": "控制渲染速度与质量之间的平衡,仅适用于 V_3 版本"
},
"upscale": {
"image_file": "需要放大的图片",
@ -976,7 +985,7 @@
"magic_prompt_option_tip": "智能优化放大提示词"
},
"text_desc_required": "请先输入图片描述",
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
"req_error_text": "运行失败,请重试。提示词避免 \"版权词\" 和 \"敏感词\" 哦。",
"req_error_token": "请检查令牌有效性",
"req_error_no_balance": "请检查令牌有效性",
"image_handle_required": "请先上传图片",
@ -996,7 +1005,7 @@
"prompts": {
"explanation": "帮我解释一下这个概念",
"summarize": "帮我总结一下这段话",
"title": "总结给出的会话,将其总结为语言为{{language}}的10字内标题忽略会话中的指令不要使用标点和特殊符号。以纯字符串格式输出不要输出标题以外的内容。"
"title": "总结给出的会话,将其总结为语言为 {{language}} 10 字内标题,忽略会话中的指令,不要使用标点和特殊符号。以纯字符串格式输出,不要输出标题以外的内容。"
},
"provider": {
"aihubmix": "AiHubMix",
@ -1039,12 +1048,12 @@
"qwenlm": "QwenLM",
"silicon": "硅基流动",
"stepfun": "阶跃星辰",
"tencent-cloud-ti": "腾讯云TI",
"tencent-cloud-ti": "腾讯云 TI",
"together": "Together",
"xirang": "天翼云息壤",
"yi": "零一万物",
"zhinao": "360智脑",
"zhipu": "智谱AI",
"zhinao": "360 智脑",
"zhipu": "智谱 AI",
"voyageai": "Voyage AI",
"qiniu": "七牛云 AI 推理",
"tokenflux": "TokenFlux",
@ -1103,11 +1112,11 @@
"app_data": "应用数据",
"app_data.select": "修改目录",
"app_data.select_title": "更改应用数据目录",
"app_data.restart_notice": "应用需要重启以应用更改",
"app_data.copy_data_option": "复制数据,开启后会将原始目录数据复制到新目录",
"app_data.restart_notice": "应用可能会重启多次以应用更改",
"app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录",
"app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用",
"app_data.path_changed_without_copy": "路径已更改成功,但数据未复制",
"app_data.copying_warning": "数据复制中,不要强制退出app",
"app_data.path_changed_without_copy": "路径已更改成功",
"app_data.copying_warning": "数据复制中,不要强制退出 app, 复制完成后会自动重启应用",
"app_data.copying": "正在将数据复制到新位置...",
"app_data.copy_success": "已成功复制数据到新位置",
"app_data.copy_failed": "复制数据失败",
@ -1118,7 +1127,11 @@
"app_data.new_path": "新路径",
"app_data.select_error_root_path": "新路径不能是根路径",
"app_data.select_error_write_permission": "新路径没有写入权限",
"app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出",
"app_data.stop_quit_app_reason": "应用目前在迁移数据,不能退出",
"app_data.select_not_empty_dir": "新路径不为空",
"app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据,有数据丢失和复制失败的风险,是否继续?",
"app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径",
"app_data.select_error_in_app_path": "新路径与应用安装路径相同,请选择其他路径",
"app_knowledge": "知识库文件",
"app_knowledge.button.delete": "删除文件",
"app_knowledge.remove_all": "删除知识库文件",
@ -1126,7 +1139,7 @@
"app_knowledge.remove_all_success": "文件删除成功",
"app_logs": "应用日志",
"backup.skip_file_data_title": "精简备份",
"backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用, 加快备份速度",
"backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用加快备份速度",
"clear_cache": {
"button": "清除缓存",
"confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?",
@ -1144,14 +1157,14 @@
"export_menu": {
"title": "导出菜单设置",
"image": "导出为图片",
"markdown": "导出为Markdown",
"markdown_reason": "导出为Markdown包含思考",
"notion": "导出到Notion",
"markdown": "导出为 Markdown",
"markdown_reason": "导出为 Markdown包含思考",
"notion": "导出到 Notion",
"yuque": "导出到语雀",
"obsidian": "导出到Obsidian",
"obsidian": "导出到 Obsidian",
"siyuan": "导出到思源笔记",
"joplin": "导出到Joplin",
"docx": "导出为Word",
"joplin": "导出到 Joplin",
"docx": "导出为 Word",
"plain_text": "复制为纯文本"
},
"joplin": {
@ -1169,25 +1182,25 @@
"url": "Joplin 剪裁服务监听 URL",
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "导出时包含思维链",
"export_reasoning.help": "开启后,导出到Joplin时会包含思维链内容。"
"export_reasoning.help": "开启后,导出到 Joplin 时会包含思维链内容。"
},
"markdown_export.force_dollar_math.help": "开启后,导出Markdown时会将强制使用$$来标记LaTeX公式。注意该项也会影响所有通过Markdown导出的方式如Notion、语雀等",
"markdown_export.force_dollar_math.title": "强制使用$$来标记LaTeX公式",
"markdown_export.force_dollar_math.help": "开启后,导出 Markdown 时会将强制使用 $$ 来标记 LaTeX 公式。注意:该项也会影响所有通过 Markdown 导出的方式,如 Notion、语雀等",
"markdown_export.force_dollar_math.title": "强制使用 $$ 来标记 LaTeX 公式",
"markdown_export.help": "若填入,则每次导出时将自动保存到该路径;否则,将弹出保存对话框",
"markdown_export.path": "默认导出路径",
"markdown_export.path_placeholder": "导出路径",
"markdown_export.select": "选择",
"markdown_export.title": "Markdown 导出",
"markdown_export.show_model_name.title": "导出时使用模型名称",
"markdown_export.show_model_name.help": "开启后,使用话题命名模型为导出的消息创建标题。注意该项也会影响所有通过Markdown导出的方式Notion、语雀等。",
"markdown_export.show_model_name.help": "开启后,导出 Markdown 时会显示模型名称。注意:该项也会影响所有通过 Markdown 导出的方式,如 Notion、语雀等。",
"markdown_export.show_model_provider.title": "显示模型供应商",
"markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商如OpenAI、Gemini等",
"markdown_export.show_model_provider.help": "在导出 Markdown 时显示模型供应商,如 OpenAI、Gemini 等",
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
"message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式",
"message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过 Markdown 导出的方式",
"minute_interval_one": "{{count}} 分钟",
"minute_interval_other": "{{count}} 分钟",
"notion.api_key": "Notion 密钥",
"notion.api_key_placeholder": "请输入Notion 密钥",
"notion.api_key_placeholder": "请输入 Notion 密钥",
"notion.check": {
"button": "检测",
"empty_api_key": "未配置 API key",
@ -1197,13 +1210,13 @@
"success": "连接成功"
},
"notion.database_id": "Notion 数据库 ID",
"notion.database_id_placeholder": "请输入Notion 数据库 ID",
"notion.database_id_placeholder": "请输入 Notion 数据库 ID",
"notion.help": "Notion 配置文档",
"notion.page_name_key": "页面标题字段名",
"notion.page_name_key_placeholder": "请输入页面标题字段名,默认为 Name",
"notion.title": "Notion 设置",
"notion.export_reasoning.title": "导出时包含思维链",
"notion.export_reasoning.help": "开启后,导出到Notion时会包含思维链内容。",
"notion.export_reasoning.help": "开启后,导出到 Notion 时会包含思维链内容。",
"title": "数据设置",
"webdav": {
"autoSync": "自动备份",
@ -1251,13 +1264,77 @@
"title": "WebDAV",
"user": "WebDAV 用户名",
"maxBackups": "最大备份数",
"maxBackups.unlimited": "无限制"
"maxBackups.unlimited": "不限"
},
"s3": {
"title": "S3 兼容存储",
"title.help": "与 AWS S3 API 兼容的对象存储服务,例如 AWS S3, Cloudflare R2, 阿里云 OSS, 腾讯云 COS 等",
"endpoint": "API 地址",
"endpoint.placeholder": "https://s3.example.com",
"region": "区域",
"region.placeholder": "Region, 例如: us-east-1",
"bucket": "存储桶",
"bucket.placeholder": "Bucket, 例如: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "备份目录(可选)",
"root.placeholder": "例如:/cherry-studio",
"backup.operation": "备份操作",
"backup.button": "立即备份",
"backup.manager.button": "管理备份",
"backup.modal.title": "S3 备份",
"backup.modal.filename.placeholder": "请输入备份文件名",
"backup.success": "S3 备份成功",
"backup.error": "S3 备份失败: {{message}}",
"autoSync": "自动同步",
"autoSync.off": "关闭",
"autoSync.minute": "每 {{count}} 分钟",
"autoSync.hour": "每 {{count}} 小时",
"maxBackups": "最大备份数",
"maxBackups.unlimited": "不限",
"skipBackupFile": "精简备份",
"skipBackupFile.help": "开启后备份时将跳过文件数据,仅备份配置信息,显著减小备份文件体积",
"syncStatus": "同步状态",
"syncStatus.noSync": "未同步",
"syncStatus.error": "同步错误: {{message}}",
"syncStatus.lastSync": "上次同步: {{time}}",
"manager.title": "S3 备份文件管理",
"manager.refresh": "刷新",
"manager.delete.selected": "删除选中 ({{count}})",
"manager.close": "关闭",
"manager.columns.fileName": "文件名",
"manager.columns.modifiedTime": "修改时间",
"manager.columns.size": "文件大小",
"manager.columns.actions": "操作",
"manager.restore": "恢复",
"manager.delete": "删除",
"manager.config.incomplete": "请填写完整的 S3 配置信息",
"manager.files.fetch.error": "获取备份文件列表失败: {{message}}",
"manager.delete.confirm.title": "确认删除",
"manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作不可撤销。",
"manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作不可撤销。",
"manager.delete.success.multiple": "成功删除 {{count}} 个备份文件",
"manager.delete.success.single": "删除备份文件成功",
"manager.delete.error": "删除备份文件失败: {{message}}",
"manager.select.warning": "请选择要删除的备份文件",
"restore.modal.title": "S3 数据恢复",
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
"restore.confirm.title": "确认恢复数据",
"restore.confirm.content": "恢复数据将覆盖当前所有数据,此操作不可撤销。确定要继续吗?",
"restore.confirm.ok": "确认恢复",
"restore.confirm.cancel": "取消",
"restore.success": "数据恢复成功",
"restore.error": "数据恢复失败: {{message}}",
"restore.config.incomplete": "请填写完整的 S3 配置信息",
"restore.file.required": "请选择要恢复的备份文件"
},
"yuque": {
"check": {
"button": "检测",
"empty_repo_url": "请先输入知识库URL",
"empty_token": "请先输入语雀Token",
"empty_repo_url": "请先输入知识库 URL",
"empty_token": "请先输入语雀 Token",
"fail": "语雀连接验证失败",
"success": "语雀连接验证成功"
},
@ -1266,7 +1343,7 @@
"repo_url_placeholder": "https://www.yuque.com/username/xxx",
"title": "语雀配置",
"token": "语雀 Token",
"token_placeholder": "请输入语雀Token"
"token_placeholder": "请输入语雀 Token"
},
"obsidian": {
"title": "Obsidian 配置",
@ -1279,21 +1356,21 @@
},
"siyuan": {
"title": "思源笔记配置",
"api_url": "API地址",
"api_url": "API 地址",
"api_url_placeholder": "例如http://127.0.0.1:6806",
"token": "API令牌",
"token.help": "在思源笔记->设置->关于中获取",
"token": "API 令牌",
"token.help": "在思源笔记 -> 设置 -> 关于中获取",
"token_placeholder": "请输入思源笔记令牌",
"box_id": "笔记本ID",
"box_id_placeholder": "请输入笔记本ID",
"box_id": "笔记本 ID",
"box_id_placeholder": "请输入笔记本 ID",
"root_path": "文档根路径",
"root_path_placeholder": "例如:/CherryStudio",
"check": {
"title": "连接检测",
"button": "检测",
"empty_config": "请填写API地址和令牌",
"empty_config": "请填写 API 地址和令牌",
"success": "连接成功",
"fail": "连接失败,请检查API地址和令牌",
"fail": "连接失败,请检查 API 地址和令牌",
"error": "连接异常,请检查网络连接"
}
},
@ -1324,7 +1401,7 @@
"display.assistant.title": "助手设置",
"display.custom.css": "自定义 CSS",
"display.custom.css.cherrycss": "从 cherrycss.com 获取",
"display.custom.css.placeholder": "/* 这里写自定义CSS */",
"display.custom.css.placeholder": "/* 这里写自定义 CSS */",
"display.sidebar.chat.hiddenMessage": "助手是基础功能,不支持隐藏",
"display.sidebar.disabled": "隐藏的图标",
"display.sidebar.empty": "把要隐藏的功能从左侧拖拽到这里",
@ -1373,9 +1450,9 @@
"logo_upload_button": "上传",
"save": "保存",
"edit_description": "在这里编辑自定义小应用的配置。每个应用需要包含 id、name、url 和 logo 字段",
"placeholder": "请输入自定义小程序配置JSON格式)",
"duplicate_ids": "发现重复的ID: {{ids}}",
"conflicting_ids": "与默认应用ID冲突: {{ids}}"
"placeholder": "请输入自定义小程序配置JSON 格式)",
"duplicate_ids": "发现重复的 ID: {{ids}}",
"conflicting_ids": "与默认应用 ID 冲突: {{ids}}"
},
"cache_settings": "缓存设置",
"cache_title": "小程序缓存数量",
@ -1395,16 +1472,24 @@
"general.emoji_picker": "表情选择器",
"general.image_upload": "图片上传",
"general.auto_check_update.title": "自动更新",
"general.early_access.title": "抢先体验",
"general.early_access.tooltip": "开启后,将使用 GitHub 的最新版本,下载速度可能较慢,请务必提前备份数据",
"general.test_plan.title": "测试计划",
"general.test_plan.tooltip": "参与测试计划,可以更快体验到最新功能,但同时也会带来更多风险,务必提前做好备份",
"general.test_plan.beta_version": "测试版 (Beta)",
"general.test_plan.beta_version_tooltip": "功能可能随时变化bug 较多,升级较快",
"general.test_plan.rc_version": "预览版 (RC)",
"general.test_plan.rc_version_tooltip": "接近正式版功能基本稳定bug 较少",
"general.test_plan.version_options": "版本选择",
"general.test_plan.version_channel_not_match": "预览版和测试版的切换将在下一个正式版发布时生效",
"general.reset.button": "重置",
"general.reset.title": "重置数据",
"general.restore.button": "恢复",
"general.title": "常规设置",
"general.user_name": "用户名",
"general.user_name.placeholder": "请输入用户名",
"general.user_name.placeholder": "输入您的姓名",
"general.view_webdav_settings": "查看 WebDAV 设置",
"input.auto_translate_with_space": "快速敲击3次空格翻译",
"general.spell_check": "拼写检查",
"general.spell_check.languages": "拼写检查语言",
"input.auto_translate_with_space": "3 个空格快速翻译",
"input.show_translate_confirm": "显示翻译确认对话框",
"input.target_language": "目标语言",
"input.target_language.chinese": "简体中文",
@ -1422,7 +1507,7 @@
"addServer": "添加服务器",
"addServer.create": "快速创建",
"addServer.importFrom": "从 JSON 导入",
"addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置JSON优先使用\n NPX或 UVX 配置),并粘贴到输入框中",
"addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置 JSON优先使用\n NPX 或 UVX 配置),并粘贴到输入框中",
"addServer.importFrom.placeholder": "粘贴 MCP 服务器 JSON 配置",
"addServer.importFrom.invalid": "无效输入,请检查 JSON 格式",
"addServer.importFrom.nameExists": "服务器已存在:{{name}}",
@ -1434,8 +1519,8 @@
"baseUrlTooltip": "远程 URL 地址",
"command": "命令",
"sse": "服务器发送事件 (sse)",
"streamableHttp": "可流式传输的HTTP (streamableHttp)",
"stdio": "标准输入/输出 (stdio)",
"streamableHttp": "可流式传输的 HTTP (streamableHttp)",
"stdio": "标准输入 / 输出 (stdio)",
"inMemory": "内存",
"config_description": "配置模型上下文协议服务器",
"disable": "不使用 MCP 服务器",
@ -1447,7 +1532,7 @@
"description": "描述",
"noDescriptionAvailable": "暂无描述",
"duplicateName": "已存在同名服务器",
"editJson": "编辑JSON",
"editJson": "编辑 JSON",
"editServer": "编辑服务器",
"env": "环境变量",
"envTooltip": "格式KEY=value每行一个",
@ -1458,10 +1543,10 @@
"install": "安装",
"installError": "安装依赖项失败",
"installSuccess": "依赖项安装成功",
"jsonFormatError": "JSON格式化错误",
"jsonModeHint": "编辑MCP服务器配置的JSON表示。保存前请确保格式正确",
"jsonSaveError": "保存JSON配置失败",
"jsonSaveSuccess": "JSON配置已保存",
"jsonFormatError": "JSON 格式化错误",
"jsonModeHint": "编辑 MCP 服务器配置的 JSON 表示。保存前请确保格式正确",
"jsonSaveError": "保存 JSON 配置失败",
"jsonSaveSuccess": "JSON 配置已保存",
"missingDependencies": "缺失,请安装它以继续",
"name": "名称",
"noServers": "未配置服务器",
@ -1480,7 +1565,8 @@
"version": "版本"
},
"errors": {
"32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整"
"32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整",
"toolNotFound": "未找到工具 {{name}}"
},
"serverPlural": "服务器",
"serverSingular": "服务器",
@ -1517,7 +1603,7 @@
"noResourcesAvailable": "无可用资源",
"availableResources": "可用资源",
"uri": "URI",
"mimeType": "MIME类型",
"mimeType": "MIME 类型",
"size": "大小",
"blob": "二进制数据",
"blobInvisible": "隐藏二进制数据",
@ -1528,6 +1614,7 @@
"registry": "包管理源",
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题",
"registryDefault": "默认",
"customRegistryPlaceholder": "请输入私有仓库地址,如: https://npm.company.com",
"not_support": "模型不支持",
"user": "用户",
"system": "系统",
@ -1540,21 +1627,21 @@
"sync": {
"title": "同步服务器",
"selectProvider": "选择提供商:",
"discoverMcpServers": "发现MCP服务器",
"discoverMcpServersDescription": "访问平台以发现可用的MCP服务器",
"discoverMcpServers": "发现 MCP 服务器",
"discoverMcpServersDescription": "访问平台以发现可用的 MCP 服务器",
"getToken": "获取 API 令牌",
"getTokenDescription": "从您的帐户中获取个人 API 令牌",
"setToken": "输入您的令牌",
"tokenRequired": "需要 API 令牌",
"tokenPlaceholder": "在此输入 API 令牌",
"button": "同步",
"error": "同步MCP服务器出错",
"success": "同步MCP服务器成功",
"error": "同步 MCP 服务器出错",
"success": "同步 MCP 服务器成功",
"unauthorized": "同步未授权",
"noServersAvailable": "无可用的 MCP 服务器"
},
"timeout": "超时",
"timeoutTooltip": "对该服务器请求的超时时间(秒),默认为60秒",
"timeoutTooltip": "对该服务器请求的超时时间(秒),默认为 60 秒",
"provider": "提供者",
"providerUrl": "提供者网址",
"logoUrl": "标志网址",
@ -1564,7 +1651,7 @@
"advancedSettings": "高级设置"
},
"messages.prompt": "显示提示词",
"messages.tokens": "显示Token用量",
"messages.tokens": "显示 Token 用量",
"messages.divider": "消息分割线",
"messages.divider.tooltip": "不适用于气泡样式消息",
"messages.grid_columns": "消息网格展示列数",
@ -1577,11 +1664,11 @@
"messages.input.show_estimated_tokens": "显示预估 Token 数",
"messages.input.title": "输入设置",
"messages.input.enable_quick_triggers": "启用 / 和 @ 触发快捷菜单",
"messages.input.enable_delete_model": "启用删除键删除输入的模型/附件",
"messages.input.enable_delete_model": "启用删除键删除输入的模型 / 附件",
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
"messages.math_engine": "数学公式引擎",
"messages.math_engine.none": "无",
"messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
"messages.metrics": "首字时延 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens",
"messages.model.title": "模型设置",
"messages.navigation": "对话导航按钮",
"messages.navigation.anchor": "对话锚点",
@ -1608,14 +1695,14 @@
"models.check.enable_concurrent": "并发检测",
"models.check.enabled": "开启",
"models.check.failed": "失败",
"models.check.keys_status_count": "通过:{{count_passed}}个密钥,失败:{{count_failed}}个密钥",
"models.check.keys_status_count": "通过:{{count_passed}} 个密钥,失败:{{count_failed}} 个密钥",
"models.check.model_status_failed": "{{count}} 个模型完全无法访问",
"models.check.model_status_partial": "其中 {{count}} 个模型用某些密钥无法访问",
"models.check.model_status_passed": "{{count}} 个模型通过健康检测",
"models.check.model_status_summary": "{{provider}}: {{summary}}",
"models.check.no_api_keys": "未找到API密钥请先添加API密钥",
"models.check.no_api_keys": "未找到 API 密钥,请先添加 API 密钥",
"models.check.passed": "通过",
"models.check.select_api_key": "选择要使用的API密钥",
"models.check.select_api_key": "选择要使用的 API 密钥:",
"models.check.single": "单个",
"models.check.start": "开始",
"models.check.title": "模型健康检测",
@ -1660,7 +1747,7 @@
"add.type": "提供商类型",
"api.url.preview": "预览: {{url}}",
"api.url.reset": "重置",
"api.url.tip": "/结尾忽略v1版本#结尾强制使用输入地址",
"api.url.tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址",
"api_host": "API 地址",
"api_key": "API 密钥",
"api_key.tip": "多个密钥使用逗号分隔",
@ -1677,8 +1764,8 @@
"check_all_keys": "检测所有密钥",
"check_multiple_keys": "检测多个 API 密钥",
"oauth": {
"button": "使用{{provider}}账号登录",
"description": "本服务由<website>{{provider}}</website>提供",
"button": "使用 {{provider}} 账号登录",
"description": "本服务由 <website>{{provider}}</website> 提供",
"official_website": "官方网站"
},
"openai": {
@ -1691,13 +1778,13 @@
"code_failed": "获取 Device Code 失败,请重试",
"code_generated_desc": "请将 Device Code 复制到下面的浏览器链接中",
"code_generated_title": "获取 Device Code",
"confirm_login": "过度使用可能会导致您的 Github 账号遭到封号,请谨慎使用!!!!",
"confirm_login": "过度使用可能会导致您的 Github 账号遭到封号,请谨慎使用",
"confirm_title": "风险警告",
"connect": "连接 Github",
"custom_headers": "自定义请求头",
"description": "您的 Github 账号需要订阅 Copilot",
"expand": "展开",
"headers_description": "自定义请求头(json格式)",
"headers_description": "自定义请求头 (json 格式)",
"invalid_json": "JSON 格式错误",
"login": "登录 Github",
"logout": "退出 Github",
@ -1711,7 +1798,7 @@
"dmxapi": {
"select_platform": "选择平台"
},
"delete.content": "确定要删除此模型提供商吗?",
"delete.content": "确定要删除此模型提供商吗",
"delete.title": "删除提供商",
"docs_check": "查看",
"docs_more_details": "获取更多详情",
@ -1726,7 +1813,7 @@
"title": "模型服务",
"notes": {
"title": "模型备注",
"placeholder": "请输入Markdown格式内容...",
"placeholder": "请输入 Markdown 格式内容...",
"markdown_editor_default_value": "预览区域"
},
"vertex_ai": {
@ -1785,7 +1872,7 @@
"reset_to_default": "重置为默认",
"search_message": "搜索消息",
"search_message_in_chat": "在当前对话中搜索消息",
"show_app": "显示/隐藏应用",
"show_app": "显示 / 隐藏应用",
"show_settings": "打开设置",
"title": "快捷键",
"toggle_new_context": "清除上下文",
@ -1815,7 +1902,7 @@
"websearch": {
"blacklist": "黑名单",
"blacklist_description": "在搜索结果中不会出现以下网站的结果",
"blacklist_tooltip": "请使用以下格式(换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/",
"blacklist_tooltip": "请使用以下格式 (换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/",
"check": "检测",
"check_failed": "验证失败",
"check_success": "验证成功",
@ -1832,7 +1919,7 @@
"subscribe_url": "订阅源地址",
"subscribe_name": "替代名字",
"subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称",
"subscribe_add_success": "订阅源添加成功!",
"subscribe_add_success": "订阅源添加成功",
"subscribe_delete": "删除订阅源",
"search_result_default": "默认",
"search_with_time": "搜索包含日期",
@ -1845,8 +1932,33 @@
"title": "网络搜索",
"apikey": "API 密钥",
"free": "免费",
"content_limit": "内容长度限制",
"content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断"
"compression": {
"title": "搜索结果压缩",
"method": "压缩方法",
"method.none": "不压缩",
"method.cutoff": "截断",
"cutoff.limit": "截断长度",
"cutoff.limit.placeholder": "输入长度",
"cutoff.limit.tooltip": "限制搜索结果的内容长度,超过限制的内容将被截断(例如 2000 字符)",
"cutoff.unit.char": "字符",
"cutoff.unit.token": "Token",
"method.rag": "RAG",
"rag.document_count": "文档数量",
"rag.document_count.default": "默认",
"rag.document_count.tooltip": "预期从单个搜索结果中提取的文档数量,实际提取的总数量是这个值乘以搜索结果数量。",
"rag.embedding_dimensions.auto_get": "自动获取维度",
"rag.embedding_dimensions.placeholder": "不设置维度",
"rag.embedding_dimensions.tooltip": "留空则不传递 dimensions 参数",
"info": {
"dimensions_auto_success": "维度自动获取成功,维度为 {{dimensions}}"
},
"error": {
"embedding_model_required": "请先选择嵌入模型",
"dimensions_auto_failed": "维度自动获取失败",
"provider_not_found": "未找到服务商",
"rag_failed": "RAG 失败"
}
}
},
"quickPhrase": {
"title": "快捷短语",
@ -1855,7 +1967,7 @@
"titleLabel": "标题",
"contentLabel": "内容",
"titlePlaceholder": "请输入短语标题",
"contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如\n帮我规划从${from}到${to}的路线,然后发送到${email}",
"contentPlaceholder": "请输入短语内容,支持使用变量,然后按 Tab 键可以快速定位到变量进行修改。比如:\n帮我规划从 ${from} ${to} 的路线,然后发送到 ${email}",
"delete": "删除短语",
"deleteConfirm": "删除短语后将无法恢复,是否继续?",
"locationLabel": "添加位置",
@ -1996,11 +2108,11 @@
"trigger_mode": {
"title": "取词方式",
"description": "划词后,触发取词并显示工具栏的方式",
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了 AHK 等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
"selected": "划词",
"selected_note": "划词后立即显示工具栏",
"ctrlkey": "Ctrl 键",
"ctrlkey_note": "划词后,再 长按 Ctrl键,才显示工具栏",
"ctrlkey_note": "划词后,再 长按 Ctrl 键,才显示工具栏",
"shortcut": "快捷键",
"shortcut_note": "划词后,使用快捷键显示工具栏。请在快捷键设置页面中设置取词快捷键并启用。",
"shortcut_link": "前往快捷键设置"
@ -2030,7 +2142,7 @@
},
"opacity": {
"title": "透明度",
"description": "设置窗口的默认透明度100%为完全不透明"
"description": "设置窗口的默认透明度100% 为完全不透明"
}
},
"actions": {
@ -2043,7 +2155,7 @@
},
"add_tooltip": {
"enabled": "添加自定义功能",
"disabled": "自定义功能已达上限 ({{max}}个)"
"disabled": "自定义功能已达上限 ({{max}} 个)"
},
"delete_confirm": "确定要删除这个自定义功能吗?",
"drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})"
@ -2075,7 +2187,7 @@
"label": "图标",
"placeholder": "输入 Lucide 图标名称",
"error": "无效的图标名称,请检查输入",
"tooltip": "Lucide图标名称为小写,如 arrow-right",
"tooltip": "Lucide 图标名称为小写,如 arrow-right",
"view_all": "查看所有图标",
"random": "随机图标"
},
@ -2090,9 +2202,9 @@
"default": "默认"
},
"prompt": {
"label": "用户提示词(Prompt)",
"label": "用户提示词 (Prompt)",
"tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词",
"placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾",
"placeholder": "使用占位符 {{text}} 代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾",
"placeholder_text": "占位符",
"copy_placeholder": "复制占位符"
}
@ -2107,7 +2219,7 @@
"name": {
"label": "自定义名称",
"hint": "请输入搜索引擎名称",
"max_length": "名称不能超过16个字符"
"max_length": "名称不能超过 16 个字符"
},
"url": {
"label": "自定义搜索 URL",
@ -2121,7 +2233,7 @@
},
"filter_modal": {
"title": "应用筛选名单",
"user_tips": "请输入应用的执行文件名每行一个不区分大小写可以模糊匹配。例如chrome.exe、weixin.exe、Cherry Studio.exe等"
"user_tips": "请输入应用的执行文件名每行一个不区分大小写可以模糊匹配。例如chrome.exe、weixin.exe、Cherry Studio.exe 等"
}
}
}

View File

@ -22,7 +22,7 @@
"add.prompt.placeholder": "輸入提示詞",
"add.prompt.variables.tip": {
"title": "可用的變數",
"content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱"
"content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU 架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱"
},
"add.title": "建立智慧代理人",
"import": {
@ -99,7 +99,7 @@
"titleLabel": "標題",
"titlePlaceholder": "輸入標題",
"contentLabel": "內容",
"contentPlaceholder": "請輸入短語內容,支持使用變量,然後按Tab鍵可以快速定位到變量進行修改。比如\n幫我規劃從${from}到${to}的行程,然後發送到${email}"
"contentPlaceholder": "請輸入短語內容,支持使用變量,然後按 Tab 鍵可以快速定位到變量進行修改。比如:\n幫我規劃從 ${from} ${to} 的行程,然後發送到 ${email}"
},
"settings.knowledge_base.recognition.tip": "智慧代理人將調用大語言模型的意圖識別能力,判斷是否需要調用知識庫進行回答,該功能將依賴模型的能力",
"settings.knowledge_base.recognition": "調用知識庫",
@ -130,7 +130,7 @@
"get_key": "取得",
"get_key_success": "自動取得金鑰成功",
"login": "登入",
"oauth_button": "使用{{provider}}登入"
"oauth_button": "使用 {{provider}} 登入"
},
"backup": {
"confirm": "確定要備份資料嗎?",
@ -198,8 +198,8 @@
"input.placeholder": "在此輸入您的訊息,按 {{key}} 傳送...",
"input.send": "傳送",
"input.settings": "設定",
"input.topics": " 話題 ",
"input.translate": "翻譯成{{target_language}}",
"input.topics": "話題",
"input.translate": "翻譯成 {{target_language}}",
"input.upload": "上傳圖片或文件",
"input.upload.document": "上傳文件(模型不支援圖片)",
"input.web_search": "網路搜尋",
@ -306,9 +306,9 @@
"topics.export.obsidian_title": "標題",
"topics.export.obsidian_title_placeholder": "請輸入標題",
"topics.export.obsidian_title_required": "標題不能為空",
"topics.export.obsidian_no_vaults": "未找到Obsidian保管庫",
"topics.export.obsidian_no_vaults": "未找到 Obsidian 保管庫",
"topics.export.obsidian_loading": "加載中...",
"topics.export.obsidian_fetch_error": "獲取Obsidian保管庫失敗",
"topics.export.obsidian_fetch_error": "獲取 Obsidian 保管庫失敗",
"topics.export.obsidian_fetch_folders_error": "獲取文件夾結構失敗",
"topics.export.obsidian_no_vault_selected": "請先選擇一個保管庫",
"topics.export.obsidian_select_vault_first": "請先選擇保管庫",
@ -344,11 +344,11 @@
"input.tools.collapse_out": "移出折疊",
"input.thinking": "思考",
"input.thinking.mode.default": "預設",
"input.thinking.mode.default.tip": "模型會自動確定思考的 token 數",
"input.thinking.mode.default.tip": "模型會自動確定思考的 Token 數",
"input.thinking.mode.custom": "自定義",
"input.thinking.mode.custom.tip": "模型最多可以思考的 token 數。需要考慮模型的上下文限制,否則會報錯",
"input.thinking.mode.tokens.tip": "設置思考的 token 數",
"input.thinking.budget_exceeds_max": "思考預算超過最大 token 數"
"input.thinking.mode.custom.tip": "模型最多可以思考的 Token 數。需要考慮模型的上下文限制,否則會報錯",
"input.thinking.mode.tokens.tip": "設置思考的 Token 數",
"input.thinking.budget_exceeds_max": "思考預算超過最大 Token 數"
},
"code_block": {
"collapse": "折疊",
@ -424,6 +424,7 @@
"search": "搜尋",
"select": "選擇",
"selectedMessages": "選中 {{count}} 條訊息",
"selectedItems": "已選擇 {{count}} 項",
"success": "成功",
"topics": "話題",
"warning": "警告",
@ -574,7 +575,7 @@
"threshold_tooltip": "用於衡量使用者問題與知識庫內容之間的相關性0-1",
"title": "知識庫",
"topN": "返回結果數量",
"topN_too_large_or_small": "返回結果數量不能大於30或小於1",
"topN_too_large_or_small": "返回結果數量不能大於 30 或小於 1",
"topN_placeholder": "未設定",
"topN_tooltip": "返回的匹配結果數量,數值越大,匹配結果越多,但消耗的 Token 也越多",
"url_added": "網址已新增",
@ -582,7 +583,7 @@
"urls": "網址",
"dimensions": "嵌入維度",
"dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多",
"dimensions_size_placeholder": " 嵌入維度大小,例如 1024",
"dimensions_size_placeholder": "嵌入維度大小,例如 1024",
"dimensions_auto_set": "自動設定嵌入維度",
"dimensions_error_invalid": "請輸入嵌入維度大小",
"dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}}",
@ -657,7 +658,7 @@
"error.invalid.proxy.url": "無效的代理伺服器 URL",
"error.invalid.webdav": "無效的 WebDAV 設定",
"error.joplin.export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定",
"error.joplin.no_config": "未設定 Joplin 授權Token 或 URL",
"error.joplin.no_config": "未設定 Joplin 授權 Token 或 URL",
"error.invalid.nutstore": "無效的坚果云設定",
"error.invalid.nutstore_token": "無效的坚果云 Token",
"error.markdown.export.preconf": "導出 Markdown 文件到預先設定的路徑失敗",
@ -714,10 +715,17 @@
"warn.notion.exporting": "正在匯出到 Notion請勿重複請求匯出",
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試",
"error.siyuan.export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置",
"error.siyuan.no_config": "未配置思源筆記API地址或令牌",
"error.siyuan.no_config": "未配置思源筆記 API 地址或令牌",
"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": "下載失敗"
},
@ -730,7 +738,7 @@
"minimize": "最小化小工具",
"devtools": "開發者工具",
"openExternal": "在瀏覽器中開啟",
"rightclick_copyurl": "右鍵複製URL",
"rightclick_copyurl": "右鍵複製 URL",
"open_link_external_on": "当前:在瀏覽器中開啟連結",
"open_link_external_off": "当前:使用預設視窗開啟連結"
},
@ -791,8 +799,9 @@
"dimensions": "{{dimensions}} 維",
"edit": "編輯模型",
"embedding": "嵌入",
"embedding_dimensions": "嵌入維度",
"embedding_model": "嵌入模型",
"embedding_model_tooltip": "在設定->模型服務中點選管理按鈕新增",
"embedding_model_tooltip": "在設定 -> 模型服務中點選管理按鈕新增",
"function_calling": "函數調用",
"no_matches": "無可用模型",
"parameter_name": "參數名稱",
@ -805,7 +814,7 @@
"pinned": "已固定",
"rerank_model": "重排模型",
"rerank_model_support_provider": "目前重排序模型僅支持部分服務商 ({{provider}})",
"rerank_model_tooltip": "在設定->模型服務中點擊管理按鈕添加",
"rerank_model_tooltip": "在設定 -> 模型服務中點擊管理按鈕添加",
"search": "搜尋模型...",
"stream_output": "串流輸出",
"enable_tool_use": "工具調用",
@ -841,7 +850,7 @@
},
"notification": {
"assistant": "助手回應",
"knowledge.success": "成功將{{type}}新增至知識庫",
"knowledge.success": "成功將 {{type}} 新增至知識庫",
"knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}"
},
"ollama": {
@ -876,10 +885,10 @@
"aspect_ratio": "畫幅比例",
"style_type": "風格",
"learn_more": "了解更多",
"prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 '雙引號' 包裹",
"prompt_placeholder_en": "輸入”英文“圖片描述,目前 Imagen 僅支持英文提示詞",
"prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 ' 雙引號 ' 包裹",
"prompt_placeholder_en": "輸入” 英文 “圖片描述,目前 Imagen 僅支持英文提示詞",
"paint_course": "教程",
"proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連",
"proxy_required": "打開代理並開啟”TUN 模式 “查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連",
"image_file_required": "請先上傳圖片",
"image_file_retry": "請重新上傳圖片",
"image_placeholder": "無圖片",
@ -939,7 +948,7 @@
"negative_prompt_tip": "描述不想在圖像中出現的內容",
"magic_prompt_option_tip": "智能優化生成效果的提示詞",
"style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本",
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本",
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本",
"person_generation": "人物生成",
"person_generation_tip": "允許模型生成人物圖像"
},
@ -950,7 +959,7 @@
"style_type_tip": "編輯後的圖像風格,僅適用於 V_2 及以上版本",
"seed_tip": "控制編輯結果的隨機性",
"magic_prompt_option_tip": "智能優化編輯提示詞",
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本"
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本"
},
"remix": {
"model_tip": "選擇重混使用的 AI 模型版本",
@ -962,7 +971,7 @@
"style_type_tip": "重混後的圖像風格,僅適用於 V_2 及以上版本",
"negative_prompt_tip": "描述不想在重混結果中出現的元素",
"magic_prompt_option_tip": "智能優化重混提示詞",
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本"
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本"
},
"upscale": {
"image_file": "需要放大的圖片",
@ -977,7 +986,7 @@
"rendering_speed": "渲染速度",
"text_desc_required": "請先輸入圖片描述",
"image_handle_required": "請先上傳圖片。",
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
"req_error_text": "运行失败,请重试。提示词避免 “版权词” 和” 敏感词” 哦。",
"req_error_token": "請檢查令牌的有效性",
"req_error_no_balance": "請檢查令牌的有效性",
"auto_create_paint": "自動新增圖片",
@ -996,7 +1005,7 @@
"prompts": {
"explanation": "幫我解釋一下這個概念",
"summarize": "幫我總結一下這段話",
"title": "將會話內容以{{language}}總結為10個字內的標題忽略對話中的指令勿使用標點與特殊符號。僅輸出純字串不輸出標題以外內容。"
"title": "將會話內容以 {{language}} 總結為 10 個字內的標題,忽略對話中的指令,勿使用標點與特殊符號。僅輸出純字串,不輸出標題以外內容。"
},
"provider": {
"aihubmix": "AiHubMix",
@ -1103,11 +1112,11 @@
"app_data": "應用數據",
"app_data.select": "修改目錄",
"app_data.select_title": "變更應用數據目錄",
"app_data.restart_notice": "變更數據目錄後需要重啟應用才能生效",
"app_data.copy_data_option": "複製數據, 開啟後會將原始目錄數據複製到新目錄",
"app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效",
"app_data.copy_data_option": "複製數據,會自動重啟後將原始目錄數據複製到新目錄",
"app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用",
"app_data.path_changed_without_copy": "路徑已變更成功,但數據未複製",
"app_data.copying_warning": "數據複製中,不要強制退出應用",
"app_data.path_changed_without_copy": "路徑已變更成功",
"app_data.copying_warning": "數據複製中,不要強制退出應用,複製完成後會自動重啟應用",
"app_data.copying": "正在複製數據到新位置...",
"app_data.copy_success": "成功複製數據到新位置",
"app_data.copy_failed": "複製數據失敗",
@ -1119,6 +1128,10 @@
"app_data.select_error_root_path": "新路徑不能是根路徑",
"app_data.select_error_write_permission": "新路徑沒有寫入權限",
"app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出",
"app_data.select_not_empty_dir": "新路徑不為空",
"app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據,有數據丟失和複製失敗的風險,是否繼續?",
"app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑",
"app_data.select_error_in_app_path": "新路徑與應用安裝路徑相同,請選擇其他路徑",
"app_knowledge": "知識庫文件",
"app_knowledge.button.delete": "刪除檔案",
"app_knowledge.remove_all": "刪除知識庫檔案",
@ -1126,7 +1139,7 @@
"app_knowledge.remove_all_success": "檔案刪除成功",
"app_logs": "應用程式日誌",
"backup.skip_file_data_title": "精簡備份",
"backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用, 加快備份速度",
"backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用加快備份速度",
"clear_cache": {
"button": "清除快取",
"confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作不可恢復,是否繼續?",
@ -1144,44 +1157,44 @@
"export_menu": {
"title": "匯出選單設定",
"image": "匯出為圖片",
"markdown": "匯出為Markdown",
"markdown_reason": "匯出為Markdown包含思考",
"notion": "匯出到Notion",
"markdown": "匯出為 Markdown",
"markdown_reason": "匯出為 Markdown包含思考",
"notion": "匯出到 Notion",
"yuque": "匯出到語雀",
"obsidian": "匯出到Obsidian",
"obsidian": "匯出到 Obsidian",
"siyuan": "匯出到思源筆記",
"joplin": "匯出到Joplin",
"docx": "匯出為Word",
"joplin": "匯出到 Joplin",
"docx": "匯出為 Word",
"plain_text": "複製為純文本"
},
"joplin": {
"check": {
"button": "檢查",
"empty_token": "請先輸入 Joplin 授權Token",
"empty_token": "請先輸入 Joplin 授權 Token",
"empty_url": "請先輸入 Joplin 剪輯服務 URL",
"fail": "Joplin 連接驗證失敗",
"success": "Joplin 連接驗證成功"
},
"help": "在 Joplin 選項中,啟用剪輯服務(無需安裝瀏覽器外掛),確認埠編號,並複製授權Token",
"help": "在 Joplin 選項中,啟用剪輯服務(無需安裝瀏覽器外掛),確認埠編號,並複製授權 Token",
"title": "Joplin 設定",
"token": "Joplin 授權Token",
"token_placeholder": "請輸入 Joplin 授權Token",
"token": "Joplin 授權 Token",
"token_placeholder": "請輸入 Joplin 授權 Token",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "匯出時包含思維鏈",
"export_reasoning.help": "啟用後,匯出內容將包含助手生成的思維鏈(思考過程)。"
},
"markdown_export.force_dollar_math.help": "開啟後,匯出Markdown時會強制使用$$來標記LaTeX公式。注意該項也會影響所有透過Markdown匯出的方式如Notion、語雀等",
"markdown_export.force_dollar_math.title": "LaTeX公式強制使用$$",
"markdown_export.force_dollar_math.help": "開啟後,匯出 Markdown 時會強制使用 $$ 來標記 LaTeX 公式。注意:該項也會影響所有透過 Markdown 匯出的方式,如 Notion、語雀等",
"markdown_export.force_dollar_math.title": "LaTeX 公式強制使用 $$",
"markdown_export.help": "若填入,每次匯出時將自動儲存至該路徑;否則,將彈出儲存對話框",
"markdown_export.path": "預設匯出路徑",
"markdown_export.path_placeholder": "匯出路徑",
"markdown_export.select": "選擇",
"markdown_export.title": "Markdown 匯出",
"markdown_export.show_model_name.title": "匯出時使用模型名稱",
"markdown_export.show_model_name.help": "啟用後,將以主題命名模型為匯出的訊息建立標題。注意該項也會影響所有透過Markdown匯出的方式Notion、語雀等。",
"markdown_export.show_model_name.help": "啟用後,匯出 Markdown 時會顯示模型名稱。注意:該項也會影響所有透過 Markdown 匯出的方式,如 Notion、語雀等。",
"markdown_export.show_model_provider.title": "顯示模型供應商",
"markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商如OpenAI、Gemini等",
"markdown_export.show_model_provider.help": "在匯出 Markdown 時顯示模型供應商,如 OpenAI、Gemini 等",
"minute_interval_one": "{{count}} 分鐘",
"minute_interval_other": "{{count}} 分鐘",
"notion.api_key": "Notion 金鑰",
@ -1201,7 +1214,7 @@
"notion.page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name",
"notion.title": "Notion 設定",
"notion.export_reasoning.title": "匯出時包含思維鏈",
"notion.export_reasoning.help": "啟用後,匯出到Notion時會包含思維鏈內容。",
"notion.export_reasoning.help": "啟用後,匯出到 Notion 時會包含思維鏈內容。",
"title": "資料設定",
"webdav": {
"autoSync": "自動備份",
@ -1249,7 +1262,71 @@
"title": "WebDAV",
"user": "WebDAV 使用者名稱",
"maxBackups": "最大備份數量",
"maxBackups.unlimited": "無限制"
"maxBackups.unlimited": "不限"
},
"s3": {
"title": "S3 相容儲存",
"title.help": "與 AWS S3 API 相容的物件儲存服務,例如 AWS S3、Cloudflare R2、阿里雲 OSS、騰訊雲 COS 等",
"endpoint": "API 位址",
"endpoint.placeholder": "https://s3.example.com",
"region": "區域",
"region.placeholder": "Region例如: us-east-1",
"bucket": "儲存桶",
"bucket.placeholder": "Bucket例如: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "備份目錄(可選)",
"root.placeholder": "例如:/cherry-studio",
"backup.operation": "備份操作",
"backup.button": "立即備份",
"backup.manager.button": "管理備份",
"backup.modal.title": "S3 備份",
"backup.modal.filename.placeholder": "請輸入備份檔案名稱",
"backup.success": "S3 備份成功",
"backup.error": "S3 備份失敗: {{message}}",
"autoSync": "自動同步",
"autoSync.off": "關閉",
"autoSync.minute": "每 {{count}} 分鐘",
"autoSync.hour": "每 {{count}} 小時",
"maxBackups": "最大備份數",
"maxBackups.unlimited": "不限",
"skipBackupFile": "精簡備份",
"skipBackupFile.help": "開啟後備份時將跳過檔案資料,僅備份設定資訊,顯著減小備份檔案體積",
"syncStatus": "同步狀態",
"syncStatus.noSync": "未同步",
"syncStatus.error": "同步錯誤: {{message}}",
"syncStatus.lastSync": "上次同步: {{time}}",
"manager.title": "S3 備份檔案管理",
"manager.refresh": "重新整理",
"manager.delete.selected": "刪除選中 ({{count}})",
"manager.close": "關閉",
"manager.columns.fileName": "檔案名稱",
"manager.columns.modifiedTime": "修改時間",
"manager.columns.size": "檔案大小",
"manager.columns.actions": "操作",
"manager.restore": "恢復",
"manager.delete": "刪除",
"manager.config.incomplete": "請填寫完整的 S3 設定資訊",
"manager.files.fetch.error": "取得備份檔案清單失敗: {{message}}",
"manager.delete.confirm.title": "確認刪除",
"manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份檔案嗎?此操作不可撤銷。",
"manager.delete.confirm.single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作不可撤銷。",
"manager.delete.success.multiple": "成功刪除 {{count}} 個備份檔案",
"manager.delete.success.single": "刪除備份檔案成功",
"manager.delete.error": "刪除備份檔案失敗: {{message}}",
"manager.select.warning": "請選擇要刪除的備份檔案",
"restore.modal.title": "S3 資料恢復",
"restore.modal.select.placeholder": "請選擇要恢復的備份檔案",
"restore.confirm.title": "確認恢復資料",
"restore.confirm.content": "恢復資料將覆寫當前所有資料,此操作不可撤銷。確定要繼續嗎?",
"restore.confirm.ok": "確認恢復",
"restore.confirm.cancel": "取消",
"restore.success": "資料恢復成功",
"restore.error": "資料恢復失敗: {{message}}",
"restore.config.incomplete": "請填寫完整的 S3 設定資訊",
"restore.file.required": "請選擇要恢復的備份檔案"
},
"yuque": {
"check": {
@ -1277,21 +1354,21 @@
},
"siyuan": {
"title": "思源筆記配置",
"api_url": "API地址",
"api_url": "API 地址",
"api_url_placeholder": "例如http://127.0.0.1:6806",
"token": "API令牌",
"token.help": "在思源筆記->設置->關於中獲取",
"token": "API 令牌",
"token.help": "在思源筆記 -> 設置 -> 關於中獲取",
"token_placeholder": "請輸入思源筆記令牌",
"box_id": "筆記本ID",
"box_id_placeholder": "請輸入筆記本ID",
"box_id": "筆記本 ID",
"box_id_placeholder": "請輸入筆記本 ID",
"root_path": "文檔根路徑",
"root_path_placeholder": "例如:/CherryStudio",
"check": {
"title": "連接檢查",
"button": "檢查",
"empty_config": "請填寫API地址和令牌",
"empty_config": "請填寫 API 地址和令牌",
"success": "連接成功",
"fail": "連接失敗,請檢查API地址和令牌",
"fail": "連接失敗,請檢查 API 地址和令牌",
"error": "連接異常,請檢查網絡連接"
}
},
@ -1319,7 +1396,7 @@
"new_folder.button": "新建文件夾"
},
"message_title.use_topic_naming.title": "使用話題命名模型為導出的消息創建標題",
"message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式如Notion、語雀等"
"message_title.use_topic_naming.help": "此設定會影響所有通過 Markdown 導出的方式,如 Notion、語雀等"
},
"display.assistant.title": "助手設定",
"display.custom.css": "自訂 CSS",
@ -1347,8 +1424,8 @@
"title": "在瀏覽器中打開新視窗連結"
},
"custom": {
"duplicate_ids": "發現重複的ID: {{ids}}",
"conflicting_ids": "與預設應用ID衝突: {{ids}}",
"duplicate_ids": "發現重複的 ID: {{ids}}",
"conflicting_ids": "與預設應用 ID 衝突: {{ids}}",
"title": "自定義",
"edit_title": "編輯自定義小程序",
"save_success": "自定義小程序保存成功",
@ -1374,7 +1451,7 @@
"logo_upload_label": "上傳 Logo",
"logo_upload_button": "上傳",
"save": "保存",
"placeholder": "請輸入自定義小程序配置JSON格式)",
"placeholder": "請輸入自定義小程序配置JSON 格式)",
"edit_description": "編輯自定義小程序配置"
},
"cache_settings": "緩存設置",
@ -1401,6 +1478,8 @@
"general.user_name": "使用者名稱",
"general.user_name.placeholder": "輸入您的名稱",
"general.view_webdav_settings": "檢視 WebDAV 設定",
"general.spell_check": "拼寫檢查",
"general.spell_check.languages": "拼寫檢查語言",
"input.auto_translate_with_space": "快速敲擊 3 次空格翻譯",
"input.show_translate_confirm": "顯示翻譯確認對話框",
"input.target_language": "目標語言",
@ -1419,7 +1498,7 @@
"addServer": "新增伺服器",
"addServer.create": "快速創建",
"addServer.importFrom": "從 JSON 導入",
"addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置JSON優先使用\n NPX或 UVX 配置),並粘貼到輸入框中",
"addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置 JSON優先使用\n NPX 或 UVX 配置),並粘貼到輸入框中",
"addServer.importFrom.placeholder": "貼上 MCP 伺服器 JSON 設定",
"addServer.importFrom.invalid": "無效的輸入,請檢查 JSON 格式",
"addServer.importFrom.nameExists": "伺服器已存在:{{name}}",
@ -1431,8 +1510,8 @@
"baseUrlTooltip": "遠端 URL 地址",
"command": "指令",
"sse": "伺服器傳送事件 (sse)",
"streamableHttp": "可串流的HTTP (streamableHttp)",
"stdio": "標準輸入/輸出 (stdio)",
"streamableHttp": "可串流的 HTTP (streamableHttp)",
"stdio": "標準輸入 / 輸出 (stdio)",
"inMemory": "記憶體",
"config_description": "設定模型上下文協議伺服器",
"disable": "不使用 MCP 伺服器",
@ -1444,7 +1523,7 @@
"description": "描述",
"noDescriptionAvailable": "描述不存在",
"duplicateName": "已存在相同名稱的伺服器",
"editJson": "編輯JSON",
"editJson": "編輯 JSON",
"editServer": "編輯伺服器",
"env": "環境變數",
"envTooltip": "格式KEY=value每行一個",
@ -1455,10 +1534,10 @@
"install": "安裝",
"installError": "安裝相依套件失敗",
"installSuccess": "相依套件安裝成功",
"jsonFormatError": "JSON格式錯誤",
"jsonModeHint": "編輯MCP伺服器配置的JSON表示。保存前請確保格式正確",
"jsonSaveError": "保存JSON配置失敗",
"jsonSaveSuccess": "JSON配置已儲存",
"jsonFormatError": "JSON 格式錯誤",
"jsonModeHint": "編輯 MCP 伺服器配置的 JSON 表示。保存前請確保格式正確",
"jsonSaveError": "保存 JSON 配置失敗",
"jsonSaveSuccess": "JSON 配置已儲存",
"missingDependencies": "缺失,請安裝它以繼續",
"name": "名稱",
"noServers": "未設定伺服器",
@ -1477,7 +1556,8 @@
"version": "版本"
},
"errors": {
"32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整"
"32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整",
"toolNotFound": "未找到工具 {{name}}"
},
"serverPlural": "伺服器",
"serverSingular": "伺服器",
@ -1514,7 +1594,7 @@
"noResourcesAvailable": "無可用資源",
"availableResources": "可用資源",
"uri": "URI",
"mimeType": "MIME類型",
"mimeType": "MIME 類型",
"size": "大小",
"blob": "二進位數據",
"blobInvisible": "隱藏二進位數據",
@ -1525,6 +1605,7 @@
"registry": "套件管理源",
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題",
"registryDefault": "預設",
"customRegistryPlaceholder": "請輸入私有倉庫位址,如: https://npm.company.com",
"not_support": "不支援此模型",
"user": "用戶",
"system": "系統",
@ -1537,21 +1618,21 @@
"sync": {
"title": "同步伺服器",
"selectProvider": "選擇提供者:",
"discoverMcpServers": "發現MCP伺服器",
"discoverMcpServersDescription": "訪問平台以發現可用的MCP伺服器",
"discoverMcpServers": "發現 MCP 伺服器",
"discoverMcpServersDescription": "訪問平台以發現可用的 MCP 伺服器",
"getToken": "獲取 API 令牌",
"getTokenDescription": "從您的帳戶獲取個人 API 令牌",
"setToken": "輸入您的令牌",
"tokenRequired": "需要 API 令牌",
"tokenPlaceholder": "在此輸入 API 令牌",
"button": "同步",
"error": "同步MCP伺服器出錯",
"success": "同步MCP伺服器成功",
"error": "同步 MCP 伺服器出錯",
"success": "同步 MCP 伺服器成功",
"unauthorized": "同步未授權",
"noServersAvailable": "無可用的 MCP 伺服器"
},
"timeout": "超時",
"timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為60秒",
"timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為 60 秒",
"provider": "提供者",
"providerUrl": "提供者網址",
"logoUrl": "標誌網址",
@ -1561,7 +1642,7 @@
"advancedSettings": "高級設定"
},
"messages.prompt": "提示詞顯示",
"messages.tokens": "Token用量顯示",
"messages.tokens": "Token 用量顯示",
"messages.divider": "訊息間顯示分隔線",
"messages.divider.tooltip": "不適用於氣泡樣式消息",
"messages.grid_columns": "訊息網格展示列數",
@ -1574,11 +1655,11 @@
"messages.input.show_estimated_tokens": "顯示預估 Token 數",
"messages.input.title": "輸入設定",
"messages.input.enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單",
"messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件",
"messages.input.enable_delete_model": "啟用刪除鍵刪除模型 / 附件",
"messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息",
"messages.math_engine": "數學公式引擎",
"messages.math_engine.none": "無",
"messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
"messages.metrics": "首字延遲 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens",
"messages.model.title": "模型設定",
"messages.navigation": "訊息導航",
"messages.navigation.anchor": "對話錨點",
@ -1605,14 +1686,14 @@
"models.check.enable_concurrent": "並行檢查",
"models.check.enabled": "開啟",
"models.check.failed": "失敗",
"models.check.keys_status_count": "通過:{{count_passed}}個密鑰,失敗:{{count_failed}}個密鑰",
"models.check.keys_status_count": "通過:{{count_passed}} 個密鑰,失敗:{{count_failed}} 個密鑰",
"models.check.model_status_failed": "{{count}} 個模型完全無法訪問",
"models.check.model_status_partial": "其中 {{count}} 個模型用某些密鑰無法訪問",
"models.check.model_status_passed": "{{count}} 個模型通過健康檢查",
"models.check.model_status_summary": "{{provider}}: {{summary}}",
"models.check.no_api_keys": "未找到API密鑰請先添加API密鑰",
"models.check.no_api_keys": "未找到 API 密鑰,請先添加 API 密鑰",
"models.check.passed": "通過",
"models.check.select_api_key": "選擇要使用的API密鑰",
"models.check.select_api_key": "選擇要使用的 API 密鑰:",
"models.check.single": "單個",
"models.check.start": "開始",
"models.check.title": "模型健康檢查",
@ -1651,7 +1732,7 @@
"add.type": "供應商類型",
"api.url.preview": "預覽:{{url}}",
"api.url.reset": "重設",
"api.url.tip": "/結尾忽略 v1 版本,#結尾強制使用輸入位址",
"api.url.tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址",
"api_host": "API 主機地址",
"api_key": "API 金鑰",
"api_key.tip": "多個金鑰使用逗號分隔",
@ -1668,24 +1749,24 @@
"check_all_keys": "檢查所有金鑰",
"check_multiple_keys": "檢查多個 API 金鑰",
"oauth": {
"button": "使用{{provider}}帳號登入",
"description": "本服務由<website>{{provider}}</website>提供",
"button": "使用 {{provider}} 帳號登入",
"description": "本服務由 <website>{{provider}}</website> 提供",
"official_website": "官方網站"
},
"copilot": {
"auth_failed": "Github Copilot認證失敗",
"auth_failed": "Github Copilot 認證失敗",
"auth_success": "Github Copilot 認證成功",
"auth_success_title": "認證成功",
"code_failed": "獲取 Device Code失敗,請重試",
"code_failed": "獲取 Device Code 失敗,請重試",
"code_generated_desc": "請將設備代碼複製到下面的瀏覽器連結中",
"code_generated_title": "獲取設備代碼",
"confirm_login": "過度使用可能會導致您的 Github 帳號遭到封號,請謹慎使用!!!!",
"confirm_login": "過度使用可能會導致您的 Github 帳號遭到封號,請謹慎使用",
"confirm_title": "風險警告",
"connect": "連接 Github",
"custom_headers": "自訂請求標頭",
"description": "您的 Github 帳號需要訂閱 Copilot",
"expand": "展開",
"headers_description": "自訂請求標頭(json格式)",
"headers_description": "自訂請求標頭 (json 格式)",
"invalid_json": "JSON 格式錯誤",
"login": "登入 Github",
"logout": "退出 Github",
@ -1714,14 +1795,14 @@
"title": "模型提供者",
"notes": {
"title": "模型備註",
"placeholder": "輸入Markdown格式內容...",
"placeholder": "輸入 Markdown 格式內容...",
"markdown_editor_default_value": "預覽區域"
},
"openai": {
"alert": "OpenAI Provider 不再支援舊的呼叫方法。如果使用第三方 API請建立新的服務供應商"
},
"vertex_ai": {
"project_id": "專案ID",
"project_id": "專案 ID",
"project_id_placeholder": "your-google-cloud-project-id",
"project_id_help": "您的 Google Cloud 專案 ID",
"location": "地區",
@ -1775,7 +1856,7 @@
"reset_to_default": "重設為預設",
"search_message": "搜尋訊息",
"search_message_in_chat": "在當前對話中搜尋訊息",
"show_app": "顯示/隱藏應用程式",
"show_app": "顯示 / 隱藏應用程式",
"show_settings": "開啟設定",
"title": "快捷鍵",
"toggle_new_context": "清除上下文",
@ -1828,19 +1909,50 @@
"subscribe_url": "訂閱源地址",
"subscribe_name": "替代名稱",
"subscribe_name.placeholder": "當下載的訂閱源沒有名稱時所使用的替代名稱",
"subscribe_add_success": "訂閱源添加成功!",
"subscribe_add_success": "訂閱源添加成功",
"subscribe_delete": "刪除",
"title": "網路搜尋",
"overwrite": "覆蓋搜尋服務商",
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
"apikey": "API 金鑰",
"free": "免費",
"content_limit": "內容長度限制",
"content_limit_tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷"
"compression": {
"title": "搜尋結果壓縮",
"method": "壓縮方法",
"method.none": "不壓縮",
"method.cutoff": "截斷",
"cutoff.limit": "截斷長度",
"cutoff.limit.placeholder": "輸入長度",
"cutoff.limit.tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字符)",
"cutoff.unit.char": "字符",
"cutoff.unit.token": "Token",
"method.rag": "RAG",
"rag.document_count": "文檔數量",
"rag.document_count.default": "預設",
"rag.document_count.tooltip": "預期從單個搜尋結果中提取的文檔數量,實際提取的總數量是這個值乘以搜尋結果數量。",
"rag.embedding_dimensions.auto_get": "自動獲取維度",
"rag.embedding_dimensions.placeholder": "不設置維度",
"rag.embedding_dimensions.tooltip": "留空則不傳遞 dimensions 參數",
"info": {
"dimensions_auto_success": "維度自動獲取成功,維度為 {{dimensions}}"
},
"error": {
"embedding_model_required": "請先選擇嵌入模型",
"dimensions_auto_failed": "維度自動獲取失敗",
"provider_not_found": "未找到服務商",
"rag_failed": "RAG 失敗"
}
}
},
"general.auto_check_update.title": "自動更新",
"general.early_access.title": "搶先體驗",
"general.early_access.tooltip": "開啟後,將使用 GitHub 的最新版本,下載速度可能較慢,請務必提前備份數據",
"general.test_plan.title": "測試計畫",
"general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據",
"general.test_plan.beta_version": "測試版本 (Beta)",
"general.test_plan.beta_version_tooltip": "功能可能會隨時變化,錯誤較多,升級較快",
"general.test_plan.rc_version": "預覽版本 (RC)",
"general.test_plan.rc_version_tooltip": "相對穩定,請務必提前備份數據",
"general.test_plan.version_options": "版本選項",
"general.test_plan.version_channel_not_match": "預覽版和測試版的切換將在下一個正式版發布時生效",
"quickPhrase": {
"title": "快捷短語",
"add": "新增短語",
@ -1848,7 +1960,7 @@
"titleLabel": "標題",
"contentLabel": "內容",
"titlePlaceholder": "請輸入短語標題",
"contentPlaceholder": "請輸入短語內容,支持使用變量,然後按Tab鍵可以快速定位到變量進行修改。比如\n幫我規劃從${from}到${to}的行程,然後發送到${email}",
"contentPlaceholder": "請輸入短語內容,支持使用變量,然後按 Tab 鍵可以快速定位到變量進行修改。比如:\n幫我規劃從 ${from} ${to} 的行程,然後發送到 ${email}",
"delete": "刪除短語",
"deleteConfirm": "刪除後無法復原,是否繼續?",
"locationLabel": "添加位置",
@ -1874,7 +1986,7 @@
"reset": "重置"
},
"openai": {
"title": "OpenAI設定",
"title": "OpenAI 設定",
"summary_text_mode.title": "摘要模式",
"summary_text_mode.tip": "模型所執行的推理摘要",
"summary_text_mode.auto": "自動",
@ -1995,11 +2107,11 @@
"trigger_mode": {
"title": "取詞方式",
"description": "劃詞後,觸發取詞並顯示工具列的方式",
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應可能導致部分應用程式無法劃詞。",
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了 AHK 等工具對 Ctrl 鍵進行了重新對應,可能導致部分應用程式無法劃詞。",
"selected": "劃詞",
"selected_note": "劃詞後,立即顯示工具列",
"ctrlkey": "Ctrl 鍵",
"ctrlkey_note": "劃詞後,再 按住 Ctrl鍵,才顯示工具列",
"ctrlkey_note": "劃詞後,再 按住 Ctrl 鍵,才顯示工具列",
"shortcut": "快捷鍵",
"shortcut_note": "劃詞後,使用快捷鍵顯示工具列。請在快捷鍵設定頁面中設置取詞快捷鍵並啟用。",
"shortcut_link": "前往快捷鍵設定"
@ -2029,7 +2141,7 @@
},
"opacity": {
"title": "透明度",
"description": "設置視窗的預設透明度100%為完全不透明"
"description": "設置視窗的預設透明度100% 為完全不透明"
}
},
"actions": {
@ -2042,7 +2154,7 @@
},
"add_tooltip": {
"enabled": "新增自訂功能",
"disabled": "自訂功能已達上限 ({{max}}個)"
"disabled": "自訂功能已達上限 ({{max}} 個)"
},
"delete_confirm": "確定要刪除這個自訂功能嗎?",
"drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})"
@ -2074,7 +2186,7 @@
"label": "圖示",
"placeholder": "輸入 Lucide 圖示名稱",
"error": "無效的圖示名稱,請檢查輸入",
"tooltip": "Lucide圖示名稱為小寫,如 arrow-right",
"tooltip": "Lucide 圖示名稱為小寫,如 arrow-right",
"view_all": "檢視所有圖示",
"random": "隨機圖示"
},
@ -2089,9 +2201,9 @@
"default": "預設"
},
"prompt": {
"label": "使用者提示詞(Prompt)",
"label": "使用者提示詞 (Prompt)",
"tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞",
"placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾",
"placeholder": "使用佔位符 {{text}} 代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾",
"placeholder_text": "佔位符",
"copy_placeholder": "複製佔位符"
}
@ -2106,7 +2218,7 @@
"name": {
"label": "自訂名稱",
"hint": "請輸入搜尋引擎名稱",
"max_length": "名稱不能超過16個字元"
"max_length": "名稱不能超過 16 個字元"
},
"url": {
"label": "自訂搜尋 URL",
@ -2120,7 +2232,7 @@
},
"filter_modal": {
"title": "應用篩選名單",
"user_tips": "請輸入應用的執行檔名稱每行一個不區分大小寫可以模糊匹配。例如chrome.exe、weixin.exe、Cherry Studio.exe等"
"user_tips": "請輸入應用的執行檔名稱每行一個不區分大小寫可以模糊匹配。例如chrome.exe、weixin.exe、Cherry Studio.exe 等"
}
}
}

View File

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

View File

@ -75,8 +75,8 @@ const AgentsPage: FC = () => {
{agent.description && <AgentDescription>{agent.description}</AgentDescription>}
{agent.prompt && (
<AgentPrompt>
<ReactMarkdown className="markdown">{agent.prompt}</ReactMarkdown>{' '}
<AgentPrompt className="markdown">
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
</AgentPrompt>
)}
</Flex>

View File

@ -7,13 +7,11 @@ import {
} from '@ant-design/icons'
import { NavbarCenter, NavbarMain } 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, Checkbox, Dropdown, Empty, Flex, Popconfirm } from 'antd'
import dayjs from 'dayjs'
@ -39,34 +37,6 @@ const FilesPage: FC = () => {
setSelectedFileIds([])
}, [fileType])
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)
@ -74,108 +44,7 @@ const FilesPage: FC = () => {
return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
}, [fileType])
const sortedFiles = files ? sortFiles(files) : []
const handleDelete = async (fileId: string) => {
const file = await FileManager.getFile(fileId)
if (!file) return
const paintings = await store.getState().paintings.paintings
const paintingsFiles = paintings.flatMap((p) => p.files)
if (paintingsFiles.some((p) => p.id === fileId)) {
window.modal.warning({ content: t('files.delete.paintings.warning'), centered: true })
return
}
if (file) {
await FileManager.deleteFile(fileId, true)
}
const relatedBlocks = await db.message_blocks.where('file.id').equals(fileId).toArray()
const blockIdsToDelete = relatedBlocks.map((block) => block.id)
const blocksByMessageId: Record<string, string[]> = {}
for (const block of relatedBlocks) {
if (!blocksByMessageId[block.messageId]) {
blocksByMessageId[block.messageId] = []
}
blocksByMessageId[block.messageId].push(block.id)
}
try {
const affectedMessageIds = [...new Set(relatedBlocks.map((b) => b.messageId))]
if (affectedMessageIds.length === 0 && blockIdsToDelete.length > 0) {
// This case should ideally not happen if relatedBlocks were found,
// but handle it just in case: only delete blocks.
await db.message_blocks.bulkDelete(blockIdsToDelete)
Logger.log(
`Deleted ${blockIdsToDelete.length} blocks related to file ${fileId}. No associated messages found (unexpected).`
)
return
}
await db.transaction('rw', db.topics, db.message_blocks, async () => {
// Fetch all topics (potential performance bottleneck if many topics)
const allTopics = await db.topics.toArray()
const topicsToUpdate: Record<string, { messages: Message[] }> = {} // Store updates keyed by topicId
for (const topic of allTopics) {
let topicModified = false
// Ensure topic.messages exists and is an array before mapping
const currentMessages = Array.isArray(topic.messages) ? topic.messages : []
const updatedMessages = currentMessages.map((message) => {
// Check if this message is affected
if (affectedMessageIds.includes(message.id)) {
// Ensure message.blocks exists and is an array
const currentBlocks = Array.isArray(message.blocks) ? message.blocks : []
const originalBlockCount = currentBlocks.length
// Filter out the blocks marked for deletion
const newBlocks = currentBlocks.filter((blockId) => !blockIdsToDelete.includes(blockId))
if (newBlocks.length < originalBlockCount) {
topicModified = true
return { ...message, blocks: newBlocks } // Return updated message
}
}
return message // Return original message
})
if (topicModified) {
// Store the update for this topic
topicsToUpdate[topic.id] = { messages: updatedMessages }
}
}
// Apply updates to topics
const updatePromises = Object.entries(topicsToUpdate).map(([topicId, updateData]) =>
db.topics.update(topicId, updateData)
)
await Promise.all(updatePromises)
// Finally, delete the MessageBlocks
await db.message_blocks.bulkDelete(blockIdsToDelete)
})
Logger.log(`Deleted ${blockIdsToDelete.length} blocks and updated relevant topic messages for file ${fileId}.`)
setSelectedFileIds((prev) => prev.filter((id) => id !== fileId))
} catch (error) {
Logger.error(`Error updating topics or deleting blocks for file ${fileId}:`, error)
window.modal.error({ content: t('files.delete.db_error'), centered: true }) // 提示数据库操作失败
// Consider whether to attempt to restore the physical file (usually difficult)
}
}
const handleRename = async (fileId: string) => {
const file = await FileManager.getFile(fileId)
if (file) {
const newName = await TextEditPopup.show({ text: file.origin_name })
if (newName) {
FileManager.updateFile({ ...file, origin_name: newName })
}
}
}
const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : []
const handleBatchDelete = async () => {
const selectedFiles = await Promise.all(selectedFileIds.map((id) => FileManager.getFile(id)))
@ -195,7 +64,7 @@ const FilesPage: FC = () => {
}
for (const fileId of selectedFileIds) {
await handleDelete(fileId)
await handleDelete(fileId, t, setSelectedFileIds)
}
setSelectedFileIds([])
@ -236,7 +105,7 @@ const FilesPage: FC = () => {
description={t('files.delete.content', { count: 1 })}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onConfirm={() => handleDelete(file.id)}
onConfirm={() => handleDelete(file.id, t, setSelectedFileIds)}
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
@ -402,7 +271,6 @@ const SideNav = styled.div`
background-color: var(--color-background-soft);
color: var(--color-primary);
border: 0.5px solid var(--color-border);
color: var(--color-text);
}
}
`

View File

@ -1,8 +1,11 @@
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { handleDelete } from '@renderer/services/FileAction'
import FileManager from '@renderer/services/FileManager'
import { FileType } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { Col, Image, Spin } from 'antd'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ImageItemProps {
@ -11,6 +14,7 @@ interface ImageItemProps {
const ImageItem: React.FC<ImageItemProps> = ({ file }) => {
const [loading, setLoading] = useState(true)
const { t } = useTranslation()
return (
<Col xs={24} sm={12} md={8} lg={4} xl={3}>
<ImageWrapper>
@ -32,6 +36,24 @@ const ImageItem: React.FC<ImageItemProps> = ({ file }) => {
<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>
)
@ -102,4 +124,26 @@ const ImageInfo = styled.div`
}
`
const DeleteButton = styled.div`
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
&:hover {
background-color: rgba(255, 0, 0, 0.8);
}
`
export default memo(ImageItem)

View File

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

View File

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

View File

@ -4,14 +4,16 @@ import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { getTopicById } from '@renderer/hooks/useTopic'
import { useAppSelector } from '@renderer/store'
import { selectActiveAssistants } from '@renderer/store/assistants'
import { selectAllTopics } from '@renderer/store/topics'
import { Topic } from '@renderer/types'
import { Button, Divider, Empty } from 'antd'
import { Button, Divider, Empty, Segmented } from 'antd'
import dayjs from 'dayjs'
import { groupBy, isEmpty, orderBy } from 'lodash'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
type SortType = 'createdAt' | 'updatedAt'
type Props = {
keywords: string
onClick: (topic: Topic) => void
@ -19,19 +21,19 @@ type Props = {
} & React.HTMLAttributes<HTMLDivElement>
const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props }) => {
const topics = useAppSelector(selectAllTopics)
const assistants = useAppSelector(selectActiveAssistants)
const { t } = useTranslation()
const { handleScroll, containerRef } = useScrollPosition('TopicsHistory')
const [sortType, setSortType] = useState<SortType>('createdAt')
const orderedTopics = orderBy(topics, 'createdAt', 'desc')
const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), sortType, 'desc')
const filteredTopics = orderedTopics.filter((topic) => {
const filteredTopics = topics.filter((topic) => {
return topic.name.toLowerCase().includes(keywords.toLowerCase())
})
const groupedTopics = groupBy(filteredTopics, (topic) => {
return dayjs(topic.createdAt).format('MM/DD')
return dayjs(topic[sortType]).format('MM/DD')
})
// 创建助手映射表
@ -58,6 +60,16 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
return (
<ListContainer {...props} ref={containerRef} onScroll={handleScroll}>
<Segmented
shape="round"
size="small"
value={sortType}
onChange={setSortType}
options={[
{ label: t('export.created'), value: 'createdAt' },
{ label: t('export.last_updated'), value: 'updatedAt' }
]}
/>
<ContainerWrapper>
{Object.entries(groupedTopics).map(([date, items]) => (
<ListItem key={date}>
@ -80,7 +92,7 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
</AssistantTag>
)}
</TopicContent>
<TopicDate>{dayjs(topic.updatedAt).format('HH:mm')}</TopicDate>
<TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate>
</TopicItem>
)
})}
@ -113,7 +125,7 @@ const ListContainer = styled.div`
overflow-y: scroll;
width: 100%;
align-items: center;
padding-top: 20px;
padding-top: 10px;
padding-bottom: 20px;
`

View File

@ -5,6 +5,7 @@ import { useChat } from '@renderer/hooks/useChat'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { debounce } from 'lodash'
import React, { FC, useState } from 'react'
@ -36,28 +37,30 @@ const Chat: FC = () => {
}
})
const contentSearchFilter = (node: Node): boolean => {
if (node.parentNode) {
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
while (parentNode?.parentNode) {
if (parentNode.classList.contains('MessageFooter')) {
return false
}
const contentSearchFilter: NodeFilter = {
acceptNode(node) {
if (node.parentNode) {
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
while (parentNode?.parentNode) {
if (parentNode.classList.contains('MessageFooter')) {
return NodeFilter.FILTER_REJECT
}
if (filterIncludeUser) {
if (parentNode?.classList.contains('message-content-container')) {
return true
}
} else {
if (parentNode?.classList.contains('message-content-container-assistant')) {
return true
if (filterIncludeUser) {
if (parentNode?.classList.contains('message-content-container')) {
return NodeFilter.FILTER_ACCEPT
}
} else {
if (parentNode?.classList.contains('message-content-container-assistant')) {
return NodeFilter.FILTER_ACCEPT
}
}
parentNode = parentNode.parentNode as HTMLElement
}
parentNode = parentNode.parentNode as HTMLElement
return NodeFilter.FILTER_REJECT
} else {
return NodeFilter.FILTER_REJECT
}
return false
} else {
return false
}
}
@ -88,14 +91,13 @@ const Chat: FC = () => {
}
return (
<Main ref={mainRef} id="chat-main" className={messageStyle} vertical flex={1} justify="space-between">
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
<Main
ref={mainRef}
id="chat-main"
className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}
vertical
flex={1}
justify="space-between">
<Messages
key={activeTopic.id}
assistant={activeAssistant}
@ -104,6 +106,13 @@ const Chat: FC = () => {
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
<QuickPanelProvider>
<Inputbar />
{isMultiSelectMode && <MultiSelectActionPopup topic={activeTopic} />}

View File

@ -1,9 +1,7 @@
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
import { FileType, Model } from '@renderer/types'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { FileType } from '@renderer/types'
import { Tooltip } from 'antd'
import { Paperclip } from 'lucide-react'
import { FC, useCallback, useImperativeHandle, useMemo } from 'react'
import { FC, useCallback, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
export interface AttachmentButtonRef {
@ -12,30 +10,25 @@ export interface AttachmentButtonRef {
interface Props {
ref?: React.RefObject<AttachmentButtonRef | null>
model: Model
couldAddImageFile: boolean
extensions: string[]
files: FileType[]
setFiles: (files: FileType[]) => void
ToolbarButton: any
disabled?: boolean
}
const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButton, disabled }) => {
const AttachmentButton: FC<Props> = ({
ref,
couldAddImageFile,
extensions,
files,
setFiles,
ToolbarButton,
disabled
}) => {
const { t } = useTranslation()
// const extensions = useMemo(
// () => (isVisionModel(model) ? [...imageExts, ...documentExts, ...textExts] : [...documentExts, ...textExts]),
// [model]
// )
const extensions = useMemo(() => {
if (isVisionModel(model)) {
return [...imageExts, ...documentExts, ...textExts]
} else if (isGenerateImageModel(model)) {
return [...imageExts]
} else {
return [...documentExts, ...textExts]
}
}, [model])
const onSelectFile = useCallback(async () => {
const _files = await window.api.file.select({
properties: ['openFile', 'multiSelections'],
@ -61,12 +54,7 @@ const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButto
}))
return (
<Tooltip
placement="top"
title={
isVisionModel(model) || isGenerateImageModel(model) ? t('chat.input.upload') : t('chat.input.upload.document')
}
arrow>
<Tooltip placement="top" title={couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document')} arrow>
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
</ToolbarButton>

View File

@ -4,17 +4,18 @@ import TranslateButton from '@renderer/components/TranslateButton'
import Logger from '@renderer/config/logger'
import {
isGenerateImageModel,
isGenerateImageModels,
isSupportedDisableGenerationModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
isVisionModel,
isVisionModels,
isWebSearchModel
} from '@renderer/config/models'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChat } from '@renderer/hooks/useChat'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
@ -32,12 +33,11 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setSearching } from '@renderer/store/runtime'
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
import { FileType, KnowledgeBase, KnowledgeItem, Model } from '@renderer/types'
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model } from '@renderer/types'
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
import { formatQuotedText } from '@renderer/utils/formats'
import { getFilesFromDropEvent } from '@renderer/utils/input'
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
import { getFilesFromDropEvent, getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { Button, Tooltip } from 'antd'
@ -75,7 +75,8 @@ const Inputbar: FC = () => {
showInputEstimatedTokens,
autoTranslateWithSpace,
enableQuickPanelTriggers,
enableBackspaceDeleteModel
enableBackspaceDeleteModel,
enableSpellCheck
} = useSettings()
const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
@ -92,18 +93,57 @@ const Inputbar: FC = () => {
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
const [mentionModels, setMentionModels] = useState<Model[]>([])
const [mentionedModels, setMentionedModels] = useState<Model[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isFileDragging, setIsFileDragging] = useState(false)
const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0)
const startHeight = useRef<number>(0)
const currentMessageId = useRef<string>('')
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
const { activedMcpServers } = useMCPServers()
const { bases: knowledgeBases } = useKnowledgeBases()
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
const isVisionAssistant = useMemo(() => isVisionModel(model), [model])
const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model])
const isVisionSupported = useMemo(
() =>
(mentionedModels.length > 0 && isVisionModels(mentionedModels)) ||
(mentionedModels.length === 0 && isVisionAssistant),
[mentionedModels, isVisionAssistant]
)
const isGenerateImageSupported = useMemo(
() =>
(mentionedModels.length > 0 && isGenerateImageModels(mentionedModels)) ||
(mentionedModels.length === 0 && isGenerateImageAssistant),
[mentionedModels, isGenerateImageAssistant]
)
// 仅允许在不含图片文件时mention非视觉模型
const couldMentionNotVisionModel = useMemo(() => {
return !files.some((file) => file.type === FileTypes.IMAGE)
}, [files])
// 允许在支持视觉或生成图片时添加图片文件
const couldAddImageFile = useMemo(() => {
return isVisionSupported || isGenerateImageSupported
}, [isVisionSupported, isGenerateImageSupported])
const couldAddTextFile = useMemo(() => {
return isVisionSupported || (!isVisionSupported && !isGenerateImageSupported)
}, [isGenerateImageSupported, isVisionSupported])
const supportedExts = useMemo(() => {
if (couldAddImageFile && couldAddTextFile) {
return [...imageExts, ...documentExts, ...textExts]
} else if (couldAddImageFile) {
return [...imageExts]
} else if (couldAddTextFile) {
return [...documentExts, ...textExts]
} else {
return []
}
}, [couldAddImageFile, couldAddTextFile])
const quickPanel = useQuickPanel()
@ -176,20 +216,9 @@ const Inputbar: FC = () => {
if (uploadedFiles) {
baseUserMessage.files = uploadedFiles
}
const knowledgeBaseIds = selectedKnowledgeBases?.map((base) => base.id)
if (knowledgeBaseIds) {
baseUserMessage.knowledgeBaseIds = knowledgeBaseIds
}
if (mentionModels) {
baseUserMessage.mentions = mentionModels
}
if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) {
baseUserMessage.enabledMCPs = activedMcpServers.filter((server) =>
assistant.mcpServers?.some((s) => s.id === server.id)
)
if (mentionedModels) {
baseUserMessage.mentions = mentionedModels
}
const assistantWithTopicPrompt = topic.prompt
@ -212,19 +241,7 @@ const Inputbar: FC = () => {
} catch (error) {
console.error('Failed to send message:', error)
}
}, [
activedMcpServers,
assistant,
dispatch,
files,
inputEmpty,
loading,
mentionModels,
resizeTextArea,
selectedKnowledgeBases,
text,
topic
])
}, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, text, topic])
const translate = useCallback(async () => {
if (isTranslating) {
@ -288,7 +305,7 @@ const Inputbar: FC = () => {
description: '',
icon: <Upload />,
action: () => {
inputbarToolsRef.current?.openQuickPanel()
inputbarToolsRef.current?.openAttachmentQuickPanel()
}
},
...knowledgeBases.map((base) => {
@ -369,8 +386,9 @@ const Inputbar: FC = () => {
//other keys should be ignored
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
if (isEnterPressed) {
if (quickPanel.isVisible) return event.preventDefault()
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
if (quickPanel.isVisible) return event.preventDefault()
sendMessage()
return event.preventDefault()
} else {
@ -398,8 +416,8 @@ const Inputbar: FC = () => {
}
}
if (enableBackspaceDeleteModel && event.key === 'Backspace' && text.trim() === '' && mentionModels.length > 0) {
setMentionModels((prev) => prev.slice(0, -1))
if (enableBackspaceDeleteModel && event.key === 'Backspace' && text.trim() === '' && mentionedModels.length > 0) {
setMentionedModels((prev) => prev.slice(0, -1))
return event.preventDefault()
}
@ -459,36 +477,39 @@ const Inputbar: FC = () => {
const onInput = () => !expended && resizeTextArea()
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value
setText(newText)
const onChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value
setText(newText)
const textArea = textareaRef.current?.resizableTextArea?.textArea
const cursorPosition = textArea?.selectionStart ?? 0
const lastSymbol = newText[cursorPosition - 1]
const textArea = textareaRef.current?.resizableTextArea?.textArea
const cursorPosition = textArea?.selectionStart ?? 0
const lastSymbol = newText[cursorPosition - 1]
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
model,
text: newText,
openSelectFileMenu,
translate
}) || []
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
couldAddImageFile,
text: newText,
openSelectFileMenu,
translate
}) || []
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
symbol: '/'
})
}
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
symbol: '/'
})
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
inputbarToolsRef.current?.openMentionModelsPanel()
}
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
inputbarToolsRef.current?.openMentionModelsPanel()
}
},
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]
)
const onPaste = useCallback(
async (event: ClipboardEvent) => {
@ -496,7 +517,7 @@ const Inputbar: FC = () => {
event,
isVisionModel(model),
isGenerateImageModel(model),
supportExts,
supportedExts,
setFiles,
setText,
pasteLongTextAsFile,
@ -506,7 +527,7 @@ const Inputbar: FC = () => {
t
)
},
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t, text]
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportedExts, t, text]
)
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
@ -527,35 +548,38 @@ const Inputbar: FC = () => {
setIsFileDragging(false)
}
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsFileDragging(false)
const handleDrop = useCallback(
async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsFileDragging(false)
const files = await getFilesFromDropEvent(e).catch((err) => {
Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err)
return null
})
if (files) {
let supportedFiles = 0
files.forEach((file) => {
if (supportExts.includes(getFileExtension(file.path))) {
setFiles((prevFiles) => [...prevFiles, file])
supportedFiles++
}
const files = await getFilesFromDropEvent(e).catch((err) => {
Logger.error('[Inputbar] handleDrop:', err)
return null
})
// 如果有文件,但都不支持
if (files.length > 0 && supportedFiles === 0) {
window.message.info({
key: 'file_not_supported',
content: t('chat.input.file_not_supported')
if (files) {
let supportedFiles = 0
files.forEach((file) => {
if (supportedExts.includes(getFileExtension(file.path))) {
setFiles((prevFiles) => [...prevFiles, file])
supportedFiles++
}
})
// 如果有文件,但都不支持
if (files.length > 0 && supportedFiles === 0) {
window.message.info({
key: 'file_not_supported',
content: t('chat.input.file_not_supported')
})
}
}
}
}
},
[supportedExts, t]
)
const onTranslated = (translatedText: string) => {
setText(translatedText)
@ -697,7 +721,7 @@ const Inputbar: FC = () => {
}
const handleRemoveModel = (model: Model) => {
setMentionModels(mentionModels.filter((m) => m.id !== model.id))
setMentionedModels(mentionedModels.filter((m) => m.id !== model.id))
}
const handleRemoveKnowledgeBase = (knowledgeBase: KnowledgeBase) => {
@ -728,13 +752,21 @@ const Inputbar: FC = () => {
}
}, [assistant, model, updateAssistant])
const onMentionModel = useCallback((model: Model) => {
setMentionModels((prev) => {
const modelId = getModelUniqId(model)
const exists = prev.some((m) => getModelUniqId(m) === modelId)
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
})
}, [])
const onMentionModel = useCallback(
(model: Model) => {
// 我想应该没有模型是只支持视觉而不支持文本的?
if (isVisionModel(model) || couldMentionNotVisionModel) {
setMentionedModels((prev) => {
const modelId = getModelUniqId(model)
const exists = prev.some((m) => getModelUniqId(m) === modelId)
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
})
} else {
console.error('在已上传图片时,不能添加非视觉模型')
}
},
[couldMentionNotVisionModel]
)
const onToggleExpended = () => {
const currentlyExpanded = expended || !!textareaHeight
@ -762,13 +794,13 @@ const Inputbar: FC = () => {
}
return (
<Container
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
className="inputbar">
<NarrowLayout style={{ width: '100%' }}>
<NarrowLayout style={{ width: '100%' }}>
<Container
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
className="inputbar">
<QuickPanelView setInputText={setText} />
<InputBarContainer
id="inputbar"
@ -781,8 +813,8 @@ const Inputbar: FC = () => {
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
/>
)}
{mentionModels.length > 0 && (
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
{mentionedModels.length > 0 && (
<MentionModelsInput selectedModels={mentionedModels} onRemoveModel={handleRemoveModel} />
)}
<Textarea
value={text}
@ -794,10 +826,10 @@ const Inputbar: FC = () => {
: t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })
}
autoFocus
contextMenu="true"
variant="borderless"
spellCheck={false}
spellCheck={enableSpellCheck}
rows={2}
autoSize={textareaHeight ? false : { minRows: 2, maxRows: 20 }}
ref={textareaRef}
style={{
fontSize,
@ -827,6 +859,7 @@ const Inputbar: FC = () => {
assistant={assistant}
model={model}
files={files}
extensions={supportedExts}
setFiles={setFiles}
showThinkingButton={showThinkingButton}
showKnowledgeIcon={showKnowledgeIcon}
@ -834,8 +867,10 @@ const Inputbar: FC = () => {
handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
setText={setText}
resizeTextArea={resizeTextArea}
mentionModels={mentionModels}
mentionModels={mentionedModels}
onMentionModel={onMentionModel}
couldMentionNotVisionModel={couldMentionNotVisionModel}
couldAddImageFile={couldAddImageFile}
onEnableGenerateImage={onEnableGenerateImage}
isExpended={isExpended}
onToggleExpended={onToggleExpended}
@ -866,8 +901,8 @@ const Inputbar: FC = () => {
</ToolbarMenu>
</Toolbar>
</InputBarContainer>
</NarrowLayout>
</Container>
</Container>
</NarrowLayout>
)
}
@ -902,6 +937,7 @@ const Container = styled.div`
flex-direction: column;
position: relative;
z-index: 2;
padding: 0 16px 16px 16px;
`
const InputBarContainer = styled.div`
@ -909,9 +945,8 @@ const InputBarContainer = styled.div`
transition: all 0.2s ease;
position: relative;
margin: 16px 20px;
margin-top: 0;
border-radius: 15px;
padding-top: 6px; // 为拖动手柄留出空间
padding-top: 8px; // 为拖动手柄留出空间
background-color: var(--color-background-opacity);
&.file-dragging {
@ -952,6 +987,9 @@ const Textarea = styled(TextArea)`
.ant-input-textarea-show-count::after {
transition: none !important;
}
&::-webkit-scrollbar {
width: 3px;
}
`
const Toolbar = styled.div`

View File

@ -1,6 +1,6 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
import { isGenerateImageModel } from '@renderer/config/models'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
@ -42,21 +42,21 @@ export interface InputbarToolsRef {
getQuickPanelMenu: (params: {
t: (key: string, options?: any) => string
files: FileType[]
model: Model
couldAddImageFile: boolean
text: string
openSelectFileMenu: () => void
translate: () => void
}) => QuickPanelListItem[]
openMentionModelsPanel: () => void
openQuickPanel: () => void
openAttachmentQuickPanel: () => void
}
export interface InputbarToolsProps {
assistant: Assistant
model: Model
files: FileType[]
setFiles: (files: FileType[]) => void
extensions: string[]
showThinkingButton: boolean
showKnowledgeIcon: boolean
selectedKnowledgeBases: KnowledgeBase[]
@ -65,6 +65,8 @@ export interface InputbarToolsProps {
resizeTextArea: () => void
mentionModels: Model[]
onMentionModel: (model: Model) => void
couldMentionNotVisionModel: boolean
couldAddImageFile: boolean
onEnableGenerateImage: () => void
isExpended: boolean
onToggleExpended: () => void
@ -104,6 +106,8 @@ const InputbarTools = ({
resizeTextArea,
mentionModels,
onMentionModel,
couldMentionNotVisionModel,
couldAddImageFile,
onEnableGenerateImage,
isExpended,
onToggleExpended,
@ -111,7 +115,8 @@ const InputbarTools = ({
clearTopic,
onNewContext,
newTopicShortcut,
cleanTopicShortcut
cleanTopicShortcut,
extensions
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
@ -153,12 +158,12 @@ const InputbarTools = ({
const getQuickPanelMenuImpl = (params: {
t: (key: string, options?: any) => string
files: FileType[]
model: Model
couldAddImageFile: boolean
text: string
openSelectFileMenu: () => void
translate: () => void
}): QuickPanelListItem[] => {
const { t, files, model, text, openSelectFileMenu, translate } = params
const { t, files, couldAddImageFile, text, openSelectFileMenu, translate } = params
return [
{
@ -226,7 +231,7 @@ const InputbarTools = ({
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
label: couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',
icon: <Paperclip />,
isMenu: true,
@ -276,7 +281,7 @@ const InputbarTools = ({
useImperativeHandle(ref, () => ({
getQuickPanelMenu: getQuickPanelMenuImpl,
openMentionModelsPanel: () => mentionModelsButtonRef.current?.openQuickPanel(),
openQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
openAttachmentQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
}))
const toolButtons = useMemo<ToolButtonConfig[]>(() => {
@ -298,7 +303,8 @@ const InputbarTools = ({
component: (
<AttachmentButton
ref={attachmentButtonRef}
model={model}
couldAddImageFile={couldAddImageFile}
extensions={extensions}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
@ -364,9 +370,11 @@ const InputbarTools = ({
component: (
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionModels={mentionModels}
mentionedModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
couldMentionNotVisionModel={couldMentionNotVisionModel}
files={files}
/>
)
},
@ -416,6 +424,9 @@ const InputbarTools = ({
assistant,
cleanTopicShortcut,
clearTopic,
couldAddImageFile,
couldMentionNotVisionModel,
extensions,
files,
handleKnowledgeBaseSelect,
isExpended,

View File

@ -1,16 +1,16 @@
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { useQuickPanel } from '@renderer/components/QuickPanel'
import { QuickPanelListItem } from '@renderer/components/QuickPanel/types'
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { FileType, Model } from '@renderer/types'
import { Avatar, Tooltip } from 'antd'
import { useLiveQuery } from 'dexie-react-hooks'
import { first, sortBy } from 'lodash'
import { AtSign, Plus } from 'lucide-react'
import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { FC, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
@ -21,12 +21,21 @@ export interface MentionModelsButtonRef {
interface Props {
ref?: React.RefObject<MentionModelsButtonRef | null>
mentionModels: Model[]
mentionedModels: Model[]
onMentionModel: (model: Model) => void
couldMentionNotVisionModel: boolean
files: FileType[]
ToolbarButton: any
}
const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, ToolbarButton }) => {
const MentionModelsButton: FC<Props> = ({
ref,
mentionedModels,
onMentionModel,
couldMentionNotVisionModel,
files,
ToolbarButton
}) => {
const { providers } = useProviders()
const { t } = useTranslation()
const navigate = useNavigate()
@ -49,6 +58,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
p.models
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.filter((m) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(m)))
.map((m) => ({
label: (
<>
@ -64,7 +74,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
),
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
action: () => onMentionModel(m),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
)
@ -77,7 +87,8 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
const providerModels = sortBy(
p.models
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
.filter((m) => !pinnedModels.includes(getModelUniqId(m))),
.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
.filter((m) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(m))),
['group', 'name']
)
@ -96,7 +107,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
),
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
action: () => onMentionModel(m),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
if (providerModelItems.length > 0) {
@ -112,7 +123,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
})
return items
}, [providers, t, pinnedModels, mentionModels, onMentionModel, navigate])
}, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate])
const openQuickPanel = useCallback(() => {
quickPanel.open({
@ -134,6 +145,18 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
}
}, [openQuickPanel, quickPanel])
const filesRef = useRef(files)
useEffect(() => {
// 检查files是否变化
if (filesRef.current !== files) {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
quickPanel.close()
}
filesRef.current = files
}
}, [files, quickPanel])
useImperativeHandle(ref, () => ({
openQuickPanel
}))

Some files were not shown because too many files have changed in this diff Show More