diff --git a/.github/workflows/issue-management.yml b/.github/workflows/issue-management.yml index f79b31fd92..59faedc04e 100644 --- a/.github/workflows/issue-management.yml +++ b/.github/workflows/issue-management.yml @@ -54,5 +54,5 @@ jobs: days-before-pr-close: -1 # Completely disable closing for PRs # Temporary to reduce the huge issues number - operations-per-run: 100 + operations-per-run: 1000 debug-only: false diff --git a/.gitignore b/.gitignore index 459dc6201c..68ea0f203f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ local coverage .vitest-cache vitest.config.*.timestamp-* +YOUR_MEMORY_FILE_PATH diff --git a/.yarn/releases/yarn-4.6.0.cjs b/.yarn/releases/yarn-4.6.0.cjs deleted file mode 100755 index 3e7773b1ed..0000000000 Binary files a/.yarn/releases/yarn-4.6.0.cjs and /dev/null differ diff --git a/.yarn/releases/yarn-4.9.1.cjs b/.yarn/releases/yarn-4.9.1.cjs new file mode 100755 index 0000000000..657026d5c6 Binary files /dev/null and b/.yarn/releases/yarn-4.9.1.cjs differ diff --git a/.yarnrc.yml b/.yarnrc.yml index ff35b50cbe..e1e4cf05ca 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -4,4 +4,4 @@ httpTimeout: 300000 nodeLinker: node-modules -yarnPath: .yarn/releases/yarn-4.6.0.cjs +yarnPath: .yarn/releases/yarn-4.9.1.cjs diff --git a/README.md b/README.md index 96f727a96e..e5315f5ce9 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Refer to the [development documentation](docs/dev.md) Refer to the [Architecture overview documentation](https://deepwiki.com/CherryHQ/cherry-studio) -Refer to the [Branching Strategy](docs/branching-strategy.md) for contribution guidelines +Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines # 🤝 Contributing diff --git a/docs/README.ja.md b/docs/README.ja.md index 02983db685..ce5d2f6ef7 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -6,17 +6,19 @@

English | 中文 | 日本語

+
kangfenmao%2Fcherry-studio | Trendshift Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt
+ # 🍒 Cherry Studio Cherry Studio は、複数の LLM プロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linux で利用可能です。 👏 [Telegram](https://t.me/CherryStudioAI)|[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(575014769)](https://qm.qq.com/q/lo0D4qVZKi) -❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️ +❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください! # 📖 ガイド @@ -56,7 +58,7 @@ https://docs.cherry-ai.com - 🔤 AI による翻訳機能 - 🎯 ドラッグ&ドロップによる整理 - 🔌 ミニプログラム対応 -- ⚙️ MCP(モデルコンテキストプロトコル) サービス +- ⚙️ MCP(モデルコンテキストプロトコル)サービス 5. **優れたユーザー体験**: @@ -70,71 +72,78 @@ https://docs.cherry-ai.com - [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約) - [x] 複数モデルの回答の比較 -- [x] サービスプロバイダーが提供する SSO を使用したログインをサポート -- [x] すべてのモデルがネットワークをサポート +- [x] サービスプロバイダーが提供する SSO を使用したログイン対応 +- [x] すべてのモデルのネットワーク対応 - [x] 最初の公式バージョンのリリース -- [ ] 錯誤修復と改善 (開発中...) +- [x] バグ修正と改善(進行中...) - [ ] プラグイン機能(JavaScript) - [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加) - [ ] iOS & Android クライアント -- [ ] AIノート +- [ ] AI ノート - [ ] 音声入出力(AI コール) -- [ ] データバックアップはカスタムバックアップコンテンツをサポート +- [ ] データバックアップのカスタマイズ対応 # 🌈 テーマ -- テーマギャラリー: https://cherrycss.com -- Aero テーマ: https://github.com/hakadao/CherryStudio-Aero -- PaperMaterial テーマ: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial -- Claude テーマ: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic -- メープルネオンテーマ: https://github.com/BoningtonChen/CherryStudio_themes +- テーマギャラリー:https://cherrycss.com +- Aero テーマ:https://github.com/hakadao/CherryStudio-Aero +- PaperMaterial テーマ:https://github.com/rainoffallingstar/CherryStudio-PaperMaterial +- Claude テーマ:https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic +- メープルネオンテーマ:https://github.com/BoningtonChen/CherryStudio_themes -より多くのテーマのPRを歓迎します +より多くのテーマの PR を歓迎します # 🖥️ 開発 -参考[開発ドキュメント](dev.md) +[開発ドキュメント](dev.md)を参照してください + +[アーキテクチャ概要ドキュメント](https://deepwiki.com/CherryHQ/cherry-studio)を参照してください + +[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください # 🤝 貢献 Cherry Studio への貢献を歓迎します!以下の方法で貢献できます: -1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します。 -2. **バグの修正**:見つけたバグを修正します。 -3. **問題の管理**:GitHub の問題を管理するのを手伝います。 -4. **製品デザイン**:デザインの議論に参加します。 -5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します。 -6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します。 -7. **使用の促進**:Cherry Studio を広めます。 +1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します +2. **バグの修正**:見つけたバグを修正します +3. **問題の管理**:GitHub の問題を管理するのを手伝います +4. **製品デザイン**:デザインの議論に参加します +5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します +6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します +7. **使用の促進**:Cherry Studio を広めます ## 始め方 -1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします。 -2. **ブランチを作成**:変更のためのブランチを作成します。 -3. **変更を提出**:変更をコミットしてプッシュします。 -4. **プルリクエストを開く**:変更内容と理由を説明します。 +1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします +2. **ブランチを作成**:変更のためのブランチを作成します +3. **変更を提出**:変更をコミットしてプッシュします +4. **プルリクエストを開く**:変更内容と理由を説明します 詳細なガイドラインについては、[貢献ガイド](../CONTRIBUTING.md)をご覧ください。 ご支援と貢献に感謝します! -## 関連頁版 +## 関連プロジェクト - [one-api](https://github.com/songquanpeng/one-api):LLM API の管理・配信システム。OpenAI、Azure、Anthropic などの主要モデルに対応し、統一 API インターフェースを提供。API キー管理と再配布に利用可能。 +- [ublacklist](https://github.com/iorate/ublacklist):Google 検索結果から特定のサイトを非表示にします + # 🚀 コントリビューター - + +

-# コミュニティ +# 🌐 コミュニティ [Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao) -# スポンサー +# ☕ スポンサー -[Buy Me a Coffee](sponsor.md) +[開発者を支援する](sponsor.md) # 📃 ライセンス diff --git a/docs/README.zh.md b/docs/README.zh.md index 1e4876a820..ca85959dab 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -4,7 +4,8 @@

- English | 中文 | 日本語

+ English | 中文 | 日本語
+

kangfenmao%2Fcherry-studio | Trendshift Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt @@ -74,12 +75,12 @@ https://docs.cherry-ai.com - 📝 完整的 Markdown 渲染 - 🤲 便捷的内容分享功能 -# 📝 待辦事項 +# 📝 待办事项 - [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结) - [x] 多模型回答对比 -- [x] 支持使用服务供应商提供的 SSO 进行登入 -- [x] 全部模型支持连网(开发中...) +- [x] 支持使用服务供应商提供的 SSO 进行登录 +- [x] 所有模型支持联网 - [x] 推出第一个正式版 - [x] 错误修复和改进(开发中...) - [ ] 插件功能(JavaScript) @@ -93,9 +94,9 @@ https://docs.cherry-ai.com - 主题库:https://cherrycss.com - Aero 主题:https://github.com/hakadao/CherryStudio-Aero -- PaperMaterial 主题: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial -- 仿Claude 主题: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic -- 霓虹枫叶字体主题: https://github.com/BoningtonChen/CherryStudio_themes +- PaperMaterial 主题:https://github.com/rainoffallingstar/CherryStudio-PaperMaterial +- 仿 Claude 主题:https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic +- 霓虹枫叶主题:https://github.com/BoningtonChen/CherryStudio_themes 欢迎 PR 更多主题 @@ -103,26 +104,30 @@ https://docs.cherry-ai.com 参考[开发文档](dev.md) +参考[架构概览文档](https://deepwiki.com/CherryHQ/cherry-studio) + +参考[分支策略](branching-strategy-zh.md)了解贡献指南 + # 🤝 贡献 我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献: -1. **贡献代码**:开发新功能或优化现有代码。 -2. **修复错误**:提交您发现的错误修复。 -3. **维护问题**:帮助管理 GitHub 问题。 -4. **产品设计**:参与设计讨论。 -5. **撰写文档**:改进用户手册和指南。 -6. **社区参与**:加入讨论并帮助用户。 -7. **推广使用**:宣传 Cherry Studio。 +1. **贡献代码**:开发新功能或优化现有代码 +2. **修复错误**:提交您发现的错误修复 +3. **维护问题**:帮助管理 GitHub 问题 +4. **产品设计**:参与设计讨论 +5. **撰写文档**:改进用户手册和指南 +6. **社区参与**:加入讨论并帮助用户 +7. **推广使用**:宣传 Cherry Studio ## 入门 -1. **Fork 仓库**:Fork 并克隆到您的本地机器。 -2. **创建分支**:为您的更改创建分支。 -3. **提交更改**:提交并推送您的更改。 -4. **打开 Pull Request**:描述您的更改和原因。 +1. **Fork 仓库**:Fork 并克隆到您的本地机器 +2. **创建分支**:为您的更改创建分支 +3. **提交更改**:提交并推送您的更改 +4. **打开 Pull Request**:描述您的更改和原因 -有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)。 +有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md) 感谢您的支持和贡献! @@ -130,10 +135,12 @@ https://docs.cherry-ai.com - [one-api](https://github.com/songquanpeng/one-api):LLM API 管理及分发系统,支持 OpenAI、Azure、Anthropic 等主流模型,统一 API 接口,可用于密钥管理与二次分发。 +- [ublacklist](https://github.com/iorate/ublacklist):屏蔽特定网站在 Google 搜索结果中显示 + # 🚀 贡献者 - +

