Merge branch 'main' of https://github.com/CherryHQ/cherry-studio into wip/refactor/databases

This commit is contained in:
fullex 2025-05-23 11:01:19 +08:00
commit a248517520
132 changed files with 3359 additions and 1217 deletions

View File

@ -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

1
.gitignore vendored
View File

@ -51,3 +51,4 @@ local
coverage
.vitest-cache
vitest.config.*.timestamp-*
YOUR_MEMORY_FILE_PATH

Binary file not shown.

BIN
.yarn/releases/yarn-4.9.1.cjs vendored Executable file

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -6,17 +6,19 @@
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 <br>
</p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
# 🍒 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 検索結果から特定のサイトを非表示にします
# 🚀 コントリビューター
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
</a>
<br /><br />
# コミュニティ
# 🌐 コミュニティ
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# スポンサー
# スポンサー
[Buy Me a Coffee](sponsor.md)
[開発者を支援する](sponsor.md)
# 📃 ライセンス

View File

@ -4,7 +4,8 @@
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br></p>
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br>
</p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
@ -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 搜索结果中显示
# 🚀 贡献者
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
</a>
<br /><br />
@ -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)
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

@ -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.

View File

@ -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 等

View File

@ -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.

View File

@ -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 连接问题
修复消息编辑和消息多选相关问题
修复消息显示问题
修复话题提示词无效问题

View File

@ -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",

View File

@ -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',

19
scripts/win-sign.js Normal file
View File

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

View File

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

View File

@ -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))

View File

@ -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')
// 清理临时目录

View File

@ -62,7 +62,7 @@ export class ConfigManager {
}
getTrayOnClose(): boolean {
return !!this.get(ConfigKeys.TrayOnClose, false)
return !!this.get(ConfigKeys.TrayOnClose, true)
}
setTrayOnClose(value: boolean) {

View File

@ -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<string | null> => {
): Promise<string> => {
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)
}
}

View File

