Merge branch 'main' of https://github.com/CherryHQ/cherry-studio into wip/refactor/databases
2
.github/workflows/issue-management.yml
vendored
@ -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
@ -51,3 +51,4 @@ local
|
||||
coverage
|
||||
.vitest-cache
|
||||
vitest.config.*.timestamp-*
|
||||
YOUR_MEMORY_FILE_PATH
|
||||
|
||||
BIN
.yarn/releases/yarn-4.6.0.cjs
vendored
BIN
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable 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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop 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)
|
||||
|
||||
# 📃 ライセンス
|
||||
|
||||
|
||||
@ -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-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop 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 记录
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
71
docs/branching-strategy-en.md
Normal 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
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
71
docs/branching-strategy-zh.md
Normal 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` 分支并打上版本标签
|
||||
|
||||
## 工作流程
|
||||
|
||||

|
||||
|
||||
## 拉取请求(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 等
|
||||
@ -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.
|
||||
@ -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 连接问题
|
||||
修复消息编辑和消息多选相关问题
|
||||
修复消息显示问题
|
||||
修复话题提示词无效问题
|
||||
|
||||
11
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.3.6",
|
||||
"version": "1.3.9",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@ -120,11 +120,11 @@
|
||||
"@google/genai": "^0.13.0",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.3",
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.2.2",
|
||||
"@shikijs/markdown-it": "^3.4.2",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@ -203,7 +203,7 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.88.0",
|
||||
"shiki": "^3.2.2",
|
||||
"shiki": "^3.4.2",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
@ -222,11 +222,10 @@
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"shiki": "3.2.2",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
|
||||
"prettier --write",
|
||||
|
||||
@ -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
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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')
|
||||
// 清理临时目录
|
||||
|
||||
@ -62,7 +62,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
getTrayOnClose(): boolean {
|
||||
return !!this.get(ConfigKeys.TrayOnClose, false)
|
||||
return !!this.get(ConfigKeys.TrayOnClose, true)
|
||||
}
|
||||
|
||||
setTrayOnClose(value: boolean) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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()
|
||||
|
||||
31
src/main/services/NotificationService.ts
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
1
src/renderer/src/assets/images/apps/google.svg
Normal 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 |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.3 KiB |
BIN
src/renderer/src/assets/images/providers/burncloud.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 81 KiB |
@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
outline: none;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.ant-segmented-group {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -321,7 +321,6 @@ mjx-container {
|
||||
|
||||
.cm-lineWrapping * {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
65
src/renderer/src/components/CodeEditor/hook.ts
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
97
src/renderer/src/components/Popups/MultiSelectionPopup.tsx
Normal 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
|
||||
@ -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'
|
||||
|
||||
@ -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: '文心一言',
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
76
src/renderer/src/context/NotificationProvider.tsx
Normal 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
|
||||
}
|
||||
185
src/renderer/src/hooks/useChatContext.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -191,6 +191,8 @@
|
||||
"message.quote": "引用",
|
||||
"message.regenerate.model": "モデルを切り替え",
|
||||
"message.useful": "役立つ",
|
||||
"multiple.select": "選択",
|
||||
"multiple.select.empty": "メッセージが選択されていません",
|
||||
"navigation": {
|
||||
"first": "最初のメッセージです",
|
||||
"history": "チャット履歴",
|
||||
@ -393,6 +395,8 @@
|
||||
"save": "保存",
|
||||
"search": "検索",
|
||||
"select": "選択",
|
||||
"selectedMessages": "{{count}}件のメッセージを選択しました",
|
||||
"success": "成功",
|
||||
"topics": "トピック",
|
||||
"warning": "警告",
|
||||
"you": "あなた",
|
||||
@ -591,6 +595,11 @@
|
||||
"copied": "コピーしました!",
|
||||
"copy.failed": "コピーに失敗しました",
|
||||
"copy.success": "コピーしました!",
|
||||
"delete.confirm.title": "削除確認",
|
||||
"delete.confirm.content": "選択した{{count}}件のメッセージを削除しますか?",
|
||||
"delete.failed": "削除に失敗しました",
|
||||
"delete.success": "削除が成功しました",
|
||||
"error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません",
|
||||
"empty_url": "画像をダウンロードできません。プロンプトに不適切なコンテンツや禁止用語が含まれている可能性があります",
|
||||
"error.chunk_overlap_too_large": "チャンクのオーバーラップがチャンクサイズより大きくなることはできません",
|
||||
"error.dimension_too_large": "内容のサイズが大きすぎます",
|
||||
@ -773,6 +782,11 @@
|
||||
"hide_sidebar": "サイドバーを非表示",
|
||||
"show_sidebar": "サイドバーを表示"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
"knowledge.success": "ナレッジベースに{{type}}を正常に追加しました",
|
||||
"knowledge.error": "ナレッジベースへの{{type}}の追加に失敗しました: {{error}}"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
"keep_alive_time.placeholder": "分",
|
||||
@ -879,6 +893,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"aihubmix": "AiHubMix",
|
||||
"burncloud": "BurnCloud",
|
||||
"alayanew": "Alaya NeW",
|
||||
"anthropic": "Anthropic",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
@ -1266,6 +1281,14 @@
|
||||
"active": "有効",
|
||||
"addError": "サーバーの追加に失敗しました",
|
||||
"addServer": "サーバーを追加",
|
||||
"addServer.create": "クイック作成",
|
||||
"addServer.importFrom": "JSONからインポート",
|
||||
"addServer.importFrom.tooltip": "MCPサーバー紹介ページから設定JSON(NPXまたはUVX設定を優先)をコピーし、入力ボックスに貼り付けてください。",
|
||||
"addServer.importFrom.placeholder": "MCPサーバーJSON設定を貼り付け",
|
||||
"addServer.importFrom.invalid": "無効な入力です。JSON形式を確認してください。",
|
||||
"addServer.importFrom.nameExists": "サーバーはすでに存在します: {{name}}",
|
||||
"addServer.importFrom.oneServer": "一度に1つのMCPサーバー設定のみを保存できます",
|
||||
"addServer.importFrom.connectionFailed": "接続に失敗しました",
|
||||
"addSuccess": "サーバーが正常に追加されました",
|
||||
"args": "引数",
|
||||
"argsTooltip": "1行に1つの引数を入力してください",
|
||||
@ -1281,6 +1304,7 @@
|
||||
"dependenciesInstall": "依存関係をインストール",
|
||||
"dependenciesInstalling": "依存関係をインストール中...",
|
||||
"description": "説明",
|
||||
"noDescriptionAvailable": "説明がありません",
|
||||
"duplicateName": "同じ名前のサーバーが既に存在します",
|
||||
"editJson": "JSONを編集",
|
||||
"editServer": "サーバーを編集",
|
||||
@ -1690,6 +1714,12 @@
|
||||
"service_tier.auto": "自動",
|
||||
"service_tier.default": "デフォルト",
|
||||
"service_tier.flex": "フレックス"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知設定",
|
||||
"assistant": "アシスタントメッセージ",
|
||||
"backup": "バックアップメッセージ",
|
||||
"knowledge_embed": "ナレッジベースメッセージ"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
|
||||
@ -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": "Любой язык",
|
||||
|
||||
@ -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": "自动",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -835,6 +835,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"aihubmix": "AiHubMix",
|
||||
"burncloud": "BurnCloud",
|
||||
"alayanew": "Alaya NeW",
|
||||
"anthropic": "Anthropic",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
|
||||
@ -836,6 +836,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"aihubmix": "AiHubMix",
|
||||
"burncloud": "BurnCloud",
|
||||
"alayanew": "Alaya NeW",
|
||||
"anthropic": "Antropológico",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
|
||||
@ -835,6 +835,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"aihubmix": "AiHubMix",
|
||||
"burncloud": "BurnCloud",
|
||||
"alayanew": "Alaya NeW",
|
||||
"anthropic": "Anthropic",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
|
||||
@ -837,6 +837,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"aihubmix": "AiHubMix",
|
||||
"burncloud": "BurnCloud",
|
||||
"alayanew": "Alaya NeW",
|
||||
"anthropic": "Antropológico",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
`
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
65
src/renderer/src/pages/home/Messages/MessageSelect.tsx
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
141
src/renderer/src/pages/home/Messages/SelectionBox.tsx
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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} />
|
||||
}),
|
||||
[]
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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)) {
|
||||
|
||||