Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45dd76e281 | ||
|
|
568d4814e3 | ||
|
|
9468f3b511 | ||
|
|
04af940144 | ||
|
|
e33d9ac0ae | ||
|
|
cd835b7c36 | ||
|
|
dd4239da87 | ||
|
|
41c3895da4 | ||
|
|
2e9c7d0830 | ||
|
|
8ea73e14c9 | ||
|
|
3791556b13 | ||
|
|
e0dab5cf5b | ||
|
|
1785e7df0a | ||
|
|
6cb1846b23 | ||
|
|
21243579b3 | ||
|
|
0d2ad2e4c3 | ||
|
|
071a3950cd | ||
|
|
dc6066b74c | ||
|
|
ce55d8d0e7 | ||
|
|
d4ae321cd2 | ||
|
|
89dd35c98d | ||
|
|
b8c70a3061 | ||
|
|
968a749aaa | ||
|
|
e2fc593624 | ||
|
|
0e1674ce6c | ||
|
|
18566989be | ||
|
|
31fa10f185 | ||
|
|
f6aa0dc55a | ||
|
|
ca2a9ed84a | ||
|
|
79f6d598ab | ||
|
|
fb564733e4 | ||
|
|
63e5972dd2 | ||
|
|
b80270709f | ||
|
|
d7b459dcee | ||
|
|
76b9e1a65e | ||
|
|
b148c5adf5 | ||
|
|
2313f66ad9 | ||
|
|
02edd983d1 | ||
|
|
3e049baaa4 | ||
|
|
7401d85825 | ||
|
|
241dcddfed | ||
|
|
cd0ea8154d | ||
|
|
6d6788eeb2 | ||
|
|
9ac35ae3d8 | ||
|
|
72e847258d |
@@ -1,72 +1,45 @@
|
||||
## Cherry Studio目录结构和功能
|
||||
# Cherry Studio 贡献者指南
|
||||
|
||||
### 1. `/src`: 主要源代码目录
|
||||
- ** `/main`**: Electron主进程相关代码
|
||||
- 负责应用的生命周期管理、窗口创建、IPC通信等
|
||||
- ** `/renderer`**: Electron渲染进程相关代码
|
||||
- 包含用户界面的实现,使用TypeScript和SCSS
|
||||
- ** `/preload`**: 预加载脚本
|
||||
- 用于在渲染进程中安全地暴露主进程功能
|
||||
- ** `/components`**: React组件
|
||||
- 可复用的UI组件,如对话框、输入框等
|
||||
- ** `/pages`**: 应用的主要页面
|
||||
- 如聊天界面、设置页面等
|
||||
- ** `/store`**: 状态管理
|
||||
- 可能使用Redux或MobX来管理应用状态
|
||||
- ** `/utils`**: 工具函数
|
||||
- 包含各种辅助函数和工具类
|
||||
- ** `/styles`**: 全局样式文件
|
||||
- 包含SCSS文件,定义全局样式和主题
|
||||
欢迎来到 Cherry Studio 的贡献者社区!我们致力于将 Cherry Studio 打造成一个长期提供价值的项目,并希望邀请更多的开发者加入我们的行列。无论您是经验丰富的开发者还是刚刚起步的初学者,您的贡献都将帮助我们更好地服务用户,提升软件质量。
|
||||
|
||||
### 2. `/public`: 静态资源目录
|
||||
- 包含图标、字体等静态文件
|
||||
## 如何贡献
|
||||
|
||||
### 3. `/electron`: Electron相关配置
|
||||
- 包含Electron的构建和打包配置
|
||||
以下是您可以参与的几种方式:
|
||||
|
||||
### 4. `/scripts`: 构建和开发脚本
|
||||
- 包含npm脚本,用于开发、构建和部署
|
||||
1. **贡献代码**:帮助我们开发新功能或优化现有代码。请确保您的代码符合我们的编码标准,并通过所有测试。
|
||||
|
||||
### 5. `/types`: TypeScript类型定义
|
||||
- 包含自定义的类型定义文件
|
||||
2. **修复 BUG**:如果您发现了 BUG,欢迎提交修复方案。请在提交前确认问题已被解决,并附上相关测试。
|
||||
|
||||
### 6. `/tests`: 测试文件目录
|
||||
- 包含单元测试和集成测试
|
||||
3. **维护 Issue**:协助我们管理 GitHub 上的 issue,帮助标记、分类和解决问题。
|
||||
|
||||
### 7. `/docs`: 文档目录
|
||||
- 包含项目文档、API文档等
|
||||
4. **产品设计**:参与产品设计讨论,帮助我们改进用户体验和界面设计。
|
||||
|
||||
### 8. `/config`: 配置文件目录
|
||||
- 包含各种配置文件,如webpack配置、环境变量等
|
||||
5. **编写文档**:帮助我们完善用户手册、API 文档和开发者指南。
|
||||
|
||||
### 9. `/migrations`: 数据库迁移文件
|
||||
- 由于使用了Sequelize,这里可能包含数据库结构的变更记录
|
||||
6. **社区维护**:参与社区讨论,帮助解答用户问题,促进社区活跃。
|
||||
|
||||
### 10. `/models`: 数据模型
|
||||
- 定义Sequelize的数据模型,对应数据库表结构
|
||||
7. **推广使用**:通过博客、社交媒体等渠道推广 Cherry Studio,吸引更多用户和开发者。
|
||||
|
||||
## 主要功能实现
|
||||
## 开始贡献
|
||||
|
||||
### 1. LLM提供商集成
|
||||
- 可能在`/src/utils`或`/src/services`中实现与不同LLM API的集成
|
||||
1. **Fork 仓库**:在 GitHub 上 fork 我们的仓库,并将其克隆到本地。
|
||||
|
||||
### 2. 多助手和多主题支持
|
||||
- 在`/src/store`中管理助手和主题的状态
|
||||
- 在`/src/components`中实现相关的UI组件
|
||||
2. **创建分支**:为您要进行的更改创建一个新的分支。
|
||||
|
||||
### 3. 多模型对话
|
||||
- 在`/src/pages`的聊天界面中实现
|
||||
- 可能使用`/src/store`来管理对话状态
|
||||
3. **提交更改**:在本地进行更改并提交。请确保您的提交信息清晰明了。
|
||||
|
||||
### 4. 拖放排序
|
||||
- 在`/src/components`中实现相关的可拖拽组件
|
||||
4. **发起 Pull Request**:将您的更改推送到 GitHub,并发起 Pull Request。请描述您的更改内容和原因。
|
||||
|
||||
### 5. 代码高亮
|
||||
- 可能使用第三方库,如Prism.js,集成在`/src/components`中
|
||||
### 其他建议
|
||||
|
||||
### 6. Mermaid图表支持
|
||||
- 在`/src/components`中集成Mermaid库
|
||||
- **联系开发者**:在提交 PR 之前,您可以先和开发者进行联系,共同探讨或者获取帮助。
|
||||
- **成为核心开发者**:如果您能够稳定为项目贡献,恭喜您可以成为项目核心开发者,获取到项目成员身份。
|
||||
|
||||
### 7. 数据持久化
|
||||
- 使用Sequelize在`/models`中定义数据模型
|
||||
- 在`/migrations`中管理数据库结构变更
|
||||
## 联系我们
|
||||
|
||||
如果您有任何问题或建议,欢迎通过以下方式联系我们:
|
||||
|
||||
- 微信:kangfenmao
|
||||
- [GitHub Issues](https://github.com/kangfenmao/cherry-studio/issues)
|
||||
|
||||
感谢您的支持和贡献!我们期待与您一起将 Cherry Studio 打造成更好的产品。
|
||||
|
||||
84
README.md
@@ -21,25 +21,47 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||

|
||||

|
||||
|
||||
# 🌟 Features
|
||||
# 🌟 Key Features
|
||||
|
||||
<div align="center">
|
||||
<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>
|
||||
1. **Diverse LLM Provider Support**:
|
||||
|
||||
1. Support for Multiple LLM Providers.
|
||||
2. Allows creation of multiple Assistants.
|
||||
3. Enables creation of multiple topics.
|
||||
4. Allows using multiple models to answer questions in the same conversation.
|
||||
5. Supports drag-and-drop sorting.
|
||||
6. Code highlighting.
|
||||
7. Mermaid chart
|
||||
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
|
||||
- 🔗 AI Web Service Integration: Claude, Peplexity, Poe, and others
|
||||
- 💻 Local Model Support with Ollama
|
||||
|
||||
2. **AI Assistants & Conversations**:
|
||||
|
||||
- 📚 300+ Pre-configured AI Assistants
|
||||
- 🤖 Custom Assistant Creation
|
||||
- 💬 Multi-model Simultaneous Conversations
|
||||
|
||||
3. **Document & Data Processing**:
|
||||
|
||||
- 📄 Support for Text, Images, Office, PDF, and more
|
||||
- ☁️ WebDAV File Management and Backup
|
||||
- 📊 Mermaid Chart Visualization
|
||||
- 💻 Code Syntax Highlighting
|
||||
|
||||
4. **Practical Tools Integration**:
|
||||
|
||||
- 🔍 Global Search Functionality
|
||||
- 📝 Topic Management System
|
||||
- 🔤 AI-powered Translation
|
||||
- 🎯 Drag-and-drop Sorting
|
||||
- 🔌 Mini Program Support
|
||||
|
||||
5. **Enhanced User Experience**:
|
||||
- 🖥️ Cross-platform Support for Windows, Mac, and Linux
|
||||
- 📦 Ready to Use, No Environment Setup Required
|
||||
- 🎨 Light/Dark Themes and Transparent Window
|
||||
- 📝 Complete Markdown Rendering
|
||||
- 🤲 Easy Content Sharing
|
||||
|
||||
# 🖥️ Develop
|
||||
|
||||
## IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
## Project Setup
|
||||
|
||||
@@ -68,24 +90,52 @@ $ yarn build:mac
|
||||
$ yarn build:linux
|
||||
```
|
||||
|
||||
# ⭐️ Star History
|
||||
# 🤝 Contributing
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
We welcome contributions to Cherry Studio! Here are some ways you can contribute:
|
||||
|
||||
1. **Contribute Code**: Develop new features or optimize existing code.
|
||||
2. **Fix Bugs**: Submit fixes for any bugs you find.
|
||||
3. **Maintain Issues**: Help manage GitHub issues.
|
||||
4. **Product Design**: Participate in design discussions.
|
||||
5. **Write Documentation**: Improve user manuals and guides.
|
||||
6. **Community Engagement**: Join discussions and help users.
|
||||
7. **Promote Usage**: Spread the word about Cherry Studio.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork the Repository**: Fork and clone it to your local machine.
|
||||
2. **Create a Branch**: For your changes.
|
||||
3. **Submit Changes**: Commit and push your changes.
|
||||
4. **Open a Pull Request**: Describe your changes and reasons.
|
||||
|
||||
For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIBUTING.md).
|
||||
|
||||
Thank you for your support and contributions!
|
||||
|
||||
# 🚀 Contributors
|
||||
|
||||
<a href="https://github.com/kangfenmao/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# Community
|
||||
# 🌐 Community
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI)
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# Sponsor
|
||||
# 📣 Product Hunt
|
||||
|
||||
<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>
|
||||
|
||||
# ☕ Sponsor
|
||||
|
||||
[Buy Me a Coffee](docs/sponsor.md)
|
||||
|
||||
# 📃 License
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
# ⭐️ Star History
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
@@ -21,25 +21,47 @@ Cherry Studioは、複数のLLMプロバイダーをサポートするデスク
|
||||

|
||||

|
||||
|
||||
# 🌟 特徴
|
||||
# 🌟 主な機能
|
||||
|
||||
<div align="center">
|
||||
<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>
|
||||
1. **多様な LLM サービス対応**:
|
||||
|
||||
1. 複数のLLMプロバイダーをサポート。
|
||||
2. 複数のアシスタントを作成可能。
|
||||
3. 複数のトピックを作成可能。
|
||||
4. 同じ会話で複数のモデルを使用して質問に回答可能。
|
||||
5. ドラッグアンドドロップでの並べ替えをサポート。
|
||||
6. コードハイライト。
|
||||
7. Mermaidチャート
|
||||
- ☁️ 主要な LLM クラウドサービス対応:OpenAI、Gemini、Anthropic など
|
||||
- 🔗 AI Web サービス統合:Claude、Peplexity、Poe など
|
||||
- 💻 Ollama によるローカルモデル実行対応
|
||||
|
||||
2. **AI アシスタントと対話**:
|
||||
|
||||
- 📚 300+ の事前設定済み AI アシスタント
|
||||
- 🤖 カスタム AI アシスタントの作成
|
||||
- 💬 複数モデルでの同時対話機能
|
||||
|
||||
3. **文書とデータ処理**:
|
||||
|
||||
- 📄 テキスト、画像、Office、PDF など多様な形式対応
|
||||
- ☁️ WebDAV によるファイル管理とバックアップ
|
||||
- 📊 Mermaid による図表作成
|
||||
- 💻 コードハイライト機能
|
||||
|
||||
4. **実用的なツール統合**:
|
||||
|
||||
- 🔍 グローバル検索機能
|
||||
- 📝 トピック管理システム
|
||||
- 🔤 AI による翻訳機能
|
||||
- 🎯 ドラッグ&ドロップによる整理
|
||||
- 🔌 ミニプログラム対応
|
||||
|
||||
5. **優れたユーザー体験**:
|
||||
- 🖥️ Windows、Mac、Linux のクロスプラットフォーム対応
|
||||
- 📦 環境構築不要ですぐに使用可能
|
||||
- 🎨 ライト/ダークテーマと透明ウィンドウ対応
|
||||
- 📝 完全な Markdown レンダリング
|
||||
- 🤲 簡単な共有機能
|
||||
|
||||
# 🖥️ 開発
|
||||
|
||||
## IDEの設定
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
## プロジェクトの設定
|
||||
|
||||
@@ -68,9 +90,28 @@ $ yarn build:mac
|
||||
$ yarn build:linux
|
||||
```
|
||||
|
||||
# ⭐️ スター履歴
|
||||
# 🤝 貢献
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
Cherry Studioへの貢献を歓迎します!以下の方法で貢献できます:
|
||||
|
||||
1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します。
|
||||
2. **バグの修正**:見つけたバグを修正します。
|
||||
3. **問題の管理**:GitHubの問題を管理するのを手伝います。
|
||||
4. **製品デザイン**:デザインの議論に参加します。
|
||||
5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します。
|
||||
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します。
|
||||
7. **使用の促進**:Cherry Studioを広めます。
|
||||
|
||||
## 始め方
|
||||
|
||||
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします。
|
||||
2. **ブランチを作成**:変更のためのブランチを作成します。
|
||||
3. **変更を提出**:変更をコミットしてプッシュします。
|
||||
4. **プルリクエストを開く**:変更内容と理由を説明します。
|
||||
|
||||
詳細なガイドラインについては、[貢献ガイド](./CONTRIBUTING.md)をご覧ください。
|
||||
|
||||
ご支援と貢献に感謝します!
|
||||
|
||||
# 🚀 コントリビューター
|
||||
|
||||
@@ -80,7 +121,11 @@ $ yarn build:linux
|
||||
|
||||
# コミュニティ
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI)
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# 📣 プロダクトハント
|
||||
|
||||
<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>
|
||||
|
||||
# スポンサー
|
||||
|
||||
@@ -89,3 +134,7 @@ $ yarn build:linux
|
||||
# 📃 ライセンス
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
# ⭐️ スター履歴
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
@@ -11,100 +11,131 @@
|
||||
|
||||

|
||||
|
||||
Cherry Studio 是一款跨平台桌面客户端,支持多个大语言模型(LLM)服务商,兼容 Windows、Mac 和 Linux 系统,并拥丰富的个性化选项与领先的功能设计。
|
||||
Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客户端,兼容 Windows、Mac 和 Linux 系统。
|
||||
|
||||
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)
|
||||
|
||||
# 🌠 界面
|
||||
|
||||
<img width="1582" alt="Xnip2024-09-23_15-01-53" src="https://github.com/user-attachments/assets/554aa31b-87b6-49fe-877d-af313e1608b0">
|
||||
<img width="1582" alt="Xnip2024-09-23_15-02-27" src="https://github.com/user-attachments/assets/f43fb4c8-194a-4f46-8575-6db2bd136cb9">
|
||||
<img width="1582" alt="Xnip2024-09-23_16-12-19" src="https://github.com/user-attachments/assets/82ce3cc1-5a0b-49aa-9fe4-0376d34be1f8">
|
||||
<img width="1582" alt="Xnip2024-09-23_16-11-44" src="https://github.com/user-attachments/assets/55e420c8-fc0f-40a0-868e-d75bebeb5af3">
|
||||
<img width="1582" alt="Xnip2024-09-23_16-11-50" src="https://github.com/user-attachments/assets/7413384e-a7c7-4525-96ea-ccd395d7e51a">
|
||||
<img width="1582" alt="Xnip2024-09-23_16-12-59" src="https://github.com/user-attachments/assets/894b5e97-569f-4471-813c-c48d19455215">
|
||||

|
||||

|
||||