@@ -143,7 +150,7 @@ https://docs.cherry-ai.com # ☕ 赞助 -[微信赞赏码](sponsor.md) +[赞助开发者](sponsor.md) # 📃 许可证 @@ -155,4 +162,4 @@ yinsenho@cherry-ai.com # ⭐️ Star 记录 -[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline) \ No newline at end of file +[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline) diff --git a/docs/branching-strategy-en.md b/docs/branching-strategy-en.md new file mode 100644 index 0000000000..f3b7ddf508 --- /dev/null +++ b/docs/branching-strategy-en.md @@ -0,0 +1,71 @@ +# 🌿 Branching Strategy + +Cherry Studio implements a structured branching strategy to maintain code quality and streamline the development process. + +## Main Branches + +- `main`: Main development branch + + - Contains the latest development code + - Direct commits are not allowed - changes must come through pull requests + - Code may contain features in development and might not be fully stable + +- `release/*`: Release branches + - Created from `main` branch + - Contains stable code ready for release + - Only accepts documentation updates and bug fixes + - Thoroughly tested before production deployment + +## Contributing Branches + +When contributing to Cherry Studio, please follow these guidelines: + +1. **Feature Branches:** + + - Create from `main` branch + - Naming format: `feature/issue-number-brief-description` + - Submit PR back to `main` + +2. **Bug Fix Branches:** + + - Create from `main` branch + - Naming format: `fix/issue-number-brief-description` + - Submit PR back to `main` + +3. **Documentation Branches:** + + - Create from `main` branch + - Naming format: `docs/brief-description` + - Submit PR back to `main` + +4. **Hotfix Branches:** + + - Create from `main` branch + - Naming format: `hotfix/issue-number-brief-description` + - Submit PR to both `main` and relevant `release` branches + +5. **Release Branches:** + - Create from `main` branch + - Naming format: `release/version-number` + - Used for final preparation work before version release + - Only accepts bug fixes and documentation updates + - After testing and preparation, merge back to `main` and tag with version + +## Workflow Diagram + +![](https://github.com/user-attachments/assets/61db64a2-fab1-4a16-8253-0c64c9df1a63) + +## Pull Request Guidelines + +- All PRs should be submitted to the `main` branch unless fixing a critical production issue +- Ensure your branch is up to date with the latest `main` changes before submitting +- Include relevant issue numbers in your PR description +- Make sure all tests pass and code meets our quality standards +- Add before/after screenshots if you add a new feature or modify a UI component + +## Version Tag Management + +- Major releases: v1.0.0, v2.0.0, etc. +- Feature releases: v1.1.0, v1.2.0, etc. +- Patch releases: v1.0.1, v1.0.2, etc. +- Hotfix releases: v1.0.1-hotfix, etc. diff --git a/docs/branching-strategy-zh.md b/docs/branching-strategy-zh.md new file mode 100644 index 0000000000..b1379537a5 --- /dev/null +++ b/docs/branching-strategy-zh.md @@ -0,0 +1,71 @@ +# 🌿 分支策略 + +Cherry Studio 采用结构化的分支策略来维护代码质量并简化开发流程。 + +## 主要分支 + +- `main`:主开发分支 + + - 包含最新的开发代码 + - 禁止直接提交 - 所有更改必须通过拉取请求(Pull Request) + - 此分支上的代码可能包含正在开发的功能,不一定完全稳定 + +- `release/*`:发布分支 + - 从 `main` 分支创建 + - 包含准备发布的稳定代码 + - 只接受文档更新和 bug 修复 + - 经过完整测试后可以发布到生产环境 + +## 贡献分支 + +在为 Cherry Studio 贡献代码时,请遵循以下准则: + +1. **功能开发分支:** + + - 从 `main` 分支创建 + - 命名格式:`feature/issue-number-brief-description` + - 完成后提交 PR 到 `main` 分支 + +2. **Bug 修复分支:** + + - 从 `main` 分支创建 + - 命名格式:`fix/issue-number-brief-description` + - 完成后提交 PR 到 `main` 分支 + +3. **文档更新分支:** + + - 从 `main` 分支创建 + - 命名格式:`docs/brief-description` + - 完成后提交 PR 到 `main` 分支 + +4. **紧急修复分支:** + + - 从 `main` 分支创建 + - 命名格式:`hotfix/issue-number-brief-description` + - 完成后需要同时合并到 `main` 和相关的 `release` 分支 + +5. **发布分支:** + - 从 `main` 分支创建 + - 命名格式:`release/version-number` + - 用于版本发布前的最终准备工作 + - 只允许合并 bug 修复和文档更新 + - 完成测试和准备工作后,将代码合并回 `main` 分支并打上版本标签 + +## 工作流程 + +![](https://github.com/user-attachments/assets/61db64a2-fab1-4a16-8253-0c64c9df1a63) + +## 拉取请求(PR)指南 + +- 除非是修复生产环境的关键问题,否则所有 PR 都应该提交到 `main` 分支 +- 提交 PR 前确保你的分支已经同步了最新的 `main` 分支内容 +- 在 PR 描述中包含相关的 issue 编号 +- 确保所有测试通过,且代码符合我们的质量标准 +- 如果你添加了新功能或修改了 UI 组件,请附上更改前后的截图 + +## 版本标签管理 + +- 主要版本发布:v1.0.0、v2.0.0 等 +- 功能更新发布:v1.1.0、v1.2.0 等 +- 补丁修复发布:v1.0.1、v1.0.2 等 +- 紧急修复发布:v1.0.1-hotfix 等 diff --git a/docs/branching-strategy.md b/docs/branching-strategy.md deleted file mode 100644 index 897763af16..0000000000 --- a/docs/branching-strategy.md +++ /dev/null @@ -1,52 +0,0 @@ -# 🌿 Branching Strategy - -Cherry Studio follows a structured branching strategy to maintain code quality and streamline the development process: - -## Main Branches - -- `main`: Production-ready branch containing stable releases - - - All code here is thoroughly tested and ready for production - - Direct commits are not allowed - changes must come through pull requests - - Each merge to main represents a new release - -- `develop` (default): Primary development branch - - Contains the latest delivered development changes for the next release - - Relatively stable but may contain features in progress - - This is the default branch for development - -## Contributing Branches - -When contributing to Cherry Studio, please follow these guidelines: - -1. **For bug fixes:** - - - Create a branch from `develop` - - Name format: `fix/issue-number-brief-description` - - Submit pull request back to `develop` - -2. **For new features:** - - - Create a branch from `develop` - - Name format: `feature/issue-number-brief-description` - - Submit pull request back to `develop` - -3. **For documentation:** - - - Create a branch from `develop` - - Name format: `docs/brief-description` - - Submit pull request back to `develop` - -4. **For critical hotfixes:** - - Create a branch from `main` - - Name format: `hotfix/issue-number-brief-description` - - Submit pull request to both `main` and `develop` - -## Pull Request Guidelines - -- Always create pull requests against the `develop` branch unless fixing a critical production issue -- Ensure your branch is up to date with the latest `develop` changes before submitting -- Include relevant issue numbers in your PR description -- Make sure all tests pass and code meets our quality standards -- Critical hotfixes may be submitted against `main` but must also be merged into `develop` -- Add a photo to show what is different if you add a new feature or modify a component in the UI. diff --git a/electron-builder.yml b/electron-builder.yml index 4598455544..294335c36a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -45,6 +45,8 @@ win: target: - target: nsis - target: portable + signtoolOptions: + sign: scripts/win-sign.js nsis: artifactName: ${productName}-${version}-${arch}-setup.${ext} shortcutName: ${productName} @@ -92,9 +94,10 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | ⚠️ 注意:升级前请备份数据,否则将无法降级 - 优化软件启动速度 - 优化软件进入后台后性能问题 - 修复导出对话时自动重命名失败问题 - 防止输入法切换期间误发消息问题 - 修复群组消息重发功能问题及富文本粘贴兼容性问题 - 改进 MCP 服务处理及 IPC 注册逻辑 + 增加消息通知功能 + 增加 Google 小程序 + MCP 支持运行 Python 代码 + 修复 MCP SSE 连接问题 + 修复消息编辑和消息多选相关问题 + 修复消息显示问题 + 修复话题提示词无效问题 diff --git a/package.json b/package.json index d72d1ef99c..9741b6ca73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.3.6", + "version": "1.3.9", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -120,11 +120,11 @@ "@google/genai": "^0.13.0", "@hello-pangea/dnd": "^16.6.0", "@kangfenmao/keyv-storage": "^0.1.0", - "@modelcontextprotocol/sdk": "^1.11.3", + "@modelcontextprotocol/sdk": "^1.11.4", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@reduxjs/toolkit": "^2.2.5", - "@shikijs/markdown-it": "^3.2.2", + "@shikijs/markdown-it": "^3.4.2", "@swc/plugin-styled-components": "^7.1.5", "@tryfabric/martian": "^1.2.4", "@types/better-sqlite3": "^7.6.13", @@ -203,7 +203,7 @@ "remark-math": "^6.0.0", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.88.0", - "shiki": "^3.2.2", + "shiki": "^3.4.2", "string-width": "^7.2.0", "styled-components": "^6.1.11", "tiny-pinyin": "^1.3.2", @@ -222,11 +222,10 @@ "openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", "app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch", - "shiki": "3.2.2", "openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", "app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch" }, - "packageManager": "yarn@4.6.0", + "packageManager": "yarn@4.9.1", "lint-staged": { "*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [ "prettier --write", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 7c536015a9..7ba4164969 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -21,6 +21,9 @@ export enum IpcChannel { App_InstallUvBinary = 'app:install-uv-binary', App_InstallBunBinary = 'app:install-bun-binary', + Notification_Send = 'notification:send', + Notification_OnClick = 'notification:on-click', + Webview_SetOpenLinkExternal = 'webview:set-open-link-external', // Open @@ -52,6 +55,7 @@ export enum IpcChannel { Mcp_GetInstallInfo = 'mcp:get-install-info', Mcp_ServersChanged = 'mcp:servers-changed', Mcp_ServersUpdated = 'mcp:servers-updated', + Mcp_CheckConnectivity = 'mcp:check-connectivity', //copilot Copilot_GetAuthMessage = 'copilot:get-auth-message', @@ -112,7 +116,7 @@ export enum IpcChannel { File_BinaryImage = 'file:binaryImage', File_Base64File = 'file:base64File', Fs_Read = 'fs:read', - File_ResolveFilePath = 'file:resolveFilePath', + Export_Word = 'export:word', Shortcuts_Update = 'shortcuts:update', diff --git a/scripts/win-sign.js b/scripts/win-sign.js new file mode 100644 index 0000000000..f9b37c3aed --- /dev/null +++ b/scripts/win-sign.js @@ -0,0 +1,19 @@ +const { execSync } = require('child_process') + +exports.default = async function (configuration) { + if (process.env.WIN_SIGN) { + const { path } = configuration + if (configuration.path) { + try { + console.log('Start code signing...') + console.log('Signing file:', path) + const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"` + execSync(signCommand, { stdio: 'inherit' }) + console.log('Code signing completed') + } catch (error) { + console.error('Code signing failed:', error) + throw error + } + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 44d516a5ca..12b1c9c16f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -115,7 +115,7 @@ if (!app.requestSingleInstanceLock()) { app.on('will-quit', async () => { // event.preventDefault() try { - await mcpService().cleanup() + await mcpService.cleanup() } catch (error) { Logger.error('Error cleaning up MCP service:', error) } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e40b19aca1..45e9d7b72d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -8,6 +8,7 @@ import { IpcChannel } from '@shared/IpcChannel' import { Shortcut, ThemeMode } from '@types' import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron' import log from 'electron-log' +import { Notification } from 'src/renderer/src/types/notification' import { titleBarOverlayDark, titleBarOverlayLight } from './config' import AppUpdater from './services/AppUpdater' @@ -19,7 +20,8 @@ import FileService from './services/FileService' import FileStorage from './services/FileStorage' import { GeminiService } from './services/GeminiService' import KnowledgeService from './services/KnowledgeService' -import { getMcpInstance } from './services/MCPService' +import mcpService from './services/MCPService' +import NotificationService from './services/NotificationService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' @@ -41,6 +43,7 @@ const obsidianVaultService = new ObsidianVaultService() export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater(mainWindow) + const notificationService = new NotificationService(mainWindow) ipcMain.handle(IpcChannel.App_Info, () => ({ version: app.getVersion(), @@ -200,6 +203,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { await appUpdater.checkForUpdates() }) + // notification + ipcMain.handle(IpcChannel.Notification_Send, async (_, notification: Notification) => { + await notificationService.sendNotification(notification) + }) + ipcMain.handle(IpcChannel.Notification_OnClick, (_, notification: Notification) => { + mainWindow.webContents.send('notification-click', notification) + }) + // zip ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text)) ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text)) @@ -242,7 +253,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile) ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile) ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage) - ipcMain.handle(IpcChannel.File_ResolveFilePath, (_, name) => fileManager.resolveFilePath(name)) // fs ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile) @@ -310,16 +320,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ) // Register MCP handlers - ipcMain.handle(IpcChannel.Mcp_RemoveServer, (event, server) => getMcpInstance().removeServer(event, server)) - ipcMain.handle(IpcChannel.Mcp_RestartServer, (event, server) => getMcpInstance().restartServer(event, server)) - ipcMain.handle(IpcChannel.Mcp_StopServer, (event, server) => getMcpInstance().stopServer(event, server)) - ipcMain.handle(IpcChannel.Mcp_ListTools, (event, server) => getMcpInstance().listTools(event, server)) - ipcMain.handle(IpcChannel.Mcp_CallTool, (event, params) => getMcpInstance().callTool(event, params)) - ipcMain.handle(IpcChannel.Mcp_ListPrompts, (event, server) => getMcpInstance().listPrompts(event, server)) - ipcMain.handle(IpcChannel.Mcp_GetPrompt, (event, params) => getMcpInstance().getPrompt(event, params)) - ipcMain.handle(IpcChannel.Mcp_ListResources, (event, server) => getMcpInstance().listResources(event, server)) - ipcMain.handle(IpcChannel.Mcp_GetResource, (event, params) => getMcpInstance().getResource(event, params)) - ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, () => getMcpInstance().getInstallInfo()) + ipcMain.handle(IpcChannel.Mcp_RemoveServer, mcpService.removeServer) + ipcMain.handle(IpcChannel.Mcp_RestartServer, mcpService.restartServer) + ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer) + ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools) + ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool) + ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts) + ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt) + ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources) + ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource) + ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo) + ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity) ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name)) ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name)) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 4d951f3698..18dbd00422 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -255,19 +255,26 @@ class BackupManager { const sourcePath = path.join(this.tempDir, 'Data') const destPath = path.join(app.getPath('userData'), 'Data') - // 获取源目录总大小 - const totalSize = await this.getDirSize(sourcePath) - let copiedSize = 0 + const dataExists = await fs.pathExists(sourcePath) + const dataFiles = dataExists ? await fs.readdir(sourcePath) : [] - await this.setWritableRecursive(destPath) - await fs.remove(destPath) + if (dataExists && dataFiles.length > 0) { + // 获取源目录总大小 + const totalSize = await this.getDirSize(sourcePath) + let copiedSize = 0 - // 使用流式复制 - await this.copyDirWithProgress(sourcePath, destPath, (size) => { - copiedSize += size - const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50)) - onProgress({ stage: 'copying_files', progress, total: 100 }) - }) + await this.setWritableRecursive(destPath) + await fs.remove(destPath) + + // 使用流式复制 + await this.copyDirWithProgress(sourcePath, destPath, (size) => { + copiedSize += size + const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50)) + onProgress({ stage: 'copying_files', progress, total: 100 }) + }) + } else { + Logger.log('[backup] skipBackupFile is true, skip restoring Data directory') + } Logger.log('[backup] step 4: clean up temp directory') // 清理临时目录 diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index bb0cbfc422..6242709385 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -62,7 +62,7 @@ export class ConfigManager { } getTrayOnClose(): boolean { - return !!this.get(ConfigKeys.TrayOnClose, false) + return !!this.get(ConfigKeys.TrayOnClose, true) } setTrayOnClose(value: boolean) { diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index ee8d21a3a6..f055bdc5fb 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -27,10 +27,6 @@ class FileStorage { this.initStorageDir() } - public resolveFilePath = (name: string): string => { - return path.join(this.storageDir, name) - } - private initStorageDir = (): void => { try { if (!fs.existsSync(this.storageDir)) { @@ -332,7 +328,7 @@ class FileStorage { fileName: string, content: string, options?: SaveDialogOptions - ): Promise => { + ): Promise => { try { const result: SaveDialogReturnValue = await dialog.showSaveDialog({ title: '保存文件', @@ -340,14 +336,18 @@ class FileStorage { ...options }) + if (result.canceled) { + return Promise.reject(new Error('User canceled the save dialog')) + } + if (!result.canceled && result.filePath) { await writeFileSync(result.filePath, content, { encoding: 'utf-8' }) } return result.filePath - } catch (err) { + } catch (err: any) { logger.error('[IPC - Error]', 'An error occurred saving the file:', err) - return null + return Promise.reject('An error occurred saving the file: ' + err?.message) } } diff --git a/src/main/services/GeminiService.ts b/src/main/services/GeminiService.ts index 8427e1304e..e7b8310664 100644 --- a/src/main/services/GeminiService.ts +++ b/src/main/services/GeminiService.ts @@ -8,8 +8,19 @@ export class GeminiService { private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list' private static readonly CACHE_DURATION = 3000 - static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise { - const sdk = new GoogleGenAI({ vertexai: false, apiKey }) + static async uploadFile( + _: Electron.IpcMainInvokeEvent, + file: FileType, + { apiKey, baseURL }: { apiKey: string; baseURL: string } + ): Promise { + const sdk = new GoogleGenAI({ + vertexai: false, + apiKey, + httpOptions: { + baseUrl: baseURL + } + }) + return await sdk.files.upload({ file: file.path, config: { diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 90e50ec65a..bb6fc2835a 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -69,18 +69,10 @@ function withCache( } class McpService { - private static instance: McpService | null = null private clients: Map = new Map() private pendingClients: Map> = new Map() - public static getInstance(): McpService { - if (!McpService.instance) { - McpService.instance = new McpService() - } - return McpService.instance - } - - private constructor() { + constructor() { this.initClient = this.initClient.bind(this) this.listTools = this.listTools.bind(this) this.callTool = this.callTool.bind(this) @@ -251,6 +243,12 @@ class McpService { Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`) // Logger.info(`[MCP] Environment variables for server:`, server.env) const loginShellEnv = await this.getLoginShellEnv() + + // Bun not support proxy https://github.com/oven-sh/bun/issues/16812 + if (cmd.endsWith('bun')) { + this.removeProxyEnv(loginShellEnv) + } + const stdioTransport = new StdioClientTransport({ command: cmd, args, @@ -396,6 +394,26 @@ class McpService { } } + /** + * Check connectivity for an MCP server + */ + public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise { + Logger.info(`[MCP] Checking connectivity for server: ${server.name}`) + try { + const client = await this.initClient(server) + // Attempt to list tools as a way to check connectivity + await client.listTools() + Logger.info(`[MCP] Connectivity check successful for server: ${server.name}`) + return true + } catch (error) { + Logger.error(`[MCP] Connectivity check failed for server: ${server.name}`, error) + // Close the client if connectivity check fails to ensure a clean state for the next attempt + const serverKey = this.getServerKey(server) + await this.closeClient(serverKey) + return false + } + } + private async listToolsImpl(server: MCPServer): Promise { Logger.info(`[MCP] Listing tools for server: ${server.name}`) const client = await this.initClient(server) @@ -639,15 +657,14 @@ class McpService { return {} } }) -} -let mcpInstance: ReturnType | null = null - -export const getMcpInstance = () => { - if (!mcpInstance) { - mcpInstance = McpService.getInstance() + private removeProxyEnv(env: Record) { + delete env.HTTPS_PROXY + delete env.HTTP_PROXY + delete env.grpc_proxy + delete env.http_proxy + delete env.https_proxy } - return mcpInstance } -export default McpService.getInstance +export default new McpService() diff --git a/src/main/services/NotificationService.ts b/src/main/services/NotificationService.ts new file mode 100644 index 0000000000..e06036b523 --- /dev/null +++ b/src/main/services/NotificationService.ts @@ -0,0 +1,31 @@ +import { BrowserWindow, Notification as ElectronNotification } from 'electron' +import { Notification } from 'src/renderer/src/types/notification' + +import icon from '../../../build/icon.png?asset' + +class NotificationService { + private window: BrowserWindow + + constructor(window: BrowserWindow) { + // Initialize the service + this.window = window + } + + public async sendNotification(notification: Notification) { + // 使用 Electron Notification API + const electronNotification = new ElectronNotification({ + title: notification.title, + body: notification.message, + icon: icon + }) + + electronNotification.on('click', () => { + this.window.show() + this.window.webContents.send('notification-click', notification) + }) + + electronNotification.show() + } +} + +export default NotificationService diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index ba508d7048..b67cac03d6 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -331,11 +331,6 @@ export class WindowService { event.preventDefault() - if (mainWindow.isFullScreen()) { - mainWindow.setFullScreen(false) - return - } - mainWindow.hide() //for mac users, should hide dock icon if close to tray diff --git a/src/preload/index.ts b/src/preload/index.ts index f70ca04d20..81174d22d0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,6 +3,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { IpcChannel } from '@shared/IpcChannel' import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' +import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' // Custom APIs for renderer @@ -25,6 +26,9 @@ const api = { openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url), getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), + notification: { + send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification) + }, system: { getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType), getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname) @@ -55,7 +59,6 @@ const api = { }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), - resolveFilePath: (name: string) => ipcRenderer.invoke(IpcChannel.File_ResolveFilePath, name), upload: (file: FileType) => ipcRenderer.invoke(IpcChannel.File_Upload, file), delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId), read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId), @@ -113,7 +116,8 @@ const api = { resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize) }, gemini: { - uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, apiKey), + uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) => + ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }), base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file), retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey), listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey), @@ -149,7 +153,8 @@ const api = { listResources: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListResources, server), getResource: ({ server, uri }: { server: MCPServer; uri: string }) => ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }), - getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo) + getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo), + checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server) }, shell: { openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 24024374ec..b46910cd65 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import Sidebar from './components/app/Sidebar' import TopViewContainer from './components/TopView' import AntdProvider from './context/AntdProvider' import { CodeStyleProvider } from './context/CodeStyleProvider' +import { NotificationProvider } from './context/NotificationProvider' import StyleSheetManager from './context/StyleSheetManager' import { ThemeProvider } from './context/ThemeProvider' import NavigationHandler from './handler/NavigationHandler' @@ -27,26 +28,28 @@ function App(): React.ReactElement { - - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - + + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + diff --git a/src/renderer/src/assets/images/apps/google.svg b/src/renderer/src/assets/images/apps/google.svg new file mode 100644 index 0000000000..b518c52704 --- /dev/null +++ b/src/renderer/src/assets/images/apps/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/assets/images/models/hailuo.png b/src/renderer/src/assets/images/models/hailuo.png index e89ca0f26b..ca0d1e3f07 100644 Binary files a/src/renderer/src/assets/images/models/hailuo.png and b/src/renderer/src/assets/images/models/hailuo.png differ diff --git a/src/renderer/src/assets/images/models/hailuo_dark.png b/src/renderer/src/assets/images/models/hailuo_dark.png index b783bb6c62..8330a88375 100644 Binary files a/src/renderer/src/assets/images/models/hailuo_dark.png and b/src/renderer/src/assets/images/models/hailuo_dark.png differ diff --git a/src/renderer/src/assets/images/providers/burncloud.png b/src/renderer/src/assets/images/providers/burncloud.png new file mode 100644 index 0000000000..22888bff25 Binary files /dev/null and b/src/renderer/src/assets/images/providers/burncloud.png differ diff --git a/src/renderer/src/assets/images/providers/qwenlm.png b/src/renderer/src/assets/images/providers/qwenlm.png deleted file mode 100644 index d207a28997..0000000000 Binary files a/src/renderer/src/assets/images/providers/qwenlm.png and /dev/null differ diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index 3bbaa516b9..3dd08edc02 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -17,7 +17,7 @@ } .ant-tabs-tab-btn { - outline: none; + outline: none !important; } .ant-segmented-group { diff --git a/src/renderer/src/assets/styles/font.scss b/src/renderer/src/assets/styles/font.scss index 9d2d139b53..9bb6a01633 100644 --- a/src/renderer/src/assets/styles/font.scss +++ b/src/renderer/src/assets/styles/font.scss @@ -5,8 +5,9 @@ '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'; + -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans', + 'Helvetica Neue', serif, 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; } diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 40c0255468..aed4919e85 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -321,7 +321,6 @@ mjx-container { .cm-lineWrapping * { word-wrap: break-word; - white-space: pre-wrap; } } } diff --git a/src/renderer/src/components/Alert/OpenAIAlert.tsx b/src/renderer/src/components/Alert/OpenAIAlert.tsx index 7f8695fa05..455ab62987 100644 --- a/src/renderer/src/components/Alert/OpenAIAlert.tsx +++ b/src/renderer/src/components/Alert/OpenAIAlert.tsx @@ -17,7 +17,7 @@ const OpenAIAlert = () => { return ( { diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 965d6ad14b..d17a146112 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -162,21 +162,25 @@ const CodePreview = ({ children, language }: CodePreviewProps) => { } }, [highlightCode]) + const hasHighlightedCode = useMemo(() => { + return tokenLines.length > 0 + }, [tokenLines.length]) + return ( - {tokenLines.length > 0 ? ( - + {hasHighlightedCode ? ( +
+ +
) : ( -
{children}
+ {children} )}
) @@ -223,15 +227,20 @@ const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[ ) const ContentContainer = styled.div<{ - $isShowLineNumbers: boolean - $isUnwrapped: boolean - $isCodeWrappable: boolean + $lineNumbers: boolean + $wrap: boolean }>` position: relative; + overflow: auto; + display: flex; + flex-direction: column; border: 0.5px solid transparent; border-radius: 5px; margin-top: 0; - transition: opacity 0.3s ease; + + ::-webkit-scrollbar-thumb { + border-radius: 10px; + } .shiki { padding: 1em; @@ -244,13 +253,18 @@ const ContentContainer = styled.div<{ .line { display: block; min-height: 1.3rem; - padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')}; + padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')}; + + * { + word-wrap: ${(props) => (props.$wrap ? 'break-word' : undefined)}; + white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; + } } } } ${(props) => - props.$isShowLineNumbers && + props.$lineNumbers && ` code { counter-reset: step; @@ -269,15 +283,28 @@ const ContentContainer = styled.div<{ } `} - ${(props) => - props.$isCodeWrappable && - !props.$isUnwrapped && - ` - code .line * { - word-wrap: break-word; - white-space: pre-wrap; - } - `} + @keyframes contentFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .fade-in-effect { + animation: contentFadeIn 0.3s ease-in-out forwards; + } +` + +const CodePlaceholder = styled.div` + opacity: 0.1; + flex-direction: column; + white-space: pre-wrap; + word-break: break-all; + overflow-x: hidden; + display: block; + min-height: 1.3rem; ` CodePreview.displayName = 'CodePreview' diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx index e979ea1541..0dbb0aabb2 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx @@ -13,7 +13,6 @@ interface Props { const Artifacts: FC = ({ html }) => { const { t } = useTranslation() - const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview') const { openMinapp } = useMinappPopup() /** @@ -23,6 +22,7 @@ const Artifacts: FC = ({ html }) => { const path = await window.api.file.create('artifacts-preview.html') await window.api.file.write(path, html) const filePath = `file://${path}` + const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview') openMinapp({ id: 'artifacts-preview', name: title, diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx index 86a5f0d043..19b8796d09 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -9,7 +9,7 @@ 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' import { useTranslation } from 'react-i18next' -import styled, { css } from 'styled-components' +import styled from 'styled-components' import CodePreview from './CodePreview' import HtmlArtifacts from './HtmlArtifacts' @@ -199,12 +199,11 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { // 源代码视图组件 const sourceView = useMemo(() => { - const SourceView = codeEditor.enabled ? CodeEditor : CodePreview - return ( - - {children} - - ) + if (codeEditor.enabled) { + return + } else { + return {children} + } }, [children, codeEditor.enabled, language, onSave]) // 特殊视图组件映射 @@ -259,6 +258,9 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` position: relative; .code-toolbar { + margin-top: ${(props) => (props.$isInSpecialView ? '20px' : '0')}; + background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')}; + border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')}; opacity: 0; transition: opacity 0.2s ease; transform: translateZ(0); @@ -272,23 +274,6 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` opacity: 1; } } - - ${(props) => - props.$isInSpecialView && - css` - .code-toolbar { - margin-top: 20px; - } - `} - - ${(props) => - !props.$isInSpecialView && - css` - .code-toolbar { - background-color: var(--color-background-mute); - border-radius: 4px; - } - `} ` const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` @@ -298,16 +283,10 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` color: var(--color-text); font-size: 14px; font-weight: bold; - height: 34px; padding: 0 10px; border-top-left-radius: 8px; border-top-right-radius: 8px; - - ${(props) => - props.$isInSpecialView && - css` - height: 16px; - `} + height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')}; ` const SplitViewWrapper = styled.div` @@ -316,8 +295,9 @@ const SplitViewWrapper = styled.div` > * { flex: 1 1 0; + width: 0; min-width: 0; - overflow: auto; + max-width: 100%; } ` diff --git a/src/renderer/src/components/CodeEditor/hook.ts b/src/renderer/src/components/CodeEditor/hook.ts new file mode 100644 index 0000000000..c5bbab2d0d --- /dev/null +++ b/src/renderer/src/components/CodeEditor/hook.ts @@ -0,0 +1,65 @@ +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { Extension } from '@uiw/react-codemirror' +import { useEffect, useState } from 'react' + +let linterPromise: Promise | null = null +function importLintPackage() { + if (!linterPromise) { + linterPromise = import('@codemirror/lint').then((mod) => mod.linter) + } + return linterPromise +} + +// 语言对应的 linter 加载器 +const linterLoaders: Record Promise> = { + json: async () => { + const [linter, jsonParseLinter] = await Promise.all([ + importLintPackage(), + import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter) + ]) + return linter(jsonParseLinter()) + } +} + +export const useLanguageExtensions = (language: string, lint?: boolean) => { + const { languageMap } = useCodeStyle() + const [extensions, setExtensions] = useState([]) + + // 加载语言 + useEffect(() => { + let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + + // 如果语言名包含 `-`,转换为驼峰命名法 + if (normalizedLang.includes('-')) { + normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) + } + + import('@uiw/codemirror-extensions-langs') + .then(({ loadLanguage }) => { + const extension = loadLanguage(normalizedLang as any) + if (extension) { + setExtensions((prev) => [...prev, extension]) + } + }) + .catch((error) => { + console.debug(`Failed to load language: ${normalizedLang}`, error) + }) + }, [language, languageMap]) + + useEffect(() => { + if (!lint) return + + const loader = linterLoaders[language] + if (loader) { + loader() + .then((extension) => { + setExtensions((prev) => [...prev, extension]) + }) + .catch((error) => { + console.error(`Failed to load linter for ${language}`, error) + }) + } + }, [language, lint]) + + return extensions +} diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index 124912e277..6000e91b49 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -14,31 +14,50 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo } from 'react' import { useTranslation } from 'react-i18next' +import { useLanguageExtensions } from './hook' + // 标记非用户编辑的变更 const External = Annotation.define() interface Props { - children: string + value: string + placeholder?: string | HTMLElement language: string onSave?: (newContent: string) => void onChange?: (newContent: string) => void + minHeight?: string maxHeight?: string /** 用于覆写编辑器的某些设置 */ options?: { + stream?: boolean // 用于流式响应场景,默认 false + lint?: boolean collapsible?: boolean wrappable?: boolean keymap?: boolean } & BasicSetupOptions + /** 用于追加 extensions */ + extensions?: Extension[] /** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */ style?: React.CSSProperties } /** - * 源代码编辑器,基于 CodeMirror + * 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror。 * * 目前必须和 CodeToolbar 配合使用。 */ -const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, style }: Props) => { +const CodeEditor = ({ + value, + placeholder, + language, + onSave, + onChange, + minHeight, + maxHeight, + options, + extensions, + style +}: Props) => { const { fontSize, codeShowLineNumbers: _lineNumbers, @@ -59,38 +78,18 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, } }, [codeEditor, _lineNumbers, options]) - const { activeCmTheme, languageMap } = useCodeStyle() + const { activeCmTheme } = useCodeStyle() const [isExpanded, setIsExpanded] = useState(!collapsible) const [isUnwrapped, setIsUnwrapped] = useState(!wrappable) - const initialContent = useRef(children?.trimEnd() ?? '') - const [langExtension, setLangExtension] = useState([]) + const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? '')) const [editorReady, setEditorReady] = useState(false) const editorViewRef = useRef(null) const { t } = useTranslation() + const langExtensions = useLanguageExtensions(language, options?.lint) + const { registerTool, removeTool } = useCodeToolbar() - // 加载语言 - useEffect(() => { - let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() - - // 如果语言名包含 `-`,转换为驼峰命名法 - if (normalizedLang.includes('-')) { - normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) - } - - import('@uiw/codemirror-extensions-langs') - .then(({ loadLanguage }) => { - const extension = loadLanguage(normalizedLang as any) - if (extension) { - setLangExtension([extension]) - } - }) - .catch((error) => { - console.debug(`Failed to load language: ${normalizedLang}`, error) - }) - }, [language, languageMap]) - // 展开/折叠工具 useEffect(() => { registerTool({ @@ -142,7 +141,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, useEffect(() => { if (!editorViewRef.current) return - const newContent = children?.trimEnd() ?? '' + const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '') const currentDoc = editorViewRef.current.state.doc.toString() const changes = prepareCodeChanges(currentDoc, newContent) @@ -153,7 +152,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, annotations: [External.of(true)] }) } - }, [children]) + }, [options?.stream, value]) useEffect(() => { setIsExpanded(!collapsible) @@ -177,20 +176,27 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, ]) }, [handleSave]) - const enabledExtensions = useMemo(() => { - return [...langExtension, ...(isUnwrapped ? [] : [EditorView.lineWrapping]), ...(enableKeymap ? [saveKeymap] : [])] - }, [enableKeymap, langExtension, isUnwrapped, saveKeymap]) + const customExtensions = useMemo(() => { + return [ + ...(extensions ?? []), + ...langExtensions, + ...(isUnwrapped ? [] : [EditorView.lineWrapping]), + ...(enableKeymap ? [saveKeymap] : []) + ] + }, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap]) return ( { editorViewRef.current = view setEditorReady(true) @@ -217,8 +223,6 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, }} style={{ fontSize: `${fontSize - 1}px`, - overflow: collapsible && !isExpanded ? 'auto' : 'visible', - position: 'relative', border: '0.5px solid transparent', borderRadius: '5px', marginTop: 0, diff --git a/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx index 7cd49f95da..d1af8f49f2 100644 --- a/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx +++ b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx @@ -284,16 +284,6 @@ export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: const { t } = useTranslation() const { registerTool, removeTool } = useCodeToolbar() - const toolIds = useCallback(() => { - return { - zoomIn: 'preview-zoom-in', - zoomOut: 'preview-zoom-out', - copyImage: 'preview-copy-image', - downloadSvg: 'preview-download-svg', - downloadPng: 'preview-download-png' - } - }, []) - useEffect(() => { // 根据提供的功能有选择性地注册工具 if (handleZoom) { @@ -356,5 +346,5 @@ export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: removeTool(TOOL_SPECS['download-png'].id) } } - }, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t, toolIds]) + }, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t]) } diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 08f056a181..08a1fd415a 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -399,6 +399,7 @@ export const ContentSearch = React.forwardRef( searchInputFocus() } + // eslint-disable-next-line react-hooks/exhaustive-deps const implementation = { disable() { setEnableContentSearch(false) @@ -526,8 +527,7 @@ export const ContentSearch = React.forwardRef( if (enableContentSearch && searchInputRef.current?.value.trim()) { implementation.search() } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isCaseSensitive, isWholeWord, enableContentSearch]) // Add enableContentSearch dependency + }, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency const prevButtonOnClick = () => { implementation.searchPrev() @@ -558,7 +558,13 @@ export const ContentSearch = React.forwardRef( - + @@ -624,17 +630,19 @@ const Container = styled.div` ` const SearchBarContainer = styled.div` - border: 1px solid var(--color-border); + border: 1px solid var(--color-primary); border-radius: 10px; transition: all 0.2s ease; - position: relative; - margin: 5px 20px; - margin-bottom: 0; - padding: 6px 15px 8px; + position: fixed; + top: 15px; + left: 20px; + right: 20px; + margin-bottom: 5px; + padding: 5px 15px; display: flex; align-items: center; justify-content: center; - background-color: var(--color-background-opacity); + background-color: var(--color-background); flex: 1 1 auto; /* Take up input's previous space */ ` @@ -682,18 +690,18 @@ const SearchResults = styled.div` width: 80px; margin: 0 2px; flex: 0 0 auto; - color: var(--color-text-secondary); + color: var(--color-text-1); font-size: 14px; font-family: Ubuntu; ` const SearchResultsPlaceholder = styled.span` - color: var(--color-text-secondary); + color: var(--color-text-1); opacity: 0.5; ` const NoResults = styled.span` - color: var(--color-text-secondary); + color: var(--color-text-1); ` const SearchResultCount = styled.span` diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx index 02b9b3eafd..d0eace1dbf 100644 --- a/src/renderer/src/components/ContextMenu/index.tsx +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -2,6 +2,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Dropdown } from 'antd' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import styled from 'styled-components' interface ContextMenuProps { children: React.ReactNode @@ -73,7 +74,7 @@ const ContextMenu: React.FC = ({ children, onContextMenu }) => ] return ( -
+ {contextMenuPosition && ( = ({ children, onContextMenu }) => )} {children} -
+ ) } +const ContextContainer = styled.div`` + export default ContextMenu diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index 884c9a015a..e5f08c350b 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -67,6 +67,11 @@ const WebviewContainer = memo( style={WebviewStyle} allowpopups={'true' as any} partition="persist:webview" + useragent={ + appid === 'google' + ? 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36' + : undefined + } /> ) } diff --git a/src/renderer/src/components/Popups/FloatingSidebar.tsx b/src/renderer/src/components/Popups/FloatingSidebar.tsx index df77619d90..d5a576309d 100644 --- a/src/renderer/src/components/Popups/FloatingSidebar.tsx +++ b/src/renderer/src/components/Popups/FloatingSidebar.tsx @@ -52,29 +52,28 @@ const FloatingSidebar: FC = ({ setActiveAssistant={setActiveAssistant} setActiveTopic={setActiveTopic} position={position} - forceToSeeAllTab={true}> + forceToSeeAllTab={true} + style={{ + background: 'transparent', + border: 'none' + }} + /> ) return ( { - setOpen(visible) - }} + onOpenChange={setOpen} content={content} - trigger={['hover', 'click']} + trigger={['hover', 'click', 'contextMenu']} placement="bottomRight" - arrow={false} + showArrow mouseEnterDelay={0.8} // 800ms delay before showing mouseLeaveDelay={20} styles={{ body: { - padding: 0, - background: 'var(--color-background)', - border: '1px solid var(--color-border)', - borderRadius: '8px', - boxShadow: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12)' + padding: 0 } }}> {children} diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx new file mode 100644 index 0000000000..f021b631f9 --- /dev/null +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -0,0 +1,97 @@ +import { useChatContext } from '@renderer/hooks/useChatContext' +import { Topic } from '@renderer/types' +import { Button, Tooltip } from 'antd' +import { Copy, Save, Trash, X } from 'lucide-react' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface Props { + topic: Topic +} + +const MultiSelectActionPopup: FC = ({ topic }) => { + const { t } = useTranslation() + const { toggleMultiSelectMode, selectedMessageIds, isMultiSelectMode, handleMultiSelectAction } = + useChatContext(topic) + + const handleAction = (action: string) => { + handleMultiSelectAction(action, selectedMessageIds) + } + + const handleClose = () => { + toggleMultiSelectMode(false) + } + + if (!isMultiSelectMode) return null + + // TODO: 视情况调整 + // const isActionDisabled = selectedMessages.some((msg) => msg.role === 'user') + const isActionDisabled = false + + return ( + + + {t('common.selectedMessages', { count: selectedMessageIds.length })} + + + } disabled={isActionDisabled} onClick={() => handleAction('save')} /> + + + } disabled={isActionDisabled} onClick={() => handleAction('copy')} /> + + + } onClick={() => handleAction('delete')} /> + + + + } onClick={handleClose} /> + + + + ) +} + +const Container = styled.div` + width: 100%; + padding: 36px 20px; + background-color: var(--color-background); + border-top: 1px solid var(--color-border); +` + +const ActionBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +` + +const ActionButtons = styled.div` + display: flex; + gap: 16px; +` + +const ActionButton = styled(Button)` + display: flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: 50%; + .anticon { + font-size: 16px; + } + &:hover { + background-color: var(--color-background-mute); + } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +` + +const SelectionCount = styled.div` + margin-right: 15px; + color: var(--color-text-2); + font-size: 14px; +` + +export default MultiSelectActionPopup diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index 85a5265560..e5fe83b98c 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -1,6 +1,6 @@ import { isLinux, isMac, isWindows } from '@renderer/config/constant' -import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import { useFullscreen } from '@renderer/hooks/useFullscreen' +import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import type { FC, PropsWithChildren } from 'react' import type { HTMLAttributes } from 'react' import styled from 'styled-components' diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 4dc80ad751..6990e6d157 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -18,6 +18,7 @@ import FlowithAppLogo from '@renderer/assets/images/apps/flowith.svg?url' import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png?url' import GensparkLogo from '@renderer/assets/images/apps/genspark.jpg?url' import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?url' +import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url' import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url' import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url' import HikaLogo from '@renderer/assets/images/apps/hika.webp?url' @@ -164,7 +165,8 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ id: 'minimax', name: '海螺', url: 'https://chat.minimaxi.com/', - logo: HailuoModelLogo + logo: HailuoModelLogo, + bodered: true }, { id: 'groq', @@ -178,6 +180,16 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ url: 'https://claude.ai/', logo: ClaudeAppLogo }, + { + id: 'google', + name: 'Google', + url: 'https://google.com/', + logo: GoogleAppLogo, + bodered: true, + style: { + padding: 5 + } + }, { id: 'baidu-ai-chat', name: '文心一言', diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 3e3656aacf..8cb7bf8209 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -146,6 +146,8 @@ const visionAllowedModels = [ 'gemini-2\\.5', 'gemini-exp', 'claude-3', + 'claude-sonnet-4', + 'claude-opus-4', 'vision', 'glm-4v', 'qwen-vl', @@ -232,7 +234,7 @@ export const FUNCTION_CALLING_REGEX = new RegExp( ) export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp( - `\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+))\\b`, + `\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`, 'i' ) @@ -419,6 +421,30 @@ export const SYSTEM_MODELS: Record = { group: 'Qwen' } ], + + burncloud: [ + { id: 'claude-3-7-sonnet-20250219-thinking', provider: 'burncloud', name: 'Claude 3.7 thinking', group: 'Claude' }, + { id: 'claude-3-7-sonnet-20250219', provider: 'burncloud', name: 'Claude 3.7 Sonnet', group: 'Claude 3.7' }, + { id: 'claude-3-5-sonnet-20241022', provider: 'burncloud', name: 'Claude 3.5 Sonnet', group: 'Claude 3.5' }, + { id: 'claude-3-5-haiku-20241022', provider: 'burncloud', name: 'Claude 3.5 Haiku', group: 'Claude 3.5' }, + + { id: 'gpt-4.5-preview', provider: 'burncloud', name: 'gpt-4.5-preview', group: 'gpt-4.5' }, + { id: 'gpt-4o', provider: 'burncloud', name: 'GPT-4o', group: 'GPT 4o' }, + { id: 'gpt-4o-mini', provider: 'burncloud', name: 'GPT-4o-mini', group: 'GPT 4o' }, + { id: 'o3', provider: 'burncloud', name: 'GPT-o1-mini', group: 'o1' }, + { id: 'o3-mini', provider: 'burncloud', name: 'GPT-o1-preview', group: 'o1' }, + { id: 'o1-mini', provider: 'burncloud', name: 'GPT-o1-mini', group: 'o1' }, + + { id: 'gemini-2.5-pro-preview-03-25', provider: 'burncloud', name: 'Gemini 2.5 Preview', group: 'Geminit 2.5' }, + { id: 'gemini-2.5-pro-exp-03-25', provider: 'burncloud', name: 'Gemini 2.5 Pro Exp', group: 'Geminit 2.5' }, + { id: 'gemini-2.0-flash-lite', provider: 'burncloud', name: 'Gemini 2.0 Flash Lite', group: 'Geminit 2.0' }, + { id: 'gemini-2.0-flash-exp', provider: 'burncloud', name: 'Gemini 2.0 Flash Exp', group: 'Geminit 2.0' }, + { id: 'gemini-2.0-flash', provider: 'burncloud', name: 'Gemini 2.0 Flash', group: 'Geminit 2.0' }, + + { id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' }, + { id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' } + ], + o3: [ { id: 'gpt-4o', @@ -686,6 +712,18 @@ export const SYSTEM_MODELS: Record = { } ], anthropic: [ + { + id: 'claude-sonnet-4-20250514', + provider: 'anthropic', + name: 'Claude Sonnet 4', + group: 'Claude 4' + }, + { + id: 'claude-opus-4-20250514', + provider: 'anthropic', + name: 'Claude Opus 4', + group: 'Claude 4' + }, { id: 'claude-3-7-sonnet-20250219', provider: 'anthropic', @@ -2412,7 +2450,12 @@ export function isClaudeReasoningModel(model?: Model): boolean { if (!model) { return false } - return model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet') + return ( + model.id.includes('claude-3-7-sonnet') || + model.id.includes('claude-3.7-sonnet') || + model.id.includes('claude-sonnet-4') || + model.id.includes('claude-opus-4') + ) } export const isSupportedThinkingTokenClaudeModel = isClaudeReasoningModel @@ -2546,6 +2589,10 @@ export function isWebSearchModel(model: Model): boolean { return true } + if (provider.id === 'grok') { + return true + } + return false } @@ -2576,6 +2623,16 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re if (assistant.enableWebSearch) { const webSearchTools = getWebSearchTools(model) + if (model.provider === 'grok') { + return { + search_parameters: { + mode: 'auto', + return_citations: true, + sources: [{ type: 'web' }, { type: 'x' }, { type: 'news' }] + } + } + } + if (model.provider === 'hunyuan') { return { enable_enhancement: true, citation: true, search_info: true } } @@ -2678,7 +2735,8 @@ export const THINKING_TOKEN_MAP: Record = 'qwen3-.*$': { min: 1024, max: 38912 }, // Claude models - 'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 } + 'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 }, + 'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 64000 } } export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => { diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 6ffbb4a550..e995e20df0 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -7,6 +7,7 @@ import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.p import BaichuanProviderLogo from '@renderer/assets/images/providers/baichuan.png' import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-cloud.svg' import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png' +import BurnCloudProviderLogo from '@renderer/assets/images/providers/burncloud.png' import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png' import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png' import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.png' @@ -61,6 +62,7 @@ const PROVIDER_LOGO_MAP = { xirang: XirangProviderLogo, anthropic: AnthropicProviderLogo, aihubmix: AiHubMixProviderLogo, + burncloud: BurnCloudProviderLogo, gemini: GoogleProviderLogo, stepfun: StepProviderLogo, doubao: BytedanceProviderLogo, @@ -121,6 +123,17 @@ export const PROVIDER_CONFIG = { models: 'https://docs.o3.fan/models' } }, + burncloud: { + api: { + url: 'https://ai.burncloud.com' + }, + websites: { + official: 'https://ai.burncloud.com/', + apiKey: 'https://ai.burncloud.com/token', + docs: 'https://ai.burncloud.com/docs', + models: 'https://ai.burncloud.com/pricing' + } + }, ppio: { api: { url: 'https://api.ppinfra.com/v3/openai' diff --git a/src/renderer/src/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx index 23e50f8deb..06dc345f7a 100644 --- a/src/renderer/src/context/CodeStyleProvider.tsx +++ b/src/renderer/src/context/CodeStyleProvider.tsx @@ -3,6 +3,7 @@ import { useMermaid } from '@renderer/hooks/useMermaid' import { useSettings } from '@renderer/hooks/useSettings' import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService' import { ThemeMode } from '@renderer/types' +import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/shiki' import * as cmThemes from '@uiw/codemirror-themes-all' import type React from 'react' import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react' @@ -11,6 +12,8 @@ interface CodeStyleContextType { highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise cleanupTokenizers: (callerId: string) => void getShikiPreProperties: (language: string) => Promise + highlightCode: (code: string, language: string) => Promise + shikiMarkdownIt: (code: string) => Promise themeNames: string[] activeShikiTheme: string activeCmTheme: any @@ -21,6 +24,8 @@ const defaultCodeStyleContext: CodeStyleContextType = { highlightCodeChunk: async () => ({ lines: [], recall: 0 }), cleanupTokenizers: () => {}, getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }), + highlightCode: async () => '', + shikiMarkdownIt: async () => '', themeNames: ['auto'], activeShikiTheme: 'auto', activeCmTheme: null, @@ -37,7 +42,7 @@ export const CodeStyleProvider: React.FC = ({ children }) => useEffect(() => { if (!codeEditor.enabled) { - import('shiki').then(({ bundledThemes }) => { + getShiki().then(({ bundledThemes }) => { setShikiThemes(bundledThemes) }) } @@ -118,11 +123,35 @@ export const CodeStyleProvider: React.FC = ({ children }) => [activeShikiTheme, languageMap] ) + const highlightCode = useCallback( + async (code: string, language: string) => { + const highlighter = await getHighlighter() + await loadLanguageIfNeeded(highlighter, language) + await loadThemeIfNeeded(highlighter, activeShikiTheme) + return highlighter.codeToHtml(code, { lang: language, theme: activeShikiTheme }) + }, + [activeShikiTheme] + ) + + // 使用 Shiki 和 Markdown-it 渲染代码 + const shikiMarkdownIt = useCallback( + async (code: string) => { + const renderer = await getMarkdownIt(activeShikiTheme, code) + if (!renderer) { + return code + } + return renderer.render(code) + }, + [activeShikiTheme] + ) + const contextValue = useMemo( () => ({ highlightCodeChunk, cleanupTokenizers, getShikiPreProperties, + highlightCode, + shikiMarkdownIt, themeNames, activeShikiTheme, activeCmTheme, @@ -132,6 +161,8 @@ export const CodeStyleProvider: React.FC = ({ children }) => highlightCodeChunk, cleanupTokenizers, getShikiPreProperties, + highlightCode, + shikiMarkdownIt, themeNames, activeShikiTheme, activeCmTheme, diff --git a/src/renderer/src/context/NotificationProvider.tsx b/src/renderer/src/context/NotificationProvider.tsx new file mode 100644 index 0000000000..c6de09b722 --- /dev/null +++ b/src/renderer/src/context/NotificationProvider.tsx @@ -0,0 +1,76 @@ +import { NotificationQueue } from '@renderer/queue/NotificationQueue' +import { Notification } from '@renderer/types/notification' +import { isFocused } from '@renderer/utils/window' +import { notification } from 'antd' +import React, { createContext, use, useEffect, useMemo } from 'react' + +type NotificationContextType = { + open: typeof notification.open + destroy: typeof notification.destroy +} + +const typeMap: Record = { + error: 'error', + success: 'success', + warning: 'warning', + info: 'info', + progress: 'info', + action: 'info' +} + +const NotificationContext = createContext(undefined) + +export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [api, contextHolder] = notification.useNotification({ + stack: { + threshold: 3 + }, + showProgress: true + }) + + useEffect(() => { + const queue = NotificationQueue.getInstance() + const listener = async (notification: Notification) => { + // 判断是否需要系统通知 + if (notification.channel === 'system' || !isFocused()) { + window.api.notification.send(notification) + return + } + return new Promise((resolve) => { + api.open({ + message: notification.title, + description: + notification.message.length > 50 ? notification.message.slice(0, 47) + '...' : notification.message, + duration: 3, + placement: 'topRight', + type: typeMap[notification.type] || 'info', + key: notification.id, + onClose: resolve + }) + }) + } + queue.subscribe(listener) + return () => queue.unsubscribe(listener) + }, [api]) + + const value = useMemo( + () => ({ + open: api.open, + destroy: api.destroy + }), + [api] + ) + + return ( + + {contextHolder} + {children} + + ) +} + +export const useNotification = () => { + const ctx = use(NotificationContext) + if (!ctx) throw new Error('useNotification must be used within a NotificationProvider') + return ctx +} diff --git a/src/renderer/src/hooks/useChatContext.ts b/src/renderer/src/hooks/useChatContext.ts new file mode 100644 index 0000000000..ae67a85413 --- /dev/null +++ b/src/renderer/src/hooks/useChatContext.ts @@ -0,0 +1,185 @@ +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { RootState } from '@renderer/store' +import { messageBlocksSelectors } from '@renderer/store/messageBlock' +import { selectMessagesForTopic } from '@renderer/store/newMessage' +import { setActiveTopic, setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime' +import { Topic } from '@renderer/types' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector, useStore } from 'react-redux' + +export const useChatContext = (activeTopic: Topic) => { + const { t } = useTranslation() + const dispatch = useDispatch() + const store = useStore() + const { deleteMessage } = useMessageOperations(activeTopic) + + const [messageRefs, setMessageRefs] = useState>(new Map()) + + const isMultiSelectMode = useSelector((state: RootState) => state.runtime.chat.isMultiSelectMode) + const selectedMessageIds = useSelector((state: RootState) => state.runtime.chat.selectedMessageIds) + + useEffect(() => { + const unsubscribe = EventEmitter.on(EVENT_NAMES.CHANGE_TOPIC, () => { + dispatch(toggleMultiSelectMode(false)) + }) + return () => unsubscribe() + }, [dispatch]) + + useEffect(() => { + dispatch(setActiveTopic(activeTopic)) + }, [dispatch, activeTopic]) + + const handleToggleMultiSelectMode = useCallback( + (value: boolean) => { + dispatch(toggleMultiSelectMode(value)) + }, + [dispatch] + ) + + const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => { + setMessageRefs((prev) => { + const newRefs = new Map(prev) + if (element) { + newRefs.set(id, element) + } else { + newRefs.delete(id) + } + return newRefs + }) + }, []) + + const locateMessage = useCallback( + (messageId: string) => { + const messageElement = messageRefs.get(messageId) + if (messageElement) { + // 检查消息是否可见 + const display = window.getComputedStyle(messageElement).display + + if (display === 'none') { + // 如果消息隐藏,需要处理显示逻辑 + // 查找消息并设置为选中状态 + const state = store.getState() + const messages = selectMessagesForTopic(state, activeTopic.id) + const message = messages.find((m) => m.id === messageId) + if (message) { + // 这里需要实现设置消息为选中状态的逻辑 + // 可能需要调用其他函数或修改状态 + } + } + + // 滚动到消息位置 + messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, + [messageRefs, store, activeTopic.id] + ) + + const handleSelectMessage = useCallback( + (messageId: string, selected: boolean) => { + dispatch( + setSelectedMessageIds( + selected ? [...selectedMessageIds, messageId] : selectedMessageIds.filter((id) => id !== messageId) + ) + ) + }, + [dispatch, selectedMessageIds] + ) + + const handleMultiSelectAction = useCallback( + async (actionType: string, messageIds: string[]) => { + if (messageIds.length === 0) { + window.message.warning(t('chat.multiple.select.empty')) + return + } + + const state = store.getState() + const messages = selectMessagesForTopic(state, activeTopic.id) + const messageBlocks = messageBlocksSelectors.selectEntities(state) + + switch (actionType) { + case 'delete': + window.modal.confirm({ + title: t('message.delete.confirm.title'), + content: t('message.delete.confirm.content', { count: messageIds.length }), + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + try { + await Promise.all(messageIds.map((messageId) => deleteMessage(messageId))) + window.message.success(t('message.delete.success')) + handleToggleMultiSelectMode(false) + } catch (error) { + console.error('Failed to delete messages:', error) + window.message.error(t('message.delete.failed')) + } + } + }) + break + case 'save': { + const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id)) + if (assistantMessages.length > 0) { + const contentToSave = assistantMessages + .map((msg) => { + return msg.blocks + .map((blockId) => { + const block = messageBlocks[blockId] + return block && 'content' in block ? block.content : '' + }) + .filter(Boolean) + .join('\n') + .trim() + }) + .join('\n\n---\n\n') + const fileName = `chat_export_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.md` + await window.api.file.save(fileName, contentToSave) + window.message.success({ content: t('message.save.success.title'), key: 'save-messages' }) + handleToggleMultiSelectMode(false) + } else { + window.message.warning(t('message.save.no.assistant')) + } + break + } + case 'copy': { + const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id)) + if (assistantMessages.length > 0) { + const contentToCopy = assistantMessages + .map((msg) => { + return msg.blocks + .map((blockId) => { + const block = messageBlocks[blockId] + return block && 'content' in block ? block.content : '' + }) + .filter(Boolean) + .join('\n') + .trim() + }) + .join('\n\n---\n\n') + navigator.clipboard.writeText(contentToCopy) + window.message.success({ content: t('message.copied'), key: 'copy-messages' }) + handleToggleMultiSelectMode(false) + } else { + window.message.warning(t('message.copy.no.assistant')) + } + break + } + default: + break + } + }, + [t, store, activeTopic.id, deleteMessage, handleToggleMultiSelectMode] + ) + + return { + isMultiSelectMode, + selectedMessageIds, + toggleMultiSelectMode: handleToggleMultiSelectMode, + handleMultiSelectAction, + handleSelectMessage, + activeTopic, + locateMessage, + messageRefs, + registerMessageElement + } +} diff --git a/src/renderer/src/hooks/useTopic.ts b/src/renderer/src/hooks/useTopic.ts index 06c5fd89ee..611ab68555 100644 --- a/src/renderer/src/hooks/useTopic.ts +++ b/src/renderer/src/hooks/useTopic.ts @@ -1,5 +1,6 @@ import db from '@renderer/databases' import i18n from '@renderer/i18n' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { deleteMessageFiles } from '@renderer/services/MessagesService' import store from '@renderer/store' import { updateTopic } from '@renderer/store/assistants' @@ -27,6 +28,7 @@ export function useActiveTopic(_assistant: Assistant, topic?: Topic) { useEffect(() => { if (activeTopic) { store.dispatch(loadTopicMessagesThunk(activeTopic.id)) + EventEmitter.emit(EVENT_NAMES.CHANGE_TOPIC, activeTopic) } }, [activeTopic]) diff --git a/src/renderer/src/hooks/useUpdateHandler.ts b/src/renderer/src/hooks/useUpdateHandler.ts index 576da468f1..fe4d8f631c 100644 --- a/src/renderer/src/hooks/useUpdateHandler.ts +++ b/src/renderer/src/hooks/useUpdateHandler.ts @@ -1,5 +1,7 @@ +import { NotificationService } from '@renderer/services/NotificationService' import { useAppDispatch } from '@renderer/store' import { setUpdateState } from '@renderer/store/runtime' +import { uuid } from '@renderer/utils' import { IpcChannel } from '@shared/IpcChannel' import type { ProgressInfo, UpdateInfo } from 'builder-util-runtime' import { useEffect } from 'react' @@ -8,6 +10,7 @@ import { useTranslation } from 'react-i18next' export default function useUpdateHandler() { const dispatch = useAppDispatch() const { t } = useTranslation() + const notificationService = NotificationService.getInstance() useEffect(() => { if (!window.electron) return @@ -22,6 +25,14 @@ export default function useUpdateHandler() { } }), ipcRenderer.on(IpcChannel.UpdateAvailable, (_, releaseInfo: UpdateInfo) => { + notificationService.send({ + id: uuid(), + type: 'info', + title: t('button.update_available'), + message: t('button.update_available', { version: releaseInfo.version }), + timestamp: Date.now(), + source: 'update' + }) dispatch( setUpdateState({ checking: false, @@ -74,5 +85,5 @@ export default function useUpdateHandler() { }) ] return () => removers.forEach((remover) => remover()) - }, [dispatch, t]) + }, [dispatch, notificationService, t]) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 0536ced621..b6b742fd85 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -191,6 +191,8 @@ "message.quote": "Quote", "message.regenerate.model": "Switch Model", "message.useful": "Helpful", + "multiple.select": "Multiple Select", + "multiple.select.empty": "No Messages Selected", "navigation": { "first": "Already at the first message", "history": "Chat History", @@ -393,6 +395,8 @@ "save": "Save", "search": "Search", "select": "Select", + "selectedMessages": "Selected {{count}} messages", + "success": "Success", "topics": "Topics", "warning": "Warning", "you": "You", @@ -591,6 +595,10 @@ "copied": "Copied!", "copy.failed": "Copy failed", "copy.success": "Copied!", + "delete.confirm.title": "Delete Confirmation", + "delete.confirm.content": "Are you sure you want to delete the selected {{count}} message(s)?", + "delete.failed": "Delete Failed", + "delete.success": "Delete Successful", "empty_url": "Failed to download image, possibly due to prompt containing sensitive content or prohibited words", "error.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size", "error.dimension_too_large": "Content size is too large", @@ -773,6 +781,11 @@ "hide_sidebar": "Hide Sidebar", "show_sidebar": "Show Sidebar" }, + "notification": { + "assistant": "Assistant Response", + "knowledge.success": "Successfully added {{type}} to the knowledge base", + "knowledge.error": "Failed to add {{type}} to knowledge base: {{error}}" + }, "ollama": { "keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.", "keep_alive_time.placeholder": "Minutes", @@ -879,6 +892,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "azure-openai": "Azure OpenAI", @@ -1270,6 +1284,14 @@ "active": "Active", "addError": "Failed to add server", "addServer": "Add Server", + "addServer.create": "Quick Create", + "addServer.importFrom": "Import from JSON", + "addServer.importFrom.tooltip": "Please copy the configuration JSON (prioritizing\n NPX or UVX configurations) from the MCP Servers introduction page and paste it into the input box.", + "addServer.importFrom.placeholder": "Paste MCP server JSON config", + "addServer.importFrom.invalid": "Invalid input, please check JSON format", + "addServer.importFrom.nameExists": "Server already exists: {{name}}", + "addServer.importFrom.oneServer": "Only one MCP server configuration at a time", + "addServer.importFrom.connectionFailed": "Connection failed", "addSuccess": "Server added successfully", "args": "Arguments", "argsTooltip": "Each argument on a new line", @@ -1285,6 +1307,7 @@ "dependenciesInstall": "Install Dependencies", "dependenciesInstalling": "Installing dependencies...", "description": "Description", + "noDescriptionAvailable": "No description available", "duplicateName": "A server with this name already exists", "editJson": "Edit JSON", "editServer": "Edit Server", @@ -1478,6 +1501,12 @@ "moresetting.check.confirm": "Confirm Selection", "moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!", "moresetting.warn": "Risk Warning", + "notification": { + "title": "Notification Settings", + "assistant": "Assistant Message", + "backup": "Backup Message", + "knowledge_embed": "KnowledgeBase Message" + }, "provider": { "add.name": "Provider Name", "add.name.placeholder": "Example: OpenAI", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 3e16858f80..42a7564d06 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -191,6 +191,8 @@ "message.quote": "引用", "message.regenerate.model": "モデルを切り替え", "message.useful": "役立つ", + "multiple.select": "選択", + "multiple.select.empty": "メッセージが選択されていません", "navigation": { "first": "最初のメッセージです", "history": "チャット履歴", @@ -393,6 +395,8 @@ "save": "保存", "search": "検索", "select": "選択", + "selectedMessages": "{{count}}件のメッセージを選択しました", + "success": "成功", "topics": "トピック", "warning": "警告", "you": "あなた", @@ -591,6 +595,11 @@ "copied": "コピーしました!", "copy.failed": "コピーに失敗しました", "copy.success": "コピーしました!", + "delete.confirm.title": "削除確認", + "delete.confirm.content": "選択した{{count}}件のメッセージを削除しますか?", + "delete.failed": "削除に失敗しました", + "delete.success": "削除が成功しました", + "error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません", "empty_url": "画像をダウンロードできません。プロンプトに不適切なコンテンツや禁止用語が含まれている可能性があります", "error.chunk_overlap_too_large": "チャンクのオーバーラップがチャンクサイズより大きくなることはできません", "error.dimension_too_large": "内容のサイズが大きすぎます", @@ -773,6 +782,11 @@ "hide_sidebar": "サイドバーを非表示", "show_sidebar": "サイドバーを表示" }, + "notification": { + "assistant": "助手回應", + "knowledge.success": "ナレッジベースに{{type}}を正常に追加しました", + "knowledge.error": "ナレッジベースへの{{type}}の追加に失敗しました: {{error}}" + }, "ollama": { "keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)", "keep_alive_time.placeholder": "分", @@ -879,6 +893,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "azure-openai": "Azure OpenAI", @@ -1266,6 +1281,14 @@ "active": "有効", "addError": "サーバーの追加に失敗しました", "addServer": "サーバーを追加", + "addServer.create": "クイック作成", + "addServer.importFrom": "JSONからインポート", + "addServer.importFrom.tooltip": "MCPサーバー紹介ページから設定JSON(NPXまたはUVX設定を優先)をコピーし、入力ボックスに貼り付けてください。", + "addServer.importFrom.placeholder": "MCPサーバーJSON設定を貼り付け", + "addServer.importFrom.invalid": "無効な入力です。JSON形式を確認してください。", + "addServer.importFrom.nameExists": "サーバーはすでに存在します: {{name}}", + "addServer.importFrom.oneServer": "一度に1つのMCPサーバー設定のみを保存できます", + "addServer.importFrom.connectionFailed": "接続に失敗しました", "addSuccess": "サーバーが正常に追加されました", "args": "引数", "argsTooltip": "1行に1つの引数を入力してください", @@ -1281,6 +1304,7 @@ "dependenciesInstall": "依存関係をインストール", "dependenciesInstalling": "依存関係をインストール中...", "description": "説明", + "noDescriptionAvailable": "説明がありません", "duplicateName": "同じ名前のサーバーが既に存在します", "editJson": "JSONを編集", "editServer": "サーバーを編集", @@ -1690,6 +1714,12 @@ "service_tier.auto": "自動", "service_tier.default": "デフォルト", "service_tier.flex": "フレックス" + }, + "notification": { + "title": "通知設定", + "assistant": "アシスタントメッセージ", + "backup": "バックアップメッセージ", + "knowledge_embed": "ナレッジベースメッセージ" } }, "translate": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index cb182f71a9..28aacde678 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -191,6 +191,8 @@ "message.quote": "Цитата", "message.regenerate.model": "Переключить модель", "message.useful": "Полезно", + "multiple.select": "Множественный выбор", + "multiple.select.empty": "Ничего не выбрано", "navigation": { "first": "Уже первое сообщение", "history": "История чата", @@ -393,6 +395,8 @@ "save": "Сохранить", "search": "Поиск", "select": "Выбрать", + "selectedMessages": "Выбрано {{count}} сообщений", + "success": "Успешно", "topics": "Топики", "warning": "Предупреждение", "you": "Вы", @@ -591,8 +595,12 @@ "copied": "Скопировано!", "copy.failed": "Не удалось скопировать", "copy.success": "Скопировано!", - "empty_url": "Не удалось загрузить изображение, возможно, запрос содержит конфиденциальный контент или запрещенные слова", + "delete.confirm.title": "Подтверждение удаления", + "delete.confirm.content": "Вы уверены, что хотите удалить выбранные {{count}} сообщения?", + "delete.failed": "Ошибка удаления", + "delete.success": "Удаление успешно", "error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента", + "empty_url": "Не удалось загрузить изображение, возможно, запрос содержит конфиденциальный контент или запрещенные слова", "error.dimension_too_large": "Размер содержимого слишком велик", "error.enter.api.host": "Пожалуйста, введите ваш API хост", "error.enter.api.key": "Пожалуйста, введите ваш API ключ", @@ -773,6 +781,11 @@ "hide_sidebar": "Скрыть боковую панель", "show_sidebar": "Показать боковую панель" }, + "notification": { + "assistant": "Ответ ассистента", + "knowledge.success": "Успешно добавлено {{type}} в базу знаний", + "knowledge.error": "Не удалось добавить {{type}} в базу знаний: {{error}}" + }, "ollama": { "keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.", "keep_alive_time.placeholder": "Минуты", @@ -803,6 +816,7 @@ "model": "Версия", "aspect_ratio": "Пропорции изображения", "style_type": "Стиль", + "rendering_speed": "Скорость рендеринга", "learn_more": "Узнать больше", "prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки", "proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение", @@ -869,7 +883,8 @@ "number_images_tip": "Количество увеличенных результатов для генерации", "seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов", "magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов" - } + }, + "rendering_speed": "Скорость рендеринга" }, "prompts": { "explanation": "Объясните мне этот концепт", @@ -878,6 +893,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "azure-openai": "Azure OpenAI", @@ -927,7 +943,6 @@ "restore": { "confirm": "Вы уверены, что хотите восстановить данные?", "confirm.button": "Выбрать файл резервной копии", - "content": "Операция восстановления перезапишет все текущие данные приложения данными из резервной копии. Это может занять некоторое время.", "progress": { "completed": "Восстановление завершено", @@ -1266,6 +1281,14 @@ "active": "Активен", "addError": "Ошибка добавления сервера", "addServer": "Добавить сервер", + "addServer.create": "Быстрое создание", + "addServer.importFrom": "Импорт из JSON", + "addServer.importFrom.tooltip": "Скопируйте JSON-конфигурацию (приоритет NPX или UVX конфигураций) со страницы введения MCP Servers и вставьте ее в поле ввода.", + "addServer.importFrom.placeholder": "Вставьте JSON-конфигурацию сервера MCP", + "addServer.importFrom.invalid": "Неверный ввод, проверьте формат JSON", + "addServer.importFrom.nameExists": "Сервер уже существует: {{name}}", + "addServer.importFrom.oneServer": "Можно сохранить только один конфигурационный файл MCP", + "addServer.importFrom.connectionFailed": "Сбой подключения", "addSuccess": "Сервер успешно добавлен", "args": "Аргументы", "argsTooltip": "Каждый аргумент с новой строки", @@ -1281,6 +1304,7 @@ "dependenciesInstall": "Установить зависимости", "dependenciesInstalling": "Установка зависимостей...", "description": "Описание", + "noDescriptionAvailable": "Описание отсутствует", "duplicateName": "Сервер с таким именем уже существует", "editJson": "Редактировать JSON", "editServer": "Редактировать сервер", @@ -1690,7 +1714,13 @@ "service_tier.flex": "Гибкий" }, "about.debug.title": "Отладка", - "about.debug.open": "Открыть" + "about.debug.open": "Открыть", + "notification": { + "title": "Настройки уведомлений", + "assistant": "Сообщение ассистента", + "backup": "Резервное сообщение", + "knowledge_embed": "Сообщение базы знаний" + } }, "translate": { "any.language": "Любой язык", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 1493d0d249..89fac6e56e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -205,6 +205,8 @@ "message.quote": "引用", "message.regenerate.model": "切换模型", "message.useful": "有用", + "multiple.select": "多选", + "multiple.select.empty": "未选中任何消息", "navigation": { "first": "已经是第一条消息", "history": "聊天历史", @@ -244,7 +246,7 @@ "settings.context_count": "上下文数", "settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10", "settings.max": "不限", - "settings.max_tokens": "最大 Token 数", + "settings.max_tokens": "最大 TOKEN 数", "settings.max_tokens.confirm": "最大 Token 数", "settings.max_tokens.confirm_content": "设置单次交互所用的最大 Token 数, 会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", "settings.max_tokens.tip": "单次交互所用的最大 Token 数, 会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", @@ -393,6 +395,8 @@ "save": "保存", "search": "搜索", "select": "选择", + "selectedMessages": "选中 {{count}} 条消息", + "success": "成功", "topics": "话题", "warning": "警告", "you": "用户", @@ -591,6 +595,10 @@ "copied": "已复制", "copy.failed": "复制失败", "copy.success": "复制成功", + "delete.confirm.title": "删除确认", + "delete.confirm.content": "确认删除选中的{{count}}条消息吗?", + "delete.failed": "删除失败", + "delete.success": "删除成功", "empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇", "error.chunk_overlap_too_large": "分段重叠不能大于分段大小", "error.dimension_too_large": "内容尺寸过大", @@ -773,6 +781,11 @@ "hide_sidebar": "隐藏侧边栏", "show_sidebar": "显示侧边栏" }, + "notification": { + "assistant": "助手响应", + "knowledge.success": "成功添加 {{type}} 到知识库", + "knowledge.error": "添加 {{type}} 到知识库失败: {{error}}" + }, "ollama": { "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)", "keep_alive_time.placeholder": "分钟", @@ -879,6 +892,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "azure-openai": "Azure OpenAI", @@ -1270,6 +1284,14 @@ "active": "启用", "addError": "添加服务器失败", "addServer": "添加服务器", + "addServer.create": "快速创建", + "addServer.importFrom": "从 JSON 导入", + "addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置JSON(优先使用\n NPX或 UVX 配置),并粘贴到输入框中。", + "addServer.importFrom.placeholder": "粘贴 MCP 服务器 JSON 配置", + "addServer.importFrom.invalid": "无效输入,请检查 JSON 格式", + "addServer.importFrom.nameExists": "服务器已存在:{{name}}", + "addServer.importFrom.oneServer": "每次只能保存一個 MCP 伺服器配置", + "addServer.importFrom.connectionFailed": "連接失敗", "addSuccess": "服务器添加成功", "args": "参数", "argsTooltip": "每个参数占一行", @@ -1285,6 +1307,7 @@ "dependenciesInstall": "安装依赖项", "dependenciesInstalling": "正在安装依赖项...", "description": "描述", + "noDescriptionAvailable": "暂无描述", "duplicateName": "已存在同名服务器", "editJson": "编辑JSON", "editServer": "编辑服务器", @@ -1478,6 +1501,12 @@ "moresetting.check.confirm": "确认勾选", "moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!", "moresetting.warn": "风险警告", + "notification": { + "title": "通知设置", + "assistant": "助手消息", + "backup": "备份", + "knowledge_embed": "知识嵌入" + }, "provider": { "add.name": "提供商名称", "add.name.placeholder": "例如 OpenAI", @@ -1678,7 +1707,7 @@ "reset": "重置" }, "openai": { - "title": "OpenAI设置", + "title": "OpenAI 设置", "summary_text_mode.title": "摘要模式", "summary_text_mode.tip": "模型执行的推理摘要", "summary_text_mode.auto": "自动", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index f4e1adbb65..3b5650f5a2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -191,6 +191,8 @@ "message.quote": "引用", "message.regenerate.model": "切換模型", "message.useful": "有用", + "multiple.select": "多選", + "multiple.select.empty": "未選中任何訊息", "navigation": { "first": "已經是第一條訊息", "history": "聊天歷史", @@ -393,6 +395,8 @@ "save": "儲存", "search": "搜尋", "select": "選擇", + "selectedMessages": "選中 {{count}} 條訊息", + "success": "成功", "topics": "話題", "warning": "警告", "you": "您", @@ -590,6 +594,11 @@ "citations": "引用內容", "copied": "已複製!", "copy.failed": "複製失敗", + "copy.success": "已複製!", + "delete.confirm.title": "刪除確認", + "delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?", + "delete.failed": "刪除失敗", + "delete.success": "刪除成功", "copy.success": "複製成功", "empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙", "error.chunk_overlap_too_large": "分段重疊不能大於分段大小", @@ -773,6 +782,11 @@ "hide_sidebar": "隱藏側邊欄", "show_sidebar": "顯示側邊欄" }, + "notification": { + "assistant": "助手回應", + "knowledge.success": "成功將{{type}}新增至知識庫", + "knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}" + }, "ollama": { "keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。", "keep_alive_time.placeholder": "分鐘", @@ -879,6 +893,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "azure-openai": "Azure OpenAI", @@ -1269,6 +1284,14 @@ "active": "啟用", "addError": "添加伺服器失敗", "addServer": "新增伺服器", + "addServer.create": "快速創建", + "addServer.importFrom": "從 JSON 導入", + "addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置JSON(優先使用\n NPX或 UVX 配置),並粘貼到輸入框中。", + "addServer.importFrom.placeholder": "貼上 MCP 伺服器 JSON 設定", + "addServer.importFrom.invalid": "無效的輸入,請檢查 JSON 格式", + "addServer.importFrom.nameExists": "伺服器已存在:{{name}}", + "addServer.importFrom.oneServer": "每次只能保存一個 MCP 伺服器配置", + "addServer.importFrom.connectionFailed": "連線失敗", "addSuccess": "伺服器新增成功", "args": "參數", "argsTooltip": "每個參數佔一行", @@ -1284,6 +1307,7 @@ "dependenciesInstall": "安裝相依套件", "dependenciesInstalling": "正在安裝相依套件...", "description": "描述", + "noDescriptionAvailable": "描述不存在", "duplicateName": "已存在相同名稱的伺服器", "editJson": "編輯JSON", "editServer": "編輯伺服器", @@ -1690,6 +1714,12 @@ "service_tier.auto": "自動", "service_tier.default": "預設", "service_tier.flex": "彈性" + }, + "notification": { + "title": "通知設定", + "assistant": "助手訊息", + "backup": "備份訊息", + "knowledge_embed": "知識庫訊息" } }, "translate": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 394d271858..5c9e3600f8 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -835,6 +835,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "azure-openai": "Azure OpenAI", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index f71f859ce2..9acf51d965 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -836,6 +836,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Antropológico", "azure-openai": "Azure OpenAI", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 725f89717c..6457069453 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -835,6 +835,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "azure-openai": "Azure OpenAI", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index d4b5362404..048d2cba99 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -837,6 +837,7 @@ }, "provider": { "aihubmix": "AiHubMix", + "burncloud": "BurnCloud", "alayanew": "Alaya NeW", "anthropic": "Antropológico", "azure-openai": "Azure OpenAI", diff --git a/src/renderer/src/pages/apps/App.tsx b/src/renderer/src/pages/apps/App.tsx index 506aaded1e..41c069f12d 100644 --- a/src/renderer/src/pages/apps/App.tsx +++ b/src/renderer/src/pages/apps/App.tsx @@ -156,14 +156,16 @@ const App: FC = ({ app, onClick, size = 60, isLast }) => { : []) ] - if (!isVisible && !isLast) return null + if (!isVisible && !isLast) { + return null + } return ( <> {isLast ? ( - + ) : ( @@ -252,9 +254,9 @@ const AppTitle = styled.div` white-space: nowrap; ` -const AddButton = styled.div` - width: 60px; - height: 60px; +const AddButton = styled.div<{ size?: number }>` + width: ${({ size }) => size || 60}px; + height: ${({ size }) => size || 60}px; border-radius: 12px; display: flex; align-items: center; diff --git a/src/renderer/src/pages/history/HistoryPage.tsx b/src/renderer/src/pages/history/HistoryPage.tsx index 0ea5d2c481..f1b3890660 100644 --- a/src/renderer/src/pages/history/HistoryPage.tsx +++ b/src/renderer/src/pages/history/HistoryPage.tsx @@ -1,4 +1,6 @@ import { ArrowLeftOutlined, EnterOutlined } from '@ant-design/icons' +import { useAppDispatch } from '@renderer/store' +import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { Input, InputRef } from 'antd' @@ -28,6 +30,7 @@ const TopicsPage: FC = () => { const [topic, setTopic] = useState(_topic) const [message, setMessage] = useState(_message) const inputRef = useRef(null) + const dispatch = useAppDispatch() _search = search _stack = stack @@ -55,6 +58,7 @@ const TopicsPage: FC = () => { } const onMessageClick = (message: Message) => { + dispatch(loadTopicMessagesThunk(message.topicId)) setStack(['topics', 'search', 'message']) setMessage(message) } diff --git a/src/renderer/src/pages/history/components/SearchMessage.tsx b/src/renderer/src/pages/history/components/SearchMessage.tsx index b928486c28..b6b7c73146 100644 --- a/src/renderer/src/pages/history/components/SearchMessage.tsx +++ b/src/renderer/src/pages/history/components/SearchMessage.tsx @@ -1,5 +1,6 @@ import { ArrowRightOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' +import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useSettings } from '@renderer/hooks/useSettings' import { getTopicById } from '@renderer/hooks/useTopic' import { default as MessageItem } from '@renderer/pages/home/Messages/Message' @@ -41,23 +42,25 @@ const SearchMessage: FC = ({ message, ...props }) => { } return ( - - - - - - - + + + + + + + + + ) } diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index 60300adfed..5882f4945c 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -2,11 +2,10 @@ import db from '@renderer/databases' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { getTopicById } from '@renderer/hooks/useTopic' import { Topic } from '@renderer/types' -import type { Message } from '@renderer/types/newMessage' -import { getMainTextContent } from '@renderer/utils/messageUtils/find' +import { type Message, MessageBlockType } from '@renderer/types/newMessage' import { List, Typography } from 'antd' import { useLiveQuery } from 'dexie-react-hooks' -import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react' +import { FC, memo, useCallback, useEffect, useState } from 'react' import styled from 'styled-components' const { Text, Title } = Typography @@ -29,12 +28,7 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p const topics = useLiveQuery(() => db.topics.toArray(), []) - const messages = useMemo( - () => (topics || [])?.map((topic) => topic.messages.filter((message) => message.role !== 'user')).flat(), - [topics] - ) - - const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic }[]>([]) + const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic; content: string }[]>([]) const [searchStats, setSearchStats] = useState({ count: 0, time: 0 }) const removeMarkdown = (text: string) => { @@ -58,17 +52,23 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p } const startTime = performance.now() - const results: { message: Message; topic: Topic }[] = [] + const results: { message: Message; topic: Topic; content: string }[] = [] const newSearchTerms = keywords .toLowerCase() .split(' ') .filter((term) => term.length > 0) - for (const message of messages) { - const content = getMainTextContent(message) - const cleanContent = removeMarkdown(content.toLowerCase()) - if (newSearchTerms.every((term) => cleanContent.includes(term))) { - results.push({ message, topic: await getTopicById(message.topicId)! }) + const blocksArray = await db.message_blocks.toArray() + const blocks = blocksArray + .filter((block) => block.type === MessageBlockType.MAIN_TEXT) + .filter((block) => newSearchTerms.some((term) => block.content.toLowerCase().includes(term))) + + const messages = topics?.map((topic) => topic.messages).flat() + + for (const block of blocks) { + const message = messages?.find((message) => message.id === block.messageId) + if (message) { + results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content }) } } @@ -79,7 +79,7 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p time: (endTime - startTime) / 1000 }) setSearchTerms(newSearchTerms) - }, [messages, keywords]) + }, [keywords, topics]) const highlightText = (text: string) => { let highlightedText = removeMarkdown(text) @@ -115,7 +115,7 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p setTimeout(() => containerRef.current?.scrollTo({ top: 0 }), 0) } }} - renderItem={({ message, topic }) => ( + renderItem={({ message, topic, content }) => ( = ({ keywords, onMessageClick, onTopicClick, ...p {topic.name}
onMessageClick(message)}> - {highlightText(getMainTextContent(message))} + {highlightText(content)}
{new Date(message.createdAt).toLocaleString()} diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 86805dd1af..27372db4f3 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -1,6 +1,7 @@ import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' +import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' import { getAssistantById } from '@renderer/services/AssistantService' @@ -46,31 +47,33 @@ const TopicMessages: FC = ({ topic, ...props }) => { } return ( - - - {topic?.messages.map((message) => ( -
- -
- ))} - {isEmpty && } - {!isEmpty && ( - - - - )} -
-
+ + + + {topic?.messages.map((message) => ( +
+ +
+ ))} + {isEmpty && } + {!isEmpty && ( + + + + )} +
+
+
) } diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 8516692309..1c82a46b03 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -1,6 +1,8 @@ import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch' +import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup' import { QuickPanelProvider } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' @@ -26,6 +28,8 @@ const Chat: FC = (props) => { const { assistant } = useAssistant(props.assistant.id) const { topicPosition, messageStyle, showAssistants } = useSettings() const { showTopics } = useShowTopics() + const { isMultiSelectMode } = useChatContext(props.activeTopic) + const mainRef = React.useRef(null) const contentSearchRef = React.useRef(null) const [filterIncludeUser, setFilterIncludeUser] = useState(false) @@ -123,6 +127,7 @@ const Chat: FC = (props) => { + {isMultiSelectMode && } {topicPosition === 'right' && showTopics && ( @@ -154,7 +159,6 @@ const Container = styled.div` const Main = styled(Flex)` height: calc(100vh - var(--navbar-height)); - // 设置为containing block,方便子元素fixed定位 transform: translateZ(0); position: relative; ` diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index f68f5fbbbf..c4a919d8f3 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -23,10 +23,11 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import FileManager from '@renderer/services/FileManager' import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService' import { getModelUniqId } from '@renderer/services/ModelService' +import PasteService from '@renderer/services/PasteService' import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService' import { translateText } from '@renderer/services/TranslateService' import WebSearchService from '@renderer/services/WebSearchService' -import { useAppDispatch } from '@renderer/store' +import { useAppDispatch, useAppSelector } from '@renderer/store' import { setSearching } from '@renderer/store/runtime' import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk' import { Assistant, FileType, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types' @@ -126,6 +127,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) const { activedMcpServers } = useMCPServers() const { bases: knowledgeBases } = useKnowledgeBases() + const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) const quickPanel = useQuickPanel() @@ -217,7 +219,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } if (topic.prompt) { - baseUserMessage.assistant.prompt = assistant.prompt ? `${assistant.prompt}\n${topic.prompt}` : topic.prompt + assistant.prompt = assistant.prompt ? `${assistant.prompt}\n${topic.prompt}` : topic.prompt } baseUserMessage.usage = await estimateUserPromptUsage(baseUserMessage) @@ -225,10 +227,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const { message, blocks } = getUserMessage(baseUserMessage) currentMessageId.current = message.id - Logger.log('[DEBUG] Created message and blocks:', message, blocks) - Logger.log('[DEBUG] Dispatching _sendMessage') dispatch(_sendMessage(message, blocks, assistant, topic.id)) - Logger.log('[DEBUG] _sendMessage dispatched') // Clear input setText('') @@ -435,7 +434,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const text = textArea.value let match = text.slice(cursorPosition + selectionLength).match(/\$\{[^}]+\}/) - let startIndex = -1 + let startIndex: number if (!match) { match = text.match(/\$\{[^}]+\}/) @@ -581,72 +580,19 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const onPaste = useCallback( async (event: ClipboardEvent) => { - // 优先处理文本粘贴 - const clipboardText = event.clipboardData?.getData('text') - if (clipboardText) { - // 1. 文本粘贴 - if (pasteLongTextAsFile && clipboardText.length > pasteLongTextThreshold) { - // 长文本直接转文件,阻止默认粘贴 - event.preventDefault() - - const tempFilePath = await window.api.file.create('pasted_text.txt') - await window.api.file.write(tempFilePath, clipboardText) - const selectedFile = await window.api.file.get(tempFilePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - setText(text) // 保持输入框内容不变 - setTimeout(() => resizeTextArea(), 50) - return - } - // 短文本走默认粘贴行为,直接返回 - return - } - - // 2. 文件/图片粘贴(仅在无文本时处理) - if (event.clipboardData?.files && event.clipboardData.files.length > 0) { - event.preventDefault() - for (const file of event.clipboardData.files) { - try { - // 使用新的API获取文件路径 - const filePath = window.api.file.getPathForFile(file) - - // 如果没有路径,可能是剪贴板中的图像数据 - if (!filePath) { - // 图像生成也支持图像编辑 - if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) { - const tempFilePath = await window.api.file.create(file.name) - const arrayBuffer = await file.arrayBuffer() - const uint8Array = new Uint8Array(arrayBuffer) - await window.api.file.write(tempFilePath, uint8Array) - const selectedFile = await window.api.file.get(tempFilePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - break - } else { - window.message.info({ - key: 'file_not_supported', - content: t('chat.input.file_not_supported') - }) - } - continue - } - - // 有路径的情况 - if (supportExts.includes(getFileExtension(filePath))) { - const selectedFile = await window.api.file.get(filePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - } else { - window.message.info({ - key: 'file_not_supported', - content: t('chat.input.file_not_supported') - }) - } - } catch (error) { - Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] onPaste:', error) - window.message.error(t('chat.input.file_error')) - } - } - return - } - // 其他情况默认粘贴 + return await PasteService.handlePaste( + event, + isVisionModel(model), + isGenerateImageModel(model), + supportExts, + setFiles, + setText, + pasteLongTextAsFile, + pasteLongTextThreshold, + text, + resizeTextArea, + t + ) }, [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t, text] ) @@ -749,6 +695,20 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }, [isDragging, handleDrag, handleDragEnd]) + // 注册粘贴处理函数并初始化全局监听 + useEffect(() => { + // 确保全局paste监听器仅初始化一次 + PasteService.init() + + // 注册当前组件的粘贴处理函数 + PasteService.registerHandler('inputbar', onPaste) + + // 卸载时取消注册 + return () => { + PasteService.unregisterHandler('inputbar') + } + }, [onPaste]) + useShortcut('new_topic', () => { addNewTopic() EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) @@ -804,7 +764,12 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = if (document.activeElement?.closest('.ant-modal')) { return } - textareaRef.current?.focus() + + const lastFocusedComponent = PasteService.getLastFocusedComponent() + + if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') { + textareaRef.current?.focus() + } } window.addEventListener('focus', onFocus) return () => window.removeEventListener('focus', onFocus) @@ -910,6 +875,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const isExpended = expended || !!textareaHeight const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model) + if (isMultiSelectMode) { + return null + } + return ( = ({ assistant: _assistant, setActiveTopic, topic }) = styles={{ textarea: TextareaStyle }} onFocus={(e: React.FocusEvent) => { setInputFocus(true) + // 记录当前聚焦的组件 + PasteService.setLastFocusedComponent('inputbar') if (e.target.value.length === 0) { e.target.setSelectionRange(0, 0) } diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index 4aa461c9e9..46d08184ff 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -3,7 +3,6 @@ import { useAssistant } from '@renderer/hooks/useAssistant' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { EventEmitter } from '@renderer/services/EventService' import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types' -import { delay, runAsyncFunction } from '@renderer/utils' import { Form, Input, Tooltip } from 'antd' import { Plus, SquareTerminal } from 'lucide-react' import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' @@ -110,11 +109,6 @@ const extractPromptContent = (response: any): string | null => { return null } -// Add static variable before component definition -let isFirstResourcesListCall = true -let isFirstPromptListCall = true -const initMcpDelay = 3 - const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => { const { activedMcpServers } = useMCPServers() const { t } = useTranslation() @@ -314,11 +308,6 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar const promptList = useMemo(async () => { const prompts: MCPPrompt[] = [] - if (isFirstPromptListCall) { - await delay(initMcpDelay) - isFirstPromptListCall = false - } - for (const server of activedMcpServers) { const serverPrompts = await window.api.mcp.listPrompts(server) prompts.push(...serverPrompts) @@ -392,40 +381,33 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar const [resourcesList, setResourcesList] = useState([]) useEffect(() => { - runAsyncFunction(async () => { - let isMounted = true + let isMounted = true - const fetchResources = async () => { - const resources: MCPResource[] = [] + const fetchResources = async () => { + const resources: MCPResource[] = [] - for (const server of activedMcpServers) { - const serverResources = await window.api.mcp.listResources(server) - resources.push(...serverResources) - } - - if (isMounted) { - setResourcesList( - resources.map((resource) => ({ - label: resource.name, - description: resource.description, - icon: , - action: () => handleResourceSelect(resource) - })) - ) - } + for (const server of activedMcpServers) { + const serverResources = await window.api.mcp.listResources(server) + resources.push(...serverResources) } - // Avoid mcp following the software startup, affecting the startup speed - if (isFirstResourcesListCall) { - await delay(initMcpDelay) - isFirstResourcesListCall = false - fetchResources() + if (isMounted) { + setResourcesList( + resources.map((resource) => ({ + label: resource.name, + description: resource.description, + icon: , + action: () => handleResourceSelect(resource) + })) + ) } + } - return () => { - isMounted = false - } - }) + fetchResources() + + return () => { + isMounted = false + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activedMcpServers]) diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index b1995a4c05..484d212bb5 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -29,6 +29,7 @@ interface Props { index?: number total?: number hidePresetMessages?: boolean + hideMenuBar?: boolean style?: React.CSSProperties isGrouped?: boolean isStreaming?: boolean @@ -41,6 +42,7 @@ const MessageItem: FC = ({ // assistant, index, hidePresetMessages, + hideMenuBar = false, isGrouped, isStreaming = false, style @@ -49,7 +51,7 @@ const MessageItem: FC = ({ const { assistant, setModel } = useAssistant(message.assistantId) const model = useModel(getMessageModelId(message), message.model?.provider) || message.model const { isBubbleStyle } = useMessageStyle() - const { showMessageDivider, messageFont, fontSize } = useSettings() + const { showMessageDivider, messageFont, fontSize, narrowMode, messageStyle } = useSettings() const { editMessageBlocks, resendUserMessageWithEdit } = useMessageOperations(topic) const messageContainerRef = useRef(null) const { editingMessageId, stopEditing } = useMessageEditing() @@ -80,8 +82,6 @@ const MessageItem: FC = ({ const handleEditResend = useCallback( async (blocks: MessageBlock[]) => { try { - // 编辑后重新发送消息 - console.log('after resend blocks', blocks) await resendUserMessageWithEdit(message, blocks, assistant) stopEditing() } catch (error) { @@ -97,7 +97,7 @@ const MessageItem: FC = ({ const isLastMessage = index === 0 const isAssistantMessage = message.role === 'assistant' - const showMenubar = !isStreaming && !message.status.includes('ing') && !isEditing + const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing const messageBorder = showMessageDivider ? undefined : 'none' const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) @@ -126,7 +126,7 @@ const MessageItem: FC = ({ if (message.type === 'clear') { return ( - EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}> + EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}> {t('chat.message.new.context')} @@ -134,6 +134,22 @@ const MessageItem: FC = ({ ) } + if (isEditing) { + return ( + + +
+ +
+
+ ) + } + return ( = ({ fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)', fontSize, background: messageBackground, - overflowY: 'visible' + overflowY: 'visible', + maxWidth: narrowMode ? 760 : undefined }}> - {isEditing ? ( - - ) : ( - - - - )} + + + {showMenubar && ( = ({ message, onSave, onResend, onCancel }) } }, []) + const onPaste = useCallback( + async (event: ClipboardEvent) => { + return await PasteService.handlePaste( + event, + isVisionModel(model), + isGenerateImageModel(model), + supportExts, + setFiles, + undefined, // 不需要setText + pasteLongTextAsFile, + pasteLongTextThreshold, + undefined, // 不需要text + resizeTextArea, + t + ) + }, + [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t] + ) + + // 添加全局粘贴事件处理 + useEffect(() => { + // 注册当前组件的粘贴处理函数 + PasteService.registerHandler('messageEditor', onPaste) + + // 在组件加载时将焦点设置为当前组件 + PasteService.setLastFocusedComponent('messageEditor') + + // 卸载时取消注册 + return () => { + PasteService.unregisterHandler('messageEditor') + } + }, [onPaste]) + const handleTextChange = (blockId: string, content: string) => { setEditedBlocks((prev) => prev.map((block) => (block.id === blockId ? { ...block, content } : block))) } @@ -131,67 +165,6 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) } } - const onPaste = useCallback( - async (event: ClipboardEvent) => { - // 1. 文本粘贴 - const clipboardText = event.clipboardData?.getData('text') - if (clipboardText) { - if (pasteLongTextAsFile && clipboardText.length > pasteLongTextThreshold) { - // 长文本直接转文件,阻止默认粘贴 - event.preventDefault() - - const tempFilePath = await window.api.file.create('pasted_text.txt') - await window.api.file.write(tempFilePath, clipboardText) - const selectedFile = await window.api.file.get(tempFilePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - setTimeout(() => resizeTextArea(), 50) - return - } - // 短文本走默认粘贴行为,直接返回 - return - } - - // 2. 文件/图片粘贴 - if (event.clipboardData?.files && event.clipboardData.files.length > 0) { - event.preventDefault() - for (const file of event.clipboardData.files) { - const filePath = window.api.file.getPathForFile(file) - if (!filePath) { - // 图像生成也支持图像编辑 - if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) { - const tempFilePath = await window.api.file.create(file.name) - const arrayBuffer = await file.arrayBuffer() - const uint8Array = new Uint8Array(arrayBuffer) - await window.api.file.write(tempFilePath, uint8Array) - const selectedFile = await window.api.file.get(tempFilePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - break - } else { - window.message.info({ - key: 'file_not_supported', - content: t('chat.input.file_not_supported') - }) - } - } - - if (supportExts.includes(getFileExtension(filePath))) { - const selectedFile = await window.api.file.get(filePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - } else { - window.message.info({ - key: 'file_not_supported', - content: t('chat.input.file_not_supported') - }) - } - } - return - } - - // 短文本走默认粘贴行为 - }, - [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t] - ) - const autoResizeTextArea = (e: React.ChangeEvent) => { const textarea = e.target textarea.style.height = 'auto' @@ -199,115 +172,110 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) } return ( - <> - e.preventDefault()} onDrop={handleDrop}> - {editedBlocks - .filter((block) => block.type === MessageBlockType.MAIN_TEXT) - .map((block) => ( - + e.preventDefault()} onDrop={handleDrop}> + {editedBlocks + .filter((block) => block.type === MessageBlockType.MAIN_TEXT) + .map((block) => ( + + ))} + {(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) || + files.length > 0) && ( + + {editedBlocks + .filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) + .map( + (block) => + block.file && ( + handleFileRemove(block.id)}> + + + ) + )} + + {files.map((file) => ( + setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}> + + ))} - {(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) || - files.length > 0) && ( - - {editedBlocks - .filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) - .map( - (block) => - block.file && ( - handleFileRemove(block.id)}> - - - ) - )} + + )} - {files.map((file) => ( - setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}> - - - ))} - - )} - - - - - - - - - - - - - - handleClick()}> - - - + + + + + + + + + + + + + handleClick()}> + + + + {message.role === 'assistant' && ( handleClick(true)}> - - - - + )} + + + ) } -const FileBlocksContainer = styled.div` - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 0 15px; - margin: 8px 0; - background: transplant; - border-radius: 4px; -` - const EditorContainer = styled.div` padding: 8px 0; border: 1px solid var(--color-border); transition: all 0.2s ease; border-radius: 15px; - margin-top: 0; + margin-top: 5px; background-color: var(--color-background-opacity); + width: 100%; &.file-dragging { border: 2px dashed #2ecc71; @@ -327,6 +295,16 @@ const EditorContainer = styled.div` } ` +const FileBlocksContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 15px; + margin: 8px 0; + background: transparent; + border-radius: 4px; +` + const Textarea = styled(TextArea)` padding: 0; border-radius: 0; diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 3d8c1de1fd..c18c5f52b7 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -1,5 +1,6 @@ import Scrollbar from '@renderer/components/Scrollbar' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -13,16 +14,19 @@ import styled, { css } from 'styled-components' import MessageItem from './Message' import MessageGroupMenuBar from './MessageGroupMenuBar' +import SelectableMessage from './MessageSelect' interface Props { messages: (Message & { index: number })[] topic: Topic hidePresetMessages?: boolean + registerMessageElement?: (id: string, element: HTMLElement | null) => void } -const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { +const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElement }: Props) => { const { editMessage } = useMessageOperations(topic) const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() + const { isMultiSelectMode } = useChatContext(topic) const [multiModelMessageStyle, setMultiModelMessageStyle] = useState( messages[0].multiModelMessageStyle || multiModelMessageStyleSetting @@ -58,7 +62,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { [editMessage, selectedMessageId] ) - const isGrouped = messageLength > 1 && messages.every((m) => m.role === 'assistant') + const isGrouped = isMultiSelectMode ? false : messageLength > 1 && messages.every((m) => m.role === 'assistant') const isHorizontal = multiModelMessageStyle === 'horizontal' const isGrid = multiModelMessageStyle === 'grid' @@ -148,6 +152,14 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { } }, [messages, setSelectedMessage]) + useEffect(() => { + messages.forEach((message) => { + const element = document.getElementById(`message-${message.id}`) + element && registerMessageElement?.(message.id, element) + }) + return () => messages.forEach((message) => registerMessageElement?.(message.id, null)) + }, [messages, registerMessageElement]) + const renderMessage = useCallback( (message: Message & { index: number }) => { const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped @@ -162,7 +174,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { } } - const messageWrapper = ( + const messageContent = ( { ) + const wrappedMessage = ( + + {messageContent} + + ) + if (isGridGroupMessage) { return ( { trigger={gridPopoverTrigger} styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }} getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}> - {messageWrapper} + {wrappedMessage} ) } - return messageWrapper + return wrappedMessage }, [ isGrid, isGrouped, - isHorizontal, - multiModelMessageStyle, topic, hidePresetMessages, - gridPopoverTrigger, - selectedMessageId + multiModelMessageStyle, + isHorizontal, + selectedMessageId, + gridPopoverTrigger ] ) @@ -307,6 +329,7 @@ interface MessageWrapperProps { const MessageWrapper = styled(Scrollbar)` width: 100%; + &.horizontal { display: inline-block; } diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index ff765403d2..7f0354c412 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -69,8 +69,14 @@ const MessageHeader: FC = memo(({ assistant, model, message }) => { } : undefined + const containerStyle = isBubbleStyle + ? { + justifyContent: isAssistantMessage ? 'flex-start' : 'flex-end' + } + : undefined + return ( - + {isAssistantMessage ? ( = (props) => { const { message, index, isGrouped, isLastMessage, isAssistantMessage, assistant, topic, model, messageContainerRef } = props const { t } = useTranslation() + const { toggleMultiSelectMode } = useChatContext(props.topic) const [copied, setCopied] = useState(false) const [isTranslating, setIsTranslating] = useState(false) const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false) @@ -171,6 +173,14 @@ const MessageMenubar: FC = (props) => { icon: , onClick: onNewBranch }, + { + label: t('chat.multiple.select'), + key: 'multi-select', + icon: , + onClick: () => { + toggleMultiSelectMode(true) + } + }, { label: t('chat.topics.export.title'), key: 'export', @@ -265,7 +275,18 @@ const MessageMenubar: FC = (props) => { ].filter(Boolean) } ], - [message, messageContainerRef, isEditable, onEdit, mainTextContent, onNewBranch, t, topic.name, exportMenuOptions] + [ + t, + isEditable, + onEdit, + onNewBranch, + exportMenuOptions, + message, + mainTextContent, + toggleMultiSelectMode, + messageContainerRef, + topic.name + ] ) const onRegenerate = async (e: React.MouseEvent | undefined) => { diff --git a/src/renderer/src/pages/home/Messages/MessageSelect.tsx b/src/renderer/src/pages/home/Messages/MessageSelect.tsx new file mode 100644 index 0000000000..88ffeb7f41 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageSelect.tsx @@ -0,0 +1,65 @@ +import { useChatContext } from '@renderer/hooks/useChatContext' +import { Topic } from '@renderer/types' +import { Checkbox } from 'antd' +import { FC, ReactNode, useEffect, useRef } from 'react' +import styled from 'styled-components' + +interface SelectableMessageProps { + children: ReactNode + messageId: string + topic: Topic + isClearMessage?: boolean +} + +const SelectableMessage: FC = ({ children, messageId, topic, isClearMessage = false }) => { + const containerRef = useRef(null) + const { + registerMessageElement: contextRegister, + isMultiSelectMode, + selectedMessageIds, + handleSelectMessage + } = useChatContext(topic) + + const isSelected = selectedMessageIds?.includes(messageId) + + useEffect(() => { + if (containerRef.current) { + contextRegister(messageId, containerRef.current) + return () => { + contextRegister(messageId, null) + } + } + return undefined + }, [messageId, contextRegister]) + + return ( + + {isMultiSelectMode && !isClearMessage && ( + + handleSelectMessage(messageId, e.target.checked)} /> + + )} + {children} + + ) +} + +const Container = styled.div` + display: flex; + width: 100%; + position: relative; +` + +const CheckboxWrapper = styled.div` + padding: 22px 0 10px 20px; + margin-right: -10px; + display: flex; + align-items: flex-start; +` + +const MessageContent = styled.div<{ isMultiSelectMode: boolean }>` + flex: 1; + ${(props) => props.isMultiSelectMode && 'margin-left: 8px;'} +` + +export default SelectableMessage diff --git a/src/renderer/src/pages/home/Messages/MessageTools.tsx b/src/renderer/src/pages/home/Messages/MessageTools.tsx index 3bea823906..4490c3000c 100644 --- a/src/renderer/src/pages/home/Messages/MessageTools.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTools.tsx @@ -1,9 +1,9 @@ import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' import type { ToolMessageBlock } from '@renderer/types/newMessage' -import { useShikiWithMarkdownIt } from '@renderer/utils/shiki' import { Collapse, message as antdMessage, Modal, Tabs, Tooltip } from 'antd' -import { FC, useMemo, useState } from 'react' +import { FC, memo, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -192,21 +192,12 @@ const MessageTools: FC = ({ blocks }) => { { key: 'preview', label: t('message.tools.preview'), - children: renderPreview(expandedResponse.content) + children: }, { key: 'raw', label: t('message.tools.raw'), - children: ( - - ) + children: renderPreview(expandedResponse.content) } ]} /> @@ -219,15 +210,23 @@ const MessageTools: FC = ({ blocks }) => { // New component to handle collapsed content const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ isExpanded, resultString }) => { - const { renderedMarkdown: styledResult } = useShikiWithMarkdownIt( - isExpanded ? `\`\`\`json\n${resultString}\n\`\`\`` : '' - ) + const { highlightCode } = useCodeStyle() + const [styledResult, setStyledResult] = useState('') + + useEffect(() => { + const highlight = async () => { + const result = await highlightCode(isExpanded ? resultString : '', 'json') + setStyledResult(result) + } + + setTimeout(highlight, 0) + }, [isExpanded, resultString, highlightCode]) if (!isExpanded) { return null } - return
+ return } const CollapseContainer = styled(Collapse)` @@ -250,6 +249,15 @@ const CollapseContainer = styled(Collapse)` } ` +const MarkdownContainer = styled.div` + & pre { + background: transparent !important; + span { + white-space: pre-wrap; + } + } +` + const MessageTitleLabel = styled.div` display: flex; flex-direction: row; @@ -369,4 +377,4 @@ const ExpandedResponseContainer = styled.div` } ` -export default MessageTools +export default memo(MessageTools) diff --git a/src/renderer/src/pages/home/Messages/MessageTranslate.tsx b/src/renderer/src/pages/home/Messages/MessageTranslate.tsx index 83bbd77c21..b3639c3812 100644 --- a/src/renderer/src/pages/home/Messages/MessageTranslate.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTranslate.tsx @@ -14,16 +14,12 @@ interface Props { const MessageTranslate: FC = ({ block }) => { const { t } = useTranslation() - if (!block.content) { - return null - } - return ( - {block.content === t('translate.processing') ? ( + {!block.content || block.content === t('translate.processing') ? ( ) : ( diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 58ee978fe2..8270c192c9 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -58,14 +58,26 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) const [isProcessingContext, setIsProcessingContext] = useState(false) + + const messageElements = useRef>(new Map()) const messages = useTopicMessages(topic.id) const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic) const messagesRef = useRef(messages) + // const { isMultiSelectMode, handleSelectMessage } = useChatContext(topic) + useEffect(() => { messagesRef.current = messages }, [messages]) + const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => { + if (element) { + messageElements.current.set(id, element) + } else { + messageElements.current.delete(id) + } + }, []) + useEffect(() => { const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount) setDisplayMessages(newDisplayMessages) @@ -256,15 +268,19 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o useEffect(() => { requestAnimationFrame(() => onComponentUpdate?.()) - }, []) + }, [onComponentUpdate]) const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages]) return ( @@ -283,6 +299,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o messages={groupMessages} topic={topic} hidePresetMessages={assistant.settings?.hideMessages} + registerMessageElement={registerMessageElement} /> ))} {isLoadingMore && ( @@ -296,6 +313,13 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o {messageNavigation === 'anchor' && } {messageNavigation === 'buttons' && } + {/* TODO: 多选功能实现有问题,需要重新改改 */} + {/* */} ) } diff --git a/src/renderer/src/pages/home/Messages/SelectionBox.tsx b/src/renderer/src/pages/home/Messages/SelectionBox.tsx new file mode 100644 index 0000000000..b8ac6206d7 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/SelectionBox.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react' +import styled from 'styled-components' + +interface SelectionBoxProps { + isMultiSelectMode: boolean + scrollContainerRef: React.RefObject + messageElements: Map + handleSelectMessage: (messageId: string, selected: boolean) => void +} + +const SelectionBox: React.FC = ({ + isMultiSelectMode, + scrollContainerRef, + messageElements, + handleSelectMessage +}) => { + const [isDragging, setIsDragging] = useState(false) + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) + const [dragCurrent, setDragCurrent] = useState({ x: 0, y: 0 }) + + useEffect(() => { + if (!isMultiSelectMode) return + + const updateDragPos = (e: MouseEvent) => { + const container = scrollContainerRef.current! + if (!container) return { x: 0, y: 0 } + const rect = container.getBoundingClientRect() + const x = e.clientX - rect.left + container.scrollLeft + const y = e.clientY - rect.top + container.scrollTop + return { x, y } + } + + const handleMouseDown = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest('.ant-checkbox-wrapper')) return + if ((e.target as HTMLElement).closest('.MessageFooter')) return + setIsDragging(true) + const pos = updateDragPos(e) + setDragStart(pos) + setDragCurrent(pos) + document.body.classList.add('no-select') + } + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging) return + setDragCurrent(updateDragPos(e)) + const container = scrollContainerRef.current! + if (container) { + const { top, bottom } = container.getBoundingClientRect() + const scrollSpeed = 15 + if (e.clientY < top + 50) { + container.scrollBy(0, -scrollSpeed) + } else if (e.clientY > bottom - 50) { + container.scrollBy(0, scrollSpeed) + } + } + } + + const handleMouseUp = () => { + if (!isDragging) return + + const left = Math.min(dragStart.x, dragCurrent.x) + const right = Math.max(dragStart.x, dragCurrent.x) + const top = Math.min(dragStart.y, dragCurrent.y) + const bottom = Math.max(dragStart.y, dragCurrent.y) + + const MIN_SELECTION_SIZE = 5 + const isValidSelection = + Math.abs(right - left) > MIN_SELECTION_SIZE && Math.abs(bottom - top) > MIN_SELECTION_SIZE + + if (isValidSelection) { + messageElements.forEach((element, messageId) => { + try { + const rect = element.getBoundingClientRect() + const container = scrollContainerRef.current! + + const elementTop = rect.top - container.getBoundingClientRect().top + container.scrollTop + const elementLeft = rect.left - container.getBoundingClientRect().left + container.scrollLeft + const elementBottom = elementTop + rect.height + const elementRight = elementLeft + rect.width + + const isIntersecting = !( + elementRight < left || + elementLeft > right || + elementBottom < top || + elementTop > bottom + ) + + if (isIntersecting) { + handleSelectMessage(messageId, true) + element.classList.add('selection-highlight') + setTimeout(() => element.classList.remove('selection-highlight'), 300) + } + } catch (error) { + console.error('Error calculating element intersection:', error) + } + }) + } + setIsDragging(false) + document.body.classList.remove('no-select') + } + + const container = scrollContainerRef.current! + if (container) { + container.addEventListener('mousedown', handleMouseDown) + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + } + + return () => { + if (container) { + container.removeEventListener('mousedown', handleMouseDown) + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + document.body.classList.remove('no-select') + } + } + }, [isMultiSelectMode, isDragging, dragStart, dragCurrent, handleSelectMessage, scrollContainerRef, messageElements]) + + if (!isDragging || !isMultiSelectMode) return null + + return ( + + ) +} + +const SelectionBoxContainer = styled.div` + position: absolute; + border: 1px dashed var(--color-primary); + background-color: rgba(0, 114, 245, 0.1); + pointer-events: none; + z-index: 100; +` + +export default SelectionBox diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index b2f291a837..537c7583f8 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -8,7 +8,7 @@ import { FC, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import AssistantItem from './AssistantItem' +import AssistantItem from './components/AssistantItem' interface AssistantsTabProps { activeAssistant: Assistant diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 6754ccdb43..9654d8320e 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -14,6 +14,7 @@ import { isSupportedReasoningEffortOpenAIModel } from '@renderer/config/models' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { useTheme } from '@renderer/context/ThemeProvider' import { useAssistant } from '@renderer/hooks/useAssistant' import { useProvider } from '@renderer/hooks/useProvider' import { useSettings } from '@renderer/hooks/useSettings' @@ -63,7 +64,7 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import OpenAISettingsTab from './OpenAISettingsTab' +import OpenAISettingsGroup from './components/OpenAISettingsGroup' interface Props { assistant: Assistant @@ -73,7 +74,8 @@ const SettingsTab: FC = (props) => { const { assistant, updateAssistantSettings, updateAssistant } = useAssistant(props.assistant.id) const { provider } = useProvider(assistant.model.provider) - const { messageStyle, fontSize, language, theme } = useSettings() + const { messageStyle, fontSize, language } = useSettings() + const { theme } = useTheme() const { themeNames } = useCodeStyle() const [temperature, setTemperature] = useState(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE) @@ -206,9 +208,14 @@ const SettingsTab: FC = (props) => { title={t('assistants.settings.title')} defaultExpanded={true} extra={ - + - +
+ }> + { + if (e.ctrlKey || e.metaKey) { + handleConfirmDelete(topic, e) + } else if (deletingTopicId === topic.id) { + handleConfirmDelete(topic, e) + } else { + handleDeleteClick(topic.id, e) + } + }}> + {deletingTopicId === topic.id ? ( + + ) : ( + + )} + +
+ )} + {topic.pinned && ( + + + + )} + {topicPrompt && ( {fullTopicPrompt} @@ -407,37 +444,6 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic {showTopicTime && ( {dayjs(topic.createdAt).format('MM/DD HH:mm')} )} - {topic.pinned && } - {isActive && !topic.pinned && ( - -
- {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} -
-
- }> - { - if (e.ctrlKey || e.metaKey) { - handleConfirmDelete(topic, e) - } else if (deletingTopicId === topic.id) { - handleConfirmDelete(topic, e) - } else { - handleDeleteClick(topic.id, e) - } - }}> - {deletingTopicId === topic.id ? ( - - ) : ( - - )} - - - )} ) }} @@ -489,6 +495,14 @@ const TopicListItem = styled.div` } ` +const TopicNameContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + justify-content: space-between; +` + const TopicName = styled.div` display: -webkit-box; -webkit-line-clamp: 1; @@ -532,11 +546,8 @@ const MenuButton = styled.div` flex-direction: row; justify-content: center; align-items: center; - min-width: 22px; - min-height: 22px; - position: absolute; - right: 8px; - top: 6px; + min-width: 20px; + min-height: 20px; .anticon { font-size: 12px; } diff --git a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx similarity index 100% rename from src/renderer/src/pages/home/Tabs/AssistantItem.tsx rename to src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx diff --git a/src/renderer/src/pages/home/Tabs/OpenAISettingsTab.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx similarity index 54% rename from src/renderer/src/pages/home/Tabs/OpenAISettingsTab.tsx rename to src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx index 0fdd702d26..2aa25c5ff1 100644 --- a/src/renderer/src/pages/home/Tabs/OpenAISettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx @@ -1,4 +1,5 @@ -import { SettingDivider, SettingRow, SettingSubtitle } from '@renderer/pages/settings' +import { SettingDivider, SettingRow } from '@renderer/pages/settings' +import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup' import { RootState, useAppDispatch } from '@renderer/store' import { setOpenAIServiceTier, setOpenAISummaryText } from '@renderer/store/settings' import { OpenAIServiceTier, OpenAISummaryText } from '@renderer/types' @@ -9,11 +10,11 @@ import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import styled from 'styled-components' -import { SettingGroup, SettingRowTitleSmall } from './SettingsTab' - interface Props { isOpenAIReasoning: boolean isSupportedFlexServiceTier: boolean + SettingGroup: FC<{ children: React.ReactNode }> + SettingRowTitleSmall: FC<{ children: React.ReactNode }> } const FALL_BACK_SERVICE_TIER: Record = { @@ -22,7 +23,12 @@ const FALL_BACK_SERVICE_TIER: Record = { flex: 'default' } -const OpenAISettingsTab: FC = (props) => { +const OpenAISettingsGroup: FC = ({ + isOpenAIReasoning, + isSupportedFlexServiceTier, + SettingGroup, + SettingRowTitleSmall +}) => { const { t } = useTranslation() const summaryText = useSelector((state: RootState) => state.settings.openAI.summaryText) const serviceTierMode = useSelector((state: RootState) => state.settings.openAI.serviceTier) @@ -74,11 +80,11 @@ const OpenAISettingsTab: FC = (props) => { ] return baseOptions.filter((option) => { if (option.value === 'flex') { - return props.isSupportedFlexServiceTier + return isSupportedFlexServiceTier } return true }) - }, [props.isSupportedFlexServiceTier, t]) + }, [isSupportedFlexServiceTier, t]) useEffect(() => { if (serviceTierMode && !serviceTierOptions.some((option) => option.value === serviceTierMode)) { @@ -87,49 +93,49 @@ const OpenAISettingsTab: FC = (props) => { }, [serviceTierMode, serviceTierOptions, setServiceTierMode]) return ( - - {t('settings.openai.title')} - - - - {t('settings.openai.service_tier.title')}{' '} - - - - - { - setServiceTierMode(value as OpenAIServiceTier) - }} - size="small" - options={serviceTierOptions} - /> - - {props.isOpenAIReasoning && ( - <> - - - - {t('settings.openai.summary_text_mode.title')}{' '} - - - - - { - setSummaryText(value as OpenAISummaryText) - }} - size="small" - options={summaryTextOptions} - /> - - - )} - + + + + + {t('settings.openai.service_tier.title')}{' '} + + + + + { + setServiceTierMode(value as OpenAIServiceTier) + }} + size="small" + options={serviceTierOptions} + /> + + {isOpenAIReasoning && ( + <> + + + + {t('settings.openai.summary_text_mode.title')}{' '} + + + + + { + setSummaryText(value as OpenAISummaryText) + }} + size="small" + options={summaryTextOptions} + /> + + + )} + + ) } @@ -141,4 +147,4 @@ const StyledSelect = styled(Select)` } ` -export default OpenAISettingsTab +export default OpenAISettingsGroup diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index 1806e3a237..b4a0fc2c61 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -21,6 +21,7 @@ interface Props { setActiveTopic: (topic: Topic) => void position: 'left' | 'right' forceToSeeAllTab?: boolean + style?: React.CSSProperties } type Tab = 'assistants' | 'topic' | 'settings' @@ -33,7 +34,8 @@ const HomeTabs: FC = ({ setActiveAssistant, setActiveTopic, position, - forceToSeeAllTab + forceToSeeAllTab, + style }) => { const { addAssistant } = useAssistants() const [tab, setTab] = useState(position === 'left' ? _tab || 'assistants' : 'topic') @@ -100,7 +102,7 @@ const HomeTabs: FC = ({ }, [position, tab, topicPosition, forceToSeeAllTab]) return ( - + {(showTab || (forceToSeeAllTab == true && !showTopics)) && ( = ({ selectedBase }) => { .map((file) => ({ id: file.name, name: file.name, - path: file.path, + path: window.api.file.getPathForFile(file), size: file.size, ext: `.${file.name.split('.').pop()}`.toLowerCase(), count: 1, @@ -328,7 +328,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { key={item.id} fileInfo={{ name: ( - window.api.file.openPath(file.path)}> + window.api.file.openPath(FileManager.getFilePath(file))}> {file.origin_name} diff --git a/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx b/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx index 6e7aedb60a..b586186be2 100644 --- a/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx +++ b/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx @@ -60,6 +60,7 @@ export const createModeConfigs = (): Record => { type: 'select', key: 'renderingSpeed', options: RENDERING_SPEED_OPTIONS, + initialValue: 'DEFAULT', disabled: (_config, painting) => { const model = painting?.model return !model || !model.includes('V_3') @@ -153,6 +154,7 @@ export const createModeConfigs = (): Record => { type: 'select', key: 'renderingSpeed', options: RENDERING_SPEED_OPTIONS, + initialValue: 'DEFAULE', disabled: (_config, painting) => { const model = painting?.model return !model || !model.includes('V_3') @@ -227,6 +229,7 @@ export const createModeConfigs = (): Record => { type: 'select', key: 'renderingSpeed', options: RENDERING_SPEED_OPTIONS, + initialValue: 'DEFAULT', disabled: (_config, painting) => { const model = painting?.model return !model || !model.includes('V_3') diff --git a/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx b/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx index 2263651d60..89aa11ca4b 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx @@ -10,7 +10,7 @@ import { import { useAppDispatch } from '@renderer/store' import { setSidebarIcons } from '@renderer/store/settings' import { message } from 'antd' -import { Folder, Languages, LayoutGrid, LibraryBig, MessageSquareQuote, Palette, Sparkle } from 'lucide-react' +import { FileSearch, Folder, Languages, LayoutGrid, MessageSquareQuote, Palette, Sparkle } from 'lucide-react' import { FC, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -114,7 +114,7 @@ const SidebarIconsManager: FC = ({ paintings: , translate: , minapp: , - knowledge: , + knowledge: , files: }), [] diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index f86f2bde75..8435e542ea 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -1,15 +1,17 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' -import { useAppDispatch } from '@renderer/store' -import { setEnableDataCollection, setLanguage } from '@renderer/store/settings' +import { RootState, useAppDispatch } from '@renderer/store' +import { setEnableDataCollection, setLanguage, setNotificationSettings } from '@renderer/store/settings' import { setProxyMode, setProxyUrl as _setProxyUrl } from '@renderer/store/settings' import { LanguageVarious } from '@renderer/types' +import { NotificationSource } from '@renderer/types/notification' import { isValidProxyUrl } from '@renderer/utils' import { defaultLanguage } from '@shared/config/constant' import { Input, Select, Space, Switch } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '.' @@ -107,6 +109,12 @@ const GeneralSettings: FC = () => { { value: 'pt-PT', label: 'Português', flag: '🇵🇹' } ] + const notificationSettings = useSelector((state: RootState) => state.settings.notification) + + const handleNotificationChange = (type: NotificationSource, value: boolean) => { + dispatch(setNotificationSettings({ ...notificationSettings, [type]: value })) + } + return ( @@ -154,6 +162,27 @@ const GeneralSettings: FC = () => { )} + + {t('settings.notification.title')} + + + {t('settings.notification.assistant')} + handleNotificationChange('assistant', v)} /> + + + + {t('settings.notification.backup')} + handleNotificationChange('backup', v)} /> + + + + {t('settings.notification.knowledge_embed')} + handleNotificationChange('knowledgeEmbed', v)} + /> + + {t('settings.launch.title')} diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx new file mode 100644 index 0000000000..e1f399905b --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -0,0 +1,266 @@ +import { nanoid } from '@reduxjs/toolkit' +import CodeEditor from '@renderer/components/CodeEditor' +import { CodeToolbarProvider } from '@renderer/components/CodeToolbar' +import { useAppDispatch } from '@renderer/store' +import { setMCPServerActive } from '@renderer/store/mcp' +import { MCPServer } from '@renderer/types' +import { Form, Modal } from 'antd' +import { FC, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface AddMcpServerModalProps { + visible: boolean + onClose: () => void + onSuccess: (server: MCPServer) => void + existingServers: MCPServer[] +} + +interface ParsedServerData extends MCPServer { + url?: string // JSON 可能包含此欄位,而不是 baseUrl +} + +// 預設的 JSON 範例內容 +const initialJsonExample = `// 示例 JSON (stdio): +// { +// "mcpServers": { +// "stdio-server-example": { +// "command": "npx", +// "args": ["-y", "mcp-server-example"] +// } +// } +// } + +// 示例 JSON (sse): +// { +// "mcpServers": { +// "sse-server-example": { +// "type": "sse", +// "url": "http://localhost:3000" +// } +// } +// } + +// 示例 JSON (streamableHttp): +// { +// "mcpServers": { +// "streamable-http-example": { +// "type": "streamableHttp", +// "url": "http://localhost:3001" +// } +// } +// } +` + +const AddMcpServerModal: FC = ({ visible, onClose, onSuccess, existingServers }) => { + const { t } = useTranslation() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const dispatch = useAppDispatch() + + const handleOk = async () => { + try { + const values = await form.validateFields() + const inputValue = values.serverConfig.trim() + setLoading(true) + + const { serverToAdd, error } = parseAndExtractServer(inputValue, t) + + if (error) { + form.setFields([ + { + name: 'serverConfig', + errors: [error] + } + ]) + setLoading(false) + return + } + + // 檢查重複名稱 + if (existingServers && existingServers.some((server) => server.name === serverToAdd!.name)) { + form.setFields([ + { + name: 'serverConfig', + errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd!.name })] + } + ]) + setLoading(false) + return + } + + // 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框 + const newServer: MCPServer = { + id: nanoid(), + name: serverToAdd!.name!, + description: serverToAdd!.description ?? '', + baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '', + command: serverToAdd!.command ?? '', + args: serverToAdd!.args || [], + env: serverToAdd!.env || {}, + isActive: false, + type: serverToAdd!.type, + logoUrl: serverToAdd!.logoUrl, + provider: serverToAdd!.provider, + providerUrl: serverToAdd!.providerUrl, + tags: serverToAdd!.tags, + configSample: serverToAdd!.configSample + } + + onSuccess(newServer) + form.resetFields() + onClose() + + // 在背景非同步檢查伺服器可用性並更新狀態 + window.api.mcp + .checkMcpConnectivity(newServer) + .then((isConnected) => { + console.log(`Connectivity check for ${newServer.name}: ${isConnected}`) + dispatch(setMCPServerActive({ id: newServer.id, isActive: isConnected })) + }) + .catch((connError: any) => { + console.error(`Connectivity check failed for ${newServer.name}:`, connError) + window.message.error({ + content: t(`${newServer.name} settings.mcp.addServer.importFrom.connectionFailed`), + key: 'mcp-quick-add-failed' + }) + }) + } finally { + setLoading(false) + } + } + + // CodeEditor 內容變更時的回呼函式 + const handleEditorChange = useCallback( + (newContent: string) => { + form.setFieldsValue({ serverConfig: newContent }) + // 可選:如果希望即時驗證,可以取消註解下一行 + // form.validateFields(['serverConfig']); + }, + [form] + ) + + const serverConfigValue = form.getFieldValue('serverConfig') + + return ( + +
+ + + + + +
+
+ ) +} + +// 解析 JSON 提取伺服器資料 +const parseAndExtractServer = ( + inputValue: string, + t: (key: string, options?: any) => string +): { serverToAdd: Partial | null; error: string | null } => { + const trimmedInput = inputValue.trim() + + let parsedJson + try { + parsedJson = JSON.parse(trimmedInput) + } catch (e) { + // JSON 解析失敗,返回錯誤 + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } + } + + let serverToAdd: Partial | null = null + + // 檢查是否包含多個伺服器配置 (適用於 JSON 格式) + if ( + parsedJson.mcpServers && + typeof parsedJson.mcpServers === 'object' && + Object.keys(parsedJson.mcpServers).length > 1 + ) { + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.multipleServers') } + } else if (Array.isArray(parsedJson) && parsedJson.length > 1) { + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.multipleServers') } + } + + if ( + parsedJson.mcpServers && + typeof parsedJson.mcpServers === 'object' && + Object.keys(parsedJson.mcpServers).length > 0 + ) { + // Case 1: {"mcpServers": {"serverName": {...}}} + const firstServerKey = Object.keys(parsedJson.mcpServers)[0] + const potentialServer = parsedJson.mcpServers[firstServerKey] + if (typeof potentialServer === 'object' && potentialServer !== null) { + serverToAdd = { ...potentialServer } + serverToAdd!.name = potentialServer.name ?? firstServerKey + } else { + console.error('Invalid server data under mcpServers key:', potentialServer) + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } + } + } else if (Array.isArray(parsedJson) && parsedJson.length > 0) { + // Case 2: [{...}, ...] - 取第一個伺服器,確保它是物件 + if (typeof parsedJson[0] === 'object' && parsedJson[0] !== null) { + serverToAdd = { ...parsedJson[0] } + serverToAdd!.name = parsedJson[0].name ?? t('settings.mcp.newServer') + } else { + console.error('Invalid server data in array:', parsedJson[0]) + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } + } + } else if ( + typeof parsedJson === 'object' && + parsedJson !== null && + !Array.isArray(parsedJson) && + !parsedJson.mcpServers // 確保是直接的伺服器物件 + ) { + // Case 3: {...} (單一伺服器物件) + // 檢查物件是否為空 + if (Object.keys(parsedJson).length > 0) { + serverToAdd = { ...parsedJson } + serverToAdd!.name = parsedJson.name ?? t('settings.mcp.newServer') + } else { + // 空物件,視為無效 + serverToAdd = null + } + } else { + // 無效結構或空的 mcpServers + serverToAdd = null + } + + // 確保 serverToAdd 存在且 name 存在 + if (!serverToAdd || !serverToAdd.name) { + console.error('Invalid JSON structure for server config or missing name:', parsedJson) + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } + } + + return { serverToAdd, error: null } +} + +export default AddMcpServerModal diff --git a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx index c8726bfa15..497b74639e 100644 --- a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '@renderer/store' import { setMCPServers } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' import { Modal, Typography } from 'antd' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' interface Props { @@ -100,10 +100,6 @@ const PopupContainer: React.FC = ({ resolve }) => { resolve({}) } - const handleChange = useCallback((newContent: string) => { - setJsonConfig(newContent) - }, []) - EditMcpJsonPopup.hide = onCancel return ( @@ -127,19 +123,20 @@ const PopupContainer: React.FC = ({ resolve }) => {
setJsonConfig(value)} maxHeight="60vh" options={{ + lint: true, collapsible: true, wrappable: true, lineNumbers: true, foldGutter: true, highlightActiveLine: true, keymap: true - }}> - {jsonConfig} - + }} + />
)} diff --git a/src/renderer/src/pages/settings/MCPSettings/McpDescription.tsx b/src/renderer/src/pages/settings/MCPSettings/McpDescription.tsx index 9877e0b989..82597b5446 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpDescription.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpDescription.tsx @@ -1,53 +1,49 @@ -import { useTheme } from '@renderer/context/ThemeProvider' -import { runAsyncFunction } from '@renderer/utils' -import { getShikiInstance } from '@renderer/utils/shiki' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { Card } from 'antd' -import MarkdownIt from 'markdown-it' import { npxFinder } from 'npx-scope-finder' -import { useCallback, useEffect, useRef, useState } from 'react' +import { FC, memo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' interface McpDescriptionProps { searchKey: string } -const MCPDescription = ({ searchKey }: McpDescriptionProps) => { - const [renderedMarkdown, setRenderedMarkdown] = useState('') +const MCPDescription: FC = ({ searchKey }) => { + const { t } = useTranslation() + const { shikiMarkdownIt } = useCodeStyle() const [loading, setLoading] = useState(false) - - const md = useRef( - new MarkdownIt({ - linkify: true, // 自动转换 URL 为链接 - typographer: true // 启用印刷格式优化 - }) - ) - const { theme } = useTheme() - - const getMcpInfo = useCallback(async () => { - setLoading(true) - const packages = await npxFinder(searchKey).finally(() => setLoading(false)) - const readme = packages[0]?.original?.readme ?? '暂无描述' - setRenderedMarkdown(md.current.render(readme)) - }, [md, searchKey]) + const [mcpInfo, setMcpInfo] = useState('') useEffect(() => { - runAsyncFunction(async () => { - const sk = await getShikiInstance(theme) - md.current.use(sk) - getMcpInfo() - }) - }, [getMcpInfo, theme]) + let isMounted = true + setLoading(true) + npxFinder(searchKey) + .then((packages) => { + const readme = packages[0]?.original?.readme ?? t('settings.mcp.noDescriptionAvailable') + shikiMarkdownIt(readme).then((result) => { + if (isMounted) setMcpInfo(result) + }) + }) + .finally(() => { + if (isMounted) setLoading(false) + }) + return () => { + isMounted = false + } + }, [shikiMarkdownIt, searchKey, t]) return (
-
+
) } const Section = styled.div` padding-top: 8px; + max-width: calc(100vw - var(--sidebar-width) - var(--settings-width) - 75px); ` -export default MCPDescription +export default memo(MCPDescription) diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index 3f6b6eb8ae..de0b83c137 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -4,21 +4,25 @@ import DragableList from '@renderer/components/DragableList' import Scrollbar from '@renderer/components/Scrollbar' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { MCPServer } from '@renderer/types' -import { Button, Empty, Tag } from 'antd' +import { formatMcpError } from '@renderer/utils/error' +import { Button, Dropdown, Empty, Switch, Tag } from 'antd' import { MonitorCheck, Plus, RefreshCw, Settings2, SquareArrowOutUpRight } from 'lucide-react' -import { FC, useCallback } from 'react' +import { FC, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import styled from 'styled-components' import { SettingTitle } from '..' +import AddMcpServerModal from './AddMcpServerModal' import EditMcpJsonPopup from './EditMcpJsonPopup' import SyncServersPopup from './SyncServersPopup' const McpServersList: FC = () => { - const { mcpServers, addMCPServer, updateMcpServers } = useMCPServers() + const { mcpServers, addMCPServer, updateMcpServers, updateMCPServer } = useMCPServers() const { t } = useTranslation() const navigate = useNavigate() + const [isAddModalVisible, setIsAddModalVisible] = useState(false) + const [loadingServerIds, setLoadingServerIds] = useState>(new Set()) const onAddMcpServer = useCallback(async () => { const newServer = { @@ -31,7 +35,7 @@ const McpServersList: FC = () => { env: {}, isActive: false } - await addMCPServer(newServer) + addMCPServer(newServer) navigate(`/settings/mcp/settings`, { state: { server: newServer } }) window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-list' }) }, [addMCPServer, navigate, t]) @@ -40,6 +44,44 @@ const McpServersList: FC = () => { SyncServersPopup.show(mcpServers) }, [mcpServers]) + const handleAddServerSuccess = useCallback( + async (server: MCPServer) => { + addMCPServer(server) + setIsAddModalVisible(false) + window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-quick-add' }) + // Optionally navigate to the new server's settings page + // navigate(`/settings/mcp/settings`, { state: { server } }) + }, + [addMCPServer, t] + ) + + const handleToggleActive = async (server: MCPServer, active: boolean) => { + setLoadingServerIds((prev) => new Set(prev).add(server.id)) + const oldActiveState = server.isActive + + try { + if (active) { + await window.api.mcp.listTools(server) + } else { + await window.api.mcp.stopServer(server) + } + updateMCPServer({ ...server, isActive: active }) + } catch (error: any) { + window.modal.error({ + title: t('settings.mcp.startError'), + content: formatMcpError(error), + centered: true + }) + updateMCPServer({ ...server, isActive: oldActiveState }) + } finally { + setLoadingServerIds((prev) => { + const next = new Set(prev) + next.delete(server.id) + return next + }) + } + } + return ( @@ -48,9 +90,28 @@ const McpServersList: FC = () => { + { + onAddMcpServer() + } + }, + { + key: 'quick', + label: t('settings.mcp.addServer.importFrom'), + onClick: () => setIsAddModalVisible(true) + } + ] + }} + trigger={['click']}> + + @@ -76,7 +137,14 @@ const McpServersList: FC = () => { - + e.stopPropagation()}> + handleToggleActive(server, checked)} + size="small" + />