diff --git a/.vscode/settings.json b/.vscode/settings.json index bb7889776d..47640bff09 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,8 @@ { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.fixAll.eslint": "explicit", + "source.organizeImports": "explicit" }, "search.exclude": { "**/dist/**": true, diff --git a/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch b/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch new file mode 100644 index 0000000000..d5f7a89edb --- /dev/null +++ b/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch @@ -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` + })); + }; + } diff --git a/README.md b/README.md index b589376b7b..f08ad88fda 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ +
+
+ 🌐 Language +
+
+

English

+

简体中文

+

繁體中文

+

日本語

+

한국어

+

हिन्दी

+

ไทย

+

Français

+

Deutsch

+

Español

+

Itapano

+

Русский

+

Português

+

Nederlands

+

Polski

+

العربية

+

فارسی

+

Türkçe

+

Tiếng Việt

+

Bahasa Indonesia

+
+
+
+
+

banner
@@ -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
● **Employee** Management
● Shared **Knowledge Base**
● **Access** Control
● **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) + [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 + [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 + [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 diff --git a/electron-builder.yml b/electron-builder.yml index c1279a67e2..ecbbc10057 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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 服务 + 其他错误修复和优化 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 9b22ffc33b..fa4b234a54 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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'), diff --git a/package.json b/package.json index 67cb221c4e..0bfc8c30bb 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index eee8f6d740..0d90b41c0e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -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', diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 5a3465f648..2ed5dbc7cc 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -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'] diff --git a/packages/shared/config/languages.ts b/packages/shared/config/languages.ts new file mode 100644 index 0000000000..4cd7d533b4 --- /dev/null +++ b/packages/shared/config/languages.ts @@ -0,0 +1,2904 @@ +/** + * 代码语言扩展名列表 + */ + +type LanguageData = { + type: string + aliases?: string[] + extensions?: string[] +} + +export const languages: Record = { + 'c2hs haskell': { + extensions: ['.chs'], + type: 'programming', + aliases: ['c2hs'] + }, + tsql: { + extensions: ['.sql'], + type: 'programming' + }, + uno: { + extensions: ['.uno'], + type: 'programming' + }, + 'html+ecr': { + extensions: ['.ecr'], + type: 'markup', + aliases: ['ecr'] + }, + xpages: { + extensions: ['.xsp-config', '.xsp.metadata'], + type: 'data' + }, + 'module management system': { + extensions: ['.mms', '.mmk'], + type: 'programming' + }, + turing: { + extensions: ['.t', '.tu'], + type: 'programming' + }, + harbour: { + extensions: ['.hb'], + type: 'programming' + }, + sass: { + extensions: ['.sass'], + type: 'markup' + }, + cobol: { + extensions: ['.cob', '.cbl', '.ccp', '.cobol', '.cpy'], + type: 'programming' + }, + ioke: { + extensions: ['.ik'], + type: 'programming' + }, + 'standard ml': { + extensions: ['.ml', '.fun', '.sig', '.sml'], + type: 'programming', + aliases: ['sml'] + }, + less: { + extensions: ['.less'], + type: 'markup', + aliases: ['less-css'] + }, + cue: { + extensions: ['.cue'], + type: 'programming' + }, + 'q#': { + extensions: ['.qs'], + type: 'programming', + aliases: ['qsharp'] + }, + 'c#': { + extensions: ['.cs', '.cake', '.cs.pp', '.csx', '.linq'], + type: 'programming', + aliases: ['csharp', 'cake', 'cakescript'] + }, + 'closure templates': { + extensions: ['.soy'], + type: 'markup', + aliases: ['soy'] + }, + 'modula-2': { + extensions: ['.mod'], + type: 'programming' + }, + cirru: { + extensions: ['.cirru'], + type: 'programming' + }, + prisma: { + extensions: ['.prisma'], + type: 'data' + }, + xojo: { + extensions: ['.xojo_code', '.xojo_menu', '.xojo_report', '.xojo_script', '.xojo_toolbar', '.xojo_window'], + type: 'programming' + }, + 'vim script': { + extensions: ['.vim', '.vba', '.vimrc', '.vmb'], + type: 'programming', + aliases: ['vim', 'viml', 'nvim', 'vimscript'] + }, + unrealscript: { + extensions: ['.uc'], + type: 'programming' + }, + 'kicad layout': { + extensions: ['.kicad_pcb', '.kicad_mod', '.kicad_wks'], + type: 'data', + aliases: ['pcbnew'] + }, + urweb: { + extensions: ['.ur', '.urs'], + type: 'programming', + aliases: ['Ur/Web', 'Ur'] + }, + 'rpm spec': { + extensions: ['.spec'], + type: 'data', + aliases: ['specfile'] + }, + hcl: { + extensions: ['.hcl', '.nomad', '.tf', '.tfvars', '.workflow'], + type: 'programming', + aliases: ['HashiCorp Configuration Language', 'terraform'] + }, + 'vim help file': { + extensions: ['.txt'], + type: 'prose', + aliases: ['help', 'vimhelp'] + }, + 'component pascal': { + extensions: ['.cp', '.cps'], + type: 'programming' + }, + realbasic: { + extensions: ['.rbbas', '.rbfrm', '.rbmnu', '.rbres', '.rbtbar', '.rbuistate'], + type: 'programming' + }, + cil: { + extensions: ['.cil'], + type: 'data' + }, + nix: { + extensions: ['.nix'], + type: 'programming', + aliases: ['nixos'] + }, + mirah: { + extensions: ['.druby', '.duby', '.mirah'], + type: 'programming' + }, + red: { + extensions: ['.red', '.reds'], + type: 'programming', + aliases: ['red/system'] + }, + zimpl: { + extensions: ['.zimpl', '.zmpl', '.zpl'], + type: 'programming' + }, + 'world of warcraft addon data': { + extensions: ['.toc'], + type: 'data' + }, + logtalk: { + extensions: ['.lgt', '.logtalk'], + type: 'programming' + }, + 'digital command language': { + extensions: ['.com'], + type: 'programming', + aliases: ['dcl'] + }, + 'inno setup': { + extensions: ['.iss', '.isl'], + type: 'programming' + }, + ruby: { + extensions: [ + '.rb', + '.builder', + '.eye', + '.fcgi', + '.gemspec', + '.god', + '.jbuilder', + '.mspec', + '.pluginspec', + '.podspec', + '.prawn', + '.rabl', + '.rake', + '.rbi', + '.rbuild', + '.rbw', + '.rbx', + '.ru', + '.ruby', + '.spec', + '.thor', + '.watchr' + ], + type: 'programming', + aliases: ['jruby', 'macruby', 'rake', 'rb', 'rbx'] + }, + sqlpl: { + extensions: ['.sql', '.db2'], + type: 'programming' + }, + qmake: { + extensions: ['.pro', '.pri'], + type: 'programming' + }, + faust: { + extensions: ['.dsp'], + type: 'programming' + }, + nextflow: { + extensions: ['.nf'], + type: 'programming' + }, + ox: { + extensions: ['.ox', '.oxh', '.oxo'], + type: 'programming' + }, + xproc: { + extensions: ['.xpl', '.xproc'], + type: 'programming' + }, + 'directx 3d file': { + extensions: ['.x'], + type: 'data' + }, + 'jupyter notebook': { + extensions: ['.ipynb'], + type: 'markup', + aliases: ['IPython Notebook'] + }, + jolie: { + extensions: ['.ol', '.iol'], + type: 'programming' + }, + cartocss: { + extensions: ['.mss'], + type: 'programming', + aliases: ['Carto'] + }, + 'ltspice symbol': { + extensions: ['.asy'], + type: 'data' + }, + slash: { + extensions: ['.sl'], + type: 'programming' + }, + 'pure data': { + extensions: ['.pd'], + type: 'data' + }, + yang: { + extensions: ['.yang'], + type: 'data' + }, + prolog: { + extensions: ['.pl', '.plt', '.pro', '.prolog', '.yap'], + type: 'programming' + }, + 'g-code': { + extensions: ['.g', '.cnc', '.gco', '.gcode'], + type: 'programming' + }, + minid: { + extensions: ['.minid'], + type: 'programming' + }, + 'ecere projects': { + extensions: ['.epj'], + type: 'data' + }, + org: { + extensions: ['.org'], + type: 'prose' + }, + tcsh: { + extensions: ['.tcsh', '.csh'], + type: 'programming' + }, + scilab: { + extensions: ['.sci', '.sce', '.tst'], + type: 'programming' + }, + hack: { + extensions: ['.hack', '.hh', '.hhi', '.php'], + type: 'programming' + }, + coffeescript: { + extensions: ['.coffee', '._coffee', '.cake', '.cjsx', '.iced'], + type: 'programming', + aliases: ['coffee', 'coffee-script'] + }, + 'visual basic .net': { + extensions: ['.vb', '.vbhtml'], + type: 'programming', + aliases: ['visual basic', 'vbnet', 'vb .net', 'vb.net'] + }, + opa: { + extensions: ['.opa'], + type: 'programming' + }, + clean: { + extensions: ['.icl', '.dcl'], + type: 'programming' + }, + batchfile: { + extensions: ['.bat', '.cmd'], + type: 'programming', + aliases: ['bat', 'batch', 'dosbatch', 'winbatch'] + }, + v: { + extensions: ['.v'], + type: 'programming', + aliases: ['vlang'] + }, + vhdl: { + extensions: ['.vhdl', '.vhd', '.vhf', '.vhi', '.vho', '.vhs', '.vht', '.vhw'], + type: 'programming' + }, + pawn: { + extensions: ['.pwn', '.inc', '.sma'], + type: 'programming' + }, + abap: { + extensions: ['.abap'], + type: 'programming' + }, + 'public key': { + extensions: ['.asc', '.pub'], + type: 'data' + }, + svelte: { + extensions: ['.svelte'], + type: 'markup' + }, + xonsh: { + extensions: ['.xsh'], + type: 'programming' + }, + 'api blueprint': { + extensions: ['.apib'], + type: 'markup' + }, + 'glyph bitmap distribution format': { + extensions: ['.bdf'], + type: 'data' + }, + 'common lisp': { + extensions: ['.lisp', '.asd', '.cl', '.l', '.lsp', '.ny', '.podsl', '.sexp'], + type: 'programming', + aliases: ['lisp'] + }, + julia: { + extensions: ['.jl'], + type: 'programming' + }, + rmarkdown: { + extensions: ['.qmd', '.rmd'], + type: 'prose' + }, + applescript: { + extensions: ['.applescript', '.scpt'], + type: 'programming', + aliases: ['osascript'] + }, + zap: { + extensions: ['.zap', '.xzap'], + type: 'programming' + }, + filterscript: { + extensions: ['.fs'], + type: 'programming' + }, + glsl: { + extensions: [ + '.glsl', + '.fp', + '.frag', + '.frg', + '.fs', + '.fsh', + '.fshader', + '.geo', + '.geom', + '.glslf', + '.glslv', + '.gs', + '.gshader', + '.rchit', + '.rmiss', + '.shader', + '.tesc', + '.tese', + '.vert', + '.vrx', + '.vs', + '.vsh', + '.vshader' + ], + type: 'programming' + }, + vcl: { + extensions: ['.vcl'], + type: 'programming' + }, + gdb: { + extensions: ['.gdb', '.gdbinit'], + type: 'programming' + }, + nanorc: { + extensions: ['.nanorc'], + type: 'data' + }, + 'parrot internal representation': { + extensions: ['.pir'], + type: 'programming', + aliases: ['pir'] + }, + pod: { + extensions: ['.pod'], + type: 'prose' + }, + m4sugar: { + extensions: ['.m4'], + type: 'programming', + aliases: ['autoconf'] + }, + mlir: { + extensions: ['.mlir'], + type: 'programming' + }, + monkey: { + extensions: ['.monkey', '.monkey2'], + type: 'programming' + }, + nim: { + extensions: ['.nim', '.nim.cfg', '.nimble', '.nimrod', '.nims'], + type: 'programming' + }, + 'gentoo ebuild': { + extensions: ['.ebuild'], + type: 'programming' + }, + racket: { + extensions: ['.rkt', '.rktd', '.rktl', '.scrbl'], + type: 'programming' + }, + ebnf: { + extensions: ['.ebnf'], + type: 'data' + }, + charity: { + extensions: ['.ch'], + type: 'programming' + }, + groovy: { + extensions: ['.groovy', '.grt', '.gtpl', '.gvy'], + type: 'programming' + }, + hiveql: { + extensions: ['.q', '.hql'], + type: 'programming' + }, + 'f*': { + extensions: ['.fst', '.fsti'], + type: 'programming', + aliases: ['fstar'] + }, + systemverilog: { + extensions: ['.sv', '.svh', '.vh'], + type: 'programming' + }, + jison: { + extensions: ['.jison'], + type: 'programming' + }, + fantom: { + extensions: ['.fan'], + type: 'programming' + }, + scheme: { + extensions: ['.scm', '.sch', '.sld', '.sls', '.sps', '.ss'], + type: 'programming' + }, + 'cpp-objdump': { + extensions: ['.cppobjdump', '.c++-objdump', '.c++objdump', '.cpp-objdump', '.cxx-objdump'], + type: 'data', + aliases: ['c++-objdump'] + }, + arc: { + extensions: ['.arc'], + type: 'programming' + }, + logos: { + extensions: ['.xm', '.x', '.xi'], + type: 'programming' + }, + assembly: { + extensions: ['.asm', '.a51', '.i', '.inc', '.nas', '.nasm', '.s'], + type: 'programming', + aliases: ['asm', 'nasm'] + }, + 'java properties': { + extensions: ['.properties'], + type: 'data' + }, + haskell: { + extensions: ['.hs', '.hs-boot', '.hsc'], + type: 'programming' + }, + ragel: { + extensions: ['.rl'], + type: 'programming', + aliases: ['ragel-rb', 'ragel-ruby'] + }, + gn: { + extensions: ['.gn', '.gni'], + type: 'data' + }, + '1c enterprise': { + extensions: ['.bsl', '.os'], + type: 'programming' + }, + diff: { + extensions: ['.diff', '.patch'], + type: 'data', + aliases: ['udiff'] + }, + http: { + extensions: ['.http'], + type: 'data' + }, + tex: { + extensions: [ + '.tex', + '.aux', + '.bbx', + '.cbx', + '.cls', + '.dtx', + '.ins', + '.lbx', + '.ltx', + '.mkii', + '.mkiv', + '.mkvi', + '.sty', + '.toc' + ], + type: 'markup', + aliases: ['latex'] + }, + mathematica: { + extensions: ['.mathematica', '.cdf', '.m', '.ma', '.mt', '.nb', '.nbp', '.wl', '.wlt'], + type: 'programming', + aliases: ['mma', 'wolfram', 'wolfram language', 'wolfram lang', 'wl'] + }, + 'javascript+erb': { + extensions: ['.js.erb'], + type: 'programming' + }, + muse: { + extensions: ['.muse'], + type: 'prose', + aliases: ['amusewiki', 'emacs muse'] + }, + 'openedge abl': { + extensions: ['.p', '.cls', '.w'], + type: 'programming', + aliases: ['progress', 'openedge', 'abl'] + }, + ninja: { + extensions: ['.ninja'], + type: 'data' + }, + agda: { + extensions: ['.agda'], + type: 'programming' + }, + aspectj: { + extensions: ['.aj'], + type: 'programming' + }, + jq: { + extensions: ['.jq'], + type: 'programming' + }, + apex: { + extensions: ['.cls', '.apex', '.trigger'], + type: 'programming' + }, + bluespec: { + extensions: ['.bsv'], + type: 'programming', + aliases: ['bluespec bsv', 'bsv'] + }, + forth: { + extensions: ['.fth', '.4th', '.f', '.for', '.forth', '.fr', '.frt', '.fs'], + type: 'programming' + }, + xc: { + extensions: ['.xc'], + type: 'programming' + }, + fortran: { + extensions: ['.f', '.f77', '.for', '.fpp'], + type: 'programming' + }, + haxe: { + extensions: ['.hx', '.hxsl'], + type: 'programming' + }, + rust: { + extensions: ['.rs', '.rs.in'], + type: 'programming', + aliases: ['rs'] + }, + 'cabal config': { + extensions: ['.cabal'], + type: 'data', + aliases: ['Cabal'] + }, + netlogo: { + extensions: ['.nlogo'], + type: 'programming' + }, + 'imagej macro': { + extensions: ['.ijm'], + type: 'programming', + aliases: ['ijm'] + }, + autohotkey: { + extensions: ['.ahk', '.ahkl'], + type: 'programming', + aliases: ['ahk'] + }, + haproxy: { + extensions: ['.cfg'], + type: 'data' + }, + zil: { + extensions: ['.zil', '.mud'], + type: 'programming' + }, + 'abap cds': { + extensions: ['.asddls'], + type: 'programming' + }, + 'html+razor': { + extensions: ['.cshtml', '.razor'], + type: 'markup', + aliases: ['razor'] + }, + boo: { + extensions: ['.boo'], + type: 'programming' + }, + smarty: { + extensions: ['.tpl'], + type: 'programming' + }, + mako: { + extensions: ['.mako', '.mao'], + type: 'programming' + }, + nearley: { + extensions: ['.ne', '.nearley'], + type: 'programming' + }, + llvm: { + extensions: ['.ll'], + type: 'programming' + }, + piglatin: { + extensions: ['.pig'], + type: 'programming' + }, + 'unix assembly': { + extensions: ['.s', '.ms'], + type: 'programming', + aliases: ['gas', 'gnu asm', 'unix asm'] + }, + metal: { + extensions: ['.metal'], + type: 'programming' + }, + shen: { + extensions: ['.shen'], + type: 'programming' + }, + labview: { + extensions: ['.lvproj', '.lvclass', '.lvlib'], + type: 'programming' + }, + nemerle: { + extensions: ['.n'], + type: 'programming' + }, + rpc: { + extensions: ['.x'], + type: 'programming', + aliases: ['rpcgen', 'oncrpc', 'xdr'] + }, + 'python traceback': { + extensions: ['.pytb'], + type: 'data' + }, + clojure: { + extensions: ['.clj', '.bb', '.boot', '.cl2', '.cljc', '.cljs', '.cljs.hl', '.cljscm', '.cljx', '.hic'], + type: 'programming' + }, + eiffel: { + extensions: ['.e'], + type: 'programming' + }, + genie: { + extensions: ['.gs'], + type: 'programming' + }, + shaderlab: { + extensions: ['.shader'], + type: 'programming' + }, + makefile: { + extensions: ['.mak', '.d', '.make', '.makefile', '.mk', '.mkfile'], + type: 'programming', + aliases: ['bsdmake', 'make', 'mf'] + }, + rouge: { + extensions: ['.rg'], + type: 'programming' + }, + dircolors: { + extensions: ['.dircolors'], + type: 'data' + }, + ncl: { + extensions: ['.ncl'], + type: 'programming' + }, + puppet: { + extensions: ['.pp'], + type: 'programming' + }, + sparql: { + extensions: ['.sparql', '.rq'], + type: 'data' + }, + 'qt script': { + extensions: ['.qs'], + type: 'programming' + }, + golo: { + extensions: ['.golo'], + type: 'programming' + }, + lark: { + extensions: ['.lark'], + type: 'data' + }, + nginx: { + extensions: ['.nginx', '.nginxconf', '.vhost'], + type: 'data', + aliases: ['nginx configuration file'] + }, + wikitext: { + extensions: ['.mediawiki', '.wiki', '.wikitext'], + type: 'prose', + aliases: ['mediawiki', 'wiki'] + }, + ceylon: { + extensions: ['.ceylon'], + type: 'programming' + }, + stan: { + extensions: ['.stan'], + type: 'programming' + }, + cmake: { + extensions: ['.cmake', '.cmake.in'], + type: 'programming' + }, + loomscript: { + extensions: ['.ls'], + type: 'programming' + }, + ooc: { + extensions: ['.ooc'], + type: 'programming' + }, + json: { + extensions: [ + '.json', + '.4DForm', + '.4DProject', + '.avsc', + '.geojson', + '.gltf', + '.har', + '.ice', + '.JSON-tmLanguage', + '.json.example', + '.jsonl', + '.mcmeta', + '.sarif', + '.tact', + '.tfstate', + '.tfstate.backup', + '.topojson', + '.webapp', + '.webmanifest', + '.yy', + '.yyp' + ], + type: 'data', + aliases: ['geojson', 'jsonl', 'sarif', 'topojson'] + }, + formatted: { + extensions: ['.for', '.eam.fs'], + type: 'data' + }, + 'html+eex': { + extensions: ['.html.eex', '.heex', '.leex'], + type: 'markup', + aliases: ['eex', 'heex', 'leex'] + }, + q: { + extensions: ['.q'], + type: 'programming' + }, + pike: { + extensions: ['.pike', '.pmod'], + type: 'programming' + }, + robotframework: { + extensions: ['.robot', '.resource'], + type: 'programming' + }, + gedcom: { + extensions: ['.ged'], + type: 'data' + }, + rdoc: { + extensions: ['.rdoc'], + type: 'prose' + }, + 'literate agda': { + extensions: ['.lagda'], + type: 'programming' + }, + dm: { + extensions: ['.dm'], + type: 'programming', + aliases: ['byond'] + }, + ec: { + extensions: ['.ec', '.eh'], + type: 'programming' + }, + kusto: { + extensions: ['.csl', '.kql'], + type: 'data' + }, + "cap'n proto": { + extensions: ['.capnp'], + type: 'programming' + }, + 'darcs patch': { + extensions: ['.darcspatch', '.dpatch'], + type: 'data', + aliases: ['dpatch'] + }, + 'srecode template': { + extensions: ['.srt'], + type: 'markup' + }, + factor: { + extensions: ['.factor'], + type: 'programming' + }, + tsx: { + extensions: ['.tsx'], + type: 'programming' + }, + css: { + extensions: ['.css'], + type: 'markup' + }, + json5: { + extensions: ['.json5'], + type: 'data' + }, + 'jison lex': { + extensions: ['.jisonlex'], + type: 'programming' + }, + mtml: { + extensions: ['.mtml'], + type: 'markup' + }, + ballerina: { + extensions: ['.bal'], + type: 'programming' + }, + brainfuck: { + extensions: ['.b', '.bf'], + type: 'programming' + }, + swift: { + extensions: ['.swift'], + type: 'programming' + }, + gherkin: { + extensions: ['.feature', '.story'], + type: 'programming', + aliases: ['cucumber'] + }, + textile: { + extensions: ['.textile'], + type: 'prose' + }, + mql4: { + extensions: ['.mq4', '.mqh'], + type: 'programming' + }, + ejs: { + extensions: ['.ejs', '.ect', '.ejs.t', '.jst'], + type: 'markup' + }, + 'asn.1': { + extensions: ['.asn', '.asn1'], + type: 'data' + }, + parrot: { + extensions: ['.parrot'], + type: 'programming' + }, + plantuml: { + extensions: ['.puml', '.iuml', '.plantuml'], + type: 'data' + }, + brightscript: { + extensions: ['.brs'], + type: 'programming' + }, + slim: { + extensions: ['.slim'], + type: 'markup' + }, + svg: { + extensions: ['.svg'], + type: 'data' + }, + e: { + extensions: ['.e'], + type: 'programming' + }, + text: { + extensions: ['.txt', '.fr', '.nb', '.ncl', '.no'], + type: 'prose', + aliases: ['fundamental', 'plain text'] + }, + 'fortran free form': { + extensions: ['.f90', '.f03', '.f08', '.f95'], + type: 'programming' + }, + grace: { + extensions: ['.grace'], + type: 'programming' + }, + clarion: { + extensions: ['.clw'], + type: 'programming' + }, + 'kicad legacy layout': { + extensions: ['.brd'], + type: 'data' + }, + asymptote: { + extensions: ['.asy'], + type: 'programming' + }, + kotlin: { + extensions: ['.kt', '.ktm', '.kts'], + type: 'programming' + }, + texinfo: { + extensions: ['.texinfo', '.texi', '.txi'], + type: 'prose' + }, + pogoscript: { + extensions: ['.pogo'], + type: 'programming' + }, + xml: { + extensions: [ + '.xml', + '.adml', + '.admx', + '.ant', + '.axaml', + '.axml', + '.builds', + '.ccproj', + '.ccxml', + '.clixml', + '.cproject', + '.cscfg', + '.csdef', + '.csl', + '.csproj', + '.ct', + '.depproj', + '.dita', + '.ditamap', + '.ditaval', + '.dll.config', + '.dotsettings', + '.filters', + '.fsproj', + '.fxml', + '.glade', + '.gml', + '.gmx', + '.gpx', + '.grxml', + '.gst', + '.hzp', + '.iml', + '.ivy', + '.jelly', + '.jsproj', + '.kml', + '.launch', + '.mdpolicy', + '.mjml', + '.mm', + '.mod', + '.mojo', + '.mxml', + '.natvis', + '.ncl', + '.ndproj', + '.nproj', + '.nuspec', + '.odd', + '.osm', + '.pkgproj', + '.pluginspec', + '.proj', + '.props', + '.ps1xml', + '.psc1', + '.pt', + '.qhelp', + '.rdf', + '.res', + '.resx', + '.rs', + '.rss', + '.sch', + '.scxml', + '.sfproj', + '.shproj', + '.slnx', + '.srdf', + '.storyboard', + '.sublime-snippet', + '.sw', + '.targets', + '.tml', + '.ts', + '.tsx', + '.typ', + '.ui', + '.urdf', + '.ux', + '.vbproj', + '.vcxproj', + '.vsixmanifest', + '.vssettings', + '.vstemplate', + '.vxml', + '.wixproj', + '.workflow', + '.wsdl', + '.wsf', + '.wxi', + '.wxl', + '.wxs', + '.x3d', + '.xacro', + '.xaml', + '.xib', + '.xlf', + '.xliff', + '.xmi', + '.xml.dist', + '.xmp', + '.xproj', + '.xsd', + '.xspec', + '.xul', + '.zcml' + ], + type: 'data', + aliases: ['rss', 'xsd', 'wsdl'] + }, + raml: { + extensions: ['.raml'], + type: 'markup' + }, + flux: { + extensions: ['.fx', '.flux'], + type: 'programming' + }, + nasl: { + extensions: ['.nasl', '.inc'], + type: 'programming' + }, + saltstack: { + extensions: ['.sls'], + type: 'programming', + aliases: ['saltstate', 'salt'] + }, + markdown: { + extensions: [ + '.md', + '.livemd', + '.markdown', + '.mdown', + '.mdwn', + '.mkd', + '.mkdn', + '.mkdown', + '.ronn', + '.scd', + '.workbook' + ], + type: 'prose', + aliases: ['md', 'pandoc'] + }, + starlark: { + extensions: ['.bzl', '.star'], + type: 'programming', + aliases: ['bazel', 'bzl'] + }, + dylan: { + extensions: ['.dylan', '.dyl', '.intr', '.lid'], + type: 'programming' + }, + 'altium designer': { + extensions: ['.OutJob', '.PcbDoc', '.PrjPCB', '.SchDoc'], + type: 'data', + aliases: ['altium'] + }, + mask: { + extensions: ['.mask'], + type: 'markup' + }, + aidl: { + extensions: ['.aidl'], + type: 'programming' + }, + powerbuilder: { + extensions: ['.pbt', '.sra', '.sru', '.srw'], + type: 'programming' + }, + max: { + extensions: ['.maxpat', '.maxhelp', '.maxproj', '.mxt', '.pat'], + type: 'programming', + aliases: ['max/msp', 'maxmsp'] + }, + 'ti program': { + extensions: ['.8xp', '.8xp.txt'], + type: 'programming' + }, + moocode: { + extensions: ['.moo'], + type: 'programming' + }, + sql: { + extensions: ['.sql', '.cql', '.ddl', '.inc', '.mysql', '.prc', '.tab', '.udf', '.viw'], + type: 'data' + }, + dhall: { + extensions: ['.dhall'], + type: 'programming' + }, + befunge: { + extensions: ['.befunge', '.bf'], + type: 'programming' + }, + 'irc log': { + extensions: ['.irclog', '.weechatlog'], + type: 'data', + aliases: ['irc', 'irc logs'] + }, + krl: { + extensions: ['.krl'], + type: 'programming' + }, + 'apollo guidance computer': { + extensions: ['.agc'], + type: 'programming' + }, + ring: { + extensions: ['.ring'], + type: 'programming' + }, + ada: { + extensions: ['.adb', '.ada', '.ads'], + type: 'programming', + aliases: ['ada95', 'ada2005'] + }, + lua: { + extensions: ['.lua', '.fcgi', '.nse', '.p8', '.pd_lua', '.rbxs', '.rockspec', '.wlua'], + type: 'programming' + }, + gams: { + extensions: ['.gms'], + type: 'programming' + }, + csv: { + extensions: ['.csv'], + type: 'data' + }, + asl: { + extensions: ['.asl', '.dsl'], + type: 'programming' + }, + 'graphviz (dot)': { + extensions: ['.dot', '.gv'], + type: 'data' + }, + 'figlet font': { + extensions: ['.flf'], + type: 'data', + aliases: ['FIGfont'] + }, + edn: { + extensions: ['.edn'], + type: 'data' + }, + txl: { + extensions: ['.txl'], + type: 'programming' + }, + roff: { + extensions: [ + '.roff', + '.1', + '.1in', + '.1m', + '.1x', + '.2', + '.3', + '.3in', + '.3m', + '.3p', + '.3pm', + '.3qt', + '.3x', + '.4', + '.5', + '.6', + '.7', + '.8', + '.9', + '.l', + '.man', + '.mdoc', + '.me', + '.ms', + '.n', + '.nr', + '.rno', + '.tmac' + ], + type: 'markup', + aliases: ['groff', 'man', 'manpage', 'man page', 'man-page', 'mdoc', 'nroff', 'troff'] + }, + idl: { + extensions: ['.pro', '.dlm'], + type: 'programming' + }, + neon: { + extensions: ['.neon'], + type: 'data', + aliases: ['nette object notation', 'ne-on'] + }, + 'rich text format': { + extensions: ['.rtf'], + type: 'markup' + }, + 'peg.js': { + extensions: ['.pegjs', '.peggy'], + type: 'programming' + }, + glyph: { + extensions: ['.glf'], + type: 'programming' + }, + io: { + extensions: ['.io'], + type: 'programming' + }, + nsis: { + extensions: ['.nsi', '.nsh'], + type: 'programming' + }, + papyrus: { + extensions: ['.psc'], + type: 'programming' + }, + 'raw token data': { + extensions: ['.raw'], + type: 'data', + aliases: ['raw'] + }, + 'windows registry entries': { + extensions: ['.reg'], + type: 'data' + }, + zephir: { + extensions: ['.zep'], + type: 'programming' + }, + 'objective-c++': { + extensions: ['.mm'], + type: 'programming', + aliases: ['obj-c++', 'objc++', 'objectivec++'] + }, + wisp: { + extensions: ['.wisp'], + type: 'programming' + }, + 'protocol buffer': { + extensions: ['.proto'], + type: 'data', + aliases: ['proto', 'protobuf', 'Protocol Buffers'] + }, + 'object data instance notation': { + extensions: ['.odin'], + type: 'data' + }, + modelica: { + extensions: ['.mo'], + type: 'programming' + }, + easybuild: { + extensions: ['.eb'], + type: 'data' + }, + 'web ontology language': { + extensions: ['.owl'], + type: 'data' + }, + sage: { + extensions: ['.sage', '.sagews'], + type: 'programming' + }, + basic: { + extensions: ['.bas'], + type: 'programming' + }, + smt: { + extensions: ['.smt2', '.smt', '.z3'], + type: 'programming' + }, + tea: { + extensions: ['.tea'], + type: 'markup' + }, + powershell: { + extensions: ['.ps1', '.psd1', '.psm1'], + type: 'programming', + aliases: ['posh', 'pwsh'] + }, + boogie: { + extensions: ['.bpl'], + type: 'programming' + }, + maxscript: { + extensions: ['.ms', '.mcr'], + type: 'programming' + }, + gaml: { + extensions: ['.gaml'], + type: 'programming' + }, + vbscript: { + extensions: ['.vbs'], + type: 'programming' + }, + antlr: { + extensions: ['.g4'], + type: 'programming' + }, + verilog: { + extensions: ['.v', '.veo'], + type: 'programming' + }, + limbo: { + extensions: ['.b', '.m'], + type: 'programming' + }, + j: { + extensions: ['.ijs'], + type: 'programming' + }, + fennel: { + extensions: ['.fnl'], + type: 'programming' + }, + tla: { + extensions: ['.tla'], + type: 'programming' + }, + eq: { + extensions: ['.eq'], + type: 'programming' + }, + 'igor pro': { + extensions: ['.ipf'], + type: 'programming', + aliases: ['igor', 'igorpro'] + }, + 'regular expression': { + extensions: ['.regexp', '.regex'], + type: 'data', + aliases: ['regexp', 'regex'] + }, + apacheconf: { + extensions: ['.apacheconf', '.vhost'], + type: 'data', + aliases: ['aconf', 'apache'] + }, + objdump: { + extensions: ['.objdump'], + type: 'data' + }, + pickle: { + extensions: ['.pkl'], + type: 'data' + }, + cweb: { + extensions: ['.w'], + type: 'programming' + }, + plsql: { + extensions: [ + '.pls', + '.bdy', + '.ddl', + '.fnc', + '.pck', + '.pkb', + '.pks', + '.plb', + '.plsql', + '.prc', + '.spc', + '.sql', + '.tpb', + '.tps', + '.trg', + '.vw' + ], + type: 'programming' + }, + shellsession: { + extensions: ['.sh-session'], + type: 'programming', + aliases: ['bash session', 'console'] + }, + x10: { + extensions: ['.x10'], + type: 'programming', + aliases: ['xten'] + }, + thrift: { + extensions: ['.thrift'], + type: 'programming' + }, + 'microsoft visual studio solution': { + extensions: ['.sln'], + type: 'data' + }, + freemarker: { + extensions: ['.ftl'], + type: 'programming', + aliases: ['ftl'] + }, + creole: { + extensions: ['.creole'], + type: 'prose' + }, + python: { + extensions: [ + '.py', + '.cgi', + '.fcgi', + '.gyp', + '.gypi', + '.lmi', + '.py3', + '.pyde', + '.pyi', + '.pyp', + '.pyt', + '.pyw', + '.rpy', + '.spec', + '.tac', + '.wsgi', + '.xpy' + ], + type: 'programming', + aliases: ['python3', 'rusthon'] + }, + livescript: { + extensions: ['.ls', '._ls'], + type: 'programming', + aliases: ['live-script', 'ls'] + }, + numpy: { + extensions: ['.numpy', '.numpyw', '.numsc'], + type: 'programming' + }, + objectscript: { + extensions: ['.cls'], + type: 'programming' + }, + 'jest snapshot': { + extensions: ['.snap'], + type: 'data' + }, + 'unified parallel c': { + extensions: ['.upc'], + type: 'programming' + }, + 'openstep property list': { + extensions: ['.plist', '.glyphs'], + type: 'data' + }, + 'conll-u': { + extensions: ['.conllu', '.conll'], + type: 'data', + aliases: ['CoNLL', 'CoNLL-X'] + }, + frege: { + extensions: ['.fr'], + type: 'programming' + }, + toml: { + extensions: ['.toml'], + type: 'data' + }, + haml: { + extensions: ['.haml', '.haml.deface'], + type: 'markup' + }, + jsoniq: { + extensions: ['.jq'], + type: 'programming' + }, + picolisp: { + extensions: ['.l'], + type: 'programming' + }, + collada: { + extensions: ['.dae'], + type: 'data' + }, + erlang: { + extensions: ['.erl', '.app', '.app.src', '.es', '.escript', '.hrl', '.xrl', '.yrl'], + type: 'programming' + }, + 'ignore list': { + extensions: ['.gitignore'], + type: 'data', + aliases: ['ignore', 'gitignore', 'git-ignore'] + }, + ini: { + extensions: ['.ini', '.cfg', '.cnf', '.dof', '.frm', '.lektorproject', '.prefs', '.pro', '.properties', '.url'], + type: 'data', + aliases: ['dosini'] + }, + '4d': { + extensions: ['.4dm'], + type: 'programming' + }, + freebasic: { + extensions: ['.bi', '.bas'], + type: 'programming', + aliases: ['fb'] + }, + 'classic asp': { + extensions: ['.asp'], + type: 'programming', + aliases: ['asp'] + }, + 'c-objdump': { + extensions: ['.c-objdump'], + type: 'data' + }, + gradle: { + extensions: ['.gradle'], + type: 'data' + }, + dataweave: { + extensions: ['.dwl'], + type: 'programming' + }, + matlab: { + extensions: ['.matlab', '.m'], + type: 'programming', + aliases: ['octave'] + }, + bicep: { + extensions: ['.bicep', '.bicepparam'], + type: 'programming' + }, + 'e-mail': { + extensions: ['.eml', '.mbox'], + type: 'data', + aliases: ['email', 'eml', 'mail', 'mbox'] + }, + rebol: { + extensions: ['.reb', '.r', '.r2', '.r3', '.rebol'], + type: 'programming' + }, + r: { + extensions: ['.r', '.rd', '.rsx'], + type: 'programming', + aliases: ['Rscript', 'splus'] + }, + restructuredtext: { + extensions: ['.rst', '.rest', '.rest.txt', '.rst.txt'], + type: 'prose', + aliases: ['rst'] + }, + pug: { + extensions: ['.jade', '.pug'], + type: 'markup' + }, + ecl: { + extensions: ['.ecl', '.eclxml'], + type: 'programming' + }, + myghty: { + extensions: ['.myt'], + type: 'programming' + }, + 'game maker language': { + extensions: ['.gml'], + type: 'programming' + }, + redcode: { + extensions: ['.cw'], + type: 'programming' + }, + 'x pixmap': { + extensions: ['.xpm', '.pm'], + type: 'data', + aliases: ['xpm'] + }, + 'propeller spin': { + extensions: ['.spin'], + type: 'programming' + }, + xslt: { + extensions: ['.xslt', '.xsl'], + type: 'programming', + aliases: ['xsl'] + }, + dart: { + extensions: ['.dart'], + type: 'programming' + }, + astro: { + extensions: ['.astro'], + type: 'markup' + }, + java: { + extensions: ['.java', '.jav', '.jsh'], + type: 'programming' + }, + 'groovy server pages': { + extensions: ['.gsp'], + type: 'programming', + aliases: ['gsp', 'java server page'] + }, + postscript: { + extensions: ['.ps', '.eps', '.epsi', '.pfa'], + type: 'markup', + aliases: ['postscr'] + }, + bibtex: { + extensions: ['.bib', '.bibtex'], + type: 'markup' + }, + cython: { + extensions: ['.pyx', '.pxd', '.pxi'], + type: 'programming', + aliases: ['pyrex'] + }, + gosu: { + extensions: ['.gs', '.gst', '.gsx', '.vark'], + type: 'programming' + }, + ston: { + extensions: ['.ston'], + type: 'data' + }, + renderscript: { + extensions: ['.rs', '.rsh'], + type: 'programming' + }, + lfe: { + extensions: ['.lfe'], + type: 'programming' + }, + ampl: { + extensions: ['.ampl', '.mod'], + type: 'programming' + }, + beef: { + extensions: ['.bf'], + type: 'programming' + }, + 'cue sheet': { + extensions: ['.cue'], + type: 'data' + }, + 'objective-c': { + extensions: ['.m', '.h'], + type: 'programming', + aliases: ['obj-c', 'objc', 'objectivec'] + }, + scaml: { + extensions: ['.scaml'], + type: 'markup' + }, + slice: { + extensions: ['.ice'], + type: 'programming' + }, + zig: { + extensions: ['.zig', '.zig.zon'], + type: 'programming' + }, + 'open policy agent': { + extensions: ['.rego'], + type: 'programming' + }, + opal: { + extensions: ['.opal'], + type: 'programming' + }, + macaulay2: { + extensions: ['.m2'], + type: 'programming', + aliases: ['m2'] + }, + twig: { + extensions: ['.twig'], + type: 'markup' + }, + autoit: { + extensions: ['.au3'], + type: 'programming', + aliases: ['au3', 'AutoIt3', 'AutoItScript'] + }, + mupad: { + extensions: ['.mu'], + type: 'programming' + }, + coldfusion: { + extensions: ['.cfm', '.cfml'], + type: 'programming', + aliases: ['cfm', 'cfml', 'coldfusion html'] + }, + 'valve data format': { + extensions: ['.vdf'], + type: 'data', + aliases: ['keyvalues', 'vdf'] + }, + sourcepawn: { + extensions: ['.sp', '.inc'], + type: 'programming', + aliases: ['sourcemod'] + }, + p4: { + extensions: ['.p4'], + type: 'programming' + }, + 'spline font database': { + extensions: ['.sfd'], + type: 'data' + }, + c: { + extensions: ['.c', '.cats', '.h', '.h.in', '.idc'], + type: 'programming' + }, + 'xml property list': { + extensions: ['.plist', '.stTheme', '.tmCommand', '.tmLanguage', '.tmPreferences', '.tmSnippet', '.tmTheme'], + type: 'data' + }, + blitzmax: { + extensions: ['.bmx'], + type: 'programming', + aliases: ['bmax'] + }, + 'literate coffeescript': { + extensions: ['.litcoffee', '.coffee.md'], + type: 'programming', + aliases: ['litcoffee'] + }, + moonscript: { + extensions: ['.moon'], + type: 'programming' + }, + zenscript: { + extensions: ['.zs'], + type: 'programming' + }, + desktop: { + extensions: ['.desktop', '.desktop.in', '.service'], + type: 'data' + }, + angelscript: { + extensions: ['.as', '.angelscript'], + type: 'programming' + }, + 'csound score': { + extensions: ['.sco'], + type: 'programming', + aliases: ['csound-sco'] + }, + scss: { + extensions: ['.scss'], + type: 'markup' + }, + eagle: { + extensions: ['.sch', '.brd'], + type: 'data' + }, + jsonld: { + extensions: ['.jsonld'], + type: 'data' + }, + 'microsoft developer studio project': { + extensions: ['.dsp'], + type: 'data' + }, + liquid: { + extensions: ['.liquid'], + type: 'markup' + }, + yara: { + extensions: ['.yar', '.yara'], + type: 'programming' + }, + yasnippet: { + extensions: ['.yasnippet'], + type: 'markup', + aliases: ['snippet', 'yas'] + }, + qml: { + extensions: ['.qml', '.qbs'], + type: 'programming' + }, + newlisp: { + extensions: ['.nl', '.lisp', '.lsp'], + type: 'programming' + }, + m4: { + extensions: ['.m4', '.mc'], + type: 'programming' + }, + 'gcc machine description': { + extensions: ['.md'], + type: 'programming' + }, + odin: { + extensions: ['.odin'], + type: 'programming', + aliases: ['odinlang', 'odin-lang'] + }, + 'subrip text': { + extensions: ['.srt'], + type: 'data' + }, + nesc: { + extensions: ['.nc'], + type: 'programming' + }, + isabelle: { + extensions: ['.thy'], + type: 'programming' + }, + jsonnet: { + extensions: ['.jsonnet', '.libsonnet'], + type: 'programming' + }, + purebasic: { + extensions: ['.pb', '.pbi'], + type: 'programming' + }, + proguard: { + extensions: ['.pro'], + type: 'data' + }, + nunjucks: { + extensions: ['.njk'], + type: 'markup', + aliases: ['njk'] + }, + stringtemplate: { + extensions: ['.st'], + type: 'markup' + }, + 'roff manpage': { + extensions: [ + '.1', + '.1in', + '.1m', + '.1x', + '.2', + '.3', + '.3in', + '.3m', + '.3p', + '.3pm', + '.3qt', + '.3x', + '.4', + '.5', + '.6', + '.7', + '.8', + '.9', + '.man', + '.mdoc' + ], + type: 'markup' + }, + 'vim snippet': { + extensions: ['.snip', '.snippet', '.snippets'], + type: 'markup', + aliases: ['SnipMate', 'UltiSnip', 'UltiSnips', 'NeoSnippet'] + }, + 'html+erb': { + extensions: ['.erb', '.erb.deface', '.rhtml'], + type: 'markup', + aliases: ['erb', 'rhtml', 'html+ruby'] + }, + fluent: { + extensions: ['.ftl'], + type: 'programming' + }, + turtle: { + extensions: ['.ttl'], + type: 'data' + }, + 'objective-j': { + extensions: ['.j', '.sj'], + type: 'programming', + aliases: ['obj-j', 'objectivej', 'objj'] + }, + 'kaitai struct': { + extensions: ['.ksy'], + type: 'programming', + aliases: ['ksy'] + }, + scala: { + extensions: ['.scala', '.kojo', '.sbt', '.sc'], + type: 'programming' + }, + sas: { + extensions: ['.sas'], + type: 'programming' + }, + zeek: { + extensions: ['.zeek', '.bro'], + type: 'programming', + aliases: ['bro'] + }, + vba: { + extensions: ['.bas', '.cls', '.frm', '.vba'], + type: 'programming', + aliases: ['visual basic for applications'] + }, + go: { + extensions: ['.go'], + type: 'programming', + aliases: ['golang'] + }, + php: { + extensions: ['.php', '.aw', '.ctp', '.fcgi', '.inc', '.php3', '.php4', '.php5', '.phps', '.phpt'], + type: 'programming', + aliases: ['inc'] + }, + smali: { + extensions: ['.smali'], + type: 'programming' + }, + gnuplot: { + extensions: ['.gp', '.gnu', '.gnuplot', '.p', '.plot', '.plt'], + type: 'programming' + }, + fish: { + extensions: ['.fish'], + type: 'programming' + }, + 'selinux policy': { + extensions: ['.te'], + type: 'data', + aliases: ['SELinux Kernel Policy Language', 'sepolicy'] + }, + tcl: { + extensions: ['.tcl', '.adp', '.sdc', '.tcl.in', '.tm', '.xdc'], + type: 'programming', + aliases: ['sdc', 'xdc'] + }, + webvtt: { + extensions: ['.vtt'], + type: 'data', + aliases: ['vtt'] + }, + 'graph modeling language': { + extensions: ['.gml'], + type: 'data' + }, + netlinx: { + extensions: ['.axs', '.axi'], + type: 'programming' + }, + fancy: { + extensions: ['.fy', '.fancypack'], + type: 'programming' + }, + 'edje data collection': { + extensions: ['.edc'], + type: 'data' + }, + rascal: { + extensions: ['.rsc'], + type: 'programming' + }, + vue: { + extensions: ['.vue'], + type: 'markup' + }, + chuck: { + extensions: ['.ck'], + type: 'programming' + }, + nwscript: { + extensions: ['.nss'], + type: 'programming' + }, + eclipse: { + extensions: ['.ecl'], + type: 'programming' + }, + 'pod 6': { + extensions: ['.pod', '.pod6'], + type: 'prose' + }, + rescript: { + extensions: ['.res', '.resi'], + type: 'programming' + }, + idris: { + extensions: ['.idr', '.lidr'], + type: 'programming' + }, + hy: { + extensions: ['.hy'], + type: 'programming', + aliases: ['hylang'] + }, + apl: { + extensions: ['.apl', '.dyalog'], + type: 'programming' + }, + hlsl: { + extensions: ['.hlsl', '.cginc', '.fx', '.fxh', '.hlsli'], + type: 'programming' + }, + csound: { + extensions: ['.orc', '.udo'], + type: 'programming', + aliases: ['csound-orc'] + }, + genshi: { + extensions: ['.kid'], + type: 'programming', + aliases: ['xml+genshi', 'xml+kid'] + }, + elm: { + extensions: ['.elm'], + type: 'programming' + }, + swig: { + extensions: ['.i'], + type: 'programming' + }, + reason: { + extensions: ['.re', '.rei'], + type: 'programming' + }, + processing: { + extensions: ['.pde'], + type: 'programming' + }, + 'common workflow language': { + extensions: ['.cwl'], + type: 'programming', + aliases: ['cwl'] + }, + mustache: { + extensions: ['.mustache'], + type: 'markup' + }, + 'asp.net': { + extensions: ['.asax', '.ascx', '.ashx', '.asmx', '.aspx', '.axd'], + type: 'programming', + aliases: ['aspx', 'aspx-vb'] + }, + rexx: { + extensions: ['.rexx', '.pprx', '.rex'], + type: 'programming', + aliases: ['arexx'] + }, + lsl: { + extensions: ['.lsl', '.lslp'], + type: 'programming' + }, + 'pov-ray sdl': { + extensions: ['.pov', '.inc'], + type: 'programming', + aliases: ['pov-ray', 'povray'] + }, + pep8: { + extensions: ['.pep'], + type: 'programming' + }, + 'ags script': { + extensions: ['.asc', '.ash'], + type: 'programming', + aliases: ['ags'] + }, + dockerfile: { + extensions: ['.dockerfile', '.containerfile'], + type: 'programming', + aliases: ['Containerfile'] + }, + muf: { + extensions: ['.muf', '.m'], + type: 'programming' + }, + javascript: { + extensions: [ + '.js', + '._js', + '.bones', + '.cjs', + '.es', + '.es6', + '.frag', + '.gs', + '.jake', + '.javascript', + '.jsb', + '.jscad', + '.jsfl', + '.jslib', + '.jsm', + '.jspre', + '.jss', + '.jsx', + '.mjs', + '.njs', + '.pac', + '.sjs', + '.ssjs', + '.xsjs', + '.xsjslib' + ], + type: 'programming', + aliases: ['js', 'node'] + }, + 'type language': { + extensions: ['.tl'], + type: 'data', + aliases: ['tl'] + }, + runoff: { + extensions: ['.rnh', '.rno'], + type: 'markup' + }, + wdl: { + extensions: ['.wdl'], + type: 'programming', + aliases: ['Workflow Description Language'] + }, + blitzbasic: { + extensions: ['.bb', '.decls'], + type: 'programming', + aliases: ['b3d', 'blitz3d', 'blitzplus', 'bplus'] + }, + actionscript: { + extensions: ['.as'], + type: 'programming', + aliases: ['actionscript 3', 'actionscript3', 'as3'] + }, + pic: { + extensions: ['.pic', '.chem'], + type: 'markup', + aliases: ['pikchr'] + }, + xbase: { + extensions: ['.prg', '.ch', '.prw'], + type: 'programming', + aliases: ['advpl', 'clipper', 'foxpro'] + }, + sed: { + extensions: ['.sed'], + type: 'programming' + }, + 'gettext catalog': { + extensions: ['.po', '.pot'], + type: 'prose', + aliases: ['pot'] + }, + cool: { + extensions: ['.cl'], + type: 'programming' + }, + 'java server pages': { + extensions: ['.jsp', '.tag'], + type: 'programming', + aliases: ['jsp'] + }, + ocaml: { + extensions: ['.ml', '.eliom', '.eliomi', '.ml4', '.mli', '.mll', '.mly'], + type: 'programming' + }, + bison: { + extensions: ['.bison'], + type: 'programming' + }, + stylus: { + extensions: ['.styl'], + type: 'markup' + }, + click: { + extensions: ['.click'], + type: 'programming' + }, + marko: { + extensions: ['.marko'], + type: 'markup', + aliases: ['markojs'] + }, + clips: { + extensions: ['.clp'], + type: 'programming' + }, + wollok: { + extensions: ['.wlk'], + type: 'programming' + }, + sqf: { + extensions: ['.sqf', '.hqf'], + type: 'programming' + }, + al: { + extensions: ['.al'], + type: 'programming' + }, + alloy: { + extensions: ['.als'], + type: 'programming' + }, + futhark: { + extensions: ['.fut'], + type: 'programming' + }, + shell: { + extensions: [ + '.sh', + '.bash', + '.bats', + '.cgi', + '.command', + '.fcgi', + '.ksh', + '.sh.in', + '.tmux', + '.tool', + '.trigger', + '.zsh', + '.zsh-theme' + ], + type: 'programming', + aliases: ['sh', 'shell-script', 'bash', 'zsh', 'envrc'] + }, + codeql: { + extensions: ['.ql', '.qll'], + type: 'programming', + aliases: ['ql'] + }, + 'motorola 68k assembly': { + extensions: ['.asm', '.i', '.inc', '.s', '.x68'], + type: 'programming', + aliases: ['m68k'] + }, + postcss: { + extensions: ['.pcss', '.postcss'], + type: 'markup' + }, + xs: { + extensions: ['.xs'], + type: 'programming' + }, + pascal: { + extensions: ['.pas', '.dfm', '.dpr', '.inc', '.lpr', '.pascal', '.pp'], + type: 'programming', + aliases: ['delphi', 'objectpascal'] + }, + 'html+php': { + extensions: ['.phtml'], + type: 'markup' + }, + bitbake: { + extensions: ['.bb', '.bbappend', '.bbclass', '.inc'], + type: 'programming' + }, + 'kicad schematic': { + extensions: ['.kicad_sch', '.kicad_sym', '.sch'], + type: 'data', + aliases: ['eeschema schematic'] + }, + 'mirc script': { + extensions: ['.mrc'], + type: 'programming' + }, + emberscript: { + extensions: ['.em', '.emberscript'], + type: 'programming' + }, + oxygene: { + extensions: ['.oxygene'], + type: 'programming' + }, + awk: { + extensions: ['.awk', '.auk', '.gawk', '.mawk', '.nawk'], + type: 'programming' + }, + jinja: { + extensions: ['.jinja', '.j2', '.jinja2'], + type: 'markup', + aliases: ['django', 'html+django', 'html+jinja', 'htmldjango'] + }, + augeas: { + extensions: ['.aug'], + type: 'programming' + }, + webidl: { + extensions: ['.webidl'], + type: 'programming' + }, + 'opentype feature file': { + extensions: ['.fea'], + type: 'data', + aliases: ['AFDKO'] + }, + 'emacs lisp': { + extensions: ['.el', '.emacs', '.emacs.desktop'], + type: 'programming', + aliases: ['elisp', 'emacs'] + }, + 'gentoo eclass': { + extensions: ['.eclass'], + type: 'programming' + }, + pony: { + extensions: ['.pony'], + type: 'programming' + }, + chapel: { + extensions: ['.chpl'], + type: 'programming', + aliases: ['chpl'] + }, + ats: { + extensions: ['.dats', '.hats', '.sats'], + type: 'programming', + aliases: ['ats2'] + }, + 'git config': { + extensions: ['.gitconfig'], + type: 'data', + aliases: ['gitconfig', 'gitmodules'] + }, + 'd-objdump': { + extensions: ['.d-objdump'], + type: 'data' + }, + hxml: { + extensions: ['.hxml'], + type: 'data' + }, + 'dns zone': { + extensions: ['.zone', '.arpa'], + type: 'data' + }, + handlebars: { + extensions: ['.handlebars', '.hbs'], + type: 'markup', + aliases: ['hbs', 'htmlbars'] + }, + sieve: { + extensions: ['.sieve'], + type: 'programming' + }, + sugarss: { + extensions: ['.sss'], + type: 'markup' + }, + 'csound document': { + extensions: ['.csd'], + type: 'programming', + aliases: ['csound-csd'] + }, + tsv: { + extensions: ['.tsv', '.vcf'], + type: 'data', + aliases: ['tab-seperated values'] + }, + jasmin: { + extensions: ['.j'], + type: 'programming' + }, + 'linux kernel module': { + extensions: ['.mod'], + type: 'data' + }, + supercollider: { + extensions: ['.sc', '.scd'], + type: 'programming' + }, + 'x bitmap': { + extensions: ['.xbm'], + type: 'data', + aliases: ['xbm'] + }, + opencl: { + extensions: ['.cl', '.opencl'], + type: 'programming' + }, + 'literate haskell': { + extensions: ['.lhs'], + type: 'programming', + aliases: ['lhaskell', 'lhs'] + }, + html: { + extensions: ['.html', '.hta', '.htm', '.html.hl', '.inc', '.xht', '.xhtml'], + type: 'markup', + aliases: ['xhtml'] + }, + typescript: { + extensions: ['.ts', '.cts', '.mts'], + type: 'programming', + aliases: ['ts'] + }, + smalltalk: { + extensions: ['.st', '.cs'], + type: 'programming', + aliases: ['squeak'] + }, + cson: { + extensions: ['.cson'], + type: 'data' + }, + riot: { + extensions: ['.riot'], + type: 'markup' + }, + solidity: { + extensions: ['.sol'], + type: 'programming' + }, + volt: { + extensions: ['.volt'], + type: 'programming' + }, + lex: { + extensions: ['.l', '.lex'], + type: 'programming', + aliases: ['flex'] + }, + 'inform 7': { + extensions: ['.ni', '.i7x'], + type: 'programming', + aliases: ['i7', 'inform7'] + }, + yaml: { + extensions: [ + '.yml', + '.mir', + '.reek', + '.rviz', + '.sublime-syntax', + '.syntax', + '.yaml', + '.yaml-tmlanguage', + '.yaml.sed', + '.yml.mysql' + ], + type: 'data', + aliases: ['yml'] + }, + 'avro idl': { + extensions: ['.avdl'], + type: 'data' + }, + omgrofl: { + extensions: ['.omgrofl'], + type: 'programming' + }, + kit: { + extensions: ['.kit'], + type: 'markup' + }, + 'modula-3': { + extensions: ['.i3', '.ig', '.m3', '.mg'], + type: 'programming' + }, + xquery: { + extensions: ['.xquery', '.xq', '.xql', '.xqm', '.xqy'], + type: 'programming' + }, + nu: { + extensions: ['.nu'], + type: 'programming', + aliases: ['nush'] + }, + lasso: { + extensions: ['.lasso', '.las', '.lasso8', '.lasso9'], + type: 'programming', + aliases: ['lassoscript'] + }, + openscad: { + extensions: ['.scad'], + type: 'programming' + }, + vala: { + extensions: ['.vala', '.vapi'], + type: 'programming' + }, + lookml: { + extensions: ['.lkml', '.lookml'], + type: 'programming' + }, + hyphy: { + extensions: ['.bf'], + type: 'programming' + }, + openqasm: { + extensions: ['.qasm'], + type: 'programming' + }, + 'wavefront material': { + extensions: ['.mtl'], + type: 'data' + }, + 'linker script': { + extensions: ['.ld', '.lds', '.x'], + type: 'programming' + }, + nl: { + extensions: ['.nl'], + type: 'data' + }, + dogescript: { + extensions: ['.djs'], + type: 'programming' + }, + 'adobe font metrics': { + extensions: ['.afm'], + type: 'data', + aliases: ['acfm', 'adobe composite font metrics', 'adobe multiple font metrics', 'amfm'] + }, + 'gerber image': { + extensions: [ + '.gbr', + '.cmp', + '.gbl', + '.gbo', + '.gbp', + '.gbs', + '.gko', + '.gml', + '.gpb', + '.gpt', + '.gtl', + '.gto', + '.gtp', + '.gts', + '.ncl', + '.sol' + ], + type: 'data', + aliases: ['rs-274x'] + }, + nit: { + extensions: ['.nit'], + type: 'programming' + }, + 'grammatical framework': { + extensions: ['.gf'], + type: 'programming', + aliases: ['gf'] + }, + pan: { + extensions: ['.pan'], + type: 'programming' + }, + self: { + extensions: ['.self'], + type: 'programming' + }, + purescript: { + extensions: ['.purs'], + type: 'programming' + }, + latte: { + extensions: ['.latte'], + type: 'markup' + }, + blade: { + extensions: ['.blade', '.blade.php'], + type: 'markup' + }, + lolcode: { + extensions: ['.lol'], + type: 'programming' + }, + 'coldfusion cfc': { + extensions: ['.cfc'], + type: 'programming', + aliases: ['cfc'] + }, + mql5: { + extensions: ['.mq5', '.mqh'], + type: 'programming' + }, + 'wavefront object': { + extensions: ['.obj'], + type: 'data' + }, + cuda: { + extensions: ['.cu', '.cuh'], + type: 'programming' + }, + smpl: { + extensions: ['.cocci'], + type: 'programming', + aliases: ['coccinelle'] + }, + crystal: { + extensions: ['.cr'], + type: 'programming' + }, + 'netlinx+erb': { + extensions: ['.axs.erb', '.axi.erb'], + type: 'programming' + }, + xtend: { + extensions: ['.xtend'], + type: 'programming' + }, + mcfunction: { + extensions: ['.mcfunction'], + type: 'programming' + }, + 'f#': { + extensions: ['.fs', '.fsi', '.fsx'], + type: 'programming', + aliases: ['fsharp'] + }, + gdscript: { + extensions: ['.gd'], + type: 'programming' + }, + dtrace: { + extensions: ['.d'], + type: 'programming', + aliases: ['dtrace-script'] + }, + gap: { + extensions: ['.g', '.gap', '.gd', '.gi', '.tst'], + type: 'programming' + }, + oz: { + extensions: ['.oz'], + type: 'programming' + }, + "ren'py": { + extensions: ['.rpy'], + type: 'programming', + aliases: ['renpy'] + }, + elixir: { + extensions: ['.ex', '.exs'], + type: 'programming' + }, + webassembly: { + extensions: ['.wast', '.wat'], + type: 'programming', + aliases: ['wast', 'wasm'] + }, + lean: { + extensions: ['.lean', '.hlean'], + type: 'programming' + }, + lilypond: { + extensions: ['.ly', '.ily'], + type: 'programming' + }, + squirrel: { + extensions: ['.nut'], + type: 'programming' + }, + asciidoc: { + extensions: ['.asciidoc', '.adoc', '.asc'], + type: 'prose' + }, + yacc: { + extensions: ['.y', '.yacc', '.yy'], + type: 'programming' + }, + 'filebench wml': { + extensions: ['.f'], + type: 'programming' + }, + dafny: { + extensions: ['.dfy'], + type: 'programming' + }, + plpgsql: { + extensions: ['.pgsql', '.sql'], + type: 'programming' + }, + 'parrot assembly': { + extensions: ['.pasm'], + type: 'programming', + aliases: ['pasm'] + }, + kakounescript: { + extensions: ['.kak'], + type: 'programming', + aliases: ['kak', 'kakscript'] + }, + raku: { + extensions: [ + '.6pl', + '.6pm', + '.nqp', + '.p6', + '.p6l', + '.p6m', + '.pl', + '.pl6', + '.pm', + '.pm6', + '.raku', + '.rakumod', + '.t' + ], + type: 'programming', + aliases: ['perl6', 'perl-6'] + }, + stata: { + extensions: ['.do', '.ado', '.doh', '.ihlp', '.mata', '.matah', '.sthlp'], + type: 'programming' + }, + 'c++': { + extensions: [ + '.cpp', + '.c++', + '.cc', + '.cp', + '.cppm', + '.cxx', + '.h', + '.h++', + '.hh', + '.hpp', + '.hxx', + '.inc', + '.inl', + '.ino', + '.ipp', + '.ixx', + '.re', + '.tcc', + '.tpp', + '.txx' + ], + type: 'programming', + aliases: ['cpp'] + }, + holyc: { + extensions: ['.hc'], + type: 'programming' + }, + mercury: { + extensions: ['.m', '.moo'], + type: 'programming' + }, + 'unity3d asset': { + extensions: ['.anim', '.asset', '.mask', '.mat', '.meta', '.prefab', '.unity'], + type: 'data' + }, + 'json with comments': { + extensions: [ + '.jsonc', + '.code-snippets', + '.code-workspace', + '.sublime-build', + '.sublime-color-scheme', + '.sublime-commands', + '.sublime-completions', + '.sublime-keymap', + '.sublime-macro', + '.sublime-menu', + '.sublime-mousemap', + '.sublime-project', + '.sublime-settings', + '.sublime-theme', + '.sublime-workspace', + '.sublime_metrics', + '.sublime_session' + ], + type: 'data', + aliases: ['jsonc'] + }, + abnf: { + extensions: ['.abnf'], + type: 'data' + }, + perl: { + extensions: ['.pl', '.al', '.cgi', '.fcgi', '.perl', '.ph', '.plx', '.pm', '.psgi', '.t'], + type: 'programming', + aliases: ['cperl'] + }, + graphql: { + extensions: ['.graphql', '.gql', '.graphqls'], + type: 'data' + }, + d: { + extensions: ['.d', '.di'], + type: 'programming', + aliases: ['Dlang'] + }, + m: { + extensions: ['.mumps', '.m'], + type: 'programming', + aliases: ['mumps'] + }, + terra: { + extensions: ['.t'], + type: 'programming' + }, + jflex: { + extensions: ['.flex', '.jflex'], + type: 'programming' + }, + cycript: { + extensions: ['.cy'], + type: 'programming' + } +} diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js index 9637c60f3a..8e232dfa9c 100644 --- a/resources/scripts/install-bun.js +++ b/resources/scripts/install-bun.js @@ -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) { diff --git a/resources/scripts/install-uv.js b/resources/scripts/install-uv.js index 32892b9c63..2c882d07da 100644 --- a/resources/scripts/install-uv.js +++ b/resources/scripts/install-uv.js @@ -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) { diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts new file mode 100644 index 0000000000..15648f6ffc --- /dev/null +++ b/src/main/bootstrap.ts @@ -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() diff --git a/src/main/index.ts b/src/main/index.ts index 102264317a..3699335a90 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() /** diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2ab20abfab..7d21c84f00 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,13 +1,14 @@ import fs from 'node:fs' import { arch } from 'node:os' +import path from 'node:path' import { isMac, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' -import { FeedUrl } from '@shared/config/constant' +import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Shortcut, ThemeMode } from '@types' -import { BrowserWindow, dialog, ipcMain, session, shell } from 'electron' +import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron' import log from 'electron-log' import { Notification } from 'src/renderer/src/types/notification' @@ -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, 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() diff --git a/src/main/loader/index.ts b/src/main/loader/index.ts index db837f414f..ba66b33e3d 100644 --- a/src/main/loader/index.ts +++ b/src/main/loader/index.ts @@ -16,6 +16,7 @@ const FILE_LOADER_MAP: Record = { // 内置类型 '.pdf': 'common', '.csv': 'common', + '.doc': 'common', '.docx': 'common', '.pptx': 'common', '.xlsx': 'common', diff --git a/src/main/loader/noteLoader.ts b/src/main/loader/noteLoader.ts new file mode 100644 index 0000000000..693f5f3c0a --- /dev/null +++ b/src/main/loader/noteLoader.ts @@ -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' + } + } + } + } +} diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 1c508f8844..2376ef223e 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -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 = {}): 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}`) } diff --git a/src/main/mcpServers/python.ts b/src/main/mcpServers/python.ts new file mode 100644 index 0000000000..6fe0b80db1 --- /dev/null +++ b/src/main/mcpServers/python.ts @@ -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 + 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 diff --git a/src/main/mcpServers/sequentialthinking.ts b/src/main/mcpServers/sequentialthinking.ts index 4589c0bf34..bcda96e192 100644 --- a/src/main/mcpServers/sequentialthinking.ts +++ b/src/main/mcpServers/sequentialthinking.ts @@ -106,6 +106,7 @@ class SequentialThinkingServer { type: 'text', text: JSON.stringify( { + thought: validatedInput.thought, thoughtNumber: validatedInput.thoughtNumber, totalThoughts: validatedInput.totalThoughts, nextThoughtNeeded: validatedInput.nextThoughtNeeded, diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index 16d36f87fc..83d241fe85 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -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, diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 4c71a05556..82165fd715 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -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 diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index e994e90bed..6e0c813e6d 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -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 { @@ -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 { - 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 => { + 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 => { + 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((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 diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 573674bd70..8e4b5d2bf1 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -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(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION) + getTestPlan(): boolean { + return this.get(ConfigKeys.TestPlan, false) } - setFeedUrl(value: FeedUrl) { - this.set(ConfigKeys.FeedUrl, value) + setTestPlan(value: boolean) { + this.set(ConfigKeys.TestPlan, value) + } + + getTestChannel(): UpgradeChannel { + return this.get(ConfigKeys.TestChannel) + } + + setTestChannel(value: UpgradeChannel) { + this.set(ConfigKeys.TestChannel, value) } getEnableDataCollection(): boolean { diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts index 2f4f5aa20f..411d6e075d 100644 --- a/src/main/services/ContextMenu.ts +++ b/src/main/services/ContextMenu.ts @@ -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() diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 2ac689b8cc..0c81a454a7 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -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 => { 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 diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 62b2bba08f..d2d381c598 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -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 => { + public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise => { 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 diff --git a/src/main/services/PythonService.ts b/src/main/services/PythonService.ts new file mode 100644 index 0000000000..13b4546e56 --- /dev/null +++ b/src/main/services/PythonService.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto' + +import { BrowserWindow, ipcMain } from 'electron' + +interface PythonExecutionRequest { + id: string + script: string + context: Record + 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 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 = {}, + timeout: number = 60000 + ): Promise { + 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() diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts index b62489bbbe..4efc57b6c6 100644 --- a/src/main/services/RemoteStorage.ts +++ b/src/main/services/RemoteStorage.ts @@ -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 | 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 | 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 + } + } +} diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 3153f7bffe..29024b3ddc 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -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 } }) diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts index aae00e85d4..14f4801524 100644 --- a/src/main/utils/__tests__/file.test.ts +++ b/src/main/utils/__tests__/file.test.ts @@ -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) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 737dcee0ba..177a28a90f 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -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() @@ -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)) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 58d9ba91c4..34e282ebf0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, 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), diff --git a/src/renderer/selectionToolbar.html b/src/renderer/selectionToolbar.html index 1a219f6472..34efa7effc 100644 --- a/src/renderer/selectionToolbar.html +++ b/src/renderer/selectionToolbar.html @@ -2,42 +2,45 @@ - - - - Cherry Studio Selection Toolbar + + + + Cherry Studio Selection Toolbar -
- - + -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; + } + \ No newline at end of file diff --git a/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts b/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts index b24eea2a2d..d940abdfe7 100644 --- a/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts +++ b/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts @@ -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的实例 */ diff --git a/src/renderer/src/aiCore/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/clients/ApiClientFactory.ts index adc97e70e0..b0fbe3e479 100644 --- a/src/renderer/src/aiCore/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/clients/ApiClientFactory.ts @@ -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) { diff --git a/src/renderer/src/aiCore/clients/BaseApiClient.ts b/src/renderer/src/aiCore/clients/BaseApiClient.ts index 4e39e9464d..083dbda872 100644 --- a/src/renderer/src/aiCore/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/clients/BaseApiClient.ts @@ -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 { - const content = getMainTextContent(message) + const content = getContentWithTools(message) if (isEmpty(content)) { return '' } diff --git a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts index 864f2fff30..ebe76d8152 100644 --- a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts @@ -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 diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index 7f08def6cc..bfd2aff3f2 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -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 { 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 { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index a53247c1f7..65e9cc67c4 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -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 diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts index cd03607c29..72fa1d7df8 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts @@ -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' } : {}) } diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 71b809c338..0cf64fb22a 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -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) { // 处理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, diff --git a/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts b/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts new file mode 100644 index 0000000000..2b8dec332d --- /dev/null +++ b/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts @@ -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 { + 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 [] + } + } +} diff --git a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts index fa0cc2cf61..893018d4c5 100644 --- a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts @@ -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 { diff --git a/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts index d0a4dc4903..ceb8d791d7 100644 --- a/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts @@ -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( diff --git a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts index 440de40045..fe2d51d8de 100644 --- a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts @@ -11,11 +11,13 @@ export const MIDDLEWARE_NAME = 'ThinkingTagExtractionMiddleware' // 不同模型的思考标签配置 const reasoningTags: TagConfig[] = [ { openingTag: '', closingTag: '', separator: '\n' }, + { openingTag: '', closingTag: '', 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] // 默认使用 标签 } diff --git a/src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 b/src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 new file mode 100644 index 0000000000..7f5bebba53 Binary files /dev/null and b/src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 differ diff --git a/src/renderer/src/assets/fonts/country-flag-fonts/flag.css b/src/renderer/src/assets/fonts/country-flag-fonts/flag.css new file mode 100644 index 0000000000..b3daed9da0 --- /dev/null +++ b/src/renderer/src/assets/fonts/country-flag-fonts/flag.css @@ -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; +} diff --git a/src/renderer/src/assets/styles/font.scss b/src/renderer/src/assets/styles/font.scss index 9d2d139b53..5db6290bfc 100644 --- a/src/renderer/src/assets/styles/font.scss +++ b/src/renderer/src/assets/styles/font.scss @@ -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'; +} diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index e5718d989e..ed1dd0d29a 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -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); } diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 2c6d6655f5..eea9070cae 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -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; diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss index dfbb6bbd59..bfe329c696 100644 --- a/src/renderer/src/assets/styles/selection-toolbar.scss +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -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); } diff --git a/src/renderer/src/components/Avatar/EmojiAvatar.tsx b/src/renderer/src/components/Avatar/EmojiAvatar.tsx index 553869698a..e01024735a 100644 --- a/src/renderer/src/components/Avatar/EmojiAvatar.tsx +++ b/src/renderer/src/components/Avatar/EmojiAvatar.tsx @@ -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; } diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 04990d4d87..55a10d5535 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -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([]) - const codeContentRef = useRef(null) - const prevCodeLengthRef = useRef(0) - const safeCodeStringRef = useRef(children) - const highlightQueueRef = useRef>(Promise.resolve()) + const [isInViewport, setIsInViewport] = useState(false) + const codeContainerRef = useRef(null) + const processingRef = useRef(false) + const latestRequestedContentRef = useRef(null) const callerId = useRef(`${Date.now()}-${uuid()}`).current const shikiThemeRef = useRef(activeShikiTheme) @@ -45,7 +46,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { icon: isExpanded ? : , 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 ( { maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none' }}> {hasHighlightedCode ? ( - + ) : ( {children} )} @@ -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(null) +const ShikiTokensRenderer: React.FC = memo(({ language, tokenLines, showLineNumbers }) => { + const { getShikiPreProperties } = useCodeStyle() + const rendererRef = useRef(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 ( -
-        
-          {tokenLines.map((lineTokens, lineIndex) => (
-            
+  return (
+    
+      
+        {tokenLines.map((lineTokens, lineIndex) => (
+          
+            {showLineNumbers && {lineIndex + 1}}
+            
               {lineTokens.map((token, tokenIndex) => (
                 
                   {token.content}
                 
               ))}
             
-          ))}
-        
-      
- ) - } -) +
+ ))} +
+
+ ) +}) 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` diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx index 64a3bb6c02..074616a8b8 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -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 = ({ 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('')) { - 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) diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index 977eea6ac0..176e1753a7 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -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 }} /> ) diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 144f9d468c..f6d728f765 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -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.RefObject | 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() - - 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 = [] - 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( })() const containerRef = React.useRef(null) const searchInputRef = React.useRef(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())[0] + const [allRanges, setAllRanges] = useState([]) + 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() - // 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 = new Set() - while ((textNode = iter.nextNode())) { - if (filter(textNode)) { - textNodeSet.add(textNode) - } - } - - const highlightTextSetTemp = new Set() - 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) => { - 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) => { - 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) => { + 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) => { + 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( {searchCompleted !== SearchCompletedState.NotSearched ? ( - totalCount > 0 ? ( + allRanges.length > 0 ? ( <> - {searchResultIndex + 1} + {currentIndex + 1} / - {totalCount} + {allRanges.length} ) : ( {t('common.no_results')} @@ -606,10 +385,10 @@ export const ContentSearch = React.forwardRef( )} - + - + diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx index bb3136067c..1beecf2c5a 100644 --- a/src/renderer/src/components/ContextMenu/index.tsx +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -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 = ({ children, onContextMenu }) => { const { t } = useTranslation() const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) - const [selectedText, setSelectedText] = useState('') + const [selectedText, setSelectedText] = useState(undefined) const handleContextMenu = useCallback( (e: React.MouseEvent) => { @@ -36,47 +36,52 @@ const ContextMenu: React.FC = ({ 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 ( {contextMenuPosition && ( - -
+ + {children} )} - {children} ) } diff --git a/src/renderer/src/components/EmojiIcon.tsx b/src/renderer/src/components/EmojiIcon.tsx index 2e9600e399..6cd06b8715 100644 --- a/src/renderer/src/components/EmojiIcon.tsx +++ b/src/renderer/src/components/EmojiIcon.tsx @@ -4,26 +4,28 @@ import styled from 'styled-components' interface EmojiIconProps { emoji: string className?: string + size?: number + fontSize?: number } -const EmojiIcon: FC = ({ emoji, className }) => { +const EmojiIcon: FC = ({ emoji, className, size = 26, fontSize = 15 }) => { return ( - + {emoji || '⭐️'} {emoji} ) } -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; diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index eb8a90dbde..406d6d1865 100644 --- a/src/renderer/src/components/EmojiPicker/index.tsx +++ b/src/renderer/src/components/EmojiPicker/index.tsx @@ -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 = ({ onEmojiClick }) => { const { theme } = useTheme() const ref = useRef(null) + useEffect(() => { + polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2) + }, []) + useEffect(() => { if (ref.current) { ref.current.addEventListener('emoji-click', (event: any) => { diff --git a/src/renderer/src/components/MarkdownEditor/index.tsx b/src/renderer/src/components/MarkdownEditor/index.tsx index 5a355a07c2..427ff1ccc8 100644 --- a/src/renderer/src/components/MarkdownEditor/index.tsx +++ b/src/renderer/src/components/MarkdownEditor/index.tsx @@ -41,11 +41,10 @@ const MarkdownEditor: FC = ({ return ( - + + rehypePlugins={[rehypeRaw, rehypeKatex]}> {inputValue || t('settings.provider.notes.markdown_editor_default_value')} diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 252c25fe14..5d07a31536 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -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 = () => { )} - + + + + ) + } + ] + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys) + } + } + + return ( + } onClick={fetchBackupFiles} disabled={loading}> + {t('settings.data.s3.manager.refresh')} + , + , + + ]}> + + + ) +} diff --git a/src/renderer/src/components/S3Modals.tsx b/src/renderer/src/components/S3Modals.tsx new file mode 100644 index 0000000000..a74ad2e9ca --- /dev/null +++ b/src/renderer/src/components/S3Modals.tsx @@ -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 + handleCancel: () => void + backuping: boolean + customFileName: string + setCustomFileName: (value: string) => void +} + +export function S3BackupModal({ + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName +}: S3BackupModalProps) { + const { t } = useTranslation() + + return ( + + setCustomFileName(e.target.value)} + placeholder={t('settings.data.s3.backup.modal.filename.placeholder')} + /> + + ) +} + +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(null) + const [loadingFiles, setLoadingFiles] = useState(false) + const [backupFiles, setBackupFiles] = useState([]) + 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 + +export function S3RestoreModal({ + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles +}: S3RestoreModalProps) { + const { t } = useTranslation() + + return ( + +
+