|
||||
|
||||
# 🌟 特性
|
||||
# 🌟 主要特性
|
||||
|
||||
<div align="center">
|
||||
<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>
|
||||
1. **多样化 LLM 服务支持**:
|
||||
|
||||
## 😌 轻松上手
|
||||
- ☁️ 支持主流 LLM 云服务:OpenAI、Gemini、Anthropic、硅基流动等
|
||||
- 🔗 集成流行 AI Web 服务:Claude、Peplexity、Poe、腾讯元宝、知乎直答等
|
||||
- 💻 支持 Ollama 本地模型部署
|
||||
|
||||
🍏Windows,Mac,Linux跨平台支持
|
||||
2. **智能助手与对话**:
|
||||
|
||||
📦开箱即用,无需 Python 与 Docker
|
||||
- 📚 内置 300+ 预配置 AI 助手
|
||||
- 🤖 支持自定义创建专属助手
|
||||
- 💬 多模型同时对话,获得多样化观点
|
||||
|
||||
🤝简洁、友好的界面与交互设计
|
||||
3. **文档与数据处理**:
|
||||
|
||||
## 🛠️多样化的 LLM 服务模式支持
|
||||
- 📄 支持文本、图片、Office、PDF 等多种格式
|
||||
- ☁️ WebDAV 文件管理与数据备份
|
||||
- 📊 Mermaid 图表可视化
|
||||
- 💻 代码高亮显示
|
||||
|
||||
☁️ 全面覆盖 LLM 云服务,支持自定义 api key 与模型管理:OpenAI,Gemini,Anthropic,硅基流动...
|
||||
4. **实用工具集成**:
|
||||
|
||||
🔗汇聚流行的 AI Web 服务,并计划通过功能增强提升体验:Claude,Peplexity,Poe,腾讯元宝,知乎直答...
|
||||
- 🔍 全局搜索功能
|
||||
- 📝 话题管理系统
|
||||
- 🔤 AI 驱动的翻译功能
|
||||
- 🎯 拖拽排序
|
||||
- 🔌 小程序支持
|
||||
|
||||
💻支持 Ollama 运行本地模型
|
||||
5. **优质使用体验**:
|
||||
- 🖥️ Windows、Mac、Linux 跨平台支持
|
||||
- 📦 开箱即用,无需配置环境
|
||||
- 🎨 支持明暗主题与透明窗口
|
||||
- 📝 完整的 Markdown 渲染
|
||||
- 🤲 便捷的内容分享功能
|
||||
|
||||
## 📲个性化的功能体验
|
||||
# 🖥️ 开发
|
||||
|
||||
📄完整的 Markdown 与 Mermaid 渲染支持
|
||||
## IDE 设置
|
||||
|
||||
🤖使用与创建智能体提升工作效率
|
||||
|
||||
🔤持续迭代的翻译功能
|
||||
|
||||
🤲生成结果支持 Markdown 与图片分享
|
||||
|
||||
📎文件与图片上传,RAG 与多模态对话
|
||||
|
||||
🎨透明窗口与明暗主题支持
|
||||
|
||||
# 🖥️ 开发指南
|
||||
|
||||
## 开发环境
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
## 项目设置
|
||||
|
||||
### 安装依赖
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### 启动开发环境
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
$ yarn dev
|
||||
```
|
||||
|
||||
### 构建版本
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
# Windows
|
||||
$ yarn build:win
|
||||
|
||||
# For macOS
|
||||
# macOS
|
||||
$ yarn build:mac
|
||||
|
||||
# For Linux
|
||||
# Linux
|
||||
$ yarn build:linux
|
||||
```
|
||||
|
||||
# ⭐️ Star 记录
|
||||
# 🤝 贡献
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
|
||||
|
||||
# 社区
|
||||
1. **贡献代码**:开发新功能或优化现有代码。
|
||||
2. **修复错误**:提交您发现的错误修复。
|
||||
3. **维护问题**:帮助管理 GitHub 问题。
|
||||
4. **产品设计**:参与设计讨论。
|
||||
5. **撰写文档**:改进用户手册和指南。
|
||||
6. **社区参与**:加入讨论并帮助用户。
|
||||
7. **推广使用**:宣传 Cherry Studio。
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI)
|
||||
## 入门
|
||||
|
||||
# 赞助
|
||||
1. **Fork 仓库**:Fork 并克隆到您的本地机器。
|
||||
2. **创建分支**:为您的更改创建分支。
|
||||
3. **提交更改**:提交并推送您的更改。
|
||||
4. **打开 Pull Request**:描述您的更改和原因。
|
||||
|
||||
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.md)。
|
||||
|
||||
感谢您的支持和贡献!
|
||||
|
||||
# 🚀 贡献者
|
||||
|
||||
<a href="https://github.com/kangfenmao/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 社区
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# 📣 产品猎人
|
||||
|
||||
<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>
|
||||
|
||||
# ☕ 赞助
|
||||
|
||||
[微信赞赏码](sponsor.md)
|
||||
|
||||
# 📃 许可证
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
@@ -63,10 +63,6 @@ electronDownload:
|
||||
afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
增加300个智能体 by @cawabj
|
||||
增加快捷键设置界面
|
||||
支持快捷键缩放整体界面大小
|
||||
修复 WebDAV 备份文件大小限制
|
||||
修复跨平台备份回复后文件路径错误问题
|
||||
请求头增加 X-Api-Key
|
||||
修复消息滚动跳跃问题
|
||||
支持聊天气泡样式和简洁样式切换
|
||||
支持导出对话为 Word 文档
|
||||
错误修复
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -39,12 +39,14 @@
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"docx": "^9.0.2",
|
||||
"electron-log": "^5.1.5",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"fs-extra": "^11.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"unzipper": "^0.12.3",
|
||||
"webdav": "4.11.4"
|
||||
@@ -60,6 +62,7 @@
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/node": "^18.19.9",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import path from 'path'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
isDev && app.setPath('userData', app.getPath('userData') + 'Dev')
|
||||
|
||||
const getDataPath = () => {
|
||||
const dataPath = path.join(app.getPath('userData'), 'Data')
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true })
|
||||
}
|
||||
return dataPath
|
||||
if (isDev) {
|
||||
app.setPath('userData', app.getPath('userData') + 'Dev')
|
||||
}
|
||||
|
||||
export const DATA_PATH = getDataPath()
|
||||
|
||||
export const appConfig = new Store()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
color: '#00000000',
|
||||
|
||||
@@ -3,10 +3,15 @@ import { app, BrowserWindow } from 'electron'
|
||||
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { registerZoomShortcut } from './shortcut'
|
||||
import { registerZoomShortcut } from './services/ShortcutService'
|
||||
import { updateUserDataPath } from './utils/upgrade'
|
||||
import { createMainWindow } from './window'
|
||||
|
||||
// Check for single instance lock
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
@@ -42,6 +47,15 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for second instance
|
||||
app.on('second-instance', () => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0]
|
||||
if (mainWindow) {
|
||||
mainWindow.isMinimized() && mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
|
||||
@@ -2,20 +2,22 @@ import path from 'node:path'
|
||||
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
|
||||
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import FileManager from './services/FileManager'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
import { createMinappWindow } from './window'
|
||||
|
||||
const fileManager = new FileManager()
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const { autoUpdater } = new AppUpdater(mainWindow)
|
||||
|
||||
// IPC
|
||||
ipcMain.handle('app:info', () => ({
|
||||
version: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
@@ -23,23 +25,36 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
filesPath: path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
}))
|
||||
|
||||
ipcMain.handle('open-website', (_, url: string) => {
|
||||
shell.openExternal(url)
|
||||
ipcMain.handle('app:proxy', (_, proxy: string) => session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {}))
|
||||
ipcMain.handle('app:reload', () => mainWindow.reload())
|
||||
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
|
||||
|
||||
// theme
|
||||
ipcMain.handle('app:set-theme', (_, theme: 'light' | 'dark') => {
|
||||
configManager.setTheme(theme)
|
||||
mainWindow?.setTitleBarOverlay &&
|
||||
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
})
|
||||
|
||||
ipcMain.handle('set-proxy', (_, proxy: string) => {
|
||||
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
|
||||
// check for update
|
||||
ipcMain.handle('app:check-for-update', async () => {
|
||||
return {
|
||||
currentVersion: autoUpdater.currentVersion,
|
||||
update: await autoUpdater.checkForUpdates()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('reload', () => mainWindow.reload())
|
||||
|
||||
// zip
|
||||
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
|
||||
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
|
||||
|
||||
// backup
|
||||
ipcMain.handle('backup:backup', backupManager.backup)
|
||||
ipcMain.handle('backup:restore', backupManager.restore)
|
||||
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
|
||||
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
|
||||
|
||||
// file
|
||||
ipcMain.handle('file:open', fileManager.open)
|
||||
ipcMain.handle('file:save', fileManager.save)
|
||||
ipcMain.handle('file:select', fileManager.selectFile)
|
||||
@@ -53,7 +68,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle('file:write', fileManager.writeFile)
|
||||
ipcMain.handle('file:saveImage', fileManager.saveImage)
|
||||
ipcMain.handle('file:base64Image', fileManager.base64Image)
|
||||
ipcMain.handle('file:download', fileManager.downloadFile)
|
||||
ipcMain.handle('file:copy', fileManager.copyFile)
|
||||
|
||||
// minapp
|
||||
ipcMain.handle('minapp', (_, args) => {
|
||||
createMinappWindow({
|
||||
url: args.url,
|
||||
@@ -65,17 +83,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => {
|
||||
appConfig.set('theme', theme)
|
||||
mainWindow?.setTitleBarOverlay &&
|
||||
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
})
|
||||
|
||||
// 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法)
|
||||
ipcMain.handle('check-for-update', async () => {
|
||||
return {
|
||||
currentVersion: autoUpdater.currentVersion,
|
||||
update: await autoUpdater.checkForUpdates()
|
||||
}
|
||||
})
|
||||
// export
|
||||
ipcMain.handle('export:word', exportService.exportToWord)
|
||||
}
|
||||
|
||||
19
src/main/services/ConfigManager.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Store from 'electron-store'
|
||||
|
||||
export class ConfigManager {
|
||||
private store: Store
|
||||
|
||||
constructor() {
|
||||
this.store = new Store()
|
||||
}
|
||||
|
||||
getTheme(): 'light' | 'dark' {
|
||||
return this.store.get('theme', 'light') as 'light' | 'dark'
|
||||
}
|
||||
|
||||
setTheme(theme: 'light' | 'dark') {
|
||||
this.store.set('theme', theme)
|
||||
}
|
||||
}
|
||||
|
||||
export const configManager = new ConfigManager()
|
||||
222
src/main/services/ExportService.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
// ExportService
|
||||
|
||||
import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, ShadingType, TextRun } from 'docx'
|
||||
import { dialog } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
import FileStorage from './FileStorage'
|
||||
|
||||
export class ExportService {
|
||||
private fileManager: FileStorage
|
||||
private md: MarkdownIt
|
||||
|
||||
constructor(fileManager: FileStorage) {
|
||||
this.fileManager = fileManager
|
||||
this.md = new MarkdownIt()
|
||||
}
|
||||
|
||||
private convertMarkdownToDocxElements(markdown: string) {
|
||||
const tokens = this.md.parse(markdown, {})
|
||||
const elements: any[] = []
|
||||
let listLevel = 0
|
||||
|
||||
const processInlineTokens = (tokens: any[]): TextRun[] => {
|
||||
const runs: TextRun[] = []
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
runs.push(new TextRun(token.content))
|
||||
break
|
||||
case 'strong':
|
||||
runs.push(new TextRun({ text: token.content, bold: true }))
|
||||
break
|
||||
case 'em':
|
||||
runs.push(new TextRun({ text: token.content, italics: true }))
|
||||
break
|
||||
case 'code_inline':
|
||||
runs.push(new TextRun({ text: token.content, font: 'Consolas', size: 20 }))
|
||||
break
|
||||
}
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]
|
||||
|
||||
switch (token.type) {
|
||||
case 'heading_open':
|
||||
// 获取标题级别 (h1 -> h6)
|
||||
const level = parseInt(token.tag.slice(1)) as 1 | 2 | 3 | 4 | 5 | 6
|
||||
const headingText = tokens[i + 1].content
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
text: headingText,
|
||||
heading: HeadingLevel[`HEADING_${level}`],
|
||||
spacing: {
|
||||
before: 240,
|
||||
after: 120
|
||||
}
|
||||
})
|
||||
)
|
||||
i += 2 // 跳过内容标记和闭合标记
|
||||
break
|
||||
|
||||
case 'paragraph_open':
|
||||
const inlineTokens = tokens[i + 1].children || []
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: processInlineTokens(inlineTokens),
|
||||
spacing: {
|
||||
before: 120,
|
||||
after: 120
|
||||
}
|
||||
})
|
||||
)
|
||||
i += 2
|
||||
break
|
||||
|
||||
case 'bullet_list_open':
|
||||
listLevel++
|
||||
break
|
||||
|
||||
case 'bullet_list_close':
|
||||
listLevel--
|
||||
break
|
||||
|
||||
case 'list_item_open':
|
||||
const itemInlineTokens = tokens[i + 2].children || []
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: '•', bold: true }),
|
||||
new TextRun({ text: '\t' }),
|
||||
...processInlineTokens(itemInlineTokens)
|
||||
],
|
||||
indent: {
|
||||
left: listLevel * 720
|
||||
}
|
||||
})
|
||||
)
|
||||
i += 3
|
||||
break
|
||||
|
||||
case 'fence': // 代码块
|
||||
const codeLines = token.content.split('\n')
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: codeLines.map(
|
||||
(line) =>
|
||||
new TextRun({
|
||||
text: line + '\n',
|
||||
font: 'Consolas',
|
||||
size: 20,
|
||||
break: 1
|
||||
})
|
||||
),
|
||||
shading: {
|
||||
type: ShadingType.SOLID,
|
||||
color: 'F5F5F5'
|
||||
},
|
||||
spacing: {
|
||||
before: 120,
|
||||
after: 120
|
||||
},
|
||||
border: {
|
||||
top: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
|
||||
left: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
|
||||
right: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' }
|
||||
}
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
case 'hr':
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: '─'.repeat(50), color: '999999' })],
|
||||
alignment: AlignmentType.CENTER
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
case 'blockquote_open':
|
||||
const quoteText = tokens[i + 2].content
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: quoteText,
|
||||
italics: true
|
||||
})
|
||||
],
|
||||
indent: {
|
||||
left: 720
|
||||
},
|
||||
border: {
|
||||
left: {
|
||||
style: BorderStyle.SINGLE,
|
||||
size: 3,
|
||||
color: 'CCCCCC'
|
||||
}
|
||||
},
|
||||
spacing: {
|
||||
before: 120,
|
||||
after: 120
|
||||
}
|
||||
})
|
||||
)
|
||||
i += 3
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
public exportToWord = async (_: Electron.IpcMainInvokeEvent, markdown: string, fileName: string): Promise<void> => {
|
||||
try {
|
||||
const elements = this.convertMarkdownToDocxElements(markdown)
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
paragraphStyles: [
|
||||
{
|
||||
id: 'Normal',
|
||||
name: 'Normal',
|
||||
run: {
|
||||
size: 24,
|
||||
font: 'Arial'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
properties: {},
|
||||
children: elements
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const buffer = await Packer.toBuffer(doc)
|
||||
|
||||
const filePath = dialog.showSaveDialogSync({
|
||||
title: '保存文件',
|
||||
filters: [{ name: 'Word Document', extensions: ['docx'] }],
|
||||
defaultPath: fileName
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
await this.fileManager.writeFile(_, filePath, buffer)
|
||||
Logger.info('[ExportService] Document exported successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[ExportService] Export to Word failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { documentExts } from '@main/constant'
|
||||
import { documentExts, imageExts } from '@main/constant'
|
||||
import { getFileType } from '@main/utils/file'
|
||||
import { FileType } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
@@ -19,7 +19,7 @@ import * as path from 'path'
|
||||
import { chdir } from 'process'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
class FileManager {
|
||||
class FileStorage {
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
private tempDir = path.join(app.getPath('temp'), 'CherryStudio')
|
||||
|
||||
@@ -119,6 +119,31 @@ class FileManager {
|
||||
return Promise.all(fileMetadataPromises)
|
||||
}
|
||||
|
||||
private async compressImage(sourcePath: string, destPath: string): Promise<void> {
|
||||
try {
|
||||
const stats = fs.statSync(sourcePath)
|
||||
const fileSizeInMB = stats.size / (1024 * 1024)
|
||||
|
||||
// 如果图片大于1MB才进行压缩
|
||||
if (fileSizeInMB > 1) {
|
||||
try {
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
logger.info('[FileStorage] Image compressed successfully:', sourcePath)
|
||||
} catch (jimpError) {
|
||||
logger.error('[FileStorage] Image compression failed:', jimpError)
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
}
|
||||
} else {
|
||||
// 小图片直接复制
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Image handling failed:', error)
|
||||
// 错误情况下直接复制原文件
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
}
|
||||
}
|
||||
|
||||
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileType): Promise<FileType> => {
|
||||
const duplicateFile = await this.findDuplicateFile(file.path)
|
||||
|
||||
@@ -128,10 +153,18 @@ class FileManager {
|
||||
|
||||
const uuid = uuidv4()
|
||||
const origin_name = path.basename(file.path)
|
||||
const ext = path.extname(origin_name)
|
||||
const ext = path.extname(origin_name).toLowerCase()
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
await fs.promises.copyFile(file.path, destPath)
|
||||
logger.info('[FileStorage] Uploading file:', file.path)
|
||||
|
||||
// 根据文件类型选择处理方式
|
||||
if (imageExts.includes(ext)) {
|
||||
await this.compressImage(file.path, destPath)
|
||||
} else {
|
||||
await fs.promises.copyFile(file.path, destPath)
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
@@ -265,7 +298,7 @@ class FileManager {
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<void> => {
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
@@ -276,8 +309,11 @@ class FileManager {
|
||||
if (!result.canceled && result.filePath) {
|
||||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||||
}
|
||||
|
||||
return result.filePath
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +351,105 @@ class FileManager {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise<FileType> => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
// 尝试从Content-Disposition获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
let filename = 'download'
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 如果URL中有文件名,使用URL中的文件名
|
||||
const urlFilename = url.split('/').pop()
|
||||
if (urlFilename && urlFilename.includes('.')) {
|
||||
filename = urlFilename
|
||||
}
|
||||
|
||||
// 如果文件名没有后缀,根据Content-Type添加后缀
|
||||
if (!filename.includes('.')) {
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
const ext = this.getExtensionFromMimeType(contentType)
|
||||
filename += ext
|
||||
}
|
||||
|
||||
const uuid = uuidv4()
|
||||
const ext = path.extname(filename)
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
// 将响应内容写入文件
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
await fs.promises.writeFile(destPath, buffer)
|
||||
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
id: uuid,
|
||||
origin_name: filename,
|
||||
name: uuid + ext,
|
||||
path: destPath,
|
||||
created_at: stats.birthtime,
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: fileType,
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Download file error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private getExtensionFromMimeType(mimeType: string | null): string {
|
||||
if (!mimeType) return '.bin'
|
||||
|
||||
const mimeToExtension: { [key: string]: string } = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'application/pdf': '.pdf',
|
||||
'text/plain': '.txt',
|
||||
'application/msword': '.doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
||||
'application/zip': '.zip',
|
||||
'application/x-zip-compressed': '.zip',
|
||||
'application/octet-stream': '.bin'
|
||||
}
|
||||
|
||||
return mimeToExtension[mimeType] || '.bin'
|
||||
}
|
||||
|
||||
public copyFile = async (_: Electron.IpcMainInvokeEvent, id: string, destPath: string): Promise<void> => {
|
||||
try {
|
||||
const sourcePath = path.join(this.storageDir, id)
|
||||
|
||||
// 确保目标目录存在
|
||||
const destDir = path.dirname(destPath)
|
||||
if (!fs.existsSync(destDir)) {
|
||||
await fs.promises.mkdir(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 复制文件
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
logger.info('[FileStorage] File copied successfully:', { from: sourcePath, to: destPath })
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Copy file failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FileManager
|
||||
export default FileStorage
|
||||
53
src/main/services/ShortcutService.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BrowserWindow, globalShortcut } from 'electron'
|
||||
|
||||
export function registerZoomShortcut(mainWindow: BrowserWindow) {
|
||||
const registerShortcuts = () => {
|
||||
// 注册放大快捷键 (Ctrl+Plus 或 Cmd+Plus)
|
||||
globalShortcut.register('CommandOrControl+=', () => {
|
||||
if (mainWindow) {
|
||||
const currentZoom = mainWindow.webContents.getZoomFactor()
|
||||
const newZoom = currentZoom + 0.1
|
||||
// Prevent zoom factor from exceeding reasonable limits
|
||||
if (newZoom <= 5.0) {
|
||||
mainWindow.webContents.setZoomFactor(newZoom)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 注册缩小快捷键 (Ctrl+Minus 或 Cmd+Minus)
|
||||
globalShortcut.register('CommandOrControl+-', () => {
|
||||
if (mainWindow) {
|
||||
const currentZoom = mainWindow.webContents.getZoomFactor()
|
||||
const newZoom = currentZoom - 0.1
|
||||
// Prevent zoom factor from going below 0.1
|
||||
if (newZoom >= 0.1) {
|
||||
mainWindow.webContents.setZoomFactor(newZoom)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 注册重置缩放快捷键 (Ctrl+0 或 Cmd+0)
|
||||
globalShortcut.register('CommandOrControl+0', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.setZoomFactor(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unregisterShortcuts = () => {
|
||||
globalShortcut.unregister('CommandOrControl+=')
|
||||
globalShortcut.unregister('CommandOrControl+-')
|
||||
globalShortcut.unregister('CommandOrControl+0')
|
||||
}
|
||||
|
||||
// 当窗口获得焦点时注册快捷键
|
||||
mainWindow.on('focus', registerShortcuts)
|
||||
|
||||
// 当窗口失去焦点时注销快捷键
|
||||
mainWindow.on('blur', unregisterShortcuts)
|
||||
|
||||
// 初始注册(如果窗口已经处于焦点状态)
|
||||
if (mainWindow.isFocused()) {
|
||||
registerShortcuts()
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { BrowserWindow, globalShortcut } from 'electron'
|
||||
|
||||
export function registerZoomShortcut(mainWindow: BrowserWindow) {
|
||||
// 注册放大快捷键 (Ctrl+Plus 或 Cmd+Plus)
|
||||
globalShortcut.register('CommandOrControl+=', () => {
|
||||
if (mainWindow) {
|
||||
const currentZoom = mainWindow.webContents.getZoomFactor()
|
||||
mainWindow.webContents.setZoomFactor(currentZoom + 0.1)
|
||||
}
|
||||
})
|
||||
|
||||
// 注册缩小快捷键 (Ctrl+Minus 或 Cmd+Minus)
|
||||
globalShortcut.register('CommandOrControl+-', () => {
|
||||
if (mainWindow) {
|
||||
const currentZoom = mainWindow.webContents.getZoomFactor()
|
||||
mainWindow.webContents.setZoomFactor(currentZoom - 0.1)
|
||||
}
|
||||
})
|
||||
|
||||
// 注册重置缩放快捷键 (Ctrl+0 或 Cmd+0)
|
||||
globalShortcut.register('CommandOrControl+0', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.setZoomFactor(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { app } from 'electron'
|
||||
@@ -5,3 +6,11 @@ import { app } from 'electron'
|
||||
export function getResourcePath() {
|
||||
return path.join(app.getAppPath(), 'resources')
|
||||
}
|
||||
|
||||
export function getDataPath() {
|
||||
const dataPath = path.join(app.getPath('userData'), 'Data')
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true })
|
||||
}
|
||||
return dataPath
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import windowStateKeeper from 'electron-window-state'
|
||||
import { join } from 'path'
|
||||
|
||||
import icon from '../../build/icon.png?asset'
|
||||
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
|
||||
export function createMainWindow() {
|
||||
// Load the previous state with fallback to defaults
|
||||
@@ -13,7 +14,7 @@ export function createMainWindow() {
|
||||
defaultHeight: 670
|
||||
})
|
||||
|
||||
const theme = appConfig.get('theme') || 'light'
|
||||
const theme = configManager.getTheme()
|
||||
|
||||
// Create the browser window.
|
||||
const isMac = process.platform === 'darwin'
|
||||
|
||||
17
src/preload/index.d.ts
vendored
@@ -20,8 +20,10 @@ declare global {
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
|
||||
reload: () => void
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
decompress: (text: Buffer) => Promise<string>
|
||||
zip: {
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
decompress: (text: Buffer) => Promise<string>
|
||||
}
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string) => Promise<Readable>
|
||||
restore: (backupPath: string) => Promise<string>
|
||||
@@ -39,9 +41,18 @@ declare global {
|
||||
create: (fileName: string) => Promise<string>
|
||||
write: (filePath: string, data: Uint8Array | string) => Promise<void>
|
||||
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; filePath: string; content: Buffer } | null>
|
||||
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
|
||||
save: (
|
||||
path: string,
|
||||
content: string | NodeJS.ArrayBufferView,
|
||||
options?: SaveDialogOptions
|
||||
) => Promise<string | null>
|
||||
saveImage: (name: string, data: string) => void
|
||||
base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
|
||||
download: (url: string) => Promise<FileType | null>
|
||||
copy: (fileId: string, destPath: string) => Promise<void>
|
||||
}
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@ import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
getAppInfo: () => ipcRenderer.invoke('app:info'),
|
||||
checkForUpdate: () => ipcRenderer.invoke('check-for-update'),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke('open-website', url),
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
|
||||
reload: () => ipcRenderer.invoke('app:reload'),
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy),
|
||||
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||
reload: () => ipcRenderer.invoke('reload'),
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
|
||||
zip: {
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
|
||||
},
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string) =>
|
||||
ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
|
||||
@@ -35,7 +37,12 @@ const api = {
|
||||
ipcRenderer.invoke('file:save', path, content, options),
|
||||
selectFolder: () => ipcRenderer.invoke('file:selectFolder'),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId)
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
|
||||
download: (url: string) => ipcRenderer.invoke('file:download', url),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath)
|
||||
},
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HistoryPage from './pages/history/HistoryPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import PaintingsPage from './pages/paintings/PaintingsPage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
|
||||
@@ -30,6 +31,7 @@ function App(): JSX.Element {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings" element={<PaintingsPage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/messages/*" element={<HistoryPage />} />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.06667 4.73333C2.06667 3.26057 3.26057 2.06667 4.73333 2.06667H9V1H4.73333C2.67147 1 1 2.67147 1 4.73333V9H2.06667V4.73333ZM2.06667 15.2667C2.06667 16.7394 3.26057 17.9333 4.73333 17.9333H9V19H4.73333C2.67147 19 1 17.3285 1 15.2667V11H2.06667V15.2667ZM15.2667 2.06667C16.7394 2.06667 17.9333 3.26057 17.9333 4.73333V9H19V4.73333C19 2.67147 17.3285 1 15.2667 1H11V2.06667H15.2667ZM17.9333 15.2667C17.9333 16.7394 16.7394 17.9333 15.2667 17.9333H11V19H15.2667C17.3285 19 19 17.3285 19 15.2667V11H17.9333V15.2667Z" fill="#030712"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.93333 3.73333C4.93333 2.26057 5.978 1.06667 7.26667 1.06667H9V0H7.26667C5.46254 0 4 1.67147 4 3.73333V8H4.93333V3.73333ZM4.93333 16.2667C4.93333 17.7394 5.978 18.9333 7.26667 18.9333H9V20H7.26667C5.46254 20 4 18.3285 4 16.2667V12H4.93333V16.2667ZM13.7333 1.06667C15.022 1.06667 16.0667 2.26057 16.0667 3.73333V8H17V3.73333C17 1.67147 15.5375 0 13.7333 0H12V1.06667H13.7333ZM16.0667 16.2667C16.0667 17.7394 15.022 18.9333 13.7333 18.9333H12V20H13.7333C15.5375 20 17 18.3285 17 16.2667V12H16.0667V16.2667Z" fill="#030712"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.06667 7.26667C1.06667 5.978 2.26057 4.93333 3.73333 4.93333H8V4H3.73333C1.67147 4 0 5.46254 0 7.26667V9H1.06667V7.26667ZM1.06667 11.2667C1.06667 12.7394 2.26057 13.9333 3.73333 13.9333H8V15H3.73333C1.67147 15 0 13.3285 0 11.2667V10H1.06667V11.2667ZM16.2667 4.93333C17.7394 4.93333 18.9333 5.978 18.9333 7.26667V9H20V7.26667C20 5.46254 18.3285 4 16.2667 4H12V4.93333H16.2667ZM18.9333 11.2667C18.9333 12.7394 17.7394 13.9333 16.2667 13.9333H12V15H16.2667C18.3285 15 20 13.3285 20 11.2667V10H18.9333V11.2667Z" fill="#030712"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 679 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.06667 5.26667C1.06667 3.978 2.26057 2.93333 3.73333 2.93333H8V2H3.73333C1.67147 2 0 3.46254 0 5.26667V9H1.06667V5.26667ZM1.06667 14.7333C1.06667 16.022 2.26057 17.0667 3.73333 17.0667H8V18H3.73333C1.67147 18 0 16.5375 0 14.7333V11H1.06667V14.7333ZM16.2667 2.93333C17.7394 2.93333 18.9333 3.978 18.9333 5.26667V9H20V5.26667C20 3.46254 18.3285 2 16.2667 2H12V2.93333H16.2667ZM18.9333 14.7333C18.9333 16.022 17.7394 17.0667 16.2667 17.0667H12V18H16.2667C18.3285 18 20 16.5375 20 14.7333V11H18.9333V14.7333Z" fill="#030712"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.93333 3.73333C2.93333 2.26057 3.978 1.06667 5.26667 1.06667H9V0H5.26667C3.46254 0 2 1.67147 2 3.73333V8H2.93333V3.73333ZM2.93333 16.2667C2.93333 17.7394 3.978 18.9333 5.26667 18.9333H9V20H5.26667C3.46254 20 2 18.3285 2 16.2667V12H2.93333V16.2667ZM14.7333 1.06667C16.022 1.06667 17.0667 2.26057 17.0667 3.73333V8H18V3.73333C18 1.67147 16.5375 0 14.7333 0H11V1.06667H14.7333ZM17.0667 16.2667C17.0667 17.7394 16.022 18.9333 14.7333 18.9333H11V20H14.7333C16.5375 20 18 18.3285 18 16.2667V12H17.0667V16.2667Z" fill="#030712"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.93333 3.73333C5.93333 2.26057 6.978 1.06667 8.26667 1.06667H10V0H8.26667C6.46254 0 5 1.67147 5 3.73333V8H5.93333V3.73333ZM5.93333 16.2667C5.93333 17.7394 6.978 18.9333 8.26667 18.9333H10V20H8.26667C6.46254 20 5 18.3285 5 16.2667V12H5.93333V16.2667ZM12.7333 1.06667C14.022 1.06667 15.0667 2.26057 15.0667 3.73333V8H16V3.73333C16 1.67147 14.5375 0 12.7333 0H11V1.06667H12.7333ZM15.0667 16.2667C15.0667 17.7394 14.022 18.9333 12.7333 18.9333H11V20H12.7333C14.5375 20 16 18.3285 16 16.2667V12H15.0667V16.2667Z" fill="#030712"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 679 B |
BIN
src/renderer/src/assets/images/providers/hyperbolic.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/renderer/src/assets/images/providers/infini-ai.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/renderer/src/assets/images/providers/lepton.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/renderer/src/assets/images/providers/mixedbread.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
src/renderer/src/assets/images/providers/volcengine.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1,7 +1,11 @@
|
||||
#inputbar .ant-input {
|
||||
#inputbar {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.ant-image-preview-switch-left {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.ant-btn:not(:disabled):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff24;
|
||||
--color-border-soft: #ffffff20;
|
||||
--color-border-mute: #ffffff11;
|
||||
--color-error: #f44336;
|
||||
--color-link: #1677ff;
|
||||
--color-code-background: #323232;
|
||||
@@ -51,6 +52,11 @@
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
|
||||
--chat-background: #111111;
|
||||
--chat-background-user: #28b561;
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-text-user: var(--color-black);
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
@@ -83,6 +89,7 @@ body[theme-mode='light'] {
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000028;
|
||||
--color-border-soft: #00000028;
|
||||
--color-border-mute: #00000011;
|
||||
--color-error: #f44336;
|
||||
--color-link: #1677ff;
|
||||
--color-code-background: #e3e3e3;
|
||||
@@ -91,6 +98,11 @@ body[theme-mode='light'] {
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.6);
|
||||
--navbar-background: rgba(255, 255, 255);
|
||||
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-text-user: var(--color-text);
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -156,7 +168,6 @@ body,
|
||||
body[os='mac'] {
|
||||
#content-container {
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
box-shadow: 0 0 15px 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
}
|
||||
|
||||
span {
|
||||
word-break: break-all;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -107,6 +107,8 @@
|
||||
background: var(--color-background-mute);
|
||||
padding: 3px 5px;
|
||||
border-radius: 5px;
|
||||
word-break: keep-all;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
pre {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { TopView } from '@renderer/components/TopView'
|
||||
import systemAgents from '@renderer/config/agents.json'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { createAssistantFromAgent } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Agent, Assistant } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
||||
|
||||
@@ -3,7 +3,7 @@ import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { getModelLogo, isVisionModel } from '@renderer/config/models'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/model'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
|
||||
import { first, reverse, sortBy } from 'lodash'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import ImageStorage from '@renderer/services/storage'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setAvatar } from '@renderer/store/runtime'
|
||||
import { setUserName } from '@renderer/store/settings'
|
||||
|
||||
@@ -39,8 +39,6 @@ const Scrollbar: FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||
)
|
||||
})
|
||||
|
||||
Scrollbar.displayName = 'Scrollbar'
|
||||
|
||||
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@@ -54,4 +52,6 @@ const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
Scrollbar.displayName = 'Scrollbar'
|
||||
|
||||
export default Scrollbar
|
||||
|
||||
70
src/renderer/src/components/TranslateButton.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { TranslationOutlined } from '@ant-design/icons'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
import { getUserMessage } from '@renderer/services/MessagesService'
|
||||
import { Button } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
text?: string
|
||||
onTranslated: (translatedText: string) => void
|
||||
disabled?: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style }) => {
|
||||
const { t } = useTranslation()
|
||||
const { translateModel } = useDefaultModel()
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!text?.trim()) return
|
||||
|
||||
if (!translateModel) {
|
||||
window.message.error({
|
||||
content: t('translate.error.not_configured'),
|
||||
key: 'translate-message'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 先复制原文到剪贴板
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
setIsTranslating(true)
|
||||
try {
|
||||
const assistant = getDefaultTranslateAssistant('english', text)
|
||||
const message = getUserMessage({
|
||||
assistant,
|
||||
topic: getDefaultTopic('default'),
|
||||
type: 'text'
|
||||
})
|
||||
|
||||
const translatedText = await fetchTranslate({ message, assistant })
|
||||
onTranslated(translatedText)
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
window.message.error({
|
||||
content: t('translate.error.failed'),
|
||||
key: 'translate-message'
|
||||
})
|
||||
} finally {
|
||||
setIsTranslating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<TranslationOutlined style={{ fontSize: 14 }} />}
|
||||
onClick={handleTranslate}
|
||||
disabled={disabled || isTranslating}
|
||||
loading={isTranslating}
|
||||
style={style}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default TranslateButton
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FileSearchOutlined, FolderOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import { FileSearchOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { isLocalAi, UserAvatar } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -21,6 +22,7 @@ const Sidebar: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { windowStyle } = useSettings()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
const isRoute = (path: string): string => (pathname === path ? 'active' : '')
|
||||
const isRoutes = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
|
||||
@@ -58,6 +60,11 @@ const Sidebar: FC = () => {
|
||||
<i className="iconfont icon-business-smart-assistant" />
|
||||
</Icon>
|
||||
</StyledLink>
|
||||
<StyledLink onClick={() => to('/paintings')}>
|
||||
<Icon className={isRoute('/paintings')}>
|
||||
<PictureOutlined style={{ fontSize: 16 }} />
|
||||
</Icon>
|
||||
</StyledLink>
|
||||
<StyledLink onClick={() => to('/translate')}>
|
||||
<Icon className={isRoute('/translate')}>
|
||||
<TranslationOutlined />
|
||||
@@ -81,6 +88,14 @@ const Sidebar: FC = () => {
|
||||
</Menus>
|
||||
</MainMenus>
|
||||
<Menus onClick={MinApp.onClose}>
|
||||
<Icon onClick={() => toggleTheme()}>
|
||||
{theme === 'dark' ? (
|
||||
<i className="iconfont icon-theme icon-dark1" />
|
||||
) : (
|
||||
<i className="iconfont icon-theme icon-theme-light" />
|
||||
)}
|
||||
</Icon>
|
||||
|
||||
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
|
||||
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
|
||||
<i className="iconfont icon-setting" />
|
||||
|
||||
@@ -8,7 +8,7 @@ export const isMac = platform === 'darwin'
|
||||
export const isWindows = platform === 'win32' || platform === 'win64'
|
||||
export const isLinux = platform === 'linux'
|
||||
|
||||
export const imageExts = ['.jpg', '.png', '.jpeg']
|
||||
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const textExts = [
|
||||
'.txt', // 普通文本文件
|
||||
|
||||
@@ -930,6 +930,51 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
]
|
||||
}
|
||||
|
||||
export const TEXT_TO_IMAGES_MODELS = [
|
||||
{
|
||||
id: 'black-forest-labs/FLUX.1-dev',
|
||||
provider: 'silicon',
|
||||
name: 'FLUX.1-dev',
|
||||
group: 'FLUX'
|
||||
},
|
||||
{
|
||||
id: 'black-forest-labs/FLUX.1-schnell',
|
||||
provider: 'silicon',
|
||||
name: 'FLUX.1-schnell',
|
||||
group: 'FLUX'
|
||||
},
|
||||
{
|
||||
id: 'Pro/black-forest-labs/FLUX.1-schnell',
|
||||
provider: 'silicon',
|
||||
name: 'FLUX.1-schnell Pro',
|
||||
group: 'FLUX'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-3-5-large',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion 3.5 Large',
|
||||
group: 'Stable Diffusion'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-3-medium',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion 3 Medium',
|
||||
group: 'Stable Diffusion'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-2-1',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion 2.1',
|
||||
group: 'Stable Diffusion'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-xl-base-1.0',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion XL Base 1.0',
|
||||
group: 'Stable Diffusion'
|
||||
}
|
||||
]
|
||||
|
||||
export function isTextToImageModel(model: Model): boolean {
|
||||
return TEXT_TO_IMAGE_REGEX.test(model.id)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDefaultTopic } from '@renderer/services/assistant'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addAssistant,
|
||||
|
||||
42
src/renderer/src/hooks/usePaintings.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addPainting, removePainting, updatePainting, updatePaintings } from '@renderer/store/paintings'
|
||||
import { Painting } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
|
||||
export function usePaintings() {
|
||||
const paintings = useAppSelector((state) => state.paintings.paintings)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
paintings,
|
||||
addPainting: () => {
|
||||
const newPainting: Painting = {
|
||||
id: uuid(),
|
||||
urls: [],
|
||||
files: [],
|
||||
prompt: '',
|
||||
negativePrompt: '',
|
||||
imageSize: '1024x1024',
|
||||
numImages: 1,
|
||||
seed: '',
|
||||
steps: 25,
|
||||
guidanceScale: 4.5,
|
||||
model: TEXT_TO_IMAGES_MODELS[0].id
|
||||
}
|
||||
dispatch(addPainting(newPainting))
|
||||
return newPainting
|
||||
},
|
||||
removePainting: async (painting: Painting) => {
|
||||
FileManager.deleteFiles(painting.files)
|
||||
dispatch(removePainting(painting))
|
||||
},
|
||||
updatePainting: (painting: Painting) => {
|
||||
dispatch(updatePainting(painting))
|
||||
},
|
||||
updatePaintings: (paintings: Painting[]) => {
|
||||
dispatch(updatePaintings(paintings))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,12 @@ export function useSettings() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useMessageStyle() {
|
||||
const { messageStyle } = useSettings()
|
||||
const isBubbleStyle = messageStyle === 'bubble'
|
||||
|
||||
return {
|
||||
isBubbleStyle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import db from '@renderer/databases'
|
||||
import { deleteMessageFiles } from '@renderer/services/messages'
|
||||
import { deleteMessageFiles } from '@renderer/services/MessagesService'
|
||||
import store from '@renderer/store'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { find } from 'lodash'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import enUS from './en-us.json'
|
||||
import zhCN from './zh-cn.json'
|
||||
import zhTW from './zh-tw.json'
|
||||
import enUS from './locales/en-us.json'
|
||||
import zhCN from './locales/zh-cn.json'
|
||||
import zhTW from './locales/zh-tw.json'
|
||||
|
||||
const resources = {
|
||||
'en-US': enUS,
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"back": "Back",
|
||||
"chat": "Chat",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"download": "Download"
|
||||
},
|
||||
"button": {
|
||||
"add": "Add",
|
||||
@@ -63,7 +64,10 @@
|
||||
"upgrade.success.content": "Please restart the application to complete the upgrade",
|
||||
"upgrade.success.button": "Restart",
|
||||
"topic.added": "New topic added",
|
||||
"save.success.title": "Saved successfully"
|
||||
"save.success.title": "Saved successfully",
|
||||
"message.style": "Message Style",
|
||||
"message.style.bubble": "Bubble",
|
||||
"message.style.plain": "Plain"
|
||||
},
|
||||
"chat": {
|
||||
"save": "Save",
|
||||
@@ -80,6 +84,7 @@
|
||||
"topics.export.title": "Export",
|
||||
"topics.export.image": "Export as image",
|
||||
"topics.export.md": "Export as markdown",
|
||||
"topics.export.word": "Export as Word",
|
||||
"input.new_topic": "New Topic",
|
||||
"input.topics": " Topics ",
|
||||
"input.clear": "Clear",
|
||||
@@ -137,13 +142,38 @@
|
||||
"stream_output": "Stream Output",
|
||||
"search": "Search models..."
|
||||
},
|
||||
"images": {
|
||||
"title": "Images",
|
||||
"image.size": "Image Size",
|
||||
"button.new.image": "New Image",
|
||||
"button.delete.image": "Delete Image",
|
||||
"button.delete.image.confirm": "Are you sure you want to delete this image?",
|
||||
"number_images": "Number Images",
|
||||
"number_images_tip": "Number of images to generate (1-4)",
|
||||
"seed": "Seed",
|
||||
"seed_tip": "The same seed and prompt can produce similar images",
|
||||
"inference_steps": "Inference Steps",
|
||||
"inference_steps_tip": "The number of inference steps to perform. More steps produce higher quality but take longer",
|
||||
"guidance_scale": "Guidance Scale",
|
||||
"guidance_scale_tip": "Classifier Free Guidance. How close you want the model to stick to your prompt when looking for a related image to show you",
|
||||
"negative_prompt": "Negative Prompt",
|
||||
"negative_prompt_tip": "Describe what you don't want included in the image",
|
||||
"prompt_placeholder": "Describe the image you want to create, e.g. A serene lake at sunset with mountains in the background",
|
||||
"regenerate.confirm": "This will replace your existing generated images. Do you want to continue?"
|
||||
},
|
||||
"files": {
|
||||
"title": "Files",
|
||||
"file": "File",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"count": "Count",
|
||||
"created_at": "Created At"
|
||||
"created_at": "Created At",
|
||||
"image": "Image",
|
||||
"text": "Text",
|
||||
"document": "Document",
|
||||
"actions": "Actions",
|
||||
"open": "Open",
|
||||
"all": "All Files"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents",
|
||||
@@ -170,7 +200,8 @@
|
||||
"edit.message.empty.content": "Conversation input content cannot be empty",
|
||||
"edit.model.select.title": "Select Model",
|
||||
"edit.settings.hide_preset_messages": "Hide Preset Message",
|
||||
"search.no_results": "No results found"
|
||||
"search.no_results": "No results found",
|
||||
"sorting.title": "Sorting"
|
||||
},
|
||||
"minapp": {
|
||||
"title": "MinApp"
|
||||
@@ -326,7 +357,8 @@
|
||||
"button.translate": "Translate",
|
||||
"error.not_configured": "Translation model is not configured",
|
||||
"input.placeholder": "Enter text to translate",
|
||||
"output.placeholder": "Translation"
|
||||
"output.placeholder": "Translation",
|
||||
"confirm": "Original text has been copied to clipboard. Do you want to replace it with the translated text?"
|
||||
},
|
||||
"languages": {
|
||||
"english": "English",
|
||||
@@ -349,11 +381,23 @@
|
||||
},
|
||||
"error": {
|
||||
"chat.response": "Something went wrong. Please check if you have set your API key in the Settings > Providers",
|
||||
"backup.file_format": "Backup file format error"
|
||||
"backup.file_format": "Backup file format error",
|
||||
"provider_disabled": "Model provider is not enabled",
|
||||
"no_api_key": "API key is not configured"
|
||||
},
|
||||
"words": {
|
||||
"knowledgeGraph": "Knowledge Graph",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"export": {
|
||||
"attached_files": "Attached Files",
|
||||
"user": "User",
|
||||
"assistant": "Assistant",
|
||||
"created": "Created",
|
||||
"last_updated": "Last Updated",
|
||||
"messages": "Messages",
|
||||
"conversation_details": "Conversation Details",
|
||||
"conversation_history": "Conversation History"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
"back": "返回",
|
||||
"chat": "聊天",
|
||||
"close": "关闭",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"download": "下载"
|
||||
},
|
||||
"button": {
|
||||
"add": "添加",
|
||||
@@ -60,10 +61,13 @@
|
||||
"reset.double.confirm.title": "数据丢失!!!",
|
||||
"reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?",
|
||||
"upgrade.success.title": "升级成功",
|
||||
"upgrade.success.content": "重启应用以完成升级",
|
||||
"upgrade.success.content": "重启用以完成升级",
|
||||
"upgrade.success.button": "重启",
|
||||
"topic.added": "话题添加成功",
|
||||
"save.success.title": "保存成功"
|
||||
"save.success.title": "保存成功",
|
||||
"message.style": "消息样式",
|
||||
"message.style.bubble": "气泡",
|
||||
"message.style.plain": "简洁"
|
||||
},
|
||||
"chat": {
|
||||
"save": "保存",
|
||||
@@ -80,6 +84,7 @@
|
||||
"topics.export.title": "导出",
|
||||
"topics.export.image": "导出为图片",
|
||||
"topics.export.md": "导出为 Markdown",
|
||||
"topics.export.word": "导出为 Word",
|
||||
"input.new_topic": "新话题",
|
||||
"input.topics": " 话题 ",
|
||||
"input.clear": "清空消息",
|
||||
@@ -137,13 +142,38 @@
|
||||
"stream_output": "流式输出",
|
||||
"search": "搜索模型..."
|
||||
},
|
||||
"images": {
|
||||
"title": "图片",
|
||||
"image.size": "图片尺寸",
|
||||
"button.new.image": "新建图片",
|
||||
"button.delete.image": "删除图片",
|
||||
"button.delete.image.confirm": "确定要删除此图片吗?",
|
||||
"number_images": "生成数量",
|
||||
"number_images_tip": "一次生成的图片数量 (1-4)",
|
||||
"seed": "随机种子",
|
||||
"seed_tip": "相同的种子和提示词可以生成相似的图片",
|
||||
"inference_steps": "推理步数",
|
||||
"inference_steps_tip": "要执行的推理步数。步数越多,质量越高但耗时越长",
|
||||
"guidance_scale": "引导比例",
|
||||
"guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度",
|
||||
"negative_prompt": "反向提示词",
|
||||
"negative_prompt_tip": "描述你不想在图片中出现的内容",
|
||||
"prompt_placeholder": "描述你想创建的图片,例如:一个宁静的湖泊,夕阳西下,远处是群山",
|
||||
"regenerate.confirm": "这将覆盖已生成的图片,是否继续?"
|
||||
},
|
||||
"files": {
|
||||
"title": "文件",
|
||||
"file": "文件",
|
||||
"name": "文件名",
|
||||
"size": "大小",
|
||||
"count": "文件数",
|
||||
"created_at": "创建时间"
|
||||
"created_at": "创建时间",
|
||||
"image": "图片",
|
||||
"text": "文本",
|
||||
"document": "文档",
|
||||
"actions": "操作",
|
||||
"open": "打开",
|
||||
"all": "所有文件"
|
||||
},
|
||||
"agents": {
|
||||
"title": "智能体",
|
||||
@@ -170,7 +200,8 @@
|
||||
"edit.message.empty.content": "会话输入内容不能为空",
|
||||
"edit.model.select.title": "选择模型",
|
||||
"edit.settings.hide_preset_messages": "隐藏预设消息",
|
||||
"search.no_results": "没有找到相关智能体"
|
||||
"search.no_results": "没有找到相关智能体",
|
||||
"sorting.title": "排序"
|
||||
},
|
||||
"minapp": {
|
||||
"title": "小程序"
|
||||
@@ -260,7 +291,7 @@
|
||||
"provider.search_placeholder": "搜索模型 ID 或名称",
|
||||
"provider.api.url.reset": "重置",
|
||||
"provider.api.url.preview": "预览: {{url}}",
|
||||
"provider.api.url.tip": "/结尾忽略v1版本,#结尾强制使用输入地址",
|
||||
"provider.api.url.tip": "/结尾忽略v1版本,#结尾制使用输入地址",
|
||||
"models.default_assistant_model": "默认助手模型",
|
||||
"models.topic_naming_model": "话题命名模型",
|
||||
"models.translate_model": "翻译模型",
|
||||
@@ -317,7 +348,7 @@
|
||||
"new_topic": "新建话题",
|
||||
"zoom_in": "放大界面",
|
||||
"zoom_out": "缩小界面",
|
||||
"zoom_reset": "重置缩放"
|
||||
"zoom_reset": "置缩放"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
@@ -326,7 +357,8 @@
|
||||
"button.translate": "翻译",
|
||||
"error.not_configured": "翻译模型未配置",
|
||||
"input.placeholder": "输入文本进行翻译",
|
||||
"output.placeholder": "翻译"
|
||||
"output.placeholder": "翻译",
|
||||
"confirm": "原文已复制到剪贴板,是否用翻译后的文本替换?"
|
||||
},
|
||||
"languages": {
|
||||
"english": "英文",
|
||||
@@ -349,11 +381,23 @@
|
||||
},
|
||||
"error": {
|
||||
"chat.response": "出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥",
|
||||
"backup.file_format": "备份文件格式错误"
|
||||
"backup.file_format": "备份文件格式错误",
|
||||
"provider_disabled": "模型提供商未启用",
|
||||
"no_api_key": "API 密钥未配置"
|
||||
},
|
||||
"words": {
|
||||
"knowledgeGraph": "知识图谱",
|
||||
"visualization": "可视化"
|
||||
},
|
||||
"export": {
|
||||
"attached_files": "附件",
|
||||
"user": "用户",
|
||||
"assistant": "助手",
|
||||
"created": "创建时间",
|
||||
"last_updated": "最后更新",
|
||||
"messages": "消息数",
|
||||
"conversation_details": "会话详情",
|
||||
"conversation_history": "会话历史"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
"back": "返回",
|
||||
"chat": "聊天",
|
||||
"close": "關閉",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"download": "下載"
|
||||
},
|
||||
"button": {
|
||||
"add": "添加",
|
||||
@@ -63,7 +64,10 @@
|
||||
"upgrade.success.content": "請重新啟動應用以完成升級",
|
||||
"upgrade.success.button": "重新啟動",
|
||||
"topic.added": "新話題已添加",
|
||||
"save.success.title": "保存成功"
|
||||
"save.success.title": "保存成功",
|
||||
"message.style": "消息樣式",
|
||||
"message.style.bubble": "氣泡",
|
||||
"message.style.plain": "簡潔"
|
||||
},
|
||||
"chat": {
|
||||
"save": "保存",
|
||||
@@ -80,6 +84,7 @@
|
||||
"topics.export.title": "匯出",
|
||||
"topics.export.image": "匯出為圖片",
|
||||
"topics.export.md": "匯出為 Markdown",
|
||||
"topics.export.word": "導出為 Word",
|
||||
"input.new_topic": "新話題",
|
||||
"input.topics": " 話題 ",
|
||||
"input.clear": "清除",
|
||||
@@ -137,13 +142,38 @@
|
||||
"stream_output": "串流輸出",
|
||||
"search": "搜尋模型..."
|
||||
},
|
||||
"images": {
|
||||
"title": "繪圖",
|
||||
"image.size": "影像尺寸",
|
||||
"button.new.image": "新繪圖",
|
||||
"button.delete.image": "刪除繪圖",
|
||||
"button.delete.image.confirm": "確定要刪除此繪圖嗎?",
|
||||
"number_images": "生成數量",
|
||||
"number_images_tip": "一次生成的圖片數量 (1-4)",
|
||||
"seed": "隨機種子",
|
||||
"seed_tip": "相同的種子和提示詞可以生成相似的圖片",
|
||||
"inference_steps": "推理步數",
|
||||
"inference_steps_tip": "要執行的推理步數。步數越多,質量越高但耗時越長",
|
||||
"guidance_scale": "引導比例",
|
||||
"guidance_scale_tip": "無分類器指導。控制模型在尋找相關圖像時對提示詞的遵循程度",
|
||||
"negative_prompt": "反向提示詞",
|
||||
"negative_prompt_tip": "描述你不想在圖片中出現的內容",
|
||||
"prompt_placeholder": "描述你想創建的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山",
|
||||
"regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?"
|
||||
},
|
||||
"files": {
|
||||
"title": "檔案",
|
||||
"file": "檔案",
|
||||
"name": "名稱",
|
||||
"size": "大小",
|
||||
"count": "數量",
|
||||
"created_at": "建立時間"
|
||||
"created_at": "建立時間",
|
||||
"image": "圖片",
|
||||
"text": "文本",
|
||||
"document": "文檔",
|
||||
"actions": "操作",
|
||||
"open": "打開",
|
||||
"all": "所有檔案"
|
||||
},
|
||||
"agents": {
|
||||
"title": "智能體",
|
||||
@@ -170,7 +200,8 @@
|
||||
"edit.message.empty.content": "會話輸入內容不能為空",
|
||||
"edit.model.select.title": "選擇模型",
|
||||
"edit.settings.hide_preset_messages": "隱藏預設消息",
|
||||
"search.no_results": "沒有找到相關智能體"
|
||||
"search.no_results": "沒有找到相關智能體",
|
||||
"sorting.title": "排序"
|
||||
},
|
||||
"minapp": {
|
||||
"title": "小程序"
|
||||
@@ -326,7 +357,8 @@
|
||||
"button.translate": "翻譯",
|
||||
"error.not_configured": "翻譯模型未配置",
|
||||
"input.placeholder": "輸入文字進行翻譯",
|
||||
"output.placeholder": "翻譯"
|
||||
"output.placeholder": "翻譯",
|
||||
"confirm": "原文已複製到剪貼簿,是否用翻譯後的文字替換?"
|
||||
},
|
||||
"languages": {
|
||||
"english": "英文",
|
||||
@@ -349,11 +381,23 @@
|
||||
},
|
||||
"error": {
|
||||
"chat.response": "出現錯誤。如果尚未配置 API 密鑰,請前往設定 > 模型提供者中配置密鑰",
|
||||
"backup.file_format": "備份文件格式錯誤"
|
||||
"backup.file_format": "備份文件格式錯誤",
|
||||
"provider_disabled": "模型提供商未啟用",
|
||||
"no_api_key": "API 密鑰未配置"
|
||||
},
|
||||
"words": {
|
||||
"knowledgeGraph": "知識圖譜",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"export": {
|
||||
"attached_files": "附件",
|
||||
"user": "用戶",
|
||||
"assistant": "助手",
|
||||
"created": "創建時間",
|
||||
"last_updated": "最後更新",
|
||||
"messages": "訊息數",
|
||||
"conversation_details": "會話詳情",
|
||||
"conversation_history": "會話歷史"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { DeleteOutlined, EditOutlined, MoreOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import AssistantSettingsPopup from '@renderer/components/AssistantSettings'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { createAssistantFromAgent } from '@renderer/services/assistant'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { Button, Dropdown, Typography } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AddAgentPopup from './components/AddAgentPopup'
|
||||
|
||||
interface Props {
|
||||
onClick: (agent: Agent) => void
|
||||
}
|
||||
|
||||
const Agents: React.FC<Props> = ({ onClick }) => {
|
||||
const { t } = useTranslation()
|
||||
const { agents, removeAgent, updateAgents } = useAgents()
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(agent: Agent) =>
|
||||
[
|
||||
{
|
||||
label: t('agents.edit.title'),
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
|
||||
},
|
||||
{
|
||||
label: t('agents.add.button'),
|
||||
key: 'create',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => createAssistantFromAgent(agent)
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: t('common.delete'),
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
window.modal.confirm({
|
||||
centered: true,
|
||||
content: t('agents.delete.popup.content'),
|
||||
onOk: () => removeAgent(agent.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
] as ItemType[],
|
||||
[removeAgent, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<Container style={{ paddingBottom: dragging ? 30 : 0 }}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 16 }}>
|
||||
{t('agents.my_agents')}
|
||||
</Typography.Title>
|
||||
{agents.length > 0 && (
|
||||
<DragableList
|
||||
list={agents}
|
||||
onUpdate={updateAgents}
|
||||
onDragStart={() => setDragging(true)}
|
||||
onDragEnd={() => setDragging(false)}>
|
||||
{(agent: Agent) => (
|
||||
<Dropdown menu={{ items: getMenuItems(agent) }} trigger={['contextMenu']}>
|
||||
<AgentItem onClick={() => onClick(agent)}>
|
||||
<HStack alignItems="center" justifyContent="space-between" h="36px">
|
||||
<AgentItemName className="text-nowrap">
|
||||
{agent.emoji} {agent.name}
|
||||
</AgentItemName>
|
||||
<ActionButton className="actions" gap="15px" onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown menu={{ items: getMenuItems(agent) }} trigger={['hover']}>
|
||||
<MoreOutlined style={{ cursor: 'pointer' }} />
|
||||
</Dropdown>
|
||||
</ActionButton>
|
||||
</HStack>
|
||||
<AgentItemPrompt>{agent.prompt}</AgentItemPrompt>
|
||||
</AgentItem>
|
||||
</Dropdown>
|
||||
)}
|
||||
</DragableList>
|
||||
)}
|
||||
{!dragging && (
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => AddAgentPopup.show()}
|
||||
style={{ borderRadius: 20, height: 34 }}>
|
||||
{t('agents.add.title')}
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ height: 10 }} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(Scrollbar)`
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--navbar-height));
|
||||
min-width: var(--assistants-width);
|
||||
max-width: var(--assistants-width);
|
||||
`
|
||||
|
||||
const AgentItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 12px;
|
||||
min-height: 72px;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
.actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
border: 0.5px solid var(--color-primary);
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`
|
||||
|
||||
const AgentItemName = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const AgentItemPrompt = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-soft);
|
||||
margin-top: -5px;
|
||||
color: var(--color-text-3);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
line-height: 16px;
|
||||
`
|
||||
|
||||
const ActionButton = styled(HStack)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: none;
|
||||
background-color: var(--color-background-soft);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
color: var(--color-icon);
|
||||
`
|
||||
|
||||
export default Agents
|
||||
@@ -2,7 +2,7 @@ import { SearchOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import SystemAgents from '@renderer/config/agents.json'
|
||||
import { createAssistantFromAgent } from '@renderer/services/assistant'
|
||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Col, Empty, Input, Row, Tabs as TabsAntd, Typography } from 'antd'
|
||||
@@ -12,8 +12,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Agents from './Agents'
|
||||
import { groupTranslations } from './agentGroupTranslations'
|
||||
import AgentCard from './components/AgentCard'
|
||||
import MyAgents from './components/MyAgents'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
@@ -40,12 +41,18 @@ const AgentsPage: FC = () => {
|
||||
return _agentGroups
|
||||
}, [])
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const filteredAgentGroups = useMemo(() => {
|
||||
if (!search.trim()) return agentGroups
|
||||
const groups = { 我的: [] }
|
||||
|
||||
if (!search.trim()) {
|
||||
Object.entries(agentGroups).forEach(([group, agents]) => {
|
||||
groups[group] = agents
|
||||
})
|
||||
return groups
|
||||
}
|
||||
|
||||
const filtered = {}
|
||||
Object.entries(agentGroups).forEach(([group, agents]) => {
|
||||
const filteredAgents = agents.filter(
|
||||
(agent) =>
|
||||
@@ -53,10 +60,10 @@ const AgentsPage: FC = () => {
|
||||
agent.description?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
if (filteredAgents.length > 0) {
|
||||
filtered[group] = filteredAgents
|
||||
groups[group] = filteredAgents
|
||||
}
|
||||
})
|
||||
return filtered
|
||||
return groups
|
||||
}, [agentGroups, search])
|
||||
|
||||
const getAgentName = (agent: Agent) => {
|
||||
@@ -95,33 +102,47 @@ const AgentsPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getLocalizedGroupName = useCallback(
|
||||
(group: string) => {
|
||||
const currentLang = i18n.language
|
||||
return groupTranslations[group]?.[currentLang] || group
|
||||
},
|
||||
[i18n.language]
|
||||
)
|
||||
|
||||
const tabItems = useMemo(() => {
|
||||
let groups = Object.keys(filteredAgentGroups)
|
||||
groups = groups.includes('办公') ? ['办公', ...groups.filter((g) => g !== '办公')] : groups
|
||||
|
||||
groups = groups.includes('办公') ? [groups[0], '办公', ...groups.slice(1)] : groups
|
||||
|
||||
return groups.map((group, i) => {
|
||||
const id = String(i + 1)
|
||||
const localizedGroupName = getLocalizedGroupName(group)
|
||||
|
||||
return {
|
||||
label: group,
|
||||
label: localizedGroupName,
|
||||
key: id,
|
||||
children: (
|
||||
<TabContent key={group}>
|
||||
<Title level={5} key={group} style={{ marginBottom: 16 }}>
|
||||
{group}
|
||||
{localizedGroupName}
|
||||
</Title>
|
||||
<Row gutter={16}>
|
||||
{filteredAgentGroups[group].map((agent, index) => {
|
||||
return (
|
||||
<Col span={8} key={group + index}>
|
||||
<Row gutter={[20, 20]}>
|
||||
{group === '我的' ? (
|
||||
<MyAgents onClick={onAddAgentConfirm} search={search} />
|
||||
) : (
|
||||
filteredAgentGroups[group]?.map((agent, index) => (
|
||||
<Col span={6} key={group + index}>
|
||||
<AgentCard onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent))} agent={agent as any} />
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
))
|
||||
)}
|
||||
</Row>
|
||||
</TabContent>
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [filteredAgentGroups, onAddAgentConfirm])
|
||||
}, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -145,9 +166,8 @@ const AgentsPage: FC = () => {
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<AssistantsContainer>
|
||||
<Agents onClick={onAddAgentConfirm} />
|
||||
{tabItems.length > 0 ? (
|
||||
<Tabs tabPosition="left" animated items={tabItems} />
|
||||
<Tabs tabPosition="right" animated items={tabItems} />
|
||||
) : (
|
||||
<EmptyView>
|
||||
<Empty description={t('agents.search.no_results')} />
|
||||
@@ -172,6 +192,8 @@ const ContentContainer = styled.div`
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
padding-left: 0;
|
||||
`
|
||||
|
||||
const AssistantsContainer = styled.div`
|
||||
@@ -184,7 +206,9 @@ const AssistantsContainer = styled.div`
|
||||
const TabContent = styled(Scrollbar)`
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
padding: 10px 10px 10px 15px;
|
||||
margin-right: 4px;
|
||||
margin-right: -4px;
|
||||
padding-bottom: 20px !important;
|
||||
overflow-x: hidden;
|
||||
`
|
||||
|
||||
const AgentPrompt = styled.div`
|
||||
@@ -200,7 +224,6 @@ const EmptyView = styled.div`
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const Tabs = styled(TabsAntd)`
|
||||
@@ -208,7 +231,11 @@ const Tabs = styled(TabsAntd)`
|
||||
flex: 1;
|
||||
flex-direction: row-reverse;
|
||||
.ant-tabs-tabpane {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.ant-tabs-nav {
|
||||
min-width: 140px;
|
||||
max-width: 140px;
|
||||
}
|
||||
.ant-tabs-nav-list {
|
||||
padding: 10px 8px;
|
||||
@@ -220,8 +247,9 @@ const Tabs = styled(TabsAntd)`
|
||||
margin: 0 !important;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 5px !important;
|
||||
font-size: 14px;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
justify-content: left;
|
||||
padding: 7px 12px !important;
|
||||
&:hover {
|
||||
color: var(--color-text) !important;
|
||||
background-color: var(--color-background-soft);
|
||||
@@ -233,7 +261,7 @@ const Tabs = styled(TabsAntd)`
|
||||
}
|
||||
.ant-tabs-content-holder {
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
border-right: none;
|
||||
}
|
||||
.ant-tabs-ink-bar {
|
||||
display: none;
|
||||
|
||||
180
src/renderer/src/pages/agents/agentGroupTranslations.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
export type GroupTranslations = {
|
||||
[key: string]: {
|
||||
'en-US': string
|
||||
'zh-CN': string
|
||||
'zh-TW': string
|
||||
}
|
||||
}
|
||||
|
||||
export const groupTranslations: GroupTranslations = {
|
||||
我的: {
|
||||
'en-US': 'My Agents',
|
||||
'zh-CN': '我的',
|
||||
'zh-TW': '我的'
|
||||
},
|
||||
职业: {
|
||||
'en-US': 'Career',
|
||||
'zh-CN': '职业',
|
||||
'zh-TW': '職業'
|
||||
},
|
||||
商业: {
|
||||
'en-US': 'Business',
|
||||
'zh-CN': '商业',
|
||||
'zh-TW': '商業'
|
||||
},
|
||||
工具: {
|
||||
'en-US': 'Tools',
|
||||
'zh-CN': '工具',
|
||||
'zh-TW': '工具'
|
||||
},
|
||||
语言: {
|
||||
'en-US': 'Language',
|
||||
'zh-CN': '语言',
|
||||
'zh-TW': '語言'
|
||||
},
|
||||
办公: {
|
||||
'en-US': 'Office',
|
||||
'zh-CN': '办公',
|
||||
'zh-TW': '辦公'
|
||||
},
|
||||
通用: {
|
||||
'en-US': 'General',
|
||||
'zh-CN': '通用',
|
||||
'zh-TW': '通用'
|
||||
},
|
||||
写作: {
|
||||
'en-US': 'Writing',
|
||||
'zh-CN': '写作',
|
||||
'zh-TW': '寫作'
|
||||
},
|
||||
Artifacts: {
|
||||
'en-US': 'Artifacts',
|
||||
'zh-CN': 'Artifacts',
|
||||
'zh-TW': 'Artifacts'
|
||||
},
|
||||
编程: {
|
||||
'en-US': 'Programming',
|
||||
'zh-CN': '编程',
|
||||
'zh-TW': '編程'
|
||||
},
|
||||
情感: {
|
||||
'en-US': 'Emotion',
|
||||
'zh-CN': '情感',
|
||||
'zh-TW': '情感'
|
||||
},
|
||||
教育: {
|
||||
'en-US': 'Education',
|
||||
'zh-CN': '教育',
|
||||
'zh-TW': '教育'
|
||||
},
|
||||
创意: {
|
||||
'en-US': 'Creative',
|
||||
'zh-CN': '创意',
|
||||
'zh-TW': '創意'
|
||||
},
|
||||
学术: {
|
||||
'en-US': 'Academic',
|
||||
'zh-CN': '学术',
|
||||
'zh-TW': '學術'
|
||||
},
|
||||
设计: {
|
||||
'en-US': 'Design',
|
||||
'zh-CN': '设计',
|
||||
'zh-TW': '設計'
|
||||
},
|
||||
艺术: {
|
||||
'en-US': 'Art',
|
||||
'zh-CN': '艺术',
|
||||
'zh-TW': '藝術'
|
||||
},
|
||||
娱乐: {
|
||||
'en-US': 'Entertainment',
|
||||
'zh-CN': '娱乐',
|
||||
'zh-TW': '娛樂'
|
||||
},
|
||||
生活: {
|
||||
'en-US': 'Life',
|
||||
'zh-CN': '生活',
|
||||
'zh-TW': '生活'
|
||||
},
|
||||
医疗: {
|
||||
'en-US': 'Medical',
|
||||
'zh-CN': '医疗',
|
||||
'zh-TW': '醫療'
|
||||
},
|
||||
游戏: {
|
||||
'en-US': 'Games',
|
||||
'zh-CN': '游戏',
|
||||
'zh-TW': '遊戲'
|
||||
},
|
||||
翻译: {
|
||||
'en-US': 'Translation',
|
||||
'zh-CN': '翻译',
|
||||
'zh-TW': '翻譯'
|
||||
},
|
||||
音乐: {
|
||||
'en-US': 'Music',
|
||||
'zh-CN': '音乐',
|
||||
'zh-TW': '音樂'
|
||||
},
|
||||
点评: {
|
||||
'en-US': 'Review',
|
||||
'zh-CN': '点评',
|
||||
'zh-TW': '點評'
|
||||
},
|
||||
文案: {
|
||||
'en-US': 'Copywriting',
|
||||
'zh-CN': '文案',
|
||||
'zh-TW': '文案'
|
||||
},
|
||||
百科: {
|
||||
'en-US': 'Encyclopedia',
|
||||
'zh-CN': '百科',
|
||||
'zh-TW': '百科'
|
||||
},
|
||||
健康: {
|
||||
'en-US': 'Health',
|
||||
'zh-CN': '健康',
|
||||
'zh-TW': '健康'
|
||||
},
|
||||
营销: {
|
||||
'en-US': 'Marketing',
|
||||
'zh-CN': '营销',
|
||||
'zh-TW': '營銷'
|
||||
},
|
||||
科学: {
|
||||
'en-US': 'Science',
|
||||
'zh-CN': '科学',
|
||||
'zh-TW': '科學'
|
||||
},
|
||||
分析: {
|
||||
'en-US': 'Analysis',
|
||||
'zh-CN': '分析',
|
||||
'zh-TW': '分析'
|
||||
},
|
||||
法律: {
|
||||
'en-US': 'Legal',
|
||||
'zh-CN': '法律',
|
||||
'zh-TW': '法律'
|
||||
},
|
||||
咨询: {
|
||||
'en-US': 'Consulting',
|
||||
'zh-CN': '咨询',
|
||||
'zh-TW': '諮詢'
|
||||
},
|
||||
金融: {
|
||||
'en-US': 'Finance',
|
||||
'zh-CN': '金融',
|
||||
'zh-TW': '金融'
|
||||
},
|
||||
旅游: {
|
||||
'en-US': 'Travel',
|
||||
'zh-CN': '旅游',
|
||||
'zh-TW': '旅遊'
|
||||
},
|
||||
管理: {
|
||||
'en-US': 'Management',
|
||||
'zh-CN': '管理',
|
||||
'zh-TW': '管理'
|
||||
}
|
||||
}
|
||||
41
src/renderer/src/pages/agents/components/AddAgentCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface AddAgentCardProps {
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AddAgentCard = ({ onClick, className }: AddAgentCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<StyledCard className={className} onClick={onClick}>
|
||||
<PlusOutlined style={{ fontSize: 24 }} />
|
||||
<span style={{ marginTop: 10 }}>{t('agents.add.title')}</span>
|
||||
</StyledCard>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledCard = styled.div`
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background);
|
||||
border-radius: 15px;
|
||||
border: 1px dashed var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text-soft);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
export default AddAgentCard
|
||||
@@ -5,8 +5,8 @@ import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { AGENT_PROMPT } from '@renderer/config/prompts'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { fetchGenerate } from '@renderer/services/api'
|
||||
import { getDefaultModel } from '@renderer/services/assistant'
|
||||
import { fetchGenerate } from '@renderer/services/ApiService'
|
||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
||||
import { Button, Form, FormInstance, Input, Modal, Popover } from 'antd'
|
||||
|
||||
@@ -1,75 +1,208 @@
|
||||
import { EllipsisOutlined } from '@ant-design/icons'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { Col } from 'antd'
|
||||
import { getLeadingEmoji } from '@renderer/utils'
|
||||
import { Dropdown } from 'antd'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
agent: Agent
|
||||
onClick?: () => void
|
||||
contextMenu?: { label: string; onClick: () => void }[]
|
||||
menuItems?: {
|
||||
key: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
danger?: boolean
|
||||
onClick: () => void
|
||||
}[]
|
||||
}
|
||||
|
||||
const AgentCard: React.FC<Props> = ({ agent, onClick }) => {
|
||||
return (
|
||||
const AgentCard: React.FC<Props> = ({ agent, onClick, contextMenu, menuItems }) => {
|
||||
const emoji = agent.emoji || getLeadingEmoji(agent.name)
|
||||
const prompt = (agent.description || agent.prompt).substring(0, 100).replace(/\\n/g, '')
|
||||
const content = (
|
||||
<Container onClick={onClick}>
|
||||
{agent.emoji && <EmojiHeader>{agent.emoji}</EmojiHeader>}
|
||||
<Col>
|
||||
<AgentHeader>
|
||||
<AgentName style={{ marginBottom: 0 }}>{agent.name}</AgentName>
|
||||
</AgentHeader>
|
||||
<AgentCardPrompt className="text-nowrap">
|
||||
{(agent.description || agent.prompt).substring(0, 20)}
|
||||
</AgentCardPrompt>
|
||||
</Col>
|
||||
{emoji && <BannerBackground className="banner-background">{emoji}</BannerBackground>}
|
||||
<EmojiContainer className="emoji-container">{emoji}</EmojiContainer>
|
||||
{menuItems && (
|
||||
<MenuContainer onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems.map((item) => ({
|
||||
...item,
|
||||
onClick: (e) => {
|
||||
e.domEvent.stopPropagation()
|
||||
e.domEvent.preventDefault()
|
||||
setTimeout(() => {
|
||||
item.onClick()
|
||||
}, 0)
|
||||
}
|
||||
}))
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="bottomRight">
|
||||
<EllipsisOutlined style={{ cursor: 'pointer', fontSize: 20 }} />
|
||||
</Dropdown>
|
||||
</MenuContainer>
|
||||
)}
|
||||
<CardInfo className="card-info">
|
||||
<AgentName>{agent.name}</AgentName>
|
||||
<AgentPrompt className="agent-prompt">{prompt}...</AgentPrompt>
|
||||
</CardInfo>
|
||||
</Container>
|
||||
)
|
||||
|
||||
if (contextMenu) {
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: contextMenu.map((item) => ({
|
||||
key: item.label,
|
||||
label: item.label,
|
||||
onClick: () => item.onClick()
|
||||
}))
|
||||
}}
|
||||
trigger={['contextMenu']}>
|
||||
{content}
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 16px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
background-color: var(--color-background);
|
||||
border-radius: 15px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
border: 0.5px solid var(--color-primary);
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
background: var(--color-background-soft);
|
||||
transition: all 0.5s ease;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
* {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.agent-prompt {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
`
|
||||
const EmojiHeader = styled.div`
|
||||
width: 20px;
|
||||
|
||||
const EmojiContainer = styled.div`
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
min-width: 55px;
|
||||
min-height: 55px;
|
||||
background-color: var(--color-background);
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--color-border);
|
||||
margin-top: 8px;
|
||||
transition: all 0.5s ease;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 5px;
|
||||
font-size: 24px;
|
||||
line-height: 20px;
|
||||
font-size: 32px;
|
||||
`
|
||||
|
||||
const AgentHeader = styled.div`
|
||||
const CardInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.5s ease;
|
||||
padding: 0 15px;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const AgentName = styled.div`
|
||||
line-height: 1.2;
|
||||
const AgentName = styled.span`
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--color-text);
|
||||
margin-top: 5px;
|
||||
line-height: 1.4;
|
||||
max-width: 100%;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-1);
|
||||
word-break: break-word;
|
||||
`
|
||||
|
||||
const AgentCardPrompt = styled.div`
|
||||
color: #666;
|
||||
margin-top: 6px;
|
||||
const AgentPrompt = styled.p`
|
||||
color: var(--color-text-soft);
|
||||
font-size: 12px;
|
||||
max-width: auto;
|
||||
max-width: 100%;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all 0.5s ease;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
`
|
||||
|
||||
const BannerBackground = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 500px;
|
||||
opacity: 0.1;
|
||||
filter: blur(8px);
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.5s ease;
|
||||
`
|
||||
|
||||
const MenuContainer = styled.div`
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-soft);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
color: var(--color-icon);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 2;
|
||||
|
||||
${Container}:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
export default AgentCard
|
||||
|
||||
100
src/renderer/src/pages/agents/components/ManageAgentsPopup.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { MenuOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { Box, HStack } from '@renderer/components/Layout'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { Empty, Modal } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const PopupContainer: React.FC = () => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const { agents, updateAgents } = useAgents()
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = async () => {
|
||||
ManageAgentsPopup.hide()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
setOpen(false)
|
||||
}
|
||||
}, [agents])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('agents.manage.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
footer={null}
|
||||
centered>
|
||||
<Container>
|
||||
{agents.length > 0 && (
|
||||
<DragableList list={agents} onUpdate={updateAgents}>
|
||||
{(item) => (
|
||||
<AgentItem>
|
||||
<Box mr={8}>
|
||||
{item.emoji} {item.name}
|
||||
</Box>
|
||||
<HStack gap="15px">
|
||||
<MenuOutlined style={{ cursor: 'move' }} />
|
||||
</HStack>
|
||||
</AgentItem>
|
||||
)}
|
||||
</DragableList>
|
||||
)}
|
||||
{agents.length === 0 && <Empty description="" />}
|
||||
</Container>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 12px 0;
|
||||
height: 50vh;
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const AgentItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-bottom: 8px;
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
color: var(--color-icon);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
`
|
||||
|
||||
export default class ManageAgentsPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('ManageAgentsPopup')
|
||||
}
|
||||
static show() {
|
||||
TopView.show(<PopupContainer />, 'ManageAgentsPopup')
|
||||
}
|
||||
}
|
||||
109
src/renderer/src/pages/agents/components/MyAgents.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SortAscendingOutlined } from '@ant-design/icons'
|
||||
import AssistantSettingsPopup from '@renderer/components/AssistantSettings'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { Col } from 'antd'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AddAgentCard from './AddAgentCard'
|
||||
import AddAgentPopup from './AddAgentPopup'
|
||||
import AgentCard from './AgentCard'
|
||||
import ManageAgentsPopup from './ManageAgentsPopup'
|
||||
|
||||
interface Props {
|
||||
onClick?: (agent: Agent) => void
|
||||
search?: string
|
||||
}
|
||||
|
||||
const MyAgents: React.FC<Props> = ({ onClick, search }) => {
|
||||
const { t } = useTranslation()
|
||||
const { agents, removeAgent } = useAgents()
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
if (!search?.trim()) return agents
|
||||
|
||||
return agents.filter(
|
||||
(agent) =>
|
||||
agent.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
agent.description?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
}, [agents, search])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(agent: Agent) => {
|
||||
window.modal.confirm({
|
||||
centered: true,
|
||||
content: t('agents.delete.popup.content'),
|
||||
onOk: () => removeAgent(agent.id)
|
||||
})
|
||||
},
|
||||
[removeAgent, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredAgents.map((agent) => {
|
||||
const dropdownMenuItems = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('agents.edit.title'),
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
|
||||
},
|
||||
{
|
||||
key: 'create',
|
||||
label: t('agents.add.button'),
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => createAssistantFromAgent(agent)
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
label: t('agents.sorting.title'),
|
||||
icon: <SortAscendingOutlined />,
|
||||
onClick: () => ManageAgentsPopup.show()
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('common.delete'),
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDelete(agent)
|
||||
}
|
||||
]
|
||||
|
||||
const contextMenuItems = [
|
||||
{
|
||||
label: t('agents.edit.title'),
|
||||
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
|
||||
},
|
||||
{
|
||||
label: t('agents.add.button'),
|
||||
onClick: () => createAssistantFromAgent(agent)
|
||||
},
|
||||
{
|
||||
label: t('common.delete'),
|
||||
onClick: () => handleDelete(agent)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Col span={6} key={agent.id}>
|
||||
<AgentCard
|
||||
agent={agent}
|
||||
onClick={() => onClick?.(agent)}
|
||||
contextMenu={contextMenuItems}
|
||||
menuItems={dropdownMenuItems}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
<Col span={6}>
|
||||
<AddAgentCard onClick={() => AddAgentPopup.show()} />
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyAgents
|
||||
@@ -1,45 +1,44 @@
|
||||
import { FileImageOutlined, FilePdfOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import db from '@renderer/databases'
|
||||
import FileManager from '@renderer/services/file'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Image, Table } from 'antd'
|
||||
import { Col, Image, Menu, Row, Spin, Table } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { FC } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const FilesPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const files = useLiveQuery<FileType[]>(() => db.files.orderBy('count').reverse().toArray())
|
||||
const [fileType, setFileType] = useState<FileTypes | 'all'>('all')
|
||||
|
||||
const files = useLiveQuery<FileType[]>(() => {
|
||||
if (fileType === 'all') {
|
||||
return db.files.orderBy('count').toArray()
|
||||
}
|
||||
return db.files.where('type').equals(fileType).sortBy('count')
|
||||
}, [fileType])
|
||||
|
||||
const dataSource = files?.map((file) => {
|
||||
const isImage = file.type === FileTypes.IMAGE
|
||||
const ImageView = <Image src={FileManager.getFileUrl(file)} preview={false} style={{ maxHeight: '40px' }} />
|
||||
console.log(FileManager.getFileUrl(file))
|
||||
return {
|
||||
key: file.id,
|
||||
file: isImage ? ImageView : <FileNameText className="text-nowrap">{file.origin_name}</FileNameText>,
|
||||
name: <a href={'file://' + FileManager.getSafePath(file)}>{file.origin_name}</a>,
|
||||
file: <FileNameText className="text-nowrap">{file.origin_name}</FileNameText>,
|
||||
size: formatFileSize(file),
|
||||
count: file.count,
|
||||
created_at: dayjs(file.created_at).format('MM-DD HH:mm')
|
||||
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
|
||||
actions: <a href={'file://' + FileManager.getSafePath(file)}>{t('files.open')}</a>
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('files.file'),
|
||||
dataIndex: 'file',
|
||||
key: 'file',
|
||||
width: '300px'
|
||||
},
|
||||
{
|
||||
title: t('files.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
dataIndex: 'file',
|
||||
key: 'file'
|
||||
},
|
||||
{
|
||||
title: t('files.size'),
|
||||
@@ -58,23 +57,67 @@ const FilesPage: FC = () => {
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
title: t('files.actions'),
|
||||
dataIndex: 'actions',
|
||||
key: 'actions',
|
||||
width: '50px'
|
||||
}
|
||||
]
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'all', label: t('files.all'), icon: <FileTextOutlined /> },
|
||||
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImageOutlined /> },
|
||||
{ key: FileTypes.TEXT, label: t('files.text'), icon: <FileTextOutlined /> },
|
||||
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FilePdfOutlined /> }
|
||||
]
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('files.title')}</NavbarCenter>
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<SideNav>
|
||||
<Menu selectedKeys={[fileType]} items={menuItems} onSelect={({ key }) => setFileType(key as FileTypes)} />
|
||||
</SideNav>
|
||||
<TableContainer right>
|
||||
<Table
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
style={{ width: '100%' }}
|
||||
size="small"
|
||||
pagination={{ pageSize: 100 }}
|
||||
/>
|
||||
{fileType === FileTypes.IMAGE && files?.length && files?.length > 0 ? (
|
||||
<Image.PreviewGroup>
|
||||
<Row gutter={[16, 16]}>
|
||||
{files?.map((file) => (
|
||||
<Col key={file.id} xs={24} sm={12} md={8} lg={4} xl={3}>
|
||||
<ImageWrapper>
|
||||
<LoadingWrapper>
|
||||
<Spin />
|
||||
</LoadingWrapper>
|
||||
<Image
|
||||
src={FileManager.getFileUrl(file)}
|
||||
style={{ height: '100%', objectFit: 'cover', cursor: 'pointer' }}
|
||||
preview={{ mask: false }}
|
||||
onLoad={(e) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
img.parentElement?.classList.add('loaded')
|
||||
}}
|
||||
/>
|
||||
<ImageInfo>
|
||||
<div>{formatFileSize(file)}</div>
|
||||
</ImageInfo>
|
||||
</ImageWrapper>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
style={{ width: '100%' }}
|
||||
size="small"
|
||||
pagination={{ pageSize: 100 }}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
@@ -92,9 +135,7 @@ const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 2px;
|
||||
min-height: 100%;
|
||||
`
|
||||
|
||||
const TableContainer = styled(Scrollbar)`
|
||||
@@ -107,7 +148,99 @@ const TableContainer = styled(Scrollbar)`
|
||||
const FileNameText = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
max-width: 300px;
|
||||
`
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
.ant-image {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ant-image.loaded {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
div:last-child {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-soft);
|
||||
`
|
||||
|
||||
const ImageInfo = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: 5px 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
font-size: 12px;
|
||||
|
||||
> div:first-child {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`
|
||||
|
||||
const SideNav = styled.div`
|
||||
width: var(--assistants-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
padding: 15px;
|
||||
|
||||
.ant-menu {
|
||||
border-inline-end: none !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
margin: 4px 0;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default FilesPage
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowRightOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
|
||||
import { locateToMessage } from '@renderer/services/messages'
|
||||
import { locateToMessage } from '@renderer/services/MessagesService'
|
||||
import { Message } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { getAssistantById } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { locateToMessage } from '@renderer/services/messages'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { locateToMessage } from '@renderer/services/MessagesService'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { Button, Divider, Empty } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
@@ -35,7 +36,7 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
|
||||
<ListContainer {...props}>
|
||||
<VStack alignItems="center">
|
||||
<Empty description={t('history.search.topics.empty')} />
|
||||
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch}>
|
||||
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
|
||||
{t('history.search.messages')}
|
||||
</Button>
|
||||
</VStack>
|
||||
@@ -63,6 +64,13 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
|
||||
))}
|
||||
</ListItem>
|
||||
))}
|
||||
{keywords.length >= 2 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
|
||||
{t('history.search.messages')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ minHeight: 30 }}></div>
|
||||
</ContainerWrapper>
|
||||
</ListContainer>
|
||||
|
||||
@@ -19,11 +19,11 @@ interface Props {
|
||||
|
||||
const Chat: FC<Props> = (props) => {
|
||||
const { assistant } = useAssistant(props.assistant.id)
|
||||
const { topicPosition } = useSettings()
|
||||
const { topicPosition, messageStyle } = useSettings()
|
||||
const { showTopics } = useShowTopics()
|
||||
|
||||
return (
|
||||
<Container id="chat">
|
||||
<Container id="chat" className={messageStyle}>
|
||||
<Main vertical flex={1} justify="space-between">
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
@@ -52,7 +52,35 @@ const Container = styled.div`
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
background-color: var(--color-background);
|
||||
&.bubble {
|
||||
background-color: var(--chat-background);
|
||||
.system-prompt {
|
||||
background-color: var(--chat-background-assistant);
|
||||
}
|
||||
.message-content-container {
|
||||
margin: 5px 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 15px 0 15px;
|
||||
}
|
||||
.message-user {
|
||||
.markdown,
|
||||
.anticon,
|
||||
.iconfont,
|
||||
.message-tokens {
|
||||
color: var(--chat-text-user);
|
||||
}
|
||||
.message-action-button:hover {
|
||||
background-color: var(--color-white-soft);
|
||||
}
|
||||
}
|
||||
#inputbar {
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border-mute);
|
||||
background: var(--color-background);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Main = styled(Flex)`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FileManager from '@renderer/services/file'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { Upload } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
@@ -35,8 +35,9 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
margin: 10px 20px;
|
||||
margin-right: 0;
|
||||
padding: 10px 20px;
|
||||
background: var(--color-background);
|
||||
border-top: 1px solid var(--color-border-mute);
|
||||
`
|
||||
|
||||
export default AttachmentPreview
|
||||
|
||||
@@ -12,12 +12,12 @@ import { isVisionModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import FileManager from '@renderer/services/file'
|
||||
import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/tokens'
|
||||
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setGenerating, setSearching } from '@renderer/store/runtime'
|
||||
import { Assistant, FileType, Message, Topic } from '@renderer/types'
|
||||
@@ -58,6 +58,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
const containerRef = useRef(null)
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
const { searching } = useRuntime()
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
@@ -299,7 +300,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
autoFocus
|
||||
contextMenu="true"
|
||||
variant="borderless"
|
||||
rows={1}
|
||||
rows={isBubbleStyle ? 2 : 1}
|
||||
ref={textareaRef}
|
||||
style={{ fontSize }}
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
@@ -375,11 +376,6 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
paddingLeft: 0,
|
||||
padding: '10px 15px 8px'
|
||||
}
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
border: 1px solid var(--color-border-soft);
|
||||
transition: all 0.3s ease;
|
||||
@@ -388,13 +384,18 @@ const InputBarContainer = styled.div`
|
||||
border-radius: 10px;
|
||||
`
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
paddingLeft: 0,
|
||||
padding: '10px 15px 8px'
|
||||
}
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: Ubuntu;
|
||||
resize: vertical;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -8,19 +8,21 @@ import {
|
||||
ZoomOutOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { Image, Space } from 'antd'
|
||||
import { Image as AntImage, ImageProps as AntImageProps, Space } from 'antd'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ImagePreviewProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
interface ImagePreviewProps extends AntImageProps {
|
||||
src: string
|
||||
}
|
||||
|
||||
const ImagePreview: React.FC<ImagePreviewProps> = ({ src }) => {
|
||||
const ImagePreview: React.FC<ImagePreviewProps> = ({ src, ...props }) => {
|
||||
return (
|
||||
<Image
|
||||
<AntImage
|
||||
src={src}
|
||||
{...props}
|
||||
preview={{
|
||||
mask: typeof props.preview === 'object' ? props.preview.mask : false,
|
||||
toolbarRender: (
|
||||
_,
|
||||
{
|
||||
|
||||
@@ -2,12 +2,12 @@ import { FONT_FAMILY } from '@renderer/config/constant'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { fetchChatCompletion } from '@renderer/services/api'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { estimateMessageUsage } from '@renderer/services/tokens'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { estimateMessageUsage } from '@renderer/services/TokenService'
|
||||
import { Message, Topic } from '@renderer/types'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { classNames, runAsyncFunction } from '@renderer/utils'
|
||||
import { Divider } from 'antd'
|
||||
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -16,7 +16,7 @@ import styled from 'styled-components'
|
||||
import MessageContent from './MessageContent'
|
||||
import MessageHeader from './MessageHeader'
|
||||
import MessageMenubar from './MessageMenubar'
|
||||
import MessgeTokens from './MessageTokens'
|
||||
import MessageTokens from './MessageTokens'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
@@ -42,11 +42,13 @@ const MessageItem: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const { assistant, setModel } = useAssistant(message.assistantId)
|
||||
const model = useModel(message.modelId)
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { showMessageDivider, messageFont, fontSize } = useSettings()
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const isLastMessage = index === 0
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
|
||||
const showMenubar = !message.status.includes('ing')
|
||||
|
||||
const fontFamily = useMemo(() => {
|
||||
@@ -54,6 +56,11 @@ const MessageItem: FC<Props> = ({
|
||||
}, [messageFont])
|
||||
|
||||
const messageBorder = showMessageDivider ? undefined : 'none'
|
||||
const messageBackground = isBubbleStyle
|
||||
? isAssistantMessage
|
||||
? 'var(--chat-background-assistant)'
|
||||
: 'var(--chat-background-user)'
|
||||
: undefined
|
||||
|
||||
const onEditMessage = useCallback(
|
||||
(msg: Message) => {
|
||||
@@ -131,13 +138,27 @@ const MessageItem: FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageContainer key={message.id} className="message" ref={messageContainerRef}>
|
||||
<MessageContainer
|
||||
key={message.id}
|
||||
className={classNames({
|
||||
message: true,
|
||||
'message-assistant': isAssistantMessage,
|
||||
'message-user': !isAssistantMessage
|
||||
})}
|
||||
ref={messageContainerRef}
|
||||
style={isBubbleStyle ? { alignItems: isAssistantMessage ? 'start' : 'end' } : undefined}>
|
||||
<MessageHeader message={message} assistant={assistant} model={model} />
|
||||
<MessageContentContainer style={{ fontFamily, fontSize }}>
|
||||
<MessageContentContainer
|
||||
className="message-content-container"
|
||||
style={{ fontFamily, fontSize, background: messageBackground }}>
|
||||
<MessageContent message={message} model={model} />
|
||||
{showMenubar && (
|
||||
<MessageFooter style={{ border: messageBorder, flexDirection: isLastMessage ? 'row-reverse' : undefined }}>
|
||||
<MessgeTokens message={message} isLastMessage={isLastMessage} />
|
||||
<MessageFooter
|
||||
style={{
|
||||
border: messageBorder,
|
||||
flexDirection: isLastMessage || isBubbleStyle ? 'row-reverse' : undefined
|
||||
}}>
|
||||
<MessageTokens message={message} isLastMessage={isLastMessage} />
|
||||
<MessageMenubar
|
||||
message={message}
|
||||
model={model}
|
||||
@@ -179,6 +200,7 @@ const MessageContainer = styled.div`
|
||||
`
|
||||
|
||||
const MessageContentContainer = styled.div`
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
@@ -195,6 +217,7 @@ const MessageFooter = styled.div`
|
||||
padding: 2px 0;
|
||||
margin-top: 2px;
|
||||
border-top: 0.5px dashed var(--color-border);
|
||||
gap: 20px;
|
||||
`
|
||||
|
||||
export default memo(MessageItem)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FileManager from '@renderer/services/file'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileTypes, Message } from '@renderer/types'
|
||||
import { Image as AntdImage, Upload } from 'antd'
|
||||
import { FC } from 'react'
|
||||
|
||||
@@ -42,6 +42,8 @@ const MessageContentLoading = styled.div`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
margin-top: -5px;
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
export default React.memo(MessageContent)
|
||||
|
||||
@@ -4,12 +4,12 @@ import { startMinAppById } from '@renderer/config/minapps'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { Assistant, Message, Model } from '@renderer/types'
|
||||
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { Avatar } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { CSSProperties, FC, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -24,8 +24,7 @@ const MessageHeader: FC<Props> = ({ assistant, model, message }) => {
|
||||
const { theme } = useTheme()
|
||||
const { userName } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
|
||||
const avatarSource = useMemo(() => {
|
||||
if (isLocalAi) return AppLogo
|
||||
@@ -39,15 +38,23 @@ const MessageHeader: FC<Props> = ({ assistant, model, message }) => {
|
||||
return userName || t('common.you')
|
||||
}, [message.role, model?.id, model?.name, t, userName])
|
||||
|
||||
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
|
||||
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
|
||||
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
|
||||
|
||||
const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
|
||||
|
||||
const avatarStyle: CSSProperties | undefined = isBubbleStyle
|
||||
? {
|
||||
flexDirection: isAssistantMessage ? 'row' : 'row-reverse',
|
||||
textAlign: isAssistantMessage ? 'left' : 'right'
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<AvatarWrapper>
|
||||
<AvatarWrapper style={avatarStyle}>
|
||||
{isAssistantMessage ? (
|
||||
<Avatar
|
||||
src={avatarSource}
|
||||
@@ -79,25 +86,23 @@ const MessageHeader: FC<Props> = ({ assistant, model, message }) => {
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-bottom: 4px;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`
|
||||
|
||||
const UserWrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin-left: 12px;
|
||||
`
|
||||
|
||||
const UserName = styled.div`
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
import { removeTrailingDoubleSpaces } from '@renderer/utils'
|
||||
import { Dropdown, Popconfirm, Tooltip } from 'antd'
|
||||
@@ -96,27 +96,27 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
||||
{message.role === 'user' && (
|
||||
<Tooltip title="Edit" mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onEdit}>
|
||||
<ActionButton className="message-action-button" onClick={onEdit}>
|
||||
<EditOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onCopy}>
|
||||
<ActionButton className="message-action-button" onClick={onCopy}>
|
||||
{!copied && <i className="iconfont icon-copy"></i>}
|
||||
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
{canRegenerate && (
|
||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onSelectModel}>
|
||||
<ActionButton className="message-action-button" onClick={onSelectModel}>
|
||||
<SyncOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAssistantMessage && (
|
||||
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onNewBranch}>
|
||||
<ActionButton className="message-action-button" onClick={onNewBranch}>
|
||||
<ForkOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
@@ -127,14 +127,14 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
|
||||
onConfirm={() => onDeleteMessage?.(message)}>
|
||||
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
|
||||
<ActionButton>
|
||||
<ActionButton className="message-action-button">
|
||||
<DeleteOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
{!isUserMessage && (
|
||||
<Dropdown menu={{ items: dropdownItems }} trigger={['click']} placement="topRight" arrow>
|
||||
<ActionButton>
|
||||
<ActionButton className="message-action-button">
|
||||
<MenuOutlined />
|
||||
</ActionButton>
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Message } from '@renderer/types'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -15,7 +15,11 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({
|
||||
}
|
||||
|
||||
if (message.role === 'user') {
|
||||
return <MessageMetadata onClick={locateMessage}>Tokens: {message?.usage?.total_tokens}</MessageMetadata>
|
||||
return (
|
||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||
Tokens: {message?.usage?.total_tokens}
|
||||
</MessageMetadata>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLastMessage && generating) {
|
||||
@@ -24,7 +28,7 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
return (
|
||||
<MessageMetadata onClick={locateMessage}>
|
||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||
Tokens: {message?.usage?.total_tokens} | ↑{message?.usage?.prompt_tokens} | ↓{message?.usage?.completion_tokens}
|
||||
</MessageMetadata>
|
||||
)
|
||||
|
||||
@@ -3,17 +3,17 @@ import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getTopic, TopicManager } from '@renderer/hooks/useTopic'
|
||||
import { fetchMessagesSummary } from '@renderer/services/api'
|
||||
import { getDefaultTopic } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { fetchMessagesSummary } from '@renderer/services/ApiService'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import {
|
||||
deleteMessageFiles,
|
||||
filterMessages,
|
||||
getAssistantMessage,
|
||||
getContextCount,
|
||||
getUserMessage
|
||||
} from '@renderer/services/messages'
|
||||
import { estimateHistoryTokens } from '@renderer/services/tokens'
|
||||
} from '@renderer/services/MessagesService'
|
||||
import { estimateHistoryTokens } from '@renderer/services/TokenService'
|
||||
import { Assistant, Message, Model, Topic } from '@renderer/types'
|
||||
import { captureScrollableDiv, runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { t } from 'i18next'
|
||||
@@ -217,7 +217,6 @@ const Container = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 10px 0;
|
||||
background-color: var(--color-background);
|
||||
padding-bottom: 20px;
|
||||
overflow-x: hidden;
|
||||
`
|
||||
|
||||
@@ -18,7 +18,7 @@ const Prompt: FC<Props> = ({ assistant }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container onClick={() => AssistantSettingsPopup.show({ assistant })}>
|
||||
<Container className="system-prompt" onClick={() => AssistantSettingsPopup.show({ assistant })}>
|
||||
<Text>{prompt}</Text>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -3,13 +3,11 @@ import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar
|
||||
import AssistantSettingsPopup from '@renderer/components/AssistantSettings'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Switch } from 'antd'
|
||||
import { FC, useCallback } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -24,7 +22,6 @@ interface Props {
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
|
||||
const { assistant } = useAssistant(activeAssistant.id)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { topicPosition } = useSettings()
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
|
||||
@@ -63,12 +60,6 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
|
||||
<SelectModelButton assistant={assistant} />
|
||||
</HStack>
|
||||
<HStack alignItems="center">
|
||||
<ThemeSwitch
|
||||
checkedChildren={<i className="iconfont icon-theme icon-dark1" />}
|
||||
unCheckedChildren={<i className="iconfont icon-theme icon-theme-light" />}
|
||||
checked={theme === 'dark'}
|
||||
onChange={toggleTheme}
|
||||
/>
|
||||
{topicPosition === 'right' && (
|
||||
<NewButton onClick={toggleShowTopics}>
|
||||
<i className={`iconfont icon-${showTopics ? 'show' : 'hide'}-sidebar`} />
|
||||
@@ -118,12 +109,4 @@ const TitleText = styled.span`
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
const ThemeSwitch = styled(Switch)`
|
||||
-webkit-app-region: no-drag;
|
||||
margin-right: 10px;
|
||||
.icon-theme {
|
||||
font-size: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
export default HeaderNavbar
|
||||
|
||||
@@ -6,8 +6,8 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getDefaultTopic } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setSearching } from '@renderer/store/runtime'
|
||||
import { Assistant } from '@renderer/types'
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
setFontSize,
|
||||
setMathEngine,
|
||||
setMessageFont,
|
||||
setMessageStyle,
|
||||
setPasteLongTextAsFile,
|
||||
setRenderInputMessageAsMarkdown,
|
||||
setShowInputEstimatedTokens,
|
||||
@@ -35,6 +36,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
|
||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
|
||||
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
|
||||
const { messageStyle } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -214,6 +216,18 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.style')}</SettingRowTitleSmall>
|
||||
<Select
|
||||
value={messageStyle}
|
||||
onChange={(value) => dispatch(setMessageStyle(value))}
|
||||
style={{ width: 100 }}
|
||||
size="small">
|
||||
<Select.Option value="plain">{t('message.message.style.plain')}</Select.Option>
|
||||
<Select.Option value="bubble">{t('message.message.style.bubble')}</Select.Option>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.math_engine')}</SettingRowTitleSmall>
|
||||
<Select
|
||||
|
||||
@@ -12,12 +12,12 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { TopicManager } from '@renderer/hooks/useTopic'
|
||||
import { fetchMessagesSummary } from '@renderer/services/api'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { fetchMessagesSummary } from '@renderer/services/ApiService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { setGenerating } from '@renderer/store/runtime'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { exportTopicAsMarkdown } from '@renderer/utils/export'
|
||||
import { exportTopicAsMarkdown, topicToMarkdown } from '@renderer/utils/export'
|
||||
import { Dropdown, MenuProps } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { findIndex } from 'lodash'
|
||||
@@ -141,6 +141,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
label: t('chat.topics.export.md'),
|
||||
key: 'markdown',
|
||||
onClick: () => exportTopicAsMarkdown(topic)
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.word'),
|
||||
key: 'word',
|
||||
onClick: async () => {
|
||||
const markdown = await topicToMarkdown(topic)
|
||||
window.api.export.toWord(markdown, topic.name)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Segmented, SegmentedProps } from 'antd'
|
||||
@@ -150,6 +150,7 @@ const Container = styled.div`
|
||||
max-width: var(--assistants-width);
|
||||
min-width: var(--assistants-width);
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
background-color: var(--color-background);
|
||||
overflow: hidden;
|
||||
.collapsed {
|
||||
width: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fetchSuggestions } from '@renderer/services/api'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { fetchSuggestions } from '@renderer/services/ApiService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant, Message, Suggestion } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
193
src/renderer/src/pages/paintings/Artboard.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { Painting } from '@renderer/types'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { Button, Dropdown, Spin } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import ImagePreview from '../home/Markdown/ImagePreview'
|
||||
|
||||
interface ArtboardProps {
|
||||
painting: Painting
|
||||
isLoading: boolean
|
||||
currentImageIndex: number
|
||||
onPrevImage: () => void
|
||||
onNextImage: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const Artboard: FC<ArtboardProps> = ({
|
||||
painting,
|
||||
isLoading,
|
||||
currentImageIndex,
|
||||
onPrevImage,
|
||||
onNextImage,
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getCurrentImageUrl = () => {
|
||||
const currentFile = painting.files[currentImageIndex]
|
||||
return currentFile ? FileManager.getFileUrl(currentFile) : ''
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const getContextMenuItems = () => {
|
||||
return [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
icon: <CopyOutlined />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(painting.urls[currentImageIndex])
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: t('common.download'),
|
||||
icon: <DownloadOutlined />,
|
||||
onClick: () => download(getCurrentImageUrl())
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<LoadingContainer spinning={isLoading}>
|
||||
{painting.files.length > 0 ? (
|
||||
<ImageContainer>
|
||||
{painting.files.length > 1 && (
|
||||
<NavigationButton onClick={onPrevImage} style={{ left: 10 }}>
|
||||
←
|
||||
</NavigationButton>
|
||||
)}
|
||||
<Dropdown menu={{ items: getContextMenuItems() }} trigger={['contextMenu']}>
|
||||
<ImagePreview
|
||||
src={getCurrentImageUrl()}
|
||||
preview={{ mask: false }}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{
|
||||
width: '70vh',
|
||||
height: '70vh',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: 'var(--color-background-soft)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
{painting.files.length > 1 && (
|
||||
<NavigationButton onClick={onNextImage} style={{ right: 10 }}>
|
||||
→
|
||||
</NavigationButton>
|
||||
)}
|
||||
<ImageCounter>
|
||||
{currentImageIndex + 1} / {painting.files.length}
|
||||
</ImageCounter>
|
||||
</ImageContainer>
|
||||
) : (
|
||||
<ImagePlaceholder />
|
||||
)}
|
||||
{isLoading && (
|
||||
<LoadingOverlay>
|
||||
<Spin size="large" />
|
||||
<CancelButton onClick={onCancel}>{t('common.cancel')}</CancelButton>
|
||||
</LoadingOverlay>
|
||||
)}
|
||||
</LoadingContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const ImagePlaceholder = styled.div`
|
||||
display: flex;
|
||||
width: 70vh;
|
||||
height: 70vh;
|
||||
background-color: var(--color-background-soft);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const ImageContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ant-spin {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.ant-spin-spinning {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
}
|
||||
`
|
||||
|
||||
const NavigationButton = styled(Button)`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
opacity: 0.7;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const ImageCounter = styled.div`
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const LoadingContainer = styled.div<{ spinning: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: ${(props) => (props.spinning ? 0.5 : 1)};
|
||||
transition: opacity 0.3s;
|
||||
`
|
||||
|
||||
const LoadingOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
`
|
||||
|
||||
const CancelButton = styled(Button)`
|
||||
margin-top: 10px;
|
||||
z-index: 1001;
|
||||
`
|
||||
|
||||
export default Artboard
|
||||
154
src/renderer/src/pages/paintings/PaintingsList.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { Painting } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Popconfirm } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface PaintingsListProps {
|
||||
paintings: Painting[]
|
||||
selectedPainting: Painting
|
||||
onSelectPainting: (painting: Painting) => void
|
||||
onDeletePainting: (painting: Painting) => void
|
||||
onNewPainting: () => void
|
||||
}
|
||||
|
||||
const PaintingsList: FC<PaintingsListProps> = ({
|
||||
paintings,
|
||||
selectedPainting,
|
||||
onSelectPainting,
|
||||
onDeletePainting,
|
||||
onNewPainting
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const { updatePaintings } = usePaintings()
|
||||
|
||||
return (
|
||||
<Container style={{ paddingBottom: dragging ? 80 : 10 }}>
|
||||
<DragableList
|
||||
list={paintings}
|
||||
onUpdate={updatePaintings}
|
||||
onDragStart={() => setDragging(true)}
|
||||
onDragEnd={() => setDragging(false)}>
|
||||
{(item: Painting) => (
|
||||
<CanvasWrapper key={item.id}>
|
||||
<Canvas
|
||||
className={classNames(selectedPainting.id === item.id && 'selected')}
|
||||
onClick={() => onSelectPainting(item)}>
|
||||
{item.files[0] && <ThumbnailImage src={FileManager.getFileUrl(item.files[0])} alt="" />}
|
||||
</Canvas>
|
||||
<DeleteButton>
|
||||
<Popconfirm
|
||||
title={t('images.button.delete.image.confirm')}
|
||||
onConfirm={() => onDeletePainting(item)}
|
||||
okButtonProps={{ danger: true }}
|
||||
placement="left">
|
||||
<DeleteOutlined />
|
||||
</Popconfirm>
|
||||
</DeleteButton>
|
||||
</CanvasWrapper>
|
||||
)}
|
||||
</DragableList>
|
||||
{!dragging && (
|
||||
<NewPaintingButton onClick={onNewPainting}>
|
||||
<PlusOutlined />
|
||||
</NewPaintingButton>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background-color: var(--color-background);
|
||||
max-width: 100px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
overflow-x: hidden;
|
||||
`
|
||||
|
||||
const CanvasWrapper = styled.div`
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
.delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Canvas = styled.div`
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--color-background-soft);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border: 1px solid var(--color-background-soft);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&.selected {
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
`
|
||||
|
||||
const ThumbnailImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
`
|
||||
|
||||
const DeleteButton = styled.div.attrs({ className: 'delete-button' })`
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
border-radius: 50%;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-error);
|
||||
background-color: var(--color-background-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const NewPaintingButton = styled.div`
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
margin-top: -10px;
|
||||
background-color: var(--color-background-soft);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border: 1px dashed var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-2);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
export default PaintingsList
|
||||
507
src/renderer/src/pages/paintings/PaintingsPage.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import ImageSize1_1 from '@renderer/assets/images/paintings/image-size-1-1.svg'
|
||||
import ImageSize1_2 from '@renderer/assets/images/paintings/image-size-1-2.svg'
|
||||
import ImageSize3_2 from '@renderer/assets/images/paintings/image-size-3-2.svg'
|
||||
import ImageSize3_4 from '@renderer/assets/images/paintings/image-size-3-4.svg'
|
||||
import ImageSize9_16 from '@renderer/assets/images/paintings/image-size-9-16.svg'
|
||||
import ImageSize16_9 from '@renderer/assets/images/paintings/image-size-16-9.svg'
|
||||
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import AiProvider from '@renderer/providers/AiProvider'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { DEFAULT_PAINTING } from '@renderer/store/paintings'
|
||||
import { setGenerating } from '@renderer/store/runtime'
|
||||
import { FileType, Painting } from '@renderer/types'
|
||||
import { getErrorMessage } from '@renderer/utils'
|
||||
import { Button, Input, InputNumber, Radio, Select, Slider, Tooltip } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { FC, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
||||
import { SettingTitle } from '../settings'
|
||||
import Artboard from './Artboard'
|
||||
import PaintingsList from './PaintingsList'
|
||||
|
||||
const IMAGE_SIZES = [
|
||||
{
|
||||
label: '1:1',
|
||||
value: '1024x1024',
|
||||
icon: ImageSize1_1
|
||||
},
|
||||
{
|
||||
label: '1:2',
|
||||
value: '512x1024',
|
||||
icon: ImageSize1_2
|
||||
},
|
||||
{
|
||||
label: '3:2',
|
||||
value: '768x512',
|
||||
icon: ImageSize3_2
|
||||
},
|
||||
{
|
||||
label: '3:4',
|
||||
value: '768x1024',
|
||||
icon: ImageSize3_4
|
||||
},
|
||||
{
|
||||
label: '16:9',
|
||||
value: '1024x576',
|
||||
icon: ImageSize16_9
|
||||
},
|
||||
{
|
||||
label: '9:16',
|
||||
value: '576x1024',
|
||||
icon: ImageSize9_16
|
||||
}
|
||||
]
|
||||
|
||||
let _painting: Painting
|
||||
|
||||
const PaintingsPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { paintings, addPainting, removePainting, updatePainting } = usePaintings()
|
||||
const [painting, setPainting] = useState<Painting>(_painting || paintings[0])
|
||||
const { theme } = useTheme()
|
||||
const providers = useAllProviders()
|
||||
const siliconProvider = providers.find((p) => p.id === 'silicon')!
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
const dispatch = useAppDispatch()
|
||||
const { generating } = useRuntime()
|
||||
|
||||
const modelOptions = TEXT_TO_IMAGES_MODELS.map((model) => ({
|
||||
label: model.name,
|
||||
value: model.id
|
||||
}))
|
||||
|
||||
const textareaRef = useRef<any>(null)
|
||||
_painting = painting
|
||||
|
||||
const updatePaintingState = (updates: Partial<Painting>) => {
|
||||
const updatedPainting = { ...painting, ...updates }
|
||||
setPainting(updatedPainting)
|
||||
updatePainting(updatedPainting)
|
||||
}
|
||||
|
||||
const onSelectModel = (modelId: string) => {
|
||||
const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === modelId)
|
||||
if (model) {
|
||||
updatePaintingState({ model: modelId })
|
||||
}
|
||||
}
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (painting.files.length > 0) {
|
||||
const confirmed = await window.modal.confirm({
|
||||
content: t('images.regenerate.confirm'),
|
||||
centered: true
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
await FileManager.deleteFiles(painting.files)
|
||||
}
|
||||
|
||||
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
|
||||
|
||||
updatePaintingState({ prompt })
|
||||
|
||||
const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === painting.model)
|
||||
const provider = getProviderByModel(model)
|
||||
|
||||
if (!provider.enabled) {
|
||||
window.modal.error({
|
||||
content: t('error.provider_disabled'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!provider.apiKey) {
|
||||
window.modal.error({
|
||||
content: t('error.no_api_key'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
setIsLoading(true)
|
||||
dispatch(setGenerating(true))
|
||||
const AI = new AiProvider(provider)
|
||||
|
||||
try {
|
||||
const urls = await AI.generateImage({
|
||||
prompt,
|
||||
negativePrompt: painting.negativePrompt || '',
|
||||
imageSize: painting.imageSize || '1024x1024',
|
||||
batchSize: painting.numImages || 1,
|
||||
seed: painting.seed || undefined,
|
||||
numInferenceSteps: painting.steps || 25,
|
||||
guidanceScale: painting.guidanceScale || 4.5,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (urls.length > 0) {
|
||||
const downloadedFiles = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
return await window.api.file.download(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const validFiles = downloadedFiles.filter((file): file is FileType => file !== null)
|
||||
|
||||
await FileManager.addFiles(validFiles)
|
||||
|
||||
updatePaintingState({ files: validFiles, urls })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
window.modal.error({
|
||||
content: getErrorMessage(error),
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
setAbortController(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
abortController?.abort()
|
||||
}
|
||||
|
||||
const onSelectImageSize = (v: string) => {
|
||||
const size = IMAGE_SIZES.find((i) => i.value === v)
|
||||
size && updatePaintingState({ imageSize: size.value })
|
||||
}
|
||||
|
||||
const nextImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev + 1) % painting.files.length)
|
||||
}
|
||||
|
||||
const prevImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length)
|
||||
}
|
||||
|
||||
const onDeletePainting = (paintingToDelete: Painting) => {
|
||||
if (paintingToDelete.id === painting.id) {
|
||||
const currentIndex = paintings.findIndex((p) => p.id === paintingToDelete.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
setPainting(paintings[currentIndex - 1])
|
||||
} else if (paintings.length > 1) {
|
||||
setPainting(paintings[1])
|
||||
}
|
||||
}
|
||||
|
||||
removePainting(paintingToDelete)
|
||||
|
||||
if (paintings.length === 1) {
|
||||
setPainting(DEFAULT_PAINTING)
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectPainting = (newPainting: Painting) => {
|
||||
if (generating) return
|
||||
setPainting(newPainting)
|
||||
setCurrentImageIndex(0)
|
||||
}
|
||||
|
||||
const handleTranslation = async (translatedText: string) => {
|
||||
const currentText = textareaRef.current?.resizableTextArea?.textArea?.value
|
||||
|
||||
if (currentText) {
|
||||
await navigator.clipboard.writeText(currentText)
|
||||
|
||||
const confirmed = await window.modal.confirm({
|
||||
content: t('translate.confirm'),
|
||||
centered: true
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
updatePaintingState({ prompt: translatedText })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('images.title')}</NavbarCenter>
|
||||
{isMac && (
|
||||
<NavbarRight style={{ justifyContent: 'flex-end' }}>
|
||||
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={() => setPainting(addPainting())}>
|
||||
{t('images.button.new.image')}
|
||||
</Button>
|
||||
</NavbarRight>
|
||||
)}
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<LeftContainer>
|
||||
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
|
||||
<Select
|
||||
value={siliconProvider.id}
|
||||
disabled={true}
|
||||
options={[{ label: siliconProvider.name, value: siliconProvider.id }]}
|
||||
/>
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
|
||||
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('images.image.size')}</SettingTitle>
|
||||
<Radio.Group
|
||||
value={painting.imageSize}
|
||||
onChange={(e) => onSelectImageSize(e.target.value)}
|
||||
style={{ display: 'flex' }}>
|
||||
{IMAGE_SIZES.map((size) => (
|
||||
<RadioButton value={size.value} key={size.value}>
|
||||
<VStack alignItems="center">
|
||||
<ImageSizeImage src={size.icon} theme={theme} />
|
||||
<span>{size.label}</span>
|
||||
</VStack>
|
||||
</RadioButton>
|
||||
))}
|
||||
</Radio.Group>
|
||||
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t('images.number_images')}
|
||||
<Tooltip title={t('images.number_images_tip')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={4}
|
||||
value={painting.numImages}
|
||||
onChange={(v) => updatePaintingState({ numImages: v || 1 })}
|
||||
/>
|
||||
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t('images.seed')}
|
||||
<Tooltip title={t('images.seed_tip')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<Input
|
||||
value={painting.seed}
|
||||
onChange={(e) => updatePaintingState({ seed: e.target.value })}
|
||||
suffix={<RefreshIcon onClick={() => updatePaintingState({ seed: '' })} />}
|
||||
/>
|
||||
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t('images.inference_steps')}
|
||||
<Tooltip title={t('images.inference_steps_tip')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<Slider min={1} max={50} value={painting.steps} onChange={(v) => updatePaintingState({ steps: v })} />
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={50}
|
||||
value={painting.steps}
|
||||
onChange={(v) => updatePaintingState({ steps: v || 25 })}
|
||||
/>
|
||||
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t('images.guidance_scale')}
|
||||
<Tooltip title={t('images.guidance_scale_tip')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<Slider
|
||||
min={1}
|
||||
max={20}
|
||||
step={0.1}
|
||||
value={painting.guidanceScale}
|
||||
onChange={(v) => updatePaintingState({ guidanceScale: v })}
|
||||
/>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={20}
|
||||
step={0.1}
|
||||
value={painting.guidanceScale}
|
||||
onChange={(v) => updatePaintingState({ guidanceScale: v || 4.5 })}
|
||||
/>
|
||||
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t('images.negative_prompt')}
|
||||
<Tooltip title={t('images.negative_prompt_tip')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<TextArea
|
||||
value={painting.negativePrompt}
|
||||
onChange={(e) => updatePaintingState({ negativePrompt: e.target.value })}
|
||||
rows={4}
|
||||
/>
|
||||
</LeftContainer>
|
||||
<MainContainer>
|
||||
<Artboard
|
||||
painting={painting}
|
||||
isLoading={isLoading}
|
||||
currentImageIndex={currentImageIndex}
|
||||
onPrevImage={prevImage}
|
||||
onNextImage={nextImage}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<InputContainer>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
variant="borderless"
|
||||
disabled={isLoading}
|
||||
value={painting.prompt}
|
||||
onChange={(e) => updatePaintingState({ prompt: e.target.value })}
|
||||
placeholder={t('images.prompt_placeholder')}
|
||||
/>
|
||||
<Toolbar>
|
||||
<ToolbarMenu>
|
||||
<TranslateButton
|
||||
text={textareaRef.current?.resizableTextArea?.textArea?.value}
|
||||
onTranslated={handleTranslation}
|
||||
disabled={isLoading}
|
||||
style={{ marginRight: 6, borderRadius: '50%' }}
|
||||
/>
|
||||
<SendMessageButton sendMessage={onGenerate} disabled={isLoading} />
|
||||
</ToolbarMenu>
|
||||
</Toolbar>
|
||||
</InputContainer>
|
||||
</MainContainer>
|
||||
<PaintingsList
|
||||
paintings={paintings}
|
||||
selectedPainting={painting}
|
||||
onSelectPainting={onSelectPainting}
|
||||
onDeletePainting={onDeletePainting}
|
||||
onNewPainting={() => setPainting(addPainting())}
|
||||
/>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const LeftContainer = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background-color: var(--color-background);
|
||||
max-width: var(--assistants-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const MainContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 95px;
|
||||
max-height: 95px;
|
||||
position: relative;
|
||||
border-top: 1px solid var(--color-border-soft);
|
||||
transition: all 0.3s ease;
|
||||
`
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
padding: 10px;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: Ubuntu;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: auto;
|
||||
`
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
padding: 0 8px;
|
||||
padding-bottom: 0;
|
||||
height: 40px;
|
||||
`
|
||||
|
||||
const ToolbarMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const ImageSizeImage = styled.img<{ theme: string }>`
|
||||
filter: ${({ theme }) => (theme === 'dark' ? 'invert(100%)' : 'none')};
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
const RadioButton = styled(Radio.Button)`
|
||||
width: 30px;
|
||||
height: 55px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const InfoIcon = styled(QuestionCircleOutlined)`
|
||||
margin-left: 5px;
|
||||
cursor: help;
|
||||
color: var(--color-text-2);
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const RefreshIcon = styled.span`
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
export default PaintingsPage
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { HStack, VStack } from '@renderer/components/Layout'
|
||||
import { backup, reset, restore } from '@renderer/services/backup'
|
||||
import { backup, reset, restore } from '@renderer/services/BackupService'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/backup'
|
||||
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/BackupService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setWebdavHost as _setWebdavHost,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EditOutlined, MessageOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId, hasModel } from '@renderer/services/model'
|
||||
import { getModelUniqId, hasModel } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Select } from 'antd'
|
||||
import { find, sortBy } from 'lodash'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined }
|
||||
import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
||||
import { getModelLogo, isVisionModel, SYSTEM_MODELS } from '@renderer/config/models'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { fetchModels } from '@renderer/services/api'
|
||||
import { fetchModels } from '@renderer/services/ApiService'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils'
|
||||
import { Avatar, Button, Empty, Flex, Modal, Tag } from 'antd'
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { isOpenAIProvider } from '@renderer/providers/ProviderFactory'
|
||||
import { checkApi } from '@renderer/services/api'
|
||||
import { checkApi } from '@renderer/services/ApiService'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { Avatar, Button, Card, Divider, Flex, Input, Space, Switch } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
|
||||
@@ -36,7 +36,7 @@ const ShortcutSettings: FC = () => {
|
||||
return (
|
||||
<span>
|
||||
{keys.map((key) => (
|
||||
<Tag key={key} style={{ padding: '0 8px' }}>
|
||||
<Tag key={key} style={{ padding: '2px 8px', fontSize: '13px' }}>
|
||||
<span style={{ fontFamily: 'monospace' }}>{key}</span>
|
||||
</Tag>
|
||||
))}
|
||||
|
||||
@@ -4,8 +4,8 @@ import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import db from '@renderer/databases'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { fetchTranslate } from '@renderer/services/api'
|
||||
import { getDefaultAssistant } from '@renderer/services/assistant'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
import { Assistant, Message } from '@renderer/types'
|
||||
import { runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { Button, Select, Space } from 'antd'
|
||||
@@ -104,9 +104,7 @@ const TranslatePage: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const assistant: Assistant = getDefaultAssistant()
|
||||
assistant.model = translateModel
|
||||
assistant.prompt = `Translate from input language to ${targetLanguage}, provide the translation result directly without any explanation, keep original format. If the target language is the same as the source language, do not translate. The text to be translated is as follows:\n\n ${text}`
|
||||
const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text)
|
||||
|
||||
const message: Message = {
|
||||
id: uuid(),
|
||||
|
||||
@@ -41,4 +41,17 @@ export default class AiProvider {
|
||||
public async models(): Promise<OpenAI.Models.Model[]> {
|
||||
return this.sdk.models()
|
||||
}
|
||||
|
||||
public async generateImage(params: {
|
||||
prompt: string
|
||||
negativePrompt: string
|
||||
imageSize: string
|
||||
batchSize: number
|
||||
seed?: string
|
||||
numInferenceSteps: number
|
||||
guidanceScale: number
|
||||
signal?: AbortSignal
|
||||
}): Promise<string[]> {
|
||||
return this.sdk.generateImage(params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import Anthropic from '@anthropic-ai/sdk'
|
||||
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources'
|
||||
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
|
||||
import { SUMMARIZE_PROMPT } from '@renderer/config/prompts'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES } from '@renderer/services/event'
|
||||
import { filterContextMessages } from '@renderer/services/messages'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES } from '@renderer/services/EventService'
|
||||
import { filterContextMessages } from '@renderer/services/MessagesService'
|
||||
import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types'
|
||||
import { first, flatten, sum, takeRight } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
@@ -193,6 +193,10 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
return message.content[0].type === 'text' ? message.content[0].text : ''
|
||||
}
|
||||
|
||||
public async generateImage(): Promise<string[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
public async suggestions(): Promise<Suggestion[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -41,4 +41,14 @@ export default abstract class BaseProvider {
|
||||
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
|
||||
abstract check(): Promise<{ valid: boolean; error: Error | null }>
|
||||
abstract models(): Promise<OpenAI.Models.Model[]>
|
||||
abstract generateImage(_params: {
|
||||
prompt: string
|
||||
negativePrompt: string
|
||||
imageSize: string
|
||||
batchSize: number
|
||||
seed?: string
|
||||
numInferenceSteps: number
|
||||
guidanceScale: number
|
||||
signal?: AbortSignal
|
||||
}): Promise<string[]>
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
TextPart
|
||||
} from '@google/generative-ai'
|
||||
import { SUMMARIZE_PROMPT } from '@renderer/config/prompts'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES } from '@renderer/services/event'
|
||||
import { filterContextMessages } from '@renderer/services/messages'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES } from '@renderer/services/EventService'
|
||||
import { filterContextMessages } from '@renderer/services/MessagesService'
|
||||
import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types'
|
||||
import axios from 'axios'
|
||||
import { first, isEmpty, takeRight } from 'lodash'
|
||||
@@ -199,6 +199,10 @@ export default class GeminiProvider extends BaseProvider {
|
||||
return []
|
||||
}
|
||||
|
||||
public async generateImage(): Promise<string[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
public async check(): Promise<{ valid: boolean; error: Error | null }> {
|
||||
const model = this.provider.models[0]
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isSupportedModel, isVisionModel } from '@renderer/config/models'
|
||||
import { SUMMARIZE_PROMPT } from '@renderer/config/prompts'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES } from '@renderer/services/event'
|
||||
import { filterContextMessages } from '@renderer/services/messages'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES } from '@renderer/services/EventService'
|
||||
import { filterContextMessages } from '@renderer/services/MessagesService'
|
||||
import { Assistant, FileTypes, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import { removeQuotes } from '@renderer/utils'
|
||||
import { takeRight } from 'lodash'
|
||||
@@ -350,4 +350,43 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async generateImage({
|
||||
prompt,
|
||||
negativePrompt,
|
||||
imageSize,
|
||||
batchSize,
|
||||
seed,
|
||||
numInferenceSteps,
|
||||
guidanceScale,
|
||||
signal
|
||||
}: {
|
||||
prompt: string
|
||||
negativePrompt?: string
|
||||
imageSize: string
|
||||
batchSize: number
|
||||
seed?: string
|
||||
numInferenceSteps: number
|
||||
guidanceScale: number
|
||||
signal?: AbortSignal
|
||||
}): Promise<string[]> {
|
||||
const response = (await this.sdk.request({
|
||||
method: 'post',
|
||||
path: '/images/generations',
|
||||
headers: this.getHeaders(),
|
||||
signal,
|
||||
body: {
|
||||
model: 'stabilityai/stable-diffusion-3-5-large',
|
||||
prompt,
|
||||
negative_prompt: negativePrompt,
|
||||
image_size: imageSize,
|
||||
batch_size: batchSize,
|
||||
seed: seed ? parseInt(seed) : undefined,
|
||||
num_inference_steps: numInferenceSteps,
|
||||
guidance_scale: guidanceScale
|
||||
}
|
||||
})) as { data: Array<{ url: string }> }
|
||||
|
||||
return response.data.map((item) => item.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
getProviderByModel,
|
||||
getTopNamingModel,
|
||||
getTranslateModel
|
||||
} from './assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from './event'
|
||||
import { filterMessages } from './messages'
|
||||
import { estimateMessagesUsage } from './tokens'
|
||||
} from './AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from './EventService'
|
||||
import { filterMessages } from './MessagesService'
|
||||
import { estimateMessagesUsage } from './TokenService'
|
||||
|
||||
export async function fetchChatCompletion({
|
||||
message,
|
||||
@@ -6,7 +6,7 @@ import { addAssistant } from '@renderer/store/assistants'
|
||||
import { Agent, Assistant, AssistantSettings, Message, Model, Provider, Topic } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
|
||||
import { estimateMessageUsage } from './tokens'
|
||||
import { estimateMessageUsage } from './TokenService'
|
||||
|
||||
export function getDefaultAssistant(): Assistant {
|
||||
return {
|
||||
@@ -19,6 +19,14 @@ export function getDefaultAssistant(): Assistant {
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultTranslateAssistant(targetLanguage: string, text: string): Assistant {
|
||||
const translateModel = getTranslateModel()
|
||||
const assistant: Assistant = getDefaultAssistant()
|
||||
assistant.model = translateModel
|
||||
assistant.prompt = `Translate from input language to ${targetLanguage}, provide the translation result directly without any explanation, keep original format. If the target language is the same as the source language, do not translate. The text to be translated is as follows:\n\n ${text}`
|
||||
return assistant
|
||||
}
|
||||
|
||||
export function getDefaultAssistantSettings() {
|
||||
return store.getState().assistants.defaultAssistant.settings
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function restore() {
|
||||
const restoreData = await window.api.backup.restore(file.filePath)
|
||||
data = JSON.parse(restoreData)
|
||||
} else {
|
||||
data = JSON.parse(await window.api.decompress(file.content))
|
||||
data = JSON.parse(await window.api.zip.decompress(file.content))
|
||||
}
|
||||
|
||||
await handleData(data)
|
||||
@@ -9,6 +9,23 @@ class FileManager {
|
||||
return files
|
||||
}
|
||||
|
||||
static async addFile(file: FileType): Promise<FileType> {
|
||||
const fileRecord = await db.files.get(file.id)
|
||||
|
||||
if (fileRecord) {
|
||||
await db.files.update(fileRecord.id, { ...fileRecord, count: fileRecord.count + 1 })
|
||||
return fileRecord
|
||||
}
|
||||
|
||||
await db.files.add(file)
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
static async addFiles(files: FileType[]): Promise<FileType[]> {
|
||||
return Promise.all(files.map((file) => this.addFile(file)))
|
||||
}
|
||||
|
||||
static async uploadFile(file: FileType): Promise<FileType> {
|
||||
const uploadFile = await window.api.file.upload(file)
|
||||
const fileRecord = await db.files.get(uploadFile.id)
|
||||
@@ -50,12 +67,12 @@ class FileManager {
|
||||
return
|
||||
}
|
||||
|
||||
db.files.delete(id)
|
||||
await db.files.delete(id)
|
||||
await window.api.file.delete(id + file.ext)
|
||||
}
|
||||
|
||||
static async deleteFiles(ids: string[]): Promise<void> {
|
||||
await Promise.all(ids.map((id) => this.deleteFile(id)))
|
||||
static async deleteFiles(files: FileType[]): Promise<void> {
|
||||
await Promise.all(files.map((file) => this.deleteFile(file.id)))
|
||||
}
|
||||
|
||||
static async allFiles(): Promise<FileType[]> {
|
||||