@ -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<File> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
static async uploadFile(
_: Electron.IpcMainInvokeEvent,
file: FileType,
{ apiKey, baseURL }: { apiKey: string; baseURL: string }
): Promise<File> {
const sdk = new GoogleGenAI({
vertexai: false,
apiKey,
httpOptions: {
baseUrl: baseURL
}
})
return await sdk.files.upload({
file: file.path,
config: {

View File

@ -69,18 +69,10 @@ function withCache<T extends unknown[], R>(
}
class McpService {
private static instance: McpService | null = null
private clients: Map<string, Client> = new Map()
private pendingClients: Map<string, Promise<Client>> = 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<boolean> {
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<MCPTool[]> {
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<typeof McpService.getInstance> | null = null
export const getMcpInstance = () => {
if (!mcpInstance) {
mcpInstance = McpService.getInstance()
private removeProxyEnv(env: Record<string, string>) {
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()

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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 {
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg>

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@ -17,7 +17,7 @@
}
.ant-tabs-tab-btn {
outline: none;
outline: none !important;
}
.ant-segmented-group {

View File

@ -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;
}

View File

@ -321,7 +321,6 @@ mjx-container {
.cm-lineWrapping * {
word-wrap: break-word;
white-space: pre-wrap;
}
}
}

View File

@ -17,7 +17,7 @@ const OpenAIAlert = () => {
return (
<Alert
style={{ width: '100%', marginTop: 5 }}
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
message={t('settings.provider.openai.alert')}
closable
afterClose={() => {

View File

@ -162,21 +162,25 @@ const CodePreview = ({ children, language }: CodePreviewProps) => {
}
}, [highlightCode])
const hasHighlightedCode = useMemo(() => {
return tokenLines.length > 0
}, [tokenLines.length])
return (
<ContentContainer
ref={codeContentRef}
$isShowLineNumbers={codeShowLineNumbers}
$isUnwrapped={isUnwrapped}
$isCodeWrappable={codeWrappable}
$lineNumbers={codeShowLineNumbers}
$wrap={codeWrappable && !isUnwrapped}
style={{
fontSize: fontSize - 1,
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible'
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
}}>
{tokenLines.length > 0 ? (
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
{hasHighlightedCode ? (
<div className="fade-in-effect">
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
</div>
) : (
<div style={{ opacity: 0.1 }}>{children}</div>
<CodePlaceholder>{children}</CodePlaceholder>
)}
</ContentContainer>
)
@ -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'

View File

@ -13,7 +13,6 @@ interface Props {
const Artifacts: FC<Props> = ({ html }) => {
const { t } = useTranslation()
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
const { openMinapp } = useMinappPopup()
/**
@ -23,6 +22,7 @@ const Artifacts: FC<Props> = ({ 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,

View File

@ -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<Props> = ({ children, language, onSave }) => {
// 源代码视图组件
const sourceView = useMemo(() => {
const SourceView = codeEditor.enabled ? CodeEditor : CodePreview
return (
<SourceView language={language} onSave={onSave}>
{children}
</SourceView>
)
if (codeEditor.enabled) {
return <CodeEditor value={children} language={language} onSave={onSave} options={{ stream: true }} />
} else {
return <CodePreview language={language}>{children}</CodePreview>
}
}, [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%;
}
`

View File

@ -0,0 +1,65 @@
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Extension } from '@uiw/react-codemirror'
import { useEffect, useState } from 'react'
let linterPromise: Promise<any> | null = null
function importLintPackage() {
if (!linterPromise) {
linterPromise = import('@codemirror/lint').then((mod) => mod.linter)
}
return linterPromise
}
// 语言对应的 linter 加载器
const linterLoaders: Record<string, () => Promise<any>> = {
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<Extension[]>([])
// 加载语言
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
}

View File

@ -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<boolean>()
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<Extension[]>([])
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
const [editorReady, setEditorReady] = useState(false)
const editorViewRef = useRef<EditorView | null>(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 (
<CodeMirror
// 维持一个稳定值,避免触发 CodeMirror 重置
value={initialContent.current}
placeholder={placeholder}
width="100%"
minHeight={minHeight}
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
editable={true}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={enabledExtensions}
extensions={customExtensions}
onCreateEditor={(view: EditorView) => {
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,

View File

@ -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])
}

View File

@ -399,6 +399,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
searchInputFocus()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const implementation = {
disable() {
setEnableContentSearch(false)
@ -526,8 +527,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
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<ContentSearchRef, Props>(
<NarrowLayout style={{ width: '100%' }}>
<SearchBarContainer>
<InputWrapper>
<Input ref={searchInputRef} onInput={userInputHandler} onKeyDown={keyDownHandler} />
<Input
ref={searchInputRef}
onInput={userInputHandler}
onKeyDown={keyDownHandler}
placeholder={t('chat.assistant.search.placeholder')}
style={{ lineHeight: '20px' }}
/>
<ToolBar>
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
@ -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`

View File

@ -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<ContextMenuProps> = ({ children, onContextMenu }) =>
]
return (
<div onContextMenu={handleContextMenu} style={{ width: '100%' }}>
<ContextContainer onContextMenu={handleContextMenu}>
{contextMenuPosition && (
<Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
@ -84,8 +85,10 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
</Dropdown>
)}
{children}
</div>
</ContextContainer>
)
}
const ContextContainer = styled.div``
export default ContextMenu

View File

@ -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
}
/>
)
}

View File

@ -52,29 +52,28 @@ const FloatingSidebar: FC<Props> = ({
setActiveAssistant={setActiveAssistant}
setActiveTopic={setActiveTopic}
position={position}
forceToSeeAllTab={true}></HomeTabs>
forceToSeeAllTab={true}
style={{
background: 'transparent',
border: 'none'
}}
/>
</PopoverContent>
)
return (
<Popover
open={open}
onOpenChange={(visible) => {
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}

View File

@ -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<Props> = ({ 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 (
<Container>
<ActionBar>
<SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount>
<ActionButtons>
<Tooltip title={t('common.save')}>
<ActionButton icon={<Save size={16} />} disabled={isActionDisabled} onClick={() => handleAction('save')} />
</Tooltip>
<Tooltip title={t('common.copy')}>
<ActionButton icon={<Copy size={16} />} disabled={isActionDisabled} onClick={() => handleAction('copy')} />
</Tooltip>
<Tooltip title={t('common.delete')}>
<ActionButton danger icon={<Trash size={16} />} onClick={() => handleAction('delete')} />
</Tooltip>
</ActionButtons>
<Tooltip title={t('chat.navigation.close')}>
<ActionButton icon={<X size={16} />} onClick={handleClose} />
</Tooltip>
</ActionBar>
</Container>
)
}
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

View File

@ -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'

View File

@ -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: '文心一言',

View File

@ -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<string, Model[]> = {
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<string, Model[]> = {
}
],
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<string, { min: number; max: number }> =
'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 => {

View File

@ -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'

View File

@ -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<HighlightChunkResult>
cleanupTokenizers: (callerId: string) => void
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
highlightCode: (code: string, language: string) => Promise<string>
shikiMarkdownIt: (code: string) => Promise<string>
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<PropsWithChildren> = ({ children }) =>
useEffect(() => {
if (!codeEditor.enabled) {
import('shiki').then(({ bundledThemes }) => {
getShiki().then(({ bundledThemes }) => {
setShikiThemes(bundledThemes)
})
}
@ -118,11 +123,35 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ children }) =>
highlightCodeChunk,
cleanupTokenizers,
getShikiPreProperties,
highlightCode,
shikiMarkdownIt,
themeNames,
activeShikiTheme,
activeCmTheme,

View File

@ -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<string, 'info' | 'success' | 'warning' | 'error'> = {
error: 'error',
success: 'success',
warning: 'warning',
info: 'info',
progress: 'info',
action: 'info'
}
const NotificationContext = createContext<NotificationContextType | undefined>(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<void>((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 (
<NotificationContext value={value}>
{contextHolder}
{children}
</NotificationContext>
)
}
export const useNotification = () => {
const ctx = use(NotificationContext)
if (!ctx) throw new Error('useNotification must be used within a NotificationProvider')
return ctx
}

View File

@ -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<RootState>()
const { deleteMessage } = useMessageOperations(activeTopic)
const [messageRefs, setMessageRefs] = useState<Map<string, HTMLElement>>(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
}
}

View File

@ -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])

View File

@ -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])
}

View File

@ -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",

View File

@ -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サーバー紹介ページから設定JSONNPXまたは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": {

View File

@ -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": "Любой язык",

View File

@ -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": "自动",

View File

@ -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": {

View File

@ -835,6 +835,7 @@
},
"provider": {
"aihubmix": "AiHubMix",
"burncloud": "BurnCloud",
"alayanew": "Alaya NeW",
"anthropic": "Anthropic",
"azure-openai": "Azure OpenAI",

View File

@ -836,6 +836,7 @@
},
"provider": {
"aihubmix": "AiHubMix",
"burncloud": "BurnCloud",
"alayanew": "Alaya NeW",
"anthropic": "Antropológico",
"azure-openai": "Azure OpenAI",

View File

@ -835,6 +835,7 @@
},
"provider": {
"aihubmix": "AiHubMix",
"burncloud": "BurnCloud",
"alayanew": "Alaya NeW",
"anthropic": "Anthropic",
"azure-openai": "Azure OpenAI",

View File

@ -837,6 +837,7 @@
},
"provider": {
"aihubmix": "AiHubMix",
"burncloud": "BurnCloud",
"alayanew": "Alaya NeW",
"anthropic": "Antropológico",
"azure-openai": "Azure OpenAI",

View File

@ -156,14 +156,16 @@ const App: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
: [])
]
if (!isVisible && !isLast) return null
if (!isVisible && !isLast) {
return null
}
return (
<>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Container onClick={handleClick}>
{isLast ? (
<AddButton>
<AddButton size={size}>
<PlusOutlined />
</AddButton>
) : (
@ -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;

View File

@ -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 | undefined>(_topic)
const [message, setMessage] = useState<Message | undefined>(_message)
const inputRef = useRef<InputRef>(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)
}

View File

@ -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<Props> = ({ message, ...props }) => {
}
return (
<MessagesContainer {...props} className={messageStyle}>
<ContainerWrapper style={{ paddingTop: 20, paddingBottom: 20, position: 'relative' }}>
<MessageItem message={message} topic={topic} />
<Button
type="text"
size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 10 }}
onClick={() => locateToMessage(navigate, message)}
icon={<ArrowRightOutlined />}
/>
<HStack mt="10px" justifyContent="center">
<Button onClick={() => locateToMessage(navigate, message)} icon={<ArrowRightOutlined />}>
{t('history.locate.message')}
</Button>
</HStack>
</ContainerWrapper>
</MessagesContainer>
<MessageEditingProvider>
<MessagesContainer {...props} className={messageStyle}>
<ContainerWrapper style={{ paddingTop: 20, paddingBottom: 20, position: 'relative' }}>
<MessageItem message={message} topic={topic} hideMenuBar={true} />
<Button
type="text"
size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 10 }}
onClick={() => locateToMessage(navigate, message)}
icon={<ArrowRightOutlined />}
/>
<HStack mt="10px" justifyContent="center">
<Button onClick={() => locateToMessage(navigate, message)} icon={<ArrowRightOutlined />}>
{t('history.locate.message')}
</Button>
</HStack>
</ContainerWrapper>
</MessagesContainer>
</MessageEditingProvider>
)
}

View File

@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
setTimeout(() => containerRef.current?.scrollTo({ top: 0 }), 0)
}
}}
renderItem={({ message, topic }) => (
renderItem={({ message, topic, content }) => (
<List.Item>
<Title
level={5}
@ -127,7 +127,7 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
{topic.name}
</Title>
<div style={{ cursor: 'pointer' }} onClick={() => onMessageClick(message)}>
<Text>{highlightText(getMainTextContent(message))}</Text>
<Text>{highlightText(content)}</Text>
</div>
<SearchResultTime>
<Text type="secondary">{new Date(message.createdAt).toLocaleString()}</Text>

View File

@ -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<Props> = ({ topic, ...props }) => {
}
return (
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll} className={messageStyle}>
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
{topic?.messages.map((message) => (
<div key={message.id} style={{ position: 'relative' }}>
<MessageItem message={message} topic={topic} />
<Button
type="text"
size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
onClick={() => locateToMessage(navigate, message)}
icon={<ArrowRightOutlined />}
/>
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
</div>
))}
{isEmpty && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{!isEmpty && (
<HStack justifyContent="center">
<Button onClick={() => onContinueChat(topic)} icon={<MessageOutlined />}>
{t('history.continue_chat')}
</Button>
</HStack>
)}
</ContainerWrapper>
</MessagesContainer>
<MessageEditingProvider>
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll} className={messageStyle}>
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
{topic?.messages.map((message) => (
<div key={message.id} style={{ position: 'relative' }}>
<MessageItem message={message} topic={topic} hideMenuBar={true} />
<Button
type="text"
size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
onClick={() => locateToMessage(navigate, message)}
icon={<ArrowRightOutlined />}
/>
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
</div>
))}
{isEmpty && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{!isEmpty && (
<HStack justifyContent="center">
<Button onClick={() => onContinueChat(topic)} icon={<MessageOutlined />}>
{t('history.continue_chat')}
</Button>
</HStack>
)}
</ContainerWrapper>
</MessagesContainer>
</MessageEditingProvider>
)
}

View File

@ -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> = (props) => {
const { assistant } = useAssistant(props.assistant.id)
const { topicPosition, messageStyle, showAssistants } = useSettings()
const { showTopics } = useShowTopics()
const { isMultiSelectMode } = useChatContext(props.activeTopic)
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
@ -123,6 +127,7 @@ const Chat: FC<Props> = (props) => {
</MessagesContainer>
<QuickPanelProvider>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
</QuickPanelProvider>
</Main>
{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;
`

View File

@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const isExpended = expended || !!textareaHeight
const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)
if (isMultiSelectMode) {
return null
}
return (
<Container
onDragOver={handleDragOver}
@ -951,6 +920,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
styles={{ textarea: TextareaStyle }}
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
setInputFocus(true)
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('inputbar')
if (e.target.value.length === 0) {
e.target.setSelectionRange(0, 0)
}

View File

@ -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<Props> = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => {
const { activedMcpServers } = useMCPServers()
const { t } = useTranslation()
@ -314,11 +308,6 @@ const MCPToolsButton: FC<Props> = ({ 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<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
const [resourcesList, setResourcesList] = useState<QuickPanelListItem[]>([])
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: <SquareTerminal />,
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: <SquareTerminal />,
action: () => handleResourceSelect(resource)
}))
)
}
}
return () => {
isMounted = false
}
})
fetchResources()
return () => {
isMounted = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activedMcpServers])

View File

@ -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<Props> = ({
// assistant,
index,
hidePresetMessages,
hideMenuBar = false,
isGrouped,
isStreaming = false,
style
@ -49,7 +51,7 @@ const MessageItem: FC<Props> = ({
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<HTMLDivElement>(null)
const { editingMessageId, stopEditing } = useMessageEditing()
@ -80,8 +82,6 @@ const MessageItem: FC<Props> = ({
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<Props> = ({
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<Props> = ({
if (message.type === 'clear') {
return (
<NewContextMessage onClick={() => EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}>
<NewContextMessage className="clear-context-divider" onClick={() => EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}>
<Divider dashed style={{ padding: '0 20px' }} plain>
{t('chat.message.new.context')}
</Divider>
@ -134,6 +134,22 @@ const MessageItem: FC<Props> = ({
)
}
if (isEditing) {
return (
<MessageContainer style={{ paddingTop: 15 }}>
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
<div style={{ paddingLeft: messageStyle === 'plain' ? 46 : undefined }}>
<MessageEditor
message={message}
onSave={handleEditSave}
onResend={handleEditResend}
onCancel={handleEditCancel}
/>
</div>
</MessageContainer>
)
}
return (
<MessageContainer
key={message.id}
@ -158,20 +174,12 @@ const MessageItem: FC<Props> = ({
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize,
background: messageBackground,
overflowY: 'visible'
overflowY: 'visible',
maxWidth: narrowMode ? 760 : undefined
}}>
{isEditing ? (
<MessageEditor
message={message}
onSave={handleEditSave}
onResend={handleEditResend}
onCancel={handleEditCancel}
/>
) : (
<MessageErrorBoundary>
<MessageContent message={message} />
</MessageErrorBoundary>
)}
<MessageErrorBoundary>
<MessageContent message={message} />
</MessageErrorBoundary>
{showMenubar && (
<MessageFooter
className="MessageFooter"

View File

@ -4,6 +4,7 @@ import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import FileManager from '@renderer/services/FileManager'
import PasteService from '@renderer/services/PasteService'
import { FileType, FileTypes } from '@renderer/types'
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { classNames, getFileExtension } from '@renderer/utils'
@ -62,6 +63,39 @@ const MessageBlockEditor: FC<Props> = ({ 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<Props> = ({ 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<HTMLTextAreaElement>) => {
const textarea = e.target
textarea.style.height = 'auto'
@ -199,115 +172,110 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
}
return (
<>
<EditorContainer onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
{editedBlocks
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.map((block) => (
<Textarea
className={classNames(isFileDragging && 'file-dragging')}
key={block.id}
ref={textareaRef}
variant="borderless"
value={block.content}
onChange={(e) => {
handleTextChange(block.id, e.target.value)
autoResizeTextArea(e)
}}
autoFocus
contextMenu="true"
spellCheck={false}
onPaste={(e) => onPaste(e.nativeEvent)}
style={{
fontSize,
padding: '0px 15px 8px 15px'
}}>
<TranslateButton onTranslated={onTranslated} />
</Textarea>
<EditorContainer onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
{editedBlocks
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.map((block) => (
<Textarea
className={classNames('editing-message', isFileDragging && 'file-dragging')}
key={block.id}
ref={textareaRef}
variant="borderless"
value={block.content}
onChange={(e) => {
handleTextChange(block.id, e.target.value)
autoResizeTextArea(e)
}}
autoFocus
contextMenu="true"
spellCheck={false}
onPaste={(e) => onPaste(e.nativeEvent)}
onFocus={() => {
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('messageEditor')
}}
style={{
fontSize,
padding: '0px 15px 8px 15px'
}}>
<TranslateButton onTranslated={onTranslated} />
</Textarea>
))}
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
files.length > 0) && (
<FileBlocksContainer>
{editedBlocks
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
.map(
(block) =>
block.file && (
<CustomTag
key={block.id}
icon={getFileIcon(block.file.ext)}
color="#37a5aa"
closable
onClose={() => handleFileRemove(block.id)}>
<FileNameRender file={block.file} />
</CustomTag>
)
)}
{files.map((file) => (
<CustomTag
key={file.id}
icon={getFileIcon(file.ext)}
color="#37a5aa"
closable
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
<FileNameRender file={file} />
</CustomTag>
))}
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
files.length > 0) && (
<FileBlocksContainer>
{editedBlocks
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
.map(
(block) =>
block.file && (
<CustomTag
key={block.id}
icon={getFileIcon(block.file.ext)}
color="#37a5aa"
closable
onClose={() => handleFileRemove(block.id)}>
<FileNameRender file={block.file} />
</CustomTag>
)
)}
</FileBlocksContainer>
)}
{files.map((file) => (
<CustomTag
key={file.id}
icon={getFileIcon(file.ext)}
color="#37a5aa"
closable
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
<FileNameRender file={file} />
</CustomTag>
))}
</FileBlocksContainer>
)}
<ActionBar>
<ActionBarLeft>
<AttachmentButton
ref={attachmentButtonRef}
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
/>
</ActionBarLeft>
<ActionBarMiddle />
<ActionBarRight>
<Tooltip title={t('common.cancel')}>
<ToolbarButton type="text" onClick={onCancel}>
<X size={16} />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('common.save')}>
<ToolbarButton type="text" onClick={() => handleClick()}>
<Save size={16} />
</ToolbarButton>
</Tooltip>
<ActionBar>
<ActionBarLeft>
<AttachmentButton
ref={attachmentButtonRef}
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
/>
</ActionBarLeft>
<ActionBarMiddle />
<ActionBarRight>
<Tooltip title={t('common.cancel')}>
<ToolbarButton type="text" onClick={onCancel}>
<X size={16} />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('common.save')}>
<ToolbarButton type="text" onClick={() => handleClick()}>
<Save size={16} />
</ToolbarButton>
</Tooltip>
{message.role === 'assistant' && (
<Tooltip title={t('chat.resend')}>
<ToolbarButton type="text" onClick={() => handleClick(true)}>
<Send size={16} />
</ToolbarButton>
</Tooltip>
</ActionBarRight>
</ActionBar>
</EditorContainer>
</>
)}
</ActionBarRight>
</ActionBar>
</EditorContainer>
)
}
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;

View File

@ -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<MultiModelMessageStyle>(
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 = (
<MessageWrapper
id={`message-${message.id}`}
$layout={multiModelMessageStyle}
@ -178,6 +190,16 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
</MessageWrapper>
)
const wrappedMessage = (
<SelectableMessage
key={`selectable-${message.id}`}
messageId={message.id}
topic={topic}
isClearMessage={message.type === 'clear'}>
{messageContent}
</SelectableMessage>
)
if (isGridGroupMessage) {
return (
<Popover
@ -194,22 +216,22 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
trigger={gridPopoverTrigger}
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}>
{messageWrapper}
{wrappedMessage}
</Popover>
)
}
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)<MessageWrapperProps>`
width: 100%;
&.horizontal {
display: inline-block;
}

View File

@ -69,8 +69,14 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
}
: undefined
const containerStyle = isBubbleStyle
? {
justifyContent: isAssistantMessage ? 'flex-start' : 'flex-end'
}
: undefined
return (
<Container className="message-header">
<Container className="message-header" style={containerStyle}>
<AvatarWrapper style={avatarStyle}>
{isAssistantMessage ? (
<Avatar

View File

@ -1,8 +1,9 @@
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
import { CheckOutlined, EditOutlined, MenuOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageTitle } from '@renderer/services/MessagesService'
@ -50,6 +51,7 @@ const MessageMenubar: FC<Props> = (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> = (props) => {
icon: <Split size={16} />,
onClick: onNewBranch
},
{
label: t('chat.multiple.select'),
key: 'multi-select',
icon: <MenuOutlined size={16} />,
onClick: () => {
toggleMultiSelectMode(true)
}
},
{
label: t('chat.topics.export.title'),
key: 'export',
@ -265,7 +275,18 @@ const MessageMenubar: FC<Props> = (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) => {

View File

@ -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<SelectableMessageProps> = ({ children, messageId, topic, isClearMessage = false }) => {
const containerRef = useRef<HTMLDivElement>(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 (
<Container ref={containerRef}>
{isMultiSelectMode && !isClearMessage && (
<CheckboxWrapper>
<Checkbox checked={isSelected} onChange={(e) => handleSelectMessage(messageId, e.target.checked)} />
</CheckboxWrapper>
)}
<MessageContent isMultiSelectMode={isMultiSelectMode}>{children}</MessageContent>
</Container>
)
}
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

View File

@ -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<Props> = ({ blocks }) => {
{
key: 'preview',
label: t('message.tools.preview'),
children: renderPreview(expandedResponse.content)
children: <CollapsedContent isExpanded={true} resultString={resultString} />
},
{
key: 'raw',
label: t('message.tools.raw'),
children: (
<CollapsedContent
isExpanded={true}
resultString={
typeof expandedResponse.content === 'string'
? expandedResponse.content
: JSON.stringify(expandedResponse.content, null, 2)
}
/>
)
children: renderPreview(expandedResponse.content)
}
]}
/>
@ -219,15 +210,23 @@ const MessageTools: FC<Props> = ({ 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<string>('')
useEffect(() => {
const highlight = async () => {
const result = await highlightCode(isExpanded ? resultString : '', 'json')
setStyledResult(result)
}
setTimeout(highlight, 0)
}, [isExpanded, resultString, highlightCode])
if (!isExpanded) {
return null
}
return <div className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} />
return <MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} />
}
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)

View File

@ -14,16 +14,12 @@ interface Props {
const MessageTranslate: FC<Props> = ({ block }) => {
const { t } = useTranslation()
if (!block.content) {
return null
}
return (
<Fragment>
<Divider style={{ margin: 0, marginBottom: 10 }}>
<TranslationOutlined />
</Divider>
{block.content === t('translate.processing') ? (
{!block.content || block.content === t('translate.processing') ? (
<SvgSpinners180Ring color="var(--color-text-2)" style={{ marginBottom: 15 }} />
) : (
<Markdown block={block} />

View File

@ -58,14 +58,26 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isProcessingContext, setIsProcessingContext] = useState(false)
const messageElements = useRef<Map<string, HTMLElement>>(new Map())
const messages = useTopicMessages(topic.id)
const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic)
const messagesRef = useRef<Message[]>(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<MessagesProps> = ({ assistant, topic, setActiveTopic, o
useEffect(() => {
requestAnimationFrame(() => onComponentUpdate?.())
}, [])
}, [onComponentUpdate])
const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages])
return (
<Container
id="messages"
style={{ maxWidth, paddingTop: showPrompt ? 10 : 0 }}
key={assistant.id}
ref={scrollContainerRef}
style={{
position: 'relative',
maxWidth,
paddingTop: showPrompt ? 10 : 0
}}
key={assistant.id}
onScroll={handleScrollPosition}
$right={topicPosition === 'left'}>
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
@ -283,6 +299,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
messages={groupMessages}
topic={topic}
hidePresetMessages={assistant.settings?.hideMessages}
registerMessageElement={registerMessageElement}
/>
))}
{isLoadingMore && (
@ -296,6 +313,13 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
</NarrowLayout>
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
{/* TODO: 多选功能实现有问题,需要重新改改 */}
{/* <SelectionBox
isMultiSelectMode={isMultiSelectMode}
scrollContainerRef={scrollContainerRef}
messageElements={messageElements.current}
handleSelectMessage={handleSelectMessage}
/> */}
</Container>
)
}

View File

@ -0,0 +1,141 @@
import { useEffect, useState } from 'react'
import styled from 'styled-components'
interface SelectionBoxProps {
isMultiSelectMode: boolean
scrollContainerRef: React.RefObject<HTMLDivElement | null>
messageElements: Map<string, HTMLElement>
handleSelectMessage: (messageId: string, selected: boolean) => void
}
const SelectionBox: React.FC<SelectionBoxProps> = ({
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 (
<SelectionBoxContainer
style={{
left: Math.min(dragStart.x, dragCurrent.x),
top: Math.min(dragStart.y, dragCurrent.y),
width: Math.abs(dragCurrent.x - dragStart.x),
height: Math.abs(dragCurrent.y - dragStart.y)
}}
/>
)
}
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

View File

@ -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

View File

@ -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> = (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> = (props) => {
title={t('assistants.settings.title')}
defaultExpanded={true}
extra={
<HStack alignItems="center">
<HStack alignItems="center" gap={2}>
<Tooltip title={t('chat.settings.reset')}>
<RotateCcw size={20} onClick={onReset} style={{ cursor: 'pointer', padding: '0 3px' }} />
<Button
type="text"
size="small"
onClick={onReset}
icon={<RotateCcw size={20} style={{ cursor: 'pointer', padding: '0 3px', opacity: 0.8 }} />}
/>
</Tooltip>
<Button
type="text"
@ -218,8 +225,7 @@ const SettingsTab: FC<Props> = (props) => {
/>
</HStack>
}>
<SettingGroup style={{ marginTop: 10 }}>
<SettingDivider />
<SettingGroup style={{ marginTop: 5 }}>
<Row align="middle">
<Label>{t('chat.settings.temperature')}</Label>
<Tooltip title={t('chat.settings.temperature.tip')}>
@ -227,7 +233,7 @@ const SettingsTab: FC<Props> = (props) => {
</Tooltip>
</Row>
<Row align="middle" gutter={10}>
<Col span={24}>
<Col span={23}>
<Slider
min={0}
max={2}
@ -245,7 +251,7 @@ const SettingsTab: FC<Props> = (props) => {
</Tooltip>
</Row>
<Row align="middle" gutter={10}>
<Col span={24}>
<Col span={23}>
<Slider
min={0}
max={maxContextCount}
@ -296,7 +302,7 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
{enableMaxTokens && (
<Row align="middle" gutter={10}>
<Row align="middle" gutter={10} style={{ marginTop: 10 }}>
<Col span={24}>
<InputNumber
disabled={!enableMaxTokens}
@ -314,16 +320,17 @@ const SettingsTab: FC<Props> = (props) => {
)}
<SettingDivider />
</SettingGroup>
{isOpenAI && (
<OpenAISettingsTab
isOpenAIReasoning={isOpenAIReasoning}
isSupportedFlexServiceTier={isOpenAIFlexServiceTier}
/>
)}
</CollapsibleSettingGroup>
{isOpenAI && (
<OpenAISettingsGroup
isOpenAIReasoning={isOpenAIReasoning}
isSupportedFlexServiceTier={isOpenAIFlexServiceTier}
SettingGroup={SettingGroup}
SettingRowTitleSmall={SettingRowTitleSmall}
/>
)}
<CollapsibleSettingGroup title={t('settings.messages.title')} defaultExpanded={true}>
<SettingGroup>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.prompt')}</SettingRowTitleSmall>
<Switch size="small" checked={showPrompt} onChange={(checked) => dispatch(setShowPrompt(checked))} />
@ -440,7 +447,6 @@ const SettingsTab: FC<Props> = (props) => {
</CollapsibleSettingGroup>
<CollapsibleSettingGroup title={t('chat.settings.code.title')} defaultExpanded={true}>
<SettingGroup>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
<StyledSelect
@ -568,7 +574,6 @@ const SettingsTab: FC<Props> = (props) => {
</CollapsibleSettingGroup>
<CollapsibleSettingGroup title={t('settings.messages.input.title')} defaultExpanded={true}>
<SettingGroup>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.show_estimated_tokens')}</SettingRowTitleSmall>
<Switch
@ -710,11 +715,11 @@ const Label = styled.p`
margin-right: 5px;
`
export const SettingRowTitleSmall = styled(SettingRowTitle)`
const SettingRowTitleSmall = styled(SettingRowTitle)`
font-size: 13px;
`
export const SettingGroup = styled.div<{ theme?: ThemeMode }>`
const SettingGroup = styled.div<{ theme?: ThemeMode }>`
padding: 0 5px;
width: 100%;
margin-top: 0;

View File

@ -396,9 +396,46 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
onClick={() => onSwitchTopic(topic)}
style={{ borderRadius }}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
<TopicName className="name" title={topicName}>
{topicName}
</TopicName>
<TopicNameContainer>
<TopicName className="name" title={topicName}>
{topicName}
</TopicName>
{isActive && !topic.pinned && (
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
title={
<div>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
</div>
}>
<MenuButton
className="menu"
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}>
{deletingTopicId === topic.id ? (
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
) : (
<CloseOutlined />
)}
</MenuButton>
</Tooltip>
)}
{topic.pinned && (
<MenuButton className="pin">
<PushpinOutlined />
</MenuButton>
)}
</TopicNameContainer>
{topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt}
@ -407,37 +444,6 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
{showTopicTime && (
<TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>
)}
<MenuButton className="pin">{topic.pinned && <PushpinOutlined />}</MenuButton>
{isActive && !topic.pinned && (
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
title={
<div>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
</div>
}>
<MenuButton
className="menu"
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}>
{deletingTopicId === topic.id ? (
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
) : (
<CloseOutlined />
)}
</MenuButton>
</Tooltip>
)}
</TopicListItem>
)
}}
@ -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;
}

View File

@ -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<OpenAIServiceTier, OpenAIServiceTier> = {
@ -22,7 +23,12 @@ const FALL_BACK_SERVICE_TIER: Record<OpenAIServiceTier, OpenAIServiceTier> = {
flex: 'default'
}
const OpenAISettingsTab: FC<Props> = (props) => {
const OpenAISettingsGroup: FC<Props> = ({
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> = (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> = (props) => {
}, [serviceTierMode, serviceTierOptions, setServiceTierMode])
return (
<SettingGroup>
<SettingSubtitle style={{ marginTop: 0 }}>{t('settings.openai.title')}</SettingSubtitle>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('settings.openai.service_tier.title')}{' '}
<Tooltip title={t('settings.openai.service_tier.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<StyledSelect
value={serviceTierMode}
style={{ width: 135 }}
onChange={(value) => {
setServiceTierMode(value as OpenAIServiceTier)
}}
size="small"
options={serviceTierOptions}
/>
</SettingRow>
{props.isOpenAIReasoning && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('settings.openai.summary_text_mode.title')}{' '}
<Tooltip title={t('settings.openai.summary_text_mode.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<StyledSelect
value={summaryText}
style={{ width: 135 }}
onChange={(value) => {
setSummaryText(value as OpenAISummaryText)
}}
size="small"
options={summaryTextOptions}
/>
</SettingRow>
</>
)}
</SettingGroup>
<CollapsibleSettingGroup title={t('settings.openai.title')} defaultExpanded={true}>
<SettingGroup>
<SettingRow>
<SettingRowTitleSmall>
{t('settings.openai.service_tier.title')}{' '}
<Tooltip title={t('settings.openai.service_tier.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<StyledSelect
value={serviceTierMode}
style={{ width: 135 }}
onChange={(value) => {
setServiceTierMode(value as OpenAIServiceTier)
}}
size="small"
options={serviceTierOptions}
/>
</SettingRow>
{isOpenAIReasoning && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('settings.openai.summary_text_mode.title')}{' '}
<Tooltip title={t('settings.openai.summary_text_mode.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<StyledSelect
value={summaryText}
style={{ width: 135 }}
onChange={(value) => {
setSummaryText(value as OpenAISummaryText)
}}
size="small"
options={summaryTextOptions}
/>
</SettingRow>
</>
)}
</SettingGroup>
</CollapsibleSettingGroup>
)
}
@ -141,4 +147,4 @@ const StyledSelect = styled(Select)`
}
`
export default OpenAISettingsTab
export default OpenAISettingsGroup

View File

@ -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<Props> = ({
setActiveAssistant,
setActiveTopic,
position,
forceToSeeAllTab
forceToSeeAllTab,
style
}) => {
const { addAssistant } = useAssistants()
const [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
@ -100,7 +102,7 @@ const HomeTabs: FC<Props> = ({
}, [position, tab, topicPosition, forceToSeeAllTab])
return (
<Container style={border} className="home-tabs">
<Container style={{ ...border, ...style }} className="home-tabs">
{(showTab || (forceToSeeAllTab == true && !showTopics)) && (
<Segmented
value={tab}

View File

@ -93,7 +93,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ 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<KnowledgeContentProps> = ({ selectedBase }) => {
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
<ClickableSpan onClick={() => window.api.file.openPath(FileManager.getFilePath(file))}>
<Ellipsis>
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
</Ellipsis>

View File

@ -60,6 +60,7 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
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<AihubmixMode, ConfigItem[]> => {
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<AihubmixMode, ConfigItem[]> => {
type: 'select',
key: 'renderingSpeed',
options: RENDERING_SPEED_OPTIONS,
initialValue: 'DEFAULT',
disabled: (_config, painting) => {
const model = painting?.model
return !model || !model.includes('V_3')

View File

@ -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<SidebarIconsManagerProps> = ({
paintings: <Palette size={16} />,
translate: <Languages size={16} />,
minapp: <LayoutGrid size={16} />,
knowledge: <LibraryBig size={16} />,
knowledge: <FileSearch size={16} />,
files: <Folder size={15} />
}),
[]

View File

@ -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 (
<SettingContainer theme={themeMode}>
<SettingGroup theme={theme}>
@ -154,6 +162,27 @@ const GeneralSettings: FC = () => {
</>
)}
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.notification.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.notification.assistant')}</SettingRowTitle>
<Switch checked={notificationSettings.assistant} onChange={(v) => handleNotificationChange('assistant', v)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.notification.backup')}</SettingRowTitle>
<Switch checked={notificationSettings.backup} onChange={(v) => handleNotificationChange('backup', v)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.notification.knowledge_embed')}</SettingRowTitle>
<Switch
checked={notificationSettings.knowledgeEmbed}
onChange={(v) => handleNotificationChange('knowledgeEmbed', v)}
/>
</SettingRow>
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.launch.title')}</SettingTitle>
<SettingDivider />

View File

@ -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<AddMcpServerModalProps> = ({ 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 (
<Modal
title={t('settings.mcp.addServer.importFrom')}
open={visible}
onOk={handleOk}
onCancel={onClose}
confirmLoading={loading}
destroyOnClose
centered
transitionName="animation-move-down"
width={600}>
<Form form={form} layout="vertical" name="add_mcp_server_form">
<Form.Item
name="serverConfig"
label={t('settings.mcp.addServer.importFrom.tooltip')}
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
<CodeToolbarProvider>
<CodeEditor
// 如果表單值為空,顯示範例 JSON否則顯示表單值
value={serverConfigValue}
placeholder={initialJsonExample}
language="json"
onChange={handleEditorChange}
maxHeight="300px"
options={{
lint: true,
collapsible: true,
wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
keymap: true
}}
/>
</CodeToolbarProvider>
</Form.Item>
</Form>
</Modal>
)
}
// 解析 JSON 提取伺服器資料
const parseAndExtractServer = (
inputValue: string,
t: (key: string, options?: any) => string
): { serverToAdd: Partial<ParsedServerData> | 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<ParsedServerData> | 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

View File

@ -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<Props> = ({ resolve }) => {
resolve({})
}
const handleChange = useCallback((newContent: string) => {
setJsonConfig(newContent)
}, [])
EditMcpJsonPopup.hide = onCancel
return (
@ -127,19 +123,20 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<div style={{ marginBottom: '16px' }}>
<CodeToolbarProvider>
<CodeEditor
value={jsonConfig}
language="json"
onChange={handleChange}
onChange={(value) => setJsonConfig(value)}
maxHeight="60vh"
options={{
lint: true,
collapsible: true,
wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
keymap: true
}}>
{jsonConfig}
</CodeEditor>
}}
/>
</CodeToolbarProvider>
</div>
)}

View File

@ -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<McpDescriptionProps> = ({ searchKey }) => {
const { t } = useTranslation()
const { shikiMarkdownIt } = useCodeStyle()
const [loading, setLoading] = useState(false)
const md = useRef<MarkdownIt>(
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<string>('')
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 (
<Section>
<Card loading={loading}>
<div className="markdown" dangerouslySetInnerHTML={{ __html: renderedMarkdown }} />
<div className="markdown" dangerouslySetInnerHTML={{ __html: mcpInfo }} />
</Card>
</Section>
)
}
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)

View File

@ -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<Set<string>>(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 (
<Container>
<ListHeader>
@ -48,9 +90,28 @@ const McpServersList: FC = () => {
<Button icon={<EditOutlined />} type="text" onClick={() => EditMcpJsonPopup.show()} shape="circle" />
</SettingTitle>
<ButtonGroup>
<Button icon={<Plus size={16} />} type="default" onClick={onAddMcpServer} shape="round">
{t('settings.mcp.addServer')}
</Button>
<Dropdown
menu={{
items: [
{
key: 'manual',
label: t('settings.mcp.addServer.create'),
onClick: () => {
onAddMcpServer()
}
},
{
key: 'quick',
label: t('settings.mcp.addServer.importFrom'),
onClick: () => setIsAddModalVisible(true)
}
]
}}
trigger={['click']}>
<Button icon={<Plus size={16} />} type="default" shape="round">
{t('settings.mcp.addServer')}
</Button>
</Dropdown>
<Button icon={<RefreshCw size={16} />} type="default" onClick={onSyncServers} shape="round">
{t('settings.mcp.sync.title', 'Sync Servers')}
</Button>
@ -76,7 +137,14 @@ const McpServersList: FC = () => {
<MonitorCheck size={16} color={server.isActive ? 'var(--color-primary)' : 'var(--color-text-3)'} />
</ServerIcon>
</ServerName>
<StatusIndicator>
<StatusIndicator onClick={(e) => e.stopPropagation()}>
<Switch
value={server.isActive}
key={server.id}
loading={loadingServerIds.has(server.id)}
onChange={(checked) => handleToggleActive(server, checked)}
size="small"
/>
<Button
icon={<Settings2 size={16} />}
type="text"
@ -111,6 +179,12 @@ const McpServersList: FC = () => {
style={{ marginTop: 20 }}
/>
)}
<AddMcpServerModal
visible={isAddModalVisible}
onClose={() => setIsAddModalVisible(false)}
onSuccess={handleAddServerSuccess}
existingServers={mcpServers} // 傳遞現有的伺服器列表
/>
</Container>
)
}
@ -194,6 +268,9 @@ const ServerNameText = styled.span`
const StatusIndicator = styled.div`
margin-left: 8px;
display: flex;
align-items: center;
gap: 8px;
`
const ServerDescription = styled.div`

View File

@ -3,6 +3,7 @@ import { useTheme } from '@renderer/context/ThemeProvider'
import { useMCPServer, useMCPServers } from '@renderer/hooks/useMCPServers'
import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription'
import { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
import { formatMcpError } from '@renderer/utils/error'
import { Button, Flex, Form, Input, Radio, Select, Switch, Tabs } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { ChevronDown } from 'lucide-react'
@ -168,10 +169,6 @@ const McpSettings: React.FC = () => {
setTools(localTools)
} catch (error) {
setLoadingServer(server.id)
window.message.error({
content: t('settings.mcp.tools.loadError') + ' ' + formatError(error),
key: 'mcp-tools-error'
})
} finally {
setLoadingServer(null)
}
@ -185,10 +182,6 @@ const McpSettings: React.FC = () => {
const localPrompts = await window.api.mcp.listPrompts(server)
setPrompts(localPrompts)
} catch (error) {
window.message.error({
content: t('settings.mcp.prompts.loadError') + ' ' + formatError(error),
key: 'mcp-prompts-error'
})
setPrompts([])
} finally {
setLoadingServer(null)
@ -203,10 +196,6 @@ const McpSettings: React.FC = () => {
const localResources = await window.api.mcp.listResources(server)
setResources(localResources)
} catch (error) {
window.message.error({
content: t('settings.mcp.resources.loadError') + ' ' + formatError(error),
key: 'mcp-resources-error'
})
setResources([])
} finally {
setLoadingServer(null)
@ -344,14 +333,6 @@ const McpSettings: React.FC = () => {
[server, t]
)
const formatError = (error: any) => {
if (error.message.includes('32000')) {
return t('settings.mcp.errors.32000')
}
return error.message
}
const onToggleActive = async (active: boolean) => {
if (isFormChanged && active) {
await onSave()
@ -379,7 +360,7 @@ const McpSettings: React.FC = () => {
} catch (error: any) {
window.modal.error({
title: t('settings.mcp.startError'),
content: formatError(error),
content: formatMcpError(error),
centered: true
})
updateMCPServer({ ...server, isActive: oldActiveState })
@ -597,6 +578,7 @@ const McpSettings: React.FC = () => {
)
}
]
if (server.searchKey) {
tabs.push({
key: 'description',

View File

@ -1,5 +1,6 @@
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { useTheme } from '@renderer/context/ThemeProvider'
import FileItem from '@renderer/pages/files/FileItem'
import QuickPhraseService from '@renderer/services/QuickPhraseService'
import { QuickPhrase } from '@renderer/types'
@ -19,6 +20,7 @@ const QuickPhraseSettings: FC = () => {
const [editingPhrase, setEditingPhrase] = useState<QuickPhrase | null>(null)
const [formData, setFormData] = useState({ title: '', content: '' })
const [dragging, setDragging] = useState(false)
const { theme } = useTheme()
const loadPhrases = async () => {
const data = await QuickPhraseService.getAll()
@ -68,8 +70,8 @@ const QuickPhraseSettings: FC = () => {
const reversedPhrases = [...phrasesList].reverse()
return (
<SettingContainer>
<SettingGroup style={{ marginBottom: 0 }}>
<SettingContainer theme={theme}>
<SettingGroup style={{ marginBottom: 0 }} theme={theme}>
<SettingTitle>
{t('settings.quickPhrase.title')}
<Button type="text" icon={<PlusOutlined />} onClick={handleAdd} />

View File

@ -46,8 +46,11 @@ const GroupHeader = styled.div`
display: flex;
align-items: center;
cursor: pointer;
padding: 6px 0;
padding: 8px 0;
padding-bottom: 12px;
user-select: none;
margin-bottom: 10px;
border-bottom: 0.5px solid var(--color-border);
`
const GroupTitle = styled.div`

View File

@ -39,11 +39,12 @@ export default class AihubmixProvider extends BaseProvider {
*/
private getProvider(model: Model): BaseProvider {
const id = model.id.toLowerCase()
if (id.includes('claude')) {
// claude开头
if (id.startsWith('claude')) {
return this.providers.get('claude')!
}
if (id.includes('gemini')) {
// gemini开头 且不以-nothink、-search结尾
if (id.startsWith('gemini') && !id.endsWith('-nothink') && !id.endsWith('-search')) {
return this.providers.get('gemini')!
}
if (isOpenAILLMModel(model)) {

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