Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dfa81f11f | ||
|
|
3693e115a6 | ||
|
|
2f3a3c8c48 | ||
|
|
f6d71868cb | ||
|
|
e5bf6916a6 | ||
|
|
7c119e2747 | ||
|
|
03ef52c6a7 | ||
|
|
e21e0d238e | ||
|
|
631fa3a42a | ||
|
|
ee042e11f1 | ||
|
|
178d164ff9 | ||
|
|
e5ded81d9b | ||
|
|
c8e6872fb8 | ||
|
|
2820f85be4 | ||
|
|
1989f246fd | ||
|
|
3ac414e97b | ||
|
|
bcb93fc2d0 | ||
|
|
9f579334f8 | ||
|
|
6f75b1738d | ||
|
|
72d939721d | ||
|
|
7d6ef1d69a | ||
|
|
e162da55bd | ||
|
|
fc5209723f | ||
|
|
75153ce83c | ||
|
|
fd1cf1331f | ||
|
|
a5738fdae5 | ||
|
|
86b95ee17a | ||
|
|
587e1d9971 | ||
|
|
b8e978f2a1 | ||
|
|
bd8452032e | ||
|
|
0079f4f437 | ||
|
|
0436ea671e | ||
|
|
05c29b2bc1 | ||
|
|
3f97aef93f | ||
|
|
2e3adb0b1b | ||
|
|
507da84b80 | ||
|
|
b30a30efa8 | ||
|
|
c29ff577ed | ||
|
|
f9512d6ef2 | ||
|
|
326163d798 | ||
|
|
7099bee833 | ||
|
|
b1839b722f | ||
|
|
46ae4f9b55 | ||
|
|
a9a0ae87d3 | ||
|
|
d9661602b2 | ||
|
|
877a1f8306 | ||
|
|
5dd508b4f4 | ||
|
|
406bf6a509 | ||
|
|
f0ae2aa6dc | ||
|
|
0884caea97 | ||
|
|
8e870710b5 | ||
|
|
27a92463bf | ||
|
|
3298b3f403 | ||
|
|
894c20dd05 | ||
|
|
a0945af285 | ||
|
|
6e12d2fa2e | ||
|
|
2798bd9d9d | ||
|
|
d07c6ecc6b | ||
|
|
57f10bf56f | ||
|
|
d4fc5d6503 | ||
|
|
55c57d72ba | ||
|
|
c468c3cfd5 | ||
|
|
bb3bfafe7e | ||
|
|
0641857f26 | ||
|
|
0d5b9d8d62 | ||
|
|
09952a4d3b | ||
|
|
e8753e0cf3 | ||
|
|
05dc5da7b0 | ||
|
|
12f5d9acfd | ||
|
|
30885655dd | ||
|
|
49d4ae6e9b | ||
|
|
2d7caf9fc5 | ||
|
|
5cf0a43b2c | ||
|
|
98426e084a | ||
|
|
52559534c6 | ||
|
|
ab5ffe4e2e | ||
|
|
3d8f514f30 | ||
|
|
47faa6edf2 | ||
|
|
a95ace3dc5 | ||
|
|
582427663f | ||
|
|
3abe0e803c | ||
|
|
94b602b250 | ||
|
|
d42bf89045 | ||
|
|
75f25d8a44 | ||
|
|
ae163ff0ed | ||
|
|
cc4008bf2b | ||
|
|
e9dc0d12d6 | ||
|
|
00f1fb1e11 | ||
|
|
db01b4981d | ||
|
|
6036a94690 | ||
|
|
0c32ac1262 | ||
|
|
e3f5999362 | ||
|
|
f20b8c58ee | ||
|
|
553e3d7989 | ||
|
|
b1e0b56783 | ||
|
|
6356e1c0c2 | ||
|
|
362fdc069e | ||
|
|
ee67f7bf9b | ||
|
|
5cee35b167 | ||
|
|
cd02ee3125 | ||
|
|
7dac8cee64 | ||
|
|
5d3b751aa0 | ||
|
|
0707be1e1f | ||
|
|
5e1c45370b | ||
|
|
d4abea4101 | ||
|
|
08e7b6a7ba | ||
|
|
086b09d99a | ||
|
|
1475f75a35 | ||
|
|
b1babc8cb3 | ||
|
|
537ada3256 | ||
|
|
bdbb937403 | ||
|
|
8bfbbd497c | ||
|
|
4b2417ce37 | ||
|
|
68689692b0 | ||
|
|
03fa6b5a74 | ||
|
|
2202b82f33 | ||
|
|
fed855a4ae | ||
|
|
22ee6f042f | ||
|
|
114d4850b1 | ||
|
|
711888a897 | ||
|
|
f43c16b85f | ||
|
|
2f47250748 | ||
|
|
653f1d54d2 | ||
|
|
f0e43e9bcd | ||
|
|
7706a4cc36 | ||
|
|
bd65e7bac4 | ||
|
|
f6a496d1b9 | ||
|
|
ba88a24455 | ||
|
|
42f5485899 | ||
|
|
e51a37cc74 | ||
|
|
5a7423463f | ||
|
|
928a597d38 | ||
|
|
86a58ea050 | ||
|
|
71ae6f2713 | ||
|
|
dbc6014506 | ||
|
|
d7139cca02 | ||
|
|
e9ab193a24 | ||
|
|
548db37066 | ||
|
|
ece9e2ef13 | ||
|
|
2894eec438 | ||
|
|
c2465c33b7 | ||
|
|
3c2e8e2f1d | ||
|
|
f84358adfd | ||
|
|
51071d65fb | ||
|
|
ba30bffa49 | ||
|
|
c66563d335 | ||
|
|
c7b0b5ce53 | ||
|
|
273fabafcb | ||
|
|
6f5f917c98 | ||
|
|
4ad3e44769 | ||
|
|
2dedd95fcc | ||
|
|
c6b87b307b | ||
|
|
e6e0000328 |
2
.github/workflows/issue-management.yml
vendored
@@ -54,5 +54,5 @@ jobs:
|
||||
days-before-pr-close: -1 # Completely disable closing for PRs
|
||||
|
||||
# Temporary to reduce the huge issues number
|
||||
operations-per-run: 100
|
||||
operations-per-run: 1000
|
||||
debug-only: false
|
||||
|
||||
1
.gitignore
vendored
@@ -51,3 +51,4 @@ local
|
||||
coverage
|
||||
.vitest-cache
|
||||
vitest.config.*.timestamp-*
|
||||
YOUR_MEMORY_FILE_PATH
|
||||
|
||||
159
.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
diff --git a/out/macPackager.js b/out/macPackager.js
|
||||
index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda1811e924b8 100644
|
||||
--- a/out/macPackager.js
|
||||
+++ b/out/macPackager.js
|
||||
@@ -423,7 +423,7 @@ class MacPackager extends platformPackager_1.PlatformPackager {
|
||||
}
|
||||
appPlist.CFBundleName = appInfo.productName;
|
||||
appPlist.CFBundleDisplayName = appInfo.productName;
|
||||
- const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ const minimumSystemVersion = this.platformSpecificBuildOptions.LSMinimumSystemVersion;
|
||||
if (minimumSystemVersion != null) {
|
||||
appPlist.LSMinimumSystemVersion = minimumSystemVersion;
|
||||
}
|
||||
diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js
|
||||
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
|
||||
--- a/out/publish/updateInfoBuilder.js
|
||||
+++ b/out/publish/updateInfoBuilder.js
|
||||
@@ -133,6 +133,7 @@ async function createUpdateInfo(version, event, releaseInfo) {
|
||||
const customUpdateInfo = event.updateInfo;
|
||||
const url = path.basename(event.file);
|
||||
const sha512 = (customUpdateInfo == null ? null : customUpdateInfo.sha512) || (await (0, hash_1.hashFile)(event.file));
|
||||
+ const minimumSystemVersion = customUpdateInfo == null ? null : customUpdateInfo.minimumSystemVersion;
|
||||
const files = [{ url, sha512 }];
|
||||
const result = {
|
||||
// @ts-ignore
|
||||
@@ -143,9 +144,13 @@ async function createUpdateInfo(version, event, releaseInfo) {
|
||||
path: url /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
|
||||
// @ts-ignore
|
||||
sha512 /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
|
||||
+ minimumSystemVersion,
|
||||
...releaseInfo,
|
||||
};
|
||||
if (customUpdateInfo != null) {
|
||||
+ if (customUpdateInfo.minimumSystemVersion) {
|
||||
+ delete customUpdateInfo.minimumSystemVersion;
|
||||
+ }
|
||||
// file info or nsis web installer packages info
|
||||
Object.assign("sha512" in customUpdateInfo ? files[0] : result, customUpdateInfo);
|
||||
}
|
||||
diff --git a/out/targets/ArchiveTarget.js b/out/targets/ArchiveTarget.js
|
||||
index e1f52a5fa86fff6643b2e57eaf2af318d541f865..47cc347f154a24b365e70ae5e1f6d309f3582ed0 100644
|
||||
--- a/out/targets/ArchiveTarget.js
|
||||
+++ b/out/targets/ArchiveTarget.js
|
||||
@@ -69,6 +69,9 @@ class ArchiveTarget extends core_1.Target {
|
||||
}
|
||||
}
|
||||
}
|
||||
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
|
||||
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ }
|
||||
await packager.info.emitArtifactBuildCompleted({
|
||||
updateInfo,
|
||||
file: artifactPath,
|
||||
diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js
|
||||
index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01de4b89b1 100644
|
||||
--- a/out/targets/nsis/NsisTarget.js
|
||||
+++ b/out/targets/nsis/NsisTarget.js
|
||||
@@ -305,6 +305,9 @@ class NsisTarget extends core_1.Target {
|
||||
if (updateInfo != null && isPerMachine && (oneClick || options.packElevateHelper)) {
|
||||
updateInfo.isAdminRightsRequired = true;
|
||||
}
|
||||
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
|
||||
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ }
|
||||
await packager.info.emitArtifactBuildCompleted({
|
||||
file: installerPath,
|
||||
updateInfo,
|
||||
diff --git a/scheme.json b/scheme.json
|
||||
index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43ebd0fa8b61 100644
|
||||
--- a/scheme.json
|
||||
+++ b/scheme.json
|
||||
@@ -1975,6 +1975,13 @@
|
||||
],
|
||||
"description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing."
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"packageCategory": {
|
||||
"description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place",
|
||||
"type": [
|
||||
@@ -2327,6 +2334,13 @@
|
||||
"MacConfiguration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
+ "LSMinimumSystemVersion": {
|
||||
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"additionalArguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2737,7 +2751,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumSystemVersion": {
|
||||
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
@@ -2959,6 +2973,13 @@
|
||||
"MasConfiguration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
+ "LSMinimumSystemVersion": {
|
||||
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"additionalArguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3369,7 +3390,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumSystemVersion": {
|
||||
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
@@ -6507,6 +6528,13 @@
|
||||
"string"
|
||||
]
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"protocols": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -7376,6 +7404,13 @@
|
||||
],
|
||||
"description": "MAS (Mac Application Store) development options (`mas-dev` target)."
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"msi": {
|
||||
"anyOf": [
|
||||
{
|
||||
934
.yarn/releases/yarn-4.6.0.cjs
vendored
948
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable file
@@ -4,4 +4,4 @@ httpTimeout: 300000
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.6.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.9.1.cjs
|
||||
|
||||
12
README.md
@@ -23,9 +23,11 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
|
||||
# 🌠 Screenshot
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 Key Features
|
||||
|
||||
@@ -96,7 +98,7 @@ Refer to the [development documentation](docs/dev.md)
|
||||
|
||||
Refer to the [Architecture overview documentation](https://deepwiki.com/CherryHQ/cherry-studio)
|
||||
|
||||
Refer to the [Branching Strategy](docs/branching-strategy.md) for contribution guidelines
|
||||
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
|
||||
|
||||
# 🤝 Contributing
|
||||
|
||||
@@ -121,7 +123,7 @@ For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIB
|
||||
|
||||
Thank you for your support and contributions!
|
||||
|
||||
## Related Projects
|
||||
# 🔗 Related Projects
|
||||
|
||||
- [one-api](https://github.com/songquanpeng/one-api):LLM API management and distribution system, supporting mainstream models like OpenAI, Azure, and Anthropic. Features unified API interface, suitable for key management and secondary distribution.
|
||||
|
||||
|
||||
@@ -6,17 +6,19 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 <br>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
|
||||
Cherry Studio は、複数の LLM プロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linux で利用可能です。
|
||||
|
||||
👏 [Telegram](https://t.me/CherryStudioAI)|[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
|
||||
|
||||
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
|
||||
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!
|
||||
|
||||
# 📖 ガイド
|
||||
|
||||
@@ -24,9 +26,11 @@ https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 スクリーンショット
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 主な機能
|
||||
|
||||
@@ -56,7 +60,7 @@ https://docs.cherry-ai.com
|
||||
- 🔤 AI による翻訳機能
|
||||
- 🎯 ドラッグ&ドロップによる整理
|
||||
- 🔌 ミニプログラム対応
|
||||
- ⚙️ MCP(モデルコンテキストプロトコル) サービス
|
||||
- ⚙️ MCP(モデルコンテキストプロトコル)サービス
|
||||
|
||||
5. **優れたユーザー体験**:
|
||||
|
||||
@@ -70,71 +74,78 @@ https://docs.cherry-ai.com
|
||||
|
||||
- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約)
|
||||
- [x] 複数モデルの回答の比較
|
||||
- [x] サービスプロバイダーが提供する SSO を使用したログインをサポート
|
||||
- [x] すべてのモデルがネットワークをサポート
|
||||
- [x] サービスプロバイダーが提供する SSO を使用したログイン対応
|
||||
- [x] すべてのモデルのネットワーク対応
|
||||
- [x] 最初の公式バージョンのリリース
|
||||
- [ ] 錯誤修復と改善 (開発中...)
|
||||
- [x] バグ修正と改善(進行中...)
|
||||
- [ ] プラグイン機能(JavaScript)
|
||||
- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加)
|
||||
- [ ] iOS & Android クライアント
|
||||
- [ ] AIノート
|
||||
- [ ] AI ノート
|
||||
- [ ] 音声入出力(AI コール)
|
||||
- [ ] データバックアップはカスタムバックアップコンテンツをサポート
|
||||
- [ ] データバックアップのカスタマイズ対応
|
||||
|
||||
# 🌈 テーマ
|
||||
|
||||
- テーマギャラリー: https://cherrycss.com
|
||||
- Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial テーマ: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- Claude テーマ: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- メープルネオンテーマ: https://github.com/BoningtonChen/CherryStudio_themes
|
||||
- テーマギャラリー:https://cherrycss.com
|
||||
- Aero テーマ:https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial テーマ:https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- Claude テーマ:https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- メープルネオンテーマ:https://github.com/BoningtonChen/CherryStudio_themes
|
||||
|
||||
より多くのテーマのPRを歓迎します
|
||||
より多くのテーマの PR を歓迎します
|
||||
|
||||
# 🖥️ 開発
|
||||
|
||||
参考[開発ドキュメント](dev.md)
|
||||
[開発ドキュメント](dev.md)を参照してください
|
||||
|
||||
[アーキテクチャ概要ドキュメント](https://deepwiki.com/CherryHQ/cherry-studio)を参照してください
|
||||
|
||||
[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
|
||||
|
||||
# 🤝 貢献
|
||||
|
||||
Cherry Studio への貢献を歓迎します!以下の方法で貢献できます:
|
||||
|
||||
1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します。
|
||||
2. **バグの修正**:見つけたバグを修正します。
|
||||
3. **問題の管理**:GitHub の問題を管理するのを手伝います。
|
||||
4. **製品デザイン**:デザインの議論に参加します。
|
||||
5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します。
|
||||
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します。
|
||||
7. **使用の促進**:Cherry Studio を広めます。
|
||||
1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します
|
||||
2. **バグの修正**:見つけたバグを修正します
|
||||
3. **問題の管理**:GitHub の問題を管理するのを手伝います
|
||||
4. **製品デザイン**:デザインの議論に参加します
|
||||
5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します
|
||||
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します
|
||||
7. **使用の促進**:Cherry Studio を広めます
|
||||
|
||||
## 始め方
|
||||
|
||||
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします。
|
||||
2. **ブランチを作成**:変更のためのブランチを作成します。
|
||||
3. **変更を提出**:変更をコミットしてプッシュします。
|
||||
4. **プルリクエストを開く**:変更内容と理由を説明します。
|
||||
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします
|
||||
2. **ブランチを作成**:変更のためのブランチを作成します
|
||||
3. **変更を提出**:変更をコミットしてプッシュします
|
||||
4. **プルリクエストを開く**:変更内容と理由を説明します
|
||||
|
||||
詳細なガイドラインについては、[貢献ガイド](../CONTRIBUTING.md)をご覧ください。
|
||||
|
||||
ご支援と貢献に感謝します!
|
||||
|
||||
## 関連頁版
|
||||
# 🔗 関連プロジェクト
|
||||
|
||||
- [one-api](https://github.com/songquanpeng/one-api):LLM API の管理・配信システム。OpenAI、Azure、Anthropic などの主要モデルに対応し、統一 API インターフェースを提供。API キー管理と再配布に利用可能。
|
||||
|
||||
- [ublacklist](https://github.com/iorate/ublacklist):Google 検索結果から特定のサイトを非表示にします
|
||||
|
||||
# 🚀 コントリビューター
|
||||
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# コミュニティ
|
||||
# 🌐 コミュニティ
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# スポンサー
|
||||
# ☕ スポンサー
|
||||
|
||||
[Buy Me a Coffee](sponsor.md)
|
||||
[開発者を支援する](sponsor.md)
|
||||
|
||||
# 📃 ライセンス
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
</a>
|
||||
</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br></p>
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br>
|
||||
</p>
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
@@ -18,15 +19,25 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
|
||||
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
|
||||
|
||||
# GitCode✖️Cherry Studio【新源力】贡献挑战赛
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitcode.com/CherryHQ/cherry-studio/discussion/2">
|
||||
<img src="https://raw.gitcode.com/user-images/assets/5007375/8d8d7559-1141-4691-b90f-d154558c6896/cherry-studio-gitcode.jpg" width="100%" alt="banner" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# 📖 使用教程
|
||||
|
||||
https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 界面
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 主要特性
|
||||
|
||||
@@ -66,12 +77,12 @@ https://docs.cherry-ai.com
|
||||
- 📝 完整的 Markdown 渲染
|
||||
- 🤲 便捷的内容分享功能
|
||||
|
||||
# 📝 待辦事項
|
||||
# 📝 待办事项
|
||||
|
||||
- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结)
|
||||
- [x] 多模型回答对比
|
||||
- [x] 支持使用服务供应商提供的 SSO 进行登入
|
||||
- [x] 全部模型支持连网(开发中...)
|
||||
- [x] 支持使用服务供应商提供的 SSO 进行登录
|
||||
- [x] 所有模型支持联网
|
||||
- [x] 推出第一个正式版
|
||||
- [x] 错误修复和改进(开发中...)
|
||||
- [ ] 插件功能(JavaScript)
|
||||
@@ -85,9 +96,9 @@ https://docs.cherry-ai.com
|
||||
|
||||
- 主题库:https://cherrycss.com
|
||||
- Aero 主题:https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial 主题: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- 仿Claude 主题: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- 霓虹枫叶字体主题: https://github.com/BoningtonChen/CherryStudio_themes
|
||||
- PaperMaterial 主题:https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- 仿 Claude 主题:https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- 霓虹枫叶主题:https://github.com/BoningtonChen/CherryStudio_themes
|
||||
|
||||
欢迎 PR 更多主题
|
||||
|
||||
@@ -95,37 +106,43 @@ https://docs.cherry-ai.com
|
||||
|
||||
参考[开发文档](dev.md)
|
||||
|
||||
参考[架构概览文档](https://deepwiki.com/CherryHQ/cherry-studio)
|
||||
|
||||
参考[分支策略](branching-strategy-zh.md)了解贡献指南
|
||||
|
||||
# 🤝 贡献
|
||||
|
||||
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
|
||||
|
||||
1. **贡献代码**:开发新功能或优化现有代码。
|
||||
2. **修复错误**:提交您发现的错误修复。
|
||||
3. **维护问题**:帮助管理 GitHub 问题。
|
||||
4. **产品设计**:参与设计讨论。
|
||||
5. **撰写文档**:改进用户手册和指南。
|
||||
6. **社区参与**:加入讨论并帮助用户。
|
||||
7. **推广使用**:宣传 Cherry Studio。
|
||||
1. **贡献代码**:开发新功能或优化现有代码
|
||||
2. **修复错误**:提交您发现的错误修复
|
||||
3. **维护问题**:帮助管理 GitHub 问题
|
||||
4. **产品设计**:参与设计讨论
|
||||
5. **撰写文档**:改进用户手册和指南
|
||||
6. **社区参与**:加入讨论并帮助用户
|
||||
7. **推广使用**:宣传 Cherry Studio
|
||||
|
||||
## 入门
|
||||
|
||||
1. **Fork 仓库**:Fork 并克隆到您的本地机器。
|
||||
2. **创建分支**:为您的更改创建分支。
|
||||
3. **提交更改**:提交并推送您的更改。
|
||||
4. **打开 Pull Request**:描述您的更改和原因。
|
||||
1. **Fork 仓库**:Fork 并克隆到您的本地机器
|
||||
2. **创建分支**:为您的更改创建分支
|
||||
3. **提交更改**:提交并推送您的更改
|
||||
4. **打开 Pull Request**:描述您的更改和原因
|
||||
|
||||
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)。
|
||||
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)
|
||||
|
||||
感谢您的支持和贡献!
|
||||
|
||||
## 相关项目
|
||||
# 🔗 相关项目
|
||||
|
||||
- [one-api](https://github.com/songquanpeng/one-api):LLM API 管理及分发系统,支持 OpenAI、Azure、Anthropic 等主流模型,统一 API 接口,可用于密钥管理与二次分发。
|
||||
|
||||
- [ublacklist](https://github.com/iorate/ublacklist):屏蔽特定网站在 Google 搜索结果中显示
|
||||
|
||||
# 🚀 贡献者
|
||||
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
@@ -135,7 +152,7 @@ https://docs.cherry-ai.com
|
||||
|
||||
# ☕ 赞助
|
||||
|
||||
[微信赞赏码](sponsor.md)
|
||||
[赞助开发者](sponsor.md)
|
||||
|
||||
# 📃 许可证
|
||||
|
||||
|
||||
71
docs/branching-strategy-en.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 🌿 Branching Strategy
|
||||
|
||||
Cherry Studio implements a structured branching strategy to maintain code quality and streamline the development process.
|
||||
|
||||
## Main Branches
|
||||
|
||||
- `main`: Main development branch
|
||||
|
||||
- Contains the latest development code
|
||||
- Direct commits are not allowed - changes must come through pull requests
|
||||
- Code may contain features in development and might not be fully stable
|
||||
|
||||
- `release/*`: Release branches
|
||||
- Created from `main` branch
|
||||
- Contains stable code ready for release
|
||||
- Only accepts documentation updates and bug fixes
|
||||
- Thoroughly tested before production deployment
|
||||
|
||||
## Contributing Branches
|
||||
|
||||
When contributing to Cherry Studio, please follow these guidelines:
|
||||
|
||||
1. **Feature Branches:**
|
||||
|
||||
- Create from `main` branch
|
||||
- Naming format: `feature/issue-number-brief-description`
|
||||
- Submit PR back to `main`
|
||||
|
||||
2. **Bug Fix Branches:**
|
||||
|
||||
- Create from `main` branch
|
||||
- Naming format: `fix/issue-number-brief-description`
|
||||
- Submit PR back to `main`
|
||||
|
||||
3. **Documentation Branches:**
|
||||
|
||||
- Create from `main` branch
|
||||
- Naming format: `docs/brief-description`
|
||||
- Submit PR back to `main`
|
||||
|
||||
4. **Hotfix Branches:**
|
||||
|
||||
- Create from `main` branch
|
||||
- Naming format: `hotfix/issue-number-brief-description`
|
||||
- Submit PR to both `main` and relevant `release` branches
|
||||
|
||||
5. **Release Branches:**
|
||||
- Create from `main` branch
|
||||
- Naming format: `release/version-number`
|
||||
- Used for final preparation work before version release
|
||||
- Only accepts bug fixes and documentation updates
|
||||
- After testing and preparation, merge back to `main` and tag with version
|
||||
|
||||
## Workflow Diagram
|
||||
|
||||

|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- All PRs should be submitted to the `main` branch unless fixing a critical production issue
|
||||
- Ensure your branch is up to date with the latest `main` changes before submitting
|
||||
- Include relevant issue numbers in your PR description
|
||||
- Make sure all tests pass and code meets our quality standards
|
||||
- Add before/after screenshots if you add a new feature or modify a UI component
|
||||
|
||||
## Version Tag Management
|
||||
|
||||
- Major releases: v1.0.0, v2.0.0, etc.
|
||||
- Feature releases: v1.1.0, v1.2.0, etc.
|
||||
- Patch releases: v1.0.1, v1.0.2, etc.
|
||||
- Hotfix releases: v1.0.1-hotfix, etc.
|
||||
71
docs/branching-strategy-zh.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 🌿 分支策略
|
||||
|
||||
Cherry Studio 采用结构化的分支策略来维护代码质量并简化开发流程。
|
||||
|
||||
## 主要分支
|
||||
|
||||
- `main`:主开发分支
|
||||
|
||||
- 包含最新的开发代码
|
||||
- 禁止直接提交 - 所有更改必须通过拉取请求(Pull Request)
|
||||
- 此分支上的代码可能包含正在开发的功能,不一定完全稳定
|
||||
|
||||
- `release/*`:发布分支
|
||||
- 从 `main` 分支创建
|
||||
- 包含准备发布的稳定代码
|
||||
- 只接受文档更新和 bug 修复
|
||||
- 经过完整测试后可以发布到生产环境
|
||||
|
||||
## 贡献分支
|
||||
|
||||
在为 Cherry Studio 贡献代码时,请遵循以下准则:
|
||||
|
||||
1. **功能开发分支:**
|
||||
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`feature/issue-number-brief-description`
|
||||
- 完成后提交 PR 到 `main` 分支
|
||||
|
||||
2. **Bug 修复分支:**
|
||||
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`fix/issue-number-brief-description`
|
||||
- 完成后提交 PR 到 `main` 分支
|
||||
|
||||
3. **文档更新分支:**
|
||||
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`docs/brief-description`
|
||||
- 完成后提交 PR 到 `main` 分支
|
||||
|
||||
4. **紧急修复分支:**
|
||||
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`hotfix/issue-number-brief-description`
|
||||
- 完成后需要同时合并到 `main` 和相关的 `release` 分支
|
||||
|
||||
5. **发布分支:**
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`release/version-number`
|
||||
- 用于版本发布前的最终准备工作
|
||||
- 只允许合并 bug 修复和文档更新
|
||||
- 完成测试和准备工作后,将代码合并回 `main` 分支并打上版本标签
|
||||
|
||||
## 工作流程
|
||||
|
||||

|
||||
|
||||
## 拉取请求(PR)指南
|
||||
|
||||
- 除非是修复生产环境的关键问题,否则所有 PR 都应该提交到 `main` 分支
|
||||
- 提交 PR 前确保你的分支已经同步了最新的 `main` 分支内容
|
||||
- 在 PR 描述中包含相关的 issue 编号
|
||||
- 确保所有测试通过,且代码符合我们的质量标准
|
||||
- 如果你添加了新功能或修改了 UI 组件,请附上更改前后的截图
|
||||
|
||||
## 版本标签管理
|
||||
|
||||
- 主要版本发布:v1.0.0、v2.0.0 等
|
||||
- 功能更新发布:v1.1.0、v1.2.0 等
|
||||
- 补丁修复发布:v1.0.1、v1.0.2 等
|
||||
- 紧急修复发布:v1.0.1-hotfix 等
|
||||
@@ -1,52 +0,0 @@
|
||||
# 🌿 Branching Strategy
|
||||
|
||||
Cherry Studio follows a structured branching strategy to maintain code quality and streamline the development process:
|
||||
|
||||
## Main Branches
|
||||
|
||||
- `main`: Production-ready branch containing stable releases
|
||||
|
||||
- All code here is thoroughly tested and ready for production
|
||||
- Direct commits are not allowed - changes must come through pull requests
|
||||
- Each merge to main represents a new release
|
||||
|
||||
- `develop` (default): Primary development branch
|
||||
- Contains the latest delivered development changes for the next release
|
||||
- Relatively stable but may contain features in progress
|
||||
- This is the default branch for development
|
||||
|
||||
## Contributing Branches
|
||||
|
||||
When contributing to Cherry Studio, please follow these guidelines:
|
||||
|
||||
1. **For bug fixes:**
|
||||
|
||||
- Create a branch from `develop`
|
||||
- Name format: `fix/issue-number-brief-description`
|
||||
- Submit pull request back to `develop`
|
||||
|
||||
2. **For new features:**
|
||||
|
||||
- Create a branch from `develop`
|
||||
- Name format: `feature/issue-number-brief-description`
|
||||
- Submit pull request back to `develop`
|
||||
|
||||
3. **For documentation:**
|
||||
|
||||
- Create a branch from `develop`
|
||||
- Name format: `docs/brief-description`
|
||||
- Submit pull request back to `develop`
|
||||
|
||||
4. **For critical hotfixes:**
|
||||
- Create a branch from `main`
|
||||
- Name format: `hotfix/issue-number-brief-description`
|
||||
- Submit pull request to both `main` and `develop`
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Always create pull requests against the `develop` branch unless fixing a critical production issue
|
||||
- Ensure your branch is up to date with the latest `develop` changes before submitting
|
||||
- Include relevant issue numbers in your PR description
|
||||
- Make sure all tests pass and code meets our quality standards
|
||||
- Critical hotfixes may be submitted against `main` but must also be merged into `develop`
|
||||
- Add a photo to show what is different if you add a new feature or modify a component in the UI.
|
||||
@@ -45,6 +45,8 @@ win:
|
||||
target:
|
||||
- target: nsis
|
||||
- target: portable
|
||||
signtoolOptions:
|
||||
sign: scripts/win-sign.js
|
||||
nsis:
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
@@ -61,6 +63,7 @@ mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
@@ -91,9 +94,13 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
⚠️ 注意:升级前请备份数据,否则将无法降级
|
||||
重构消息结构,支持不同类型消息按时间顺序显示
|
||||
智能体支持导入和导出
|
||||
快捷面板增加网络搜索引擎选择
|
||||
显示设置增加缩放控制按钮
|
||||
支持添加自定义小程序
|
||||
性能优化和错误修复
|
||||
增加 TokenFlux 服务商
|
||||
增加 Claude 4 模型支持
|
||||
Grok 模型增加联网能力
|
||||
小程序支持前进和后退
|
||||
修复 Windows 用户 MCP 无法启动问题
|
||||
修复无法搜索历史消息问题
|
||||
修复 MCP 代理问题
|
||||
修复精简备份恢复覆盖文件问题
|
||||
修复@模型回复插入位置错误问题
|
||||
修复搜索小程序崩溃问题
|
||||
|
||||
@@ -73,7 +73,10 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: []
|
||||
exclude: ['pyodide']
|
||||
},
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
|
||||
39
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.3.5",
|
||||
"version": "1.3.11",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -84,6 +84,7 @@
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -94,8 +95,6 @@
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react-window": "^1.8.11",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@@ -118,17 +117,14 @@
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@google/genai": "^0.13.0",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@iconify-json/svg-spinners": "^1.2.2",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.10.2",
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.2.2",
|
||||
"@swc/plugin-styled-components": "^7.1.3",
|
||||
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
|
||||
"@shikijs/markdown-it": "^3.4.2",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/adm-zip": "^0",
|
||||
"@types/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/lodash": "^4.17.5",
|
||||
@@ -142,24 +138,25 @@
|
||||
"@types/react-window": "^1",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/ws": "^8",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.12",
|
||||
"@uiw/codemirror-themes-all": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"@vitest/web-worker": "^3.1.3",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "^5.22.5",
|
||||
"applescript": "^1.0.0",
|
||||
"axios": "^1.7.3",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "31.7.6",
|
||||
"electron": "35.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^2.3.0",
|
||||
"electron-vite": "^3.1.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
"eslint": "^9.22.0",
|
||||
@@ -173,12 +170,14 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.487.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
@@ -189,6 +188,7 @@
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
"react-spinners": "^0.14.1",
|
||||
"react-window": "^1.8.11",
|
||||
"redux": "^5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
@@ -198,12 +198,11 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.77.2",
|
||||
"shiki": "^3.2.2",
|
||||
"sass": "^1.88.0",
|
||||
"shiki": "^3.4.2",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tokenx": "^0.4.1",
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
@@ -219,10 +218,10 @@
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"shiki": "3.2.2",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch"
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
|
||||
"prettier --write",
|
||||
|
||||
@@ -21,6 +21,9 @@ export enum IpcChannel {
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
|
||||
Notification_Send = 'notification:send',
|
||||
Notification_OnClick = 'notification:on-click',
|
||||
|
||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||
|
||||
// Open
|
||||
@@ -52,6 +55,7 @@ export enum IpcChannel {
|
||||
Mcp_GetInstallInfo = 'mcp:get-install-info',
|
||||
Mcp_ServersChanged = 'mcp:servers-changed',
|
||||
Mcp_ServersUpdated = 'mcp:servers-updated',
|
||||
Mcp_CheckConnectivity = 'mcp:check-connectivity',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
@@ -169,5 +173,8 @@ export enum IpcChannel {
|
||||
StoreSync_Subscribe = 'store-sync:subscribe',
|
||||
StoreSync_Unsubscribe = 'store-sync:unsubscribe',
|
||||
StoreSync_OnUpdate = 'store-sync:on-update',
|
||||
StoreSync_BroadcastSync = 'store-sync:broadcast-sync'
|
||||
StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
|
||||
|
||||
// Provider
|
||||
Provider_AddKey = 'provider:add-key'
|
||||
}
|
||||
|
||||
19
scripts/win-sign.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
exports.default = async function (configuration) {
|
||||
if (process.env.WIN_SIGN) {
|
||||
const { path } = configuration
|
||||
if (configuration.path) {
|
||||
try {
|
||||
console.log('Start code signing...')
|
||||
console.log('Signing file:', path)
|
||||
const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"`
|
||||
execSync(signCommand, { stdio: 'inherit' })
|
||||
console.log('Code signing completed')
|
||||
} catch (error) {
|
||||
console.error('Code signing failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@ import '@main/config'
|
||||
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { isDev, isMac, isWin } from './constant'
|
||||
import { isDev } from './constant'
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
@@ -85,18 +84,6 @@ if (!app.requestSingleInstanceLock()) {
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
|
||||
return isMac ? 'mac' : isWin ? 'windows' : 'linux'
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, () => {
|
||||
return require('os').hostname()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
win && win.webContents.toggleDevTools()
|
||||
})
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
@@ -20,6 +21,7 @@ import FileStorage from './services/FileStorage'
|
||||
import { GeminiService } from './services/GeminiService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
@@ -41,6 +43,7 @@ const obsidianVaultService = new ObsidianVaultService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Info, () => ({
|
||||
version: app.getVersion(),
|
||||
@@ -200,10 +203,26 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
await appUpdater.checkForUpdates()
|
||||
})
|
||||
|
||||
// notification
|
||||
ipcMain.handle(IpcChannel.Notification_Send, async (_, notification: Notification) => {
|
||||
await notificationService.sendNotification(notification)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Notification_OnClick, (_, notification: Notification) => {
|
||||
mainWindow.webContents.send('notification-click', notification)
|
||||
})
|
||||
|
||||
// zip
|
||||
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
|
||||
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
|
||||
|
||||
// system
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
||||
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
win && win.webContents.toggleDevTools()
|
||||
})
|
||||
|
||||
// backup
|
||||
ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup)
|
||||
ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore)
|
||||
@@ -311,6 +330,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
|
||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||
|
||||
@@ -38,7 +38,7 @@ export default abstract class BaseReranker {
|
||||
protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) {
|
||||
const provider = this.base.rerankModelProvider
|
||||
const documents = searchResults.map((doc) => doc.pageContent)
|
||||
const topN = this.base.topN || 10
|
||||
const topN = this.base.documentCount
|
||||
|
||||
if (provider === 'voyageai') {
|
||||
return {
|
||||
|
||||
@@ -77,7 +77,8 @@ class BackupManager {
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
data: string,
|
||||
destinationPath: string = this.backupDir
|
||||
destinationPath: string = this.backupDir,
|
||||
skipBackupFile: boolean = false
|
||||
): Promise<string> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
@@ -104,23 +105,30 @@ class BackupManager {
|
||||
|
||||
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
||||
|
||||
// 复制 Data 目录到临时目录
|
||||
const sourcePath = path.join(app.getPath('userData'), 'Data')
|
||||
const tempDataDir = path.join(this.tempDir, 'Data')
|
||||
Logger.log('[BackupManager IPC] ', skipBackupFile)
|
||||
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
if (!skipBackupFile) {
|
||||
// 复制 Data 目录到临时目录
|
||||
const sourcePath = path.join(app.getPath('userData'), 'Data')
|
||||
const tempDataDir = path.join(this.tempDir, 'Data')
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
|
||||
await this.setWritableRecursive(tempDataDir)
|
||||
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
await this.setWritableRecursive(tempDataDir)
|
||||
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
|
||||
} else {
|
||||
Logger.log('[BackupManager] Skip the backup of the file')
|
||||
await fs.promises.mkdir(path.join(this.tempDir, 'Data')) // 不创建空 Data 目录会导致 restore 失败
|
||||
}
|
||||
|
||||
// 创建输出文件流
|
||||
const backupedFilePath = path.join(destinationPath, fileName)
|
||||
@@ -247,19 +255,26 @@ class BackupManager {
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
const dataExists = await fs.pathExists(sourcePath)
|
||||
const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
|
||||
|
||||
await this.setWritableRecursive(destPath)
|
||||
await fs.remove(destPath)
|
||||
if (dataExists && dataFiles.length > 0) {
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
await this.setWritableRecursive(destPath)
|
||||
await fs.remove(destPath)
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
} else {
|
||||
Logger.log('[backup] skipBackupFile is true, skip restoring Data directory')
|
||||
}
|
||||
|
||||
Logger.log('[backup] step 4: clean up temp directory')
|
||||
// 清理临时目录
|
||||
@@ -279,7 +294,7 @@ class BackupManager {
|
||||
|
||||
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
||||
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
|
||||
const backupedFilePath = await this.backup(_, filename, data)
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
try {
|
||||
const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
|
||||
@@ -62,7 +62,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
getTrayOnClose(): boolean {
|
||||
return !!this.get(ConfigKeys.TrayOnClose, false)
|
||||
return !!this.get(ConfigKeys.TrayOnClose, true)
|
||||
}
|
||||
|
||||
setTrayOnClose(value: boolean) {
|
||||
|
||||
@@ -328,7 +328,7 @@ class FileStorage {
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<string | null> => {
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
@@ -336,14 +336,18 @@ class FileStorage {
|
||||
...options
|
||||
})
|
||||
|
||||
if (result.canceled) {
|
||||
return Promise.reject(new Error('User canceled the save dialog'))
|
||||
}
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||||
}
|
||||
|
||||
return result.filePath
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
return null
|
||||
return Promise.reject('An error occurred saving the file: ' + err?.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,19 @@ export class GeminiService {
|
||||
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
|
||||
private static readonly CACHE_DURATION = 3000
|
||||
|
||||
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
static async uploadFile(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
file: FileType,
|
||||
{ apiKey, baseURL }: { apiKey: string; baseURL: string }
|
||||
): Promise<File> {
|
||||
const sdk = new GoogleGenAI({
|
||||
vertexai: false,
|
||||
apiKey,
|
||||
httpOptions: {
|
||||
baseUrl: baseURL
|
||||
}
|
||||
})
|
||||
|
||||
return await sdk.files.upload({
|
||||
file: file.path,
|
||||
config: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path'
|
||||
|
||||
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
import { makeSureDirExists } from '@main/utils'
|
||||
import { buildFunctionCallToolName } from '@main/utils/mcp'
|
||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
@@ -69,17 +70,7 @@ function withCache<T extends unknown[], R>(
|
||||
|
||||
class McpService {
|
||||
private clients: Map<string, Client> = new Map()
|
||||
|
||||
private getServerKey(server: MCPServer): string {
|
||||
return JSON.stringify({
|
||||
baseUrl: server.baseUrl,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
registryUrl: server.registryUrl,
|
||||
env: server.env,
|
||||
id: server.id
|
||||
})
|
||||
}
|
||||
private pendingClients: Map<string, Promise<Client>> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.initClient = this.initClient.bind(this)
|
||||
@@ -96,9 +87,26 @@ class McpService {
|
||||
this.cleanup = this.cleanup.bind(this)
|
||||
}
|
||||
|
||||
private getServerKey(server: MCPServer): string {
|
||||
return JSON.stringify({
|
||||
baseUrl: server.baseUrl,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
registryUrl: server.registryUrl,
|
||||
env: server.env,
|
||||
id: server.id
|
||||
})
|
||||
}
|
||||
|
||||
async initClient(server: MCPServer): Promise<Client> {
|
||||
const serverKey = this.getServerKey(server)
|
||||
|
||||
// If there's a pending initialization, wait for it
|
||||
const pendingClient = this.pendingClients.get(serverKey)
|
||||
if (pendingClient) {
|
||||
return pendingClient
|
||||
}
|
||||
|
||||
// Check if we already have a client for this server configuration
|
||||
const existingClient = this.clients.get(serverKey)
|
||||
if (existingClient) {
|
||||
@@ -113,209 +121,232 @@ class McpService {
|
||||
} else {
|
||||
return existingClient
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Error pinging server ${server.name}:`, error)
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Error pinging server ${server.name}:`, error?.message)
|
||||
this.clients.delete(serverKey)
|
||||
}
|
||||
}
|
||||
// Create new client instance for each connection
|
||||
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
|
||||
|
||||
const args = [...(server.args || [])]
|
||||
// Create a promise for the initialization process
|
||||
const initPromise = (async () => {
|
||||
try {
|
||||
// Create new client instance for each connection
|
||||
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
|
||||
|
||||
// let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
const authProvider = new McpOAuthClientProvider({
|
||||
serverUrlHash: crypto
|
||||
.createHash('md5')
|
||||
.update(server.baseUrl || '')
|
||||
.digest('hex')
|
||||
})
|
||||
const args = [...(server.args || [])]
|
||||
|
||||
const initTransport = async (): Promise<
|
||||
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
> => {
|
||||
// Create appropriate transport based on configuration
|
||||
if (server.type === 'inMemory') {
|
||||
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
// start the in-memory server with the given name and environment variables
|
||||
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
||||
} catch (error: Error | any) {
|
||||
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
||||
}
|
||||
// set the client transport to the client
|
||||
return clientTransport
|
||||
} else if (server.baseUrl) {
|
||||
if (server.type === 'streamableHttp') {
|
||||
const options: StreamableHTTPClientTransportOptions = {
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
|
||||
} else if (server.type === 'sse') {
|
||||
const options: SSEClientTransportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: async (url, init) => {
|
||||
const headers = { ...(server.headers || {}), ...(init?.headers || {}) }
|
||||
// let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
const authProvider = new McpOAuthClientProvider({
|
||||
serverUrlHash: crypto
|
||||
.createHash('md5')
|
||||
.update(server.baseUrl || '')
|
||||
.digest('hex')
|
||||
})
|
||||
|
||||
// Get tokens from authProvider to make sure using the latest tokens
|
||||
if (authProvider && typeof authProvider.tokens === 'function') {
|
||||
try {
|
||||
const tokens = await authProvider.tokens()
|
||||
if (tokens && tokens.access_token) {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
||||
const initTransport = async (): Promise<
|
||||
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
> => {
|
||||
// Create appropriate transport based on configuration
|
||||
if (server.type === 'inMemory') {
|
||||
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
// start the in-memory server with the given name and environment variables
|
||||
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
||||
} catch (error: Error | any) {
|
||||
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
||||
}
|
||||
// set the client transport to the client
|
||||
return clientTransport
|
||||
} else if (server.baseUrl) {
|
||||
if (server.type === 'streamableHttp') {
|
||||
const options: StreamableHTTPClientTransportOptions = {
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
|
||||
} else if (server.type === 'sse') {
|
||||
const options: SSEClientTransportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: async (url, init) => {
|
||||
const headers = { ...(server.headers || {}), ...(init?.headers || {}) }
|
||||
|
||||
// Get tokens from authProvider to make sure using the latest tokens
|
||||
if (authProvider && typeof authProvider.tokens === 'function') {
|
||||
try {
|
||||
const tokens = await authProvider.tokens()
|
||||
if (tokens && tokens.access_token) {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to fetch tokens:', error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to fetch tokens:', error)
|
||||
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
},
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
return new SSEClientTransport(new URL(server.baseUrl!), options)
|
||||
} else {
|
||||
throw new Error('Invalid server type')
|
||||
}
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
if (server.command === 'npx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||
|
||||
// add -x to args if args exist
|
||||
if (args && args.length > 0) {
|
||||
if (!args.includes('-y')) {
|
||||
args.unshift('-y')
|
||||
}
|
||||
if (!args.includes('x')) {
|
||||
args.unshift('x')
|
||||
}
|
||||
}
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
NPM_CONFIG_REGISTRY: server.registryUrl
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, headers })
|
||||
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
|
||||
if (server.name.includes('mcp-auto-install')) {
|
||||
const binPath = await getBinaryPath()
|
||||
makeSureDirExists(binPath)
|
||||
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
|
||||
}
|
||||
}
|
||||
} else if (server.command === 'uvx' || server.command === 'uv') {
|
||||
cmd = await getBinaryPath(server.command)
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
UV_DEFAULT_INDEX: server.registryUrl,
|
||||
PIP_INDEX_URL: server.registryUrl
|
||||
}
|
||||
}
|
||||
},
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
return new SSEClientTransport(new URL(server.baseUrl!), options)
|
||||
} else {
|
||||
throw new Error('Invalid server type')
|
||||
}
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
if (server.command === 'npx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||
|
||||
// add -x to args if args exist
|
||||
if (args && args.length > 0) {
|
||||
if (!args.includes('-y')) {
|
||||
args.unshift('-y')
|
||||
}
|
||||
if (!args.includes('x')) {
|
||||
args.unshift('x')
|
||||
}
|
||||
}
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
NPM_CONFIG_REGISTRY: server.registryUrl
|
||||
}
|
||||
|
||||
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
|
||||
if (server.name.includes('mcp-auto-install')) {
|
||||
const binPath = await getBinaryPath()
|
||||
makeSureDirExists(binPath)
|
||||
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
|
||||
}
|
||||
}
|
||||
} else if (server.command === 'uvx' || server.command === 'uv') {
|
||||
cmd = await getBinaryPath(server.command)
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
UV_DEFAULT_INDEX: server.registryUrl,
|
||||
PIP_INDEX_URL: server.registryUrl
|
||||
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
const loginShellEnv = await this.getLoginShellEnv()
|
||||
|
||||
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
||||
if (cmd.endsWith('bun')) {
|
||||
this.removeProxyEnv(loginShellEnv)
|
||||
}
|
||||
|
||||
const stdioTransport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...loginShellEnv,
|
||||
...server.env
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
stdioTransport.stderr?.on('data', (data) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
return stdioTransport
|
||||
} else {
|
||||
throw new Error('Either baseUrl or command must be provided')
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
const loginShellEnv = await this.getLoginShellEnv()
|
||||
const stdioTransport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...loginShellEnv,
|
||||
...server.env
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
stdioTransport.stderr?.on('data', (data) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
return stdioTransport
|
||||
} else {
|
||||
throw new Error('Either baseUrl or command must be provided')
|
||||
}
|
||||
}
|
||||
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
|
||||
Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
|
||||
// Create an event emitter for the OAuth callback
|
||||
const events = new EventEmitter()
|
||||
|
||||
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
|
||||
Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
|
||||
// Create an event emitter for the OAuth callback
|
||||
const events = new EventEmitter()
|
||||
// Create a callback server
|
||||
const callbackServer = new CallBackServer({
|
||||
port: authProvider.config.callbackPort,
|
||||
path: authProvider.config.callbackPath || '/oauth/callback',
|
||||
events
|
||||
})
|
||||
|
||||
// Create a callback server
|
||||
const callbackServer = new CallBackServer({
|
||||
port: authProvider.config.callbackPort,
|
||||
path: authProvider.config.callbackPath || '/oauth/callback',
|
||||
events
|
||||
})
|
||||
// Set a timeout to close the callback server
|
||||
const timeoutId = setTimeout(() => {
|
||||
Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
|
||||
callbackServer.close()
|
||||
}, 300000) // 5 minutes timeout
|
||||
|
||||
// Set a timeout to close the callback server
|
||||
const timeoutId = setTimeout(() => {
|
||||
Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
|
||||
callbackServer.close()
|
||||
}, 300000) // 5 minutes timeout
|
||||
try {
|
||||
// Wait for the authorization code
|
||||
const authCode = await callbackServer.waitForAuthCode()
|
||||
Logger.info(`[MCP] Received auth code: ${authCode}`)
|
||||
|
||||
try {
|
||||
// Wait for the authorization code
|
||||
const authCode = await callbackServer.waitForAuthCode()
|
||||
Logger.info(`[MCP] Received auth code: ${authCode}`)
|
||||
// Complete the OAuth flow
|
||||
await transport.finishAuth(authCode)
|
||||
|
||||
// Complete the OAuth flow
|
||||
await transport.finishAuth(authCode)
|
||||
Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
|
||||
|
||||
Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
|
||||
const newTransport = await initTransport()
|
||||
// Try to connect again
|
||||
await client.connect(newTransport)
|
||||
|
||||
const newTransport = await initTransport()
|
||||
// Try to connect again
|
||||
await client.connect(newTransport)
|
||||
Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
|
||||
} catch (oauthError) {
|
||||
Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
|
||||
throw new Error(
|
||||
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
|
||||
)
|
||||
} finally {
|
||||
// Clear the timeout and close the callback server
|
||||
clearTimeout(timeoutId)
|
||||
callbackServer.close()
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
|
||||
} catch (oauthError) {
|
||||
Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
|
||||
throw new Error(
|
||||
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
|
||||
)
|
||||
try {
|
||||
const transport = await initTransport()
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error: Error | any) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))
|
||||
) {
|
||||
Logger.info(`[MCP] Authentication required for server: ${server.name}`)
|
||||
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Store the new client in the cache
|
||||
this.clients.set(serverKey, client)
|
||||
|
||||
Logger.info(`[MCP] Activated server: ${server.name}`)
|
||||
return client
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Error activating server ${server.name}:`, error?.message)
|
||||
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
|
||||
}
|
||||
} finally {
|
||||
// Clear the timeout and close the callback server
|
||||
clearTimeout(timeoutId)
|
||||
callbackServer.close()
|
||||
// Clean up the pending promise when done
|
||||
this.pendingClients.delete(serverKey)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
const transport = await initTransport()
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error: Error | any) {
|
||||
if (error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))) {
|
||||
Logger.info(`[MCP] Authentication required for server: ${server.name}`)
|
||||
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// Store the pending promise
|
||||
this.pendingClients.set(serverKey, initPromise)
|
||||
|
||||
// Store the new client in the cache
|
||||
this.clients.set(serverKey, client)
|
||||
|
||||
Logger.info(`[MCP] Activated server: ${server.name}`)
|
||||
return client
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
||||
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
|
||||
}
|
||||
return initPromise
|
||||
}
|
||||
|
||||
async closeClient(serverKey: string) {
|
||||
@@ -357,12 +388,32 @@ class McpService {
|
||||
for (const [key] of this.clients) {
|
||||
try {
|
||||
await this.closeClient(key)
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to close client: ${error}`)
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Failed to close client: ${error?.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check connectivity for an MCP server
|
||||
*/
|
||||
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
|
||||
Logger.info(`[MCP] Checking connectivity for server: ${server.name}`)
|
||||
try {
|
||||
const client = await this.initClient(server)
|
||||
// Attempt to list tools as a way to check connectivity
|
||||
await client.listTools()
|
||||
Logger.info(`[MCP] Connectivity check successful for server: ${server.name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Connectivity check failed for server: ${server.name}`, error)
|
||||
// Close the client if connectivity check fails to ensure a clean state for the next attempt
|
||||
const serverKey = this.getServerKey(server)
|
||||
await this.closeClient(serverKey)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
|
||||
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
@@ -372,15 +423,15 @@ class McpService {
|
||||
tools.map((tool: any) => {
|
||||
const serverTool: MCPTool = {
|
||||
...tool,
|
||||
id: `f${nanoid()}`,
|
||||
id: buildFunctionCallToolName(server.name, tool.name),
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
}
|
||||
serverTools.push(serverTool)
|
||||
})
|
||||
return serverTools
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error)
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error?.message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -439,8 +490,8 @@ class McpService {
|
||||
* List prompts available on an MCP server
|
||||
*/
|
||||
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
|
||||
Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
|
||||
try {
|
||||
const { prompts } = await client.listPrompts()
|
||||
return prompts.map((prompt: any) => ({
|
||||
@@ -449,8 +500,11 @@ class McpService {
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
}))
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error)
|
||||
} catch (error: any) {
|
||||
// -32601 is the code for the method not found
|
||||
if (error?.code !== -32601) {
|
||||
Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error?.message)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -508,8 +562,8 @@ class McpService {
|
||||
* List resources available on an MCP server (implementation)
|
||||
*/
|
||||
private async listResourcesImpl(server: MCPServer): Promise<MCPResource[]> {
|
||||
Logger.info(`[MCP] Listing resources for server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
Logger.info(`[MCP] Listing resources for server: ${server.name}`)
|
||||
try {
|
||||
const result = await client.listResources()
|
||||
const resources = result.resources || []
|
||||
@@ -519,8 +573,11 @@ class McpService {
|
||||
serverName: server.name
|
||||
}))
|
||||
return serverResources
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error)
|
||||
} catch (error: any) {
|
||||
// -32601 is the code for the method not found
|
||||
if (error?.code !== -32601) {
|
||||
Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error?.message)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -563,7 +620,7 @@ class McpService {
|
||||
contents: contents
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error)
|
||||
Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error.message)
|
||||
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -600,7 +657,14 @@ class McpService {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
private removeProxyEnv(env: Record<string, string>) {
|
||||
delete env.HTTPS_PROXY
|
||||
delete env.HTTP_PROXY
|
||||
delete env.grpc_proxy
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
}
|
||||
|
||||
const mcpService = new McpService()
|
||||
export default mcpService
|
||||
export default new McpService()
|
||||
|
||||
31
src/main/services/NotificationService.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
|
||||
class NotificationService {
|
||||
private window: BrowserWindow
|
||||
|
||||
constructor(window: BrowserWindow) {
|
||||
// Initialize the service
|
||||
this.window = window
|
||||
}
|
||||
|
||||
public async sendNotification(notification: Notification) {
|
||||
// 使用 Electron Notification API
|
||||
const electronNotification = new ElectronNotification({
|
||||
title: notification.title,
|
||||
body: notification.message,
|
||||
icon: icon
|
||||
})
|
||||
|
||||
electronNotification.on('click', () => {
|
||||
this.window.show()
|
||||
this.window.webContents.send('notification-click', notification)
|
||||
})
|
||||
|
||||
electronNotification.show()
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationService
|
||||
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { handleProvidersProtocolUrl } from './urlschema/handle-providers'
|
||||
import { handleMcpProtocolUrl } from './urlschema/mcp-install'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@@ -34,6 +35,9 @@ export function handleProtocolUrl(url: string) {
|
||||
case 'mcp':
|
||||
handleMcpProtocolUrl(urlObj)
|
||||
return
|
||||
case 'providers':
|
||||
handleProvidersProtocolUrl(urlObj)
|
||||
return
|
||||
}
|
||||
|
||||
// You can send the data to your renderer process
|
||||
|
||||
@@ -75,7 +75,9 @@ export class WindowService {
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: true
|
||||
allowRunningInsecureContent: true,
|
||||
zoomFactor: configManager.getZoomFactor(),
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -184,6 +186,12 @@ export class WindowService {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
})
|
||||
|
||||
// set the zoom factor again when the window is going to restore
|
||||
// minimize and restore will cause zoom reset
|
||||
mainWindow.on('restore', () => {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
})
|
||||
|
||||
// ARCH: as `will-resize` is only for Win & Mac,
|
||||
// linux has the same problem, use `resize` listener instead
|
||||
// but `resize` will fliker the ui
|
||||
@@ -440,7 +448,8 @@ export class WindowService {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
webviewTag: true
|
||||
webviewTag: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
|
||||
commandArgs = ['-ilc', shellCommandToGetEnv] // -i for interactive, -l for login, -c to execute command
|
||||
}
|
||||
|
||||
Logger.log(`Spawning shell: ${shellPath} with args: ${commandArgs.join(' ')} in ${homeDirectory}`)
|
||||
Logger.log(`[ShellEnv] Spawning shell: ${shellPath} with args: ${commandArgs.join(' ')} in ${homeDirectory}`)
|
||||
|
||||
const child = spawn(shellPath, commandArgs, {
|
||||
cwd: homeDirectory, // Run the command in the user's home directory
|
||||
@@ -85,7 +85,7 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
|
||||
Logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`)
|
||||
}
|
||||
|
||||
const env = {}
|
||||
const env: Record<string, string> = {}
|
||||
const lines = output.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
@@ -110,6 +110,8 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
|
||||
Logger.warn('Raw output from shell:\n', output)
|
||||
}
|
||||
|
||||
env.PATH = env.Path || env.PATH || ''
|
||||
|
||||
resolve(env)
|
||||
})
|
||||
})
|
||||
|
||||
37
src/main/services/urlschema/handle-providers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
|
||||
export function handleProvidersProtocolUrl(url: URL) {
|
||||
const params = new URLSearchParams(url.search)
|
||||
switch (url.pathname) {
|
||||
case '/api-keys': {
|
||||
// jsonConfig example:
|
||||
// {
|
||||
// "id": "tokenflux",
|
||||
// "baseUrl": "https://tokenflux.ai/v1",
|
||||
// "apiKey": "sk-xxxx"
|
||||
// }
|
||||
// cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))}
|
||||
const data = params.get('data')
|
||||
if (data) {
|
||||
const stringify = Buffer.from(data, 'base64').toString('utf8')
|
||||
Logger.info('get api keys from urlschema: ', stringify)
|
||||
const jsonConfig = JSON.parse(stringify)
|
||||
Logger.info('get api keys from urlschema: ', jsonConfig)
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig)
|
||||
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`)
|
||||
}
|
||||
} else {
|
||||
Logger.error('No data found in URL')
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown MCP protocol URL: ${url}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
34
src/main/utils/mcp.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export function buildFunctionCallToolName(serverName: string, toolName: string) {
|
||||
const sanitizedServer = serverName.trim().replace(/-/g, '_')
|
||||
const sanitizedTool = toolName.trim().replace(/-/g, '_')
|
||||
|
||||
// Combine server name and tool name
|
||||
let name = sanitizedTool
|
||||
if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) {
|
||||
name = `${sanitizedServer.slice(0, 7) || ''}-${sanitizedTool || ''}`
|
||||
}
|
||||
|
||||
// Replace invalid characters with underscores or dashes
|
||||
// Keep a-z, A-Z, 0-9, underscores and dashes
|
||||
name = name.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
|
||||
// Ensure name starts with a letter or underscore (for valid JavaScript identifier)
|
||||
if (!/^[a-zA-Z]/.test(name)) {
|
||||
name = `tool-${name}`
|
||||
}
|
||||
|
||||
// Remove consecutive underscores/dashes (optional improvement)
|
||||
name = name.replace(/[_-]{2,}/g, '_')
|
||||
|
||||
// Truncate to 63 characters maximum
|
||||
if (name.length > 63) {
|
||||
name = name.slice(0, 63)
|
||||
}
|
||||
|
||||
// Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges
|
||||
if (name.endsWith('_') || name.endsWith('-')) {
|
||||
name = name.slice(0, -1)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
import { isDev, isWin } from '../constant'
|
||||
|
||||
function isTilingWindowManager() {
|
||||
if (process.platform === 'darwin') {
|
||||
return false
|
||||
@@ -15,31 +17,59 @@ function isTilingWindowManager() {
|
||||
return tilingSystems.some((system) => desktopEnv?.includes(system))
|
||||
}
|
||||
|
||||
//see: https://github.com/electron/electron/issues/42055#issuecomment-2449365647
|
||||
export const replaceDevtoolsFont = (browserWindow: BrowserWindow) => {
|
||||
if (process.platform === 'win32') {
|
||||
//only for windows and dev, don't do this in production to avoid performance issues
|
||||
if (isWin && isDev) {
|
||||
browserWindow.webContents.on('devtools-opened', () => {
|
||||
const css = `
|
||||
:root {
|
||||
--sys-color-base: var(--ref-palette-neutral100);
|
||||
--source-code-font-family: consolas;
|
||||
--source-code-font-family: consolas !important;
|
||||
--source-code-font-size: 12px;
|
||||
--monospace-font-family: consolas;
|
||||
--monospace-font-family: consolas !important;
|
||||
--monospace-font-size: 12px;
|
||||
--default-font-family: system-ui, sans-serif;
|
||||
--default-font-size: 12px;
|
||||
--ref-palette-neutral99: #ffffffff;
|
||||
}
|
||||
.-theme-with-dark-background {
|
||||
.theme-with-dark-background {
|
||||
--sys-color-base: var(--ref-palette-secondary25);
|
||||
}
|
||||
body {
|
||||
--default-font-family: system-ui,sans-serif;
|
||||
}`
|
||||
|
||||
--default-font-family: system-ui, sans-serif;
|
||||
}
|
||||
`
|
||||
browserWindow.webContents.devToolsWebContents?.executeJavaScript(`
|
||||
const overriddenStyle = document.createElement('style');
|
||||
overriddenStyle.innerHTML = '${css.replaceAll('\n', ' ')}';
|
||||
document.body.append(overriddenStyle);
|
||||
document.body.classList.remove('platform-windows');`)
|
||||
document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows'));
|
||||
addStyleToAutoComplete();
|
||||
const observer = new MutationObserver((mutationList, observer) => {
|
||||
for (const mutation of mutationList) {
|
||||
if (mutation.type === 'childList') {
|
||||
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
||||
const item = mutation.addedNodes[i];
|
||||
if (item.classList.contains('editor-tooltip-host')) {
|
||||
addStyleToAutoComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {childList: true});
|
||||
function addStyleToAutoComplete() {
|
||||
document.querySelectorAll('.editor-tooltip-host').forEach(element => {
|
||||
if (element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) {
|
||||
const overriddenStyle = document.createElement('style');
|
||||
overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font');
|
||||
overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}';
|
||||
element.shadowRoot.append(overriddenStyle);
|
||||
}
|
||||
});
|
||||
}
|
||||
`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
// Custom APIs for renderer
|
||||
@@ -25,6 +26,9 @@ const api = {
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
notification: {
|
||||
send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification)
|
||||
},
|
||||
system: {
|
||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
||||
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
|
||||
@@ -37,8 +41,8 @@ const api = {
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
|
||||
},
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath),
|
||||
backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile),
|
||||
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
|
||||
@@ -73,7 +77,8 @@ const api = {
|
||||
download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId)
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
},
|
||||
fs: {
|
||||
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
|
||||
@@ -111,7 +116,8 @@ const api = {
|
||||
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
|
||||
},
|
||||
gemini: {
|
||||
uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, apiKey),
|
||||
uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }),
|
||||
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
|
||||
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
|
||||
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
|
||||
@@ -147,7 +153,8 @@ const api = {
|
||||
listResources: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListResources, server),
|
||||
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
|
||||
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
|
||||
},
|
||||
shell: {
|
||||
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
|
||||
|
||||
@@ -18,3 +18,32 @@ vi.mock('electron-log/renderer', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electron: {
|
||||
ipcRenderer: {
|
||||
on: vi.fn(), // Mocking ipcRenderer.on
|
||||
send: vi.fn() // Mocking ipcRenderer.send
|
||||
}
|
||||
},
|
||||
api: {
|
||||
file: {
|
||||
read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this)
|
||||
writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
|
||||
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
|
||||
// You can add other axios methods like put, delete etc. as needed
|
||||
}
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
...global.window, // Copy other global properties
|
||||
addEventListener: vi.fn(), // Mock addEventListener
|
||||
removeEventListener: vi.fn() // You can also mock removeEventListener if needed
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<style>
|
||||
@@ -21,7 +21,7 @@
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#spinner img {
|
||||
@@ -36,6 +36,9 @@
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script>
|
||||
console.time('init')
|
||||
</script>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/entryPoint.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -8,8 +8,9 @@ import { PersistGate } from 'redux-persist/integration/react'
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
import { NotificationProvider } from './context/NotificationProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
@@ -27,26 +28,28 @@ function App(): React.ReactElement {
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<SyntaxHighlighterProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</SyntaxHighlighterProvider>
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
|
||||
1
src/renderer/src/assets/images/apps/google.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg>
|
||||
|
After Width: | Height: | Size: 688 B |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.3 KiB |
BIN
src/renderer/src/assets/images/models/tokenflux.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/renderer/src/assets/images/models/tokenflux_dark.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/renderer/src/assets/images/providers/burncloud.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 81 KiB |
BIN
src/renderer/src/assets/images/providers/tokenflux.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
outline: none;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.ant-segmented-group {
|
||||
|
||||
130
src/renderer/src/assets/styles/color.scss
Normal file
@@ -0,0 +1,130 @@
|
||||
:root {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: rgba(255, 255, 255, 0.8);
|
||||
--color-white-mute: rgba(255, 255, 255, 0.94);
|
||||
|
||||
--color-black: #181818;
|
||||
--color-black-soft: #222222;
|
||||
--color-black-mute: #333333;
|
||||
|
||||
--color-gray-1: #515c67;
|
||||
--color-gray-2: #414853;
|
||||
--color-gray-3: #32363f;
|
||||
|
||||
--color-text-1: rgba(255, 255, 245, 0.9);
|
||||
--color-text-2: rgba(235, 235, 245, 0.6);
|
||||
--color-text-3: rgba(235, 235, 245, 0.38);
|
||||
|
||||
--color-background: var(--color-black);
|
||||
--color-background-soft: var(--color-black-soft);
|
||||
--color-background-mute: var(--color-black-mute);
|
||||
--color-background-opacity: rgba(34, 34, 34, 0.7);
|
||||
--inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
--color-link: #338cff;
|
||||
--color-code-background: #323232;
|
||||
--color-hover: rgba(40, 40, 40, 1);
|
||||
--color-active: rgba(55, 55, 55, 1);
|
||||
--color-frame-border: #333;
|
||||
--color-group-background: var(--color-background-soft);
|
||||
|
||||
--color-reference: #404040;
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--color-highlight: rgba(0, 0, 0, 1);
|
||||
--color-background-highlight: rgba(255, 255, 0, 0.9);
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.9);
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--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);
|
||||
|
||||
--list-item-border-radius: 16px;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: rgba(0, 0, 0, 0.04);
|
||||
--color-white-mute: #eee;
|
||||
|
||||
--color-black: #1b1b1f;
|
||||
--color-black-soft: #262626;
|
||||
--color-black-mute: #363636;
|
||||
|
||||
--color-gray-1: #8e8e93;
|
||||
--color-gray-2: #aeaeb2;
|
||||
--color-gray-3: #c7c7cc;
|
||||
|
||||
--color-text-1: rgba(0, 0, 0, 1);
|
||||
--color-text-2: rgba(0, 0, 0, 0.6);
|
||||
--color-text-3: rgba(0, 0, 0, 0.38);
|
||||
|
||||
--color-background: var(--color-white);
|
||||
--color-background-soft: var(--color-white-soft);
|
||||
--color-background-mute: var(--color-white-mute);
|
||||
--color-background-opacity: rgba(235, 235, 235, 0.7);
|
||||
--inner-glow-opacity: 0.1;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #00000099;
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000019;
|
||||
--color-border-soft: #00000010;
|
||||
--color-border-mute: #00000005;
|
||||
--color-error: #f44336;
|
||||
--color-link: #1677ff;
|
||||
--color-code-background: #e3e3e3;
|
||||
--color-hover: var(--color-white-mute);
|
||||
--color-active: var(--color-white-soft);
|
||||
--color-frame-border: #ddd;
|
||||
--color-group-background: var(--color-white);
|
||||
|
||||
--color-reference: #cfe1ff;
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--color-highlight: initial;
|
||||
--color-background-highlight: rgba(255, 255, 0, 0.5);
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.5);
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-text-user: var(--color-text);
|
||||
}
|
||||
13
src/renderer/src/assets/styles/font.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
:root {
|
||||
--font-family:
|
||||
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', serif, Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use './color.scss';
|
||||
@use './font.scss';
|
||||
@use './markdown.scss';
|
||||
@use './ant.scss';
|
||||
@use './scrollbar.scss';
|
||||
@@ -6,136 +8,6 @@
|
||||
@import '../fonts/icon-fonts/iconfont.css';
|
||||
@import '../fonts/ubuntu/ubuntu.css';
|
||||
|
||||
:root {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: rgba(255, 255, 255, 0.8);
|
||||
--color-white-mute: rgba(255, 255, 255, 0.94);
|
||||
|
||||
--color-black: #181818;
|
||||
--color-black-soft: #222222;
|
||||
--color-black-mute: #333333;
|
||||
|
||||
--color-gray-1: #515c67;
|
||||
--color-gray-2: #414853;
|
||||
--color-gray-3: #32363f;
|
||||
|
||||
--color-text-1: rgba(255, 255, 245, 0.9);
|
||||
--color-text-2: rgba(235, 235, 245, 0.6);
|
||||
--color-text-3: rgba(235, 235, 245, 0.38);
|
||||
|
||||
--color-background: var(--color-black);
|
||||
--color-background-soft: var(--color-black-soft);
|
||||
--color-background-mute: var(--color-black-mute);
|
||||
--color-background-opacity: rgba(34, 34, 34, 0.7);
|
||||
--inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
--color-link: #338cff;
|
||||
--color-code-background: #323232;
|
||||
--color-hover: rgba(40, 40, 40, 1);
|
||||
--color-active: rgba(55, 55, 55, 1);
|
||||
--color-frame-border: #333;
|
||||
--color-group-background: var(--color-background-soft);
|
||||
|
||||
--color-reference: #404040;
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--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);
|
||||
|
||||
--list-item-border-radius: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: rgba(0, 0, 0, 0.04);
|
||||
--color-white-mute: #eee;
|
||||
|
||||
--color-black: #1b1b1f;
|
||||
--color-black-soft: #262626;
|
||||
--color-black-mute: #363636;
|
||||
|
||||
--color-gray-1: #8e8e93;
|
||||
--color-gray-2: #aeaeb2;
|
||||
--color-gray-3: #c7c7cc;
|
||||
|
||||
--color-text-1: rgba(0, 0, 0, 1);
|
||||
--color-text-2: rgba(0, 0, 0, 0.6);
|
||||
--color-text-3: rgba(0, 0, 0, 0.38);
|
||||
|
||||
--color-background: var(--color-white);
|
||||
--color-background-soft: var(--color-white-soft);
|
||||
--color-background-mute: var(--color-white-mute);
|
||||
--color-background-opacity: rgba(235, 235, 235, 0.7);
|
||||
--inner-glow-opacity: 0.1;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #00000099;
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000019;
|
||||
--color-border-soft: #00000010;
|
||||
--color-border-mute: #00000005;
|
||||
--color-error: #f44336;
|
||||
--color-link: #1677ff;
|
||||
--color-code-background: #e3e3e3;
|
||||
--color-hover: var(--color-white-mute);
|
||||
--color-active: var(--color-white-soft);
|
||||
--color-frame-border: #ddd;
|
||||
--color-group-background: var(--color-white);
|
||||
|
||||
--color-reference: #cfe1ff;
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-text-user: var(--color-text);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -152,8 +24,18 @@ body[theme-mode='light'] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -163,13 +45,17 @@ body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: var(--font-family);
|
||||
text-rendering: optimizeLegibility;
|
||||
transition: background-color 0.3s linear;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.3s linear;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
@@ -190,20 +76,8 @@ a {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.loader {
|
||||
@@ -291,3 +165,11 @@ body,
|
||||
.lucide {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
|
||||
span.highlight {
|
||||
background-color: var(--color-background-highlight);
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
span.highlight.selected {
|
||||
background-color: var(--color-background-highlight-accent);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,8 @@
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 1em 0;
|
||||
font-weight: 800;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-weight: bold;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -117,7 +115,7 @@
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
font-family: var(--code-font-family);
|
||||
}
|
||||
|
||||
pre {
|
||||
@@ -125,7 +123,9 @@
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
&:has(> .mermaid) {
|
||||
&:has(.mermaid),
|
||||
&:has(.plantuml-preview),
|
||||
&:has(.svg-preview) {
|
||||
background-color: transparent;
|
||||
}
|
||||
&:not(pre pre) {
|
||||
@@ -153,7 +153,7 @@
|
||||
padding-left: 1em;
|
||||
color: var(--color-text-light);
|
||||
border-left: 4px solid var(--color-border);
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -171,9 +171,7 @@
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -304,3 +302,25 @@ emoji-picker {
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* CodeMirror 相关样式 */
|
||||
.cm-editor {
|
||||
.cm-scroller {
|
||||
font-family: var(--code-font-family);
|
||||
padding: 1px;
|
||||
border-radius: 5px;
|
||||
|
||||
.cm-gutters {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
line-height: 1.6;
|
||||
padding-left: 0.25em;
|
||||
}
|
||||
|
||||
.cm-lineWrapping * {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
src/renderer/src/components/Alert/OpenAIAlert.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Alert } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LOCALSTORAGE_KEY = 'openai_alert_closed'
|
||||
|
||||
const OpenAIAlert = () => {
|
||||
const { t } = useTranslation()
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const closed = localStorage.getItem(LOCALSTORAGE_KEY)
|
||||
setVisible(!closed)
|
||||
}, [])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
|
||||
message={t('settings.provider.openai.alert')}
|
||||
closable
|
||||
afterClose={() => {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, '1')
|
||||
setVisible(false)
|
||||
}}
|
||||
type="warning"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenAIAlert
|
||||
312
src/renderer/src/components/CodeBlockView/CodePreview.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { getReactStyleFromToken } from '@renderer/utils/shiki'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CodePreviewProps {
|
||||
children: string
|
||||
language: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shiki 流式代码高亮组件
|
||||
*
|
||||
* - 通过 shiki tokenizer 处理流式响应
|
||||
* - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过
|
||||
*/
|
||||
const CodePreview = ({ children, language }: CodePreviewProps) => {
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
||||
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
|
||||
const codeContentRef = useRef<HTMLDivElement>(null)
|
||||
const prevCodeLengthRef = useRef(0)
|
||||
const safeCodeStringRef = useRef(children)
|
||||
const highlightQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
const shikiThemeRef = useRef(activeShikiTheme)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { registerTool, removeTool } = useCodeToolbar()
|
||||
|
||||
// 展开/折叠工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.expand,
|
||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = codeContentRef.current?.scrollHeight
|
||||
return codeCollapsible && (scrollHeight ?? 0) > 350
|
||||
},
|
||||
onClick: () => setIsExpanded((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.expand.id)
|
||||
}, [codeCollapsible, isExpanded, registerTool, removeTool, t])
|
||||
|
||||
// 自动换行工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.wrap,
|
||||
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
visible: () => codeWrappable,
|
||||
onClick: () => setIsUnwrapped((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||
}, [codeWrappable, isUnwrapped, registerTool, removeTool, t])
|
||||
|
||||
// 更新展开状态
|
||||
useEffect(() => {
|
||||
setIsExpanded(!codeCollapsible)
|
||||
}, [codeCollapsible])
|
||||
|
||||
// 更新换行状态
|
||||
useEffect(() => {
|
||||
setIsUnwrapped(!codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
// 处理尾部空白字符
|
||||
const safeCodeString = useMemo(() => {
|
||||
return typeof children === 'string' ? children.trimEnd() : ''
|
||||
}, [children])
|
||||
|
||||
const highlightCode = useCallback(async () => {
|
||||
if (!safeCodeString) return
|
||||
|
||||
if (prevCodeLengthRef.current === safeCodeString.length) return
|
||||
|
||||
// 捕获当前状态
|
||||
const startPos = prevCodeLengthRef.current
|
||||
const endPos = safeCodeString.length
|
||||
|
||||
// 添加到处理队列,确保按顺序处理
|
||||
highlightQueueRef.current = highlightQueueRef.current.then(async () => {
|
||||
// FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮
|
||||
if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) {
|
||||
cleanupTokenizers(callerId)
|
||||
prevCodeLengthRef.current = 0
|
||||
safeCodeStringRef.current = ''
|
||||
|
||||
const result = await highlightCodeChunk(safeCodeString, language, callerId)
|
||||
setTokenLines(result.lines)
|
||||
|
||||
prevCodeLengthRef.current = safeCodeString.length
|
||||
safeCodeStringRef.current = safeCodeString
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 跳过 race condition,延迟到后续任务
|
||||
if (prevCodeLengthRef.current !== startPos) {
|
||||
return
|
||||
}
|
||||
|
||||
const incrementalCode = safeCodeString.slice(startPos, endPos)
|
||||
const result = await highlightCodeChunk(incrementalCode, language, callerId)
|
||||
setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines])
|
||||
prevCodeLengthRef.current = endPos
|
||||
safeCodeStringRef.current = safeCodeString
|
||||
})
|
||||
}, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString])
|
||||
|
||||
// 主题变化时强制重新高亮
|
||||
useEffect(() => {
|
||||
if (shikiThemeRef.current !== activeShikiTheme) {
|
||||
prevCodeLengthRef.current++
|
||||
shikiThemeRef.current = activeShikiTheme
|
||||
}
|
||||
}, [activeShikiTheme])
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
return () => cleanupTokenizers(callerId)
|
||||
}, [callerId, cleanupTokenizers])
|
||||
|
||||
// 处理第二次开始的代码高亮
|
||||
useEffect(() => {
|
||||
if (prevCodeLengthRef.current > 0) {
|
||||
setTimeout(highlightCode, 0)
|
||||
}
|
||||
}, [highlightCode])
|
||||
|
||||
// 视口检测逻辑,只处理第一次代码高亮
|
||||
useEffect(() => {
|
||||
const codeElement = codeContentRef.current
|
||||
if (!codeElement || prevCodeLengthRef.current > 0) return
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && isMounted) {
|
||||
setTimeout(highlightCode, 0)
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(codeElement)
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [highlightCode])
|
||||
|
||||
const hasHighlightedCode = useMemo(() => {
|
||||
return tokenLines.length > 0
|
||||
}, [tokenLines.length])
|
||||
|
||||
return (
|
||||
<ContentContainer
|
||||
ref={codeContentRef}
|
||||
$lineNumbers={codeShowLineNumbers}
|
||||
$wrap={codeWrappable && !isUnwrapped}
|
||||
style={{
|
||||
fontSize: fontSize - 1,
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
|
||||
}}>
|
||||
{hasHighlightedCode ? (
|
||||
<div className="fade-in-effect">
|
||||
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
|
||||
</div>
|
||||
) : (
|
||||
<CodePlaceholder>{children}</CodePlaceholder>
|
||||
)}
|
||||
</ContentContainer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Shiki 高亮后的 tokens
|
||||
*
|
||||
* 独立出来,方便将来做 virtual list
|
||||
*/
|
||||
const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo(
|
||||
({ language, tokenLines }) => {
|
||||
const { getShikiPreProperties } = useCodeStyle()
|
||||
const rendererRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
// 设置 pre 标签属性
|
||||
useEffect(() => {
|
||||
getShikiPreProperties(language).then((properties) => {
|
||||
const pre = rendererRef.current
|
||||
if (pre) {
|
||||
pre.className = properties.class
|
||||
pre.style.cssText = properties.style
|
||||
pre.tabIndex = properties.tabindex
|
||||
}
|
||||
})
|
||||
}, [language, getShikiPreProperties])
|
||||
|
||||
return (
|
||||
<pre className="shiki" ref={rendererRef}>
|
||||
<code>
|
||||
{tokenLines.map((lineTokens, lineIndex) => (
|
||||
<span key={`line-${lineIndex}`} className="line">
|
||||
{lineTokens.map((token, tokenIndex) => (
|
||||
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const ContentContainer = styled.div<{
|
||||
$lineNumbers: boolean
|
||||
$wrap: boolean
|
||||
}>`
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: 5px;
|
||||
margin-top: 0;
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.shiki {
|
||||
padding: 1em;
|
||||
|
||||
code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.line {
|
||||
display: block;
|
||||
min-height: 1.3rem;
|
||||
padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')};
|
||||
|
||||
* {
|
||||
word-wrap: ${(props) => (props.$wrap ? 'break-word' : undefined)};
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$lineNumbers &&
|
||||
`
|
||||
code {
|
||||
counter-reset: step;
|
||||
counter-increment: step 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
code .line::before {
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
width: 1rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
}
|
||||
`}
|
||||
|
||||
@keyframes contentFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-effect {
|
||||
animation: contentFadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
`
|
||||
|
||||
const CodePlaceholder = styled.div`
|
||||
opacity: 0.1;
|
||||
flex-direction: column;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-x: hidden;
|
||||
display: block;
|
||||
min-height: 1.3rem;
|
||||
`
|
||||
|
||||
CodePreview.displayName = 'CodePreview'
|
||||
|
||||
export default memo(CodePreview)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
@@ -13,7 +13,6 @@ interface Props {
|
||||
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
/**
|
||||
@@ -23,6 +22,7 @@ const Artifacts: FC<Props> = ({ html }) => {
|
||||
const path = await window.api.file.create('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
openMinapp({
|
||||
id: 'artifacts-preview',
|
||||
name: title,
|
||||
@@ -46,13 +46,6 @@ const Artifacts: FC<Props> = ({ html }) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
const onDownload = () => {
|
||||
window.api.file.save(`${title}.html`, html)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
|
||||
@@ -62,10 +55,6 @@ const Artifacts: FC<Props> = ({ html }) => {
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
|
||||
<Button icon={<DownloadOutlined />} onClick={onDownload}>
|
||||
{t('chat.artifacts.button.download')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
99
src/renderer/src/components/CodeBlockView/MermaidPreview.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { Flex } from 'antd'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
}
|
||||
|
||||
const MermaidPreview: React.FC<Props> = ({ children }) => {
|
||||
const { mermaid, isLoading, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, {
|
||||
imgSelector: 'svg',
|
||||
prefix: 'mermaid',
|
||||
enableWheelZoom: true
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
const render = useCallback(async () => {
|
||||
try {
|
||||
if (!children) return
|
||||
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(children)
|
||||
|
||||
if (!mermaidRef.current) return
|
||||
const { svg } = await mermaid.render(diagramId, children, mermaidRef.current)
|
||||
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
mermaidRef.current.innerHTML = fixedSvg
|
||||
|
||||
// 没有语法错误时清除错误记录和定时器
|
||||
setError(null)
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = null
|
||||
}
|
||||
} catch (error) {
|
||||
// 延迟显示错误
|
||||
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = setTimeout(() => {
|
||||
setError((error as Error).message)
|
||||
}, 500)
|
||||
}
|
||||
}, [children, diagramId, mermaid])
|
||||
|
||||
// 渲染Mermaid图表
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
|
||||
startTransition(render)
|
||||
|
||||
// 清理定时器
|
||||
return () => {
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isLoading, render])
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledMermaid = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
const StyledError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(MermaidPreview)
|
||||
193
src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { Spin } from 'antd'
|
||||
import pako from 'pako'
|
||||
import React, { memo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
|
||||
function encode64(data: Uint8Array) {
|
||||
let r = ''
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
if (i + 2 === data.length) {
|
||||
r += append3bytes(data[i], data[i + 1], 0)
|
||||
} else if (i + 1 === data.length) {
|
||||
r += append3bytes(data[i], 0, 0)
|
||||
} else {
|
||||
r += append3bytes(data[i], data[i + 1], data[i + 2])
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
function encode6bit(b: number) {
|
||||
if (b < 10) {
|
||||
return String.fromCharCode(48 + b)
|
||||
}
|
||||
b -= 10
|
||||
if (b < 26) {
|
||||
return String.fromCharCode(65 + b)
|
||||
}
|
||||
b -= 26
|
||||
if (b < 26) {
|
||||
return String.fromCharCode(97 + b)
|
||||
}
|
||||
b -= 26
|
||||
if (b === 0) {
|
||||
return '-'
|
||||
}
|
||||
if (b === 1) {
|
||||
return '_'
|
||||
}
|
||||
return '?'
|
||||
}
|
||||
|
||||
function append3bytes(b1: number, b2: number, b3: number) {
|
||||
const c1 = b1 >> 2
|
||||
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
|
||||
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
|
||||
const c4 = b3 & 0x3f
|
||||
let r = ''
|
||||
r += encode6bit(c1 & 0x3f)
|
||||
r += encode6bit(c2 & 0x3f)
|
||||
r += encode6bit(c3 & 0x3f)
|
||||
r += encode6bit(c4 & 0x3f)
|
||||
return r
|
||||
}
|
||||
/**
|
||||
* https://plantuml.com/zh/code-javascript-synchronous
|
||||
* To use PlantUML image generation, a text diagram description have to be :
|
||||
1. Encoded in UTF-8
|
||||
2. Compressed using Deflate algorithm
|
||||
3. Reencoded in ASCII using a transformation _close_ to base64
|
||||
*/
|
||||
function encodeDiagram(diagram: string): string {
|
||||
const utf8text = new TextEncoder().encode(diagram)
|
||||
const compressed = pako.deflateRaw(utf8text)
|
||||
return encode64(compressed)
|
||||
}
|
||||
|
||||
async function downloadUrl(url: string, filename: string) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
window.message.warning({ content: response.statusText, duration: 1.5 })
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
type PlantUMLServerImageProps = {
|
||||
format: 'png' | 'svg'
|
||||
diagram: string
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
className?: string
|
||||
}
|
||||
|
||||
function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) {
|
||||
const encodedDiagram = encodeDiagram(diagram)
|
||||
if (isDark) {
|
||||
return `${PlantUMLServer}/d${format}/${encodedDiagram}`
|
||||
}
|
||||
return `${PlantUMLServer}/${format}/${encodedDiagram}`
|
||||
}
|
||||
|
||||
const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagram, onClick, className }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
// FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景
|
||||
const url = getPlantUMLImageUrl(format, diagram, false)
|
||||
return (
|
||||
<StyledPlantUML onClick={onClick} className={className}>
|
||||
<Spin
|
||||
spinning={loading}
|
||||
indicator={
|
||||
<LoadingOutlined
|
||||
spin
|
||||
style={{
|
||||
fontSize: 32
|
||||
}}
|
||||
/>
|
||||
}>
|
||||
<img
|
||||
src={url}
|
||||
onLoad={() => {
|
||||
setLoading(false)
|
||||
}}
|
||||
onError={(e) => {
|
||||
setLoading(false)
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.opacity = '0.5'
|
||||
target.style.filter = 'blur(2px)'
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</StyledPlantUML>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlantUMLProps {
|
||||
children: string
|
||||
}
|
||||
|
||||
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const encodedDiagram = encodeDiagram(children)
|
||||
|
||||
// 自定义 PlantUML 下载方法
|
||||
const customDownload = useCallback(
|
||||
(format: 'svg' | 'png') => {
|
||||
const timestamp = Date.now()
|
||||
const url = `${PlantUMLServer}/${format}/${encodedDiagram}`
|
||||
const filename = `plantuml-diagram-${timestamp}.${format}`
|
||||
downloadUrl(url, filename).catch(() => {
|
||||
window.message.error(t('code_block.download.failed.network'))
|
||||
})
|
||||
},
|
||||
[encodedDiagram, t]
|
||||
)
|
||||
|
||||
// 使用通用图像工具,提供自定义下载方法
|
||||
const { handleZoom, handleCopyImage } = usePreviewToolHandlers(containerRef, {
|
||||
imgSelector: '.plantuml-preview img',
|
||||
prefix: 'plantuml-diagram',
|
||||
enableWheelZoom: true,
|
||||
customDownloader: customDownload
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload: customDownload
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledPlantUML = styled.div`
|
||||
max-height: calc(80vh - 100px);
|
||||
text-align: left;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: 100px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(PlantUmlPreview)
|
||||
22
src/renderer/src/components/CodeBlockView/StatusBar.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FC, memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
}
|
||||
|
||||
const StatusBar: FC<Props> = ({ children }) => {
|
||||
return <Container>{children}</Container>
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding-bottom: 10px;
|
||||
overflow-y: auto;
|
||||
text-wrap: wrap;
|
||||
`
|
||||
|
||||
export default memo(StatusBar)
|
||||
38
src/renderer/src/components/CodeBlockView/SvgPreview.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
}
|
||||
|
||||
const SvgPreview: React.FC<Props> = ({ children }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
|
||||
imgSelector: '.svg-preview svg',
|
||||
prefix: 'svg-image'
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return (
|
||||
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
|
||||
)
|
||||
}
|
||||
|
||||
const SvgPreviewContainer = styled.div`
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
`
|
||||
|
||||
export default memo(SvgPreview)
|
||||
304
src/renderer/src/components/CodeBlockView/index.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { CodeToolbar, CodeToolContext, TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { isValidPlantUML } from '@renderer/utils/markdown'
|
||||
import dayjs from 'dayjs'
|
||||
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CodePreview from './CodePreview'
|
||||
import HtmlArtifacts from './HtmlArtifacts'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import StatusBar from './StatusBar'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
type ViewMode = 'source' | 'special' | 'split'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
language: string
|
||||
onSave?: (newContent: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块视图
|
||||
*
|
||||
* 视图类型:
|
||||
* - preview: 预览视图,其中非源代码的是特殊视图
|
||||
* - edit: 编辑视图
|
||||
*
|
||||
* 视图模式:
|
||||
* - source: 源代码视图模式
|
||||
* - special: 特殊视图模式(Mermaid、PlantUML、SVG)
|
||||
* - split: 分屏模式(源代码和特殊视图并排显示)
|
||||
*
|
||||
* 顶部 sticky 工具栏:
|
||||
* - quick 工具
|
||||
* - core 工具
|
||||
*/
|
||||
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
const { t } = useTranslation()
|
||||
const { codeEditor, codeExecution } = useSettings()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('special')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [output, setOutput] = useState('')
|
||||
|
||||
const isExecutable = useMemo(() => {
|
||||
return codeExecution.enabled && language === 'python'
|
||||
}, [codeExecution.enabled, language])
|
||||
|
||||
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
|
||||
|
||||
const isInSpecialView = useMemo(() => {
|
||||
return hasSpecialView && viewMode === 'special'
|
||||
}, [hasSpecialView, viewMode])
|
||||
|
||||
const { updateContext, registerTool, removeTool } = useCodeToolbar()
|
||||
|
||||
useEffect(() => {
|
||||
updateContext({
|
||||
code: children,
|
||||
language
|
||||
})
|
||||
}, [children, language, updateContext])
|
||||
|
||||
const handleCopySource = useCallback(
|
||||
(ctx?: CodeToolContext) => {
|
||||
if (!ctx) return
|
||||
navigator.clipboard.writeText(ctx.code)
|
||||
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const handleDownloadSource = useCallback((ctx?: CodeToolContext) => {
|
||||
if (!ctx) return
|
||||
|
||||
const { code, language } = ctx
|
||||
let fileName = ''
|
||||
|
||||
// 尝试提取标题
|
||||
if (language === 'html' && code.includes('</html>')) {
|
||||
const title = extractTitle(code)
|
||||
if (title) {
|
||||
fileName = `${title}.html`
|
||||
}
|
||||
}
|
||||
|
||||
// 默认使用日期格式命名
|
||||
if (!fileName) {
|
||||
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
|
||||
}
|
||||
|
||||
window.api.file.save(fileName, code)
|
||||
}, [])
|
||||
|
||||
const handleRunScript = useCallback(
|
||||
(ctx?: CodeToolContext) => {
|
||||
if (!ctx) return
|
||||
|
||||
setIsRunning(true)
|
||||
setOutput('')
|
||||
|
||||
pyodideService
|
||||
.runScript(ctx.code, {}, codeExecution.timeoutMinutes * 60000)
|
||||
.then((formattedOutput) => {
|
||||
setOutput(formattedOutput)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error)
|
||||
setOutput(`Unexpected error: ${error.message || 'Unknown error'}`)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRunning(false)
|
||||
})
|
||||
},
|
||||
[codeExecution.timeoutMinutes]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// 复制按钮
|
||||
registerTool({
|
||||
...TOOL_SPECS.copy,
|
||||
icon: <Copy className="icon" />,
|
||||
tooltip: t('code_block.copy.source'),
|
||||
onClick: handleCopySource
|
||||
})
|
||||
|
||||
// 下载按钮
|
||||
registerTool({
|
||||
...TOOL_SPECS.download,
|
||||
icon: <Download className="icon" />,
|
||||
tooltip: t('code_block.download.source'),
|
||||
onClick: handleDownloadSource
|
||||
})
|
||||
return () => {
|
||||
removeTool(TOOL_SPECS.copy.id)
|
||||
removeTool(TOOL_SPECS.download.id)
|
||||
}
|
||||
}, [handleCopySource, handleDownloadSource, registerTool, removeTool, t])
|
||||
|
||||
// 特殊视图的编辑按钮,在分屏模式下不可用
|
||||
useEffect(() => {
|
||||
if (!hasSpecialView || viewMode === 'split') return
|
||||
|
||||
const viewSourceToolSpec = codeEditor.enabled ? TOOL_SPECS.edit : TOOL_SPECS['view-source']
|
||||
|
||||
if (codeEditor.enabled) {
|
||||
registerTool({
|
||||
...viewSourceToolSpec,
|
||||
icon: viewMode === 'source' ? <Eye className="icon" /> : <SquarePen className="icon" />,
|
||||
tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.edit'),
|
||||
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
|
||||
})
|
||||
} else {
|
||||
registerTool({
|
||||
...viewSourceToolSpec,
|
||||
icon: viewMode === 'source' ? <Eye className="icon" /> : <CodeXml className="icon" />,
|
||||
tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.preview.source'),
|
||||
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
|
||||
})
|
||||
}
|
||||
|
||||
return () => removeTool(viewSourceToolSpec.id)
|
||||
}, [codeEditor.enabled, hasSpecialView, viewMode, registerTool, removeTool, t])
|
||||
|
||||
// 特殊视图的分屏按钮
|
||||
useEffect(() => {
|
||||
if (!hasSpecialView) return
|
||||
|
||||
registerTool({
|
||||
...TOOL_SPECS['split-view'],
|
||||
icon: viewMode === 'split' ? <Square className="icon" /> : <SquareSplitHorizontal className="icon" />,
|
||||
tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split'),
|
||||
onClick: () => setViewMode(viewMode === 'split' ? 'special' : 'split')
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS['split-view'].id)
|
||||
}, [hasSpecialView, viewMode, registerTool, removeTool, t])
|
||||
|
||||
// 运行按钮
|
||||
useEffect(() => {
|
||||
if (!isExecutable) return
|
||||
|
||||
registerTool({
|
||||
...TOOL_SPECS.run,
|
||||
icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />,
|
||||
tooltip: t('code_block.run'),
|
||||
onClick: (ctx) => !isRunning && handleRunScript(ctx)
|
||||
})
|
||||
|
||||
return () => isExecutable && removeTool(TOOL_SPECS.run.id)
|
||||
}, [isExecutable, isRunning, handleRunScript, registerTool, removeTool, t])
|
||||
|
||||
// 源代码视图组件
|
||||
const sourceView = useMemo(() => {
|
||||
if (codeEditor.enabled) {
|
||||
return <CodeEditor value={children} language={language} onSave={onSave} options={{ stream: true }} />
|
||||
} else {
|
||||
return <CodePreview language={language}>{children}</CodePreview>
|
||||
}
|
||||
}, [children, codeEditor.enabled, language, onSave])
|
||||
|
||||
// 特殊视图组件映射
|
||||
const specialView = useMemo(() => {
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidPreview>{children}</MermaidPreview>
|
||||
} else if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUmlPreview>{children}</PlantUmlPreview>
|
||||
} else if (language === 'svg') {
|
||||
return <SvgPreview>{children}</SvgPreview>
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
|
||||
const renderHeader = useMemo(() => {
|
||||
const langTag = '<' + language.toUpperCase() + '>'
|
||||
return <CodeHeader $isInSpecialView={isInSpecialView}>{isInSpecialView ? '' : langTag}</CodeHeader>
|
||||
}, [isInSpecialView, language])
|
||||
|
||||
// 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图
|
||||
const renderContent = useMemo(() => {
|
||||
const showSpecialView = specialView && ['special', 'split'].includes(viewMode)
|
||||
const showSourceView = !specialView || viewMode !== 'special'
|
||||
|
||||
return (
|
||||
<SplitViewWrapper className="split-view-wrapper">
|
||||
{showSpecialView && specialView}
|
||||
{showSourceView && sourceView}
|
||||
</SplitViewWrapper>
|
||||
)
|
||||
}, [specialView, sourceView, viewMode])
|
||||
|
||||
const renderArtifacts = useMemo(() => {
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifacts html={children} />
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
|
||||
{renderHeader}
|
||||
<CodeToolbar />
|
||||
{renderContent}
|
||||
{renderArtifacts}
|
||||
{isExecutable && output && <StatusBar>{output}</StatusBar>}
|
||||
</CodeBlockWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
position: relative;
|
||||
|
||||
.code-toolbar {
|
||||
margin-top: ${(props) => (props.$isInSpecialView ? '20px' : '0')};
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')};
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
will-change: opacity;
|
||||
&.show {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.code-toolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 0 10px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')};
|
||||
`
|
||||
|
||||
const SplitViewWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
> * {
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodeBlockView)
|
||||
65
src/renderer/src/components/CodeEditor/hook.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { Extension } from '@uiw/react-codemirror'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
let linterPromise: Promise<any> | null = null
|
||||
function importLintPackage() {
|
||||
if (!linterPromise) {
|
||||
linterPromise = import('@codemirror/lint').then((mod) => mod.linter)
|
||||
}
|
||||
return linterPromise
|
||||
}
|
||||
|
||||
// 语言对应的 linter 加载器
|
||||
const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
json: async () => {
|
||||
const [linter, jsonParseLinter] = await Promise.all([
|
||||
importLintPackage(),
|
||||
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
|
||||
])
|
||||
return linter(jsonParseLinter())
|
||||
}
|
||||
}
|
||||
|
||||
export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
const { languageMap } = useCodeStyle()
|
||||
const [extensions, setExtensions] = useState<Extension[]>([])
|
||||
|
||||
// 加载语言
|
||||
useEffect(() => {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
import('@uiw/codemirror-extensions-langs')
|
||||
.then(({ loadLanguage }) => {
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
if (extension) {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(`Failed to load language: ${normalizedLang}`, error)
|
||||
})
|
||||
}, [language, languageMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lint) return
|
||||
|
||||
const loader = linterLoaders[language]
|
||||
if (loader) {
|
||||
loader()
|
||||
.then((extension) => {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to load linter for ${language}`, error)
|
||||
})
|
||||
}
|
||||
}, [language, lint])
|
||||
|
||||
return extensions
|
||||
}
|
||||
273
src/renderer/src/components/CodeEditor/index.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror'
|
||||
import diff from 'fast-diff'
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Save as SaveIcon,
|
||||
Text as UnWrapIcon,
|
||||
WrapText as WrapIcon
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useLanguageExtensions } from './hook'
|
||||
|
||||
// 标记非用户编辑的变更
|
||||
const External = Annotation.define<boolean>()
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
placeholder?: string | HTMLElement
|
||||
language: string
|
||||
onSave?: (newContent: string) => void
|
||||
onChange?: (newContent: string) => void
|
||||
minHeight?: string
|
||||
maxHeight?: string
|
||||
/** 用于覆写编辑器的某些设置 */
|
||||
options?: {
|
||||
stream?: boolean // 用于流式响应场景,默认 false
|
||||
lint?: boolean
|
||||
collapsible?: boolean
|
||||
wrappable?: boolean
|
||||
keymap?: boolean
|
||||
} & BasicSetupOptions
|
||||
/** 用于追加 extensions */
|
||||
extensions?: Extension[]
|
||||
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror。
|
||||
*
|
||||
* 目前必须和 CodeToolbar 配合使用。
|
||||
*/
|
||||
const CodeEditor = ({
|
||||
value,
|
||||
placeholder,
|
||||
language,
|
||||
onSave,
|
||||
onChange,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
options,
|
||||
extensions,
|
||||
style
|
||||
}: Props) => {
|
||||
const {
|
||||
fontSize,
|
||||
codeShowLineNumbers: _lineNumbers,
|
||||
codeCollapsible: _collapsible,
|
||||
codeWrappable: _wrappable,
|
||||
codeEditor
|
||||
} = useSettings()
|
||||
const collapsible = useMemo(() => options?.collapsible ?? _collapsible, [options?.collapsible, _collapsible])
|
||||
const wrappable = useMemo(() => options?.wrappable ?? _wrappable, [options?.wrappable, _wrappable])
|
||||
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
|
||||
|
||||
// 合并 codeEditor 和 options 的 basicSetup,options 优先
|
||||
const customBasicSetup = useMemo(() => {
|
||||
return {
|
||||
lineNumbers: _lineNumbers,
|
||||
...(codeEditor as BasicSetupOptions),
|
||||
...(options as BasicSetupOptions)
|
||||
}
|
||||
}, [codeEditor, _lineNumbers, options])
|
||||
|
||||
const { activeCmTheme } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
|
||||
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
|
||||
const [editorReady, setEditorReady] = useState(false)
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const langExtensions = useLanguageExtensions(language, options?.lint)
|
||||
|
||||
const { registerTool, removeTool } = useCodeToolbar()
|
||||
|
||||
// 展开/折叠工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.expand,
|
||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = editorViewRef?.current?.scrollDOM?.scrollHeight
|
||||
return collapsible && (scrollHeight ?? 0) > 350
|
||||
},
|
||||
onClick: () => setIsExpanded((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.expand.id)
|
||||
}, [collapsible, isExpanded, registerTool, removeTool, t, editorReady])
|
||||
|
||||
// 自动换行工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.wrap,
|
||||
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
visible: () => wrappable,
|
||||
onClick: () => setIsUnwrapped((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||
}, [wrappable, isUnwrapped, registerTool, removeTool, t])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
|
||||
onSave?.(currentDoc)
|
||||
}, [onSave])
|
||||
|
||||
// 保存按钮
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.save,
|
||||
icon: <SaveIcon className="icon" />,
|
||||
tooltip: t('code_block.edit.save'),
|
||||
onClick: handleSave
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.save.id)
|
||||
}, [handleSave, registerTool, removeTool, t])
|
||||
|
||||
// 流式响应过程中计算 changes 来更新 EditorView
|
||||
// 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理)
|
||||
useEffect(() => {
|
||||
if (!editorViewRef.current) return
|
||||
|
||||
const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
|
||||
const currentDoc = editorViewRef.current.state.doc.toString()
|
||||
|
||||
const changes = prepareCodeChanges(currentDoc, newContent)
|
||||
|
||||
if (changes && changes.length > 0) {
|
||||
editorViewRef.current.dispatch({
|
||||
changes,
|
||||
annotations: [External.of(true)]
|
||||
})
|
||||
}
|
||||
}, [options?.stream, value])
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(!collapsible)
|
||||
}, [collapsible])
|
||||
|
||||
useEffect(() => {
|
||||
setIsUnwrapped(!wrappable)
|
||||
}, [wrappable])
|
||||
|
||||
// 保存功能的快捷键
|
||||
const saveKeymap = useMemo(() => {
|
||||
return keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: () => {
|
||||
handleSave()
|
||||
return true
|
||||
},
|
||||
preventDefault: true
|
||||
}
|
||||
])
|
||||
}, [handleSave])
|
||||
|
||||
const customExtensions = useMemo(() => {
|
||||
return [
|
||||
...(extensions ?? []),
|
||||
...langExtensions,
|
||||
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
|
||||
...(enableKeymap ? [saveKeymap] : [])
|
||||
]
|
||||
}, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap])
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
// 维持一个稳定值,避免触发 CodeMirror 重置
|
||||
value={initialContent.current}
|
||||
placeholder={placeholder}
|
||||
width="100%"
|
||||
minHeight={minHeight}
|
||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||
editable={true}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
theme={activeCmTheme}
|
||||
extensions={customExtensions}
|
||||
onCreateEditor={(view: EditorView) => {
|
||||
editorViewRef.current = view
|
||||
setEditorReady(true)
|
||||
}}
|
||||
onChange={(value, viewUpdate) => {
|
||||
if (onChange && viewUpdate.docChanged) onChange(value)
|
||||
}}
|
||||
basicSetup={{
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: true,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: enableKeymap,
|
||||
searchKeymap: enableKeymap,
|
||||
foldKeymap: enableKeymap,
|
||||
completionKeymap: enableKeymap,
|
||||
lintKeymap: enableKeymap,
|
||||
...customBasicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
border: '0.5px solid transparent',
|
||||
borderRadius: '5px',
|
||||
marginTop: 0,
|
||||
...style
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CodeEditor.displayName = 'CodeEditor'
|
||||
|
||||
/**
|
||||
* 使用 fast-diff 计算代码变更,再转换为 CodeMirror 的 changes。
|
||||
* 可以处理所有类型的变更,不过流式响应过程中多是插入操作。
|
||||
* @param oldCode 旧的代码内容
|
||||
* @param newCode 新的代码内容
|
||||
* @returns 用于 EditorView.dispatch 的 changes 数组
|
||||
*/
|
||||
function prepareCodeChanges(oldCode: string, newCode: string) {
|
||||
const diffResult = diff(oldCode, newCode)
|
||||
|
||||
const changes: { from: number; to: number; insert: string }[] = []
|
||||
let offset = 0
|
||||
|
||||
// operation: 1=插入, -1=删除, 0=相等
|
||||
for (const [operation, text] of diffResult) {
|
||||
if (operation === 1) {
|
||||
changes.push({
|
||||
from: offset,
|
||||
to: offset,
|
||||
insert: text
|
||||
})
|
||||
} else if (operation === -1) {
|
||||
changes.push({
|
||||
from: offset,
|
||||
to: offset + text.length,
|
||||
insert: ''
|
||||
})
|
||||
offset += text.length
|
||||
} else {
|
||||
offset += text.length
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
export default memo(CodeEditor)
|
||||
76
src/renderer/src/components/CodeToolbar/constants.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { CodeToolSpec } from './types'
|
||||
|
||||
export const TOOL_SPECS: Record<string, CodeToolSpec> = {
|
||||
// Core tools
|
||||
copy: {
|
||||
id: 'copy',
|
||||
type: 'core',
|
||||
order: 10
|
||||
},
|
||||
download: {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 11
|
||||
},
|
||||
edit: {
|
||||
id: 'edit',
|
||||
type: 'core',
|
||||
order: 12
|
||||
},
|
||||
'view-source': {
|
||||
id: 'view-source',
|
||||
type: 'core',
|
||||
order: 12
|
||||
},
|
||||
save: {
|
||||
id: 'save',
|
||||
type: 'core',
|
||||
order: 13
|
||||
},
|
||||
expand: {
|
||||
id: 'expand',
|
||||
type: 'core',
|
||||
order: 20
|
||||
},
|
||||
// Quick tools
|
||||
'split-view': {
|
||||
id: 'split-view',
|
||||
type: 'quick',
|
||||
order: 10
|
||||
},
|
||||
run: {
|
||||
id: 'run',
|
||||
type: 'quick',
|
||||
order: 11
|
||||
},
|
||||
wrap: {
|
||||
id: 'wrap',
|
||||
type: 'quick',
|
||||
order: 20
|
||||
},
|
||||
'copy-image': {
|
||||
id: 'copy-image',
|
||||
type: 'quick',
|
||||
order: 30
|
||||
},
|
||||
'download-svg': {
|
||||
id: 'download-svg',
|
||||
type: 'quick',
|
||||
order: 31
|
||||
},
|
||||
'download-png': {
|
||||
id: 'download-png',
|
||||
type: 'quick',
|
||||
order: 32
|
||||
},
|
||||
'zoom-in': {
|
||||
id: 'zoom-in',
|
||||
type: 'quick',
|
||||
order: 40
|
||||
},
|
||||
'zoom-out': {
|
||||
id: 'zoom-out',
|
||||
type: 'quick',
|
||||
order: 41
|
||||
}
|
||||
}
|
||||
71
src/renderer/src/components/CodeToolbar/context.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { createContext, use, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { CodeTool, CodeToolContext } from './types'
|
||||
|
||||
// 定义上下文默认值
|
||||
const defaultContext: CodeToolContext = {
|
||||
code: '',
|
||||
language: ''
|
||||
}
|
||||
|
||||
export interface CodeToolbarContextType {
|
||||
tools: CodeTool[]
|
||||
context: CodeToolContext
|
||||
registerTool: (tool: CodeTool) => void
|
||||
removeTool: (id: string) => void
|
||||
updateContext: (newContext: Partial<CodeToolContext>) => void
|
||||
}
|
||||
|
||||
const defaultCodeToolbarContext: CodeToolbarContextType = {
|
||||
tools: [],
|
||||
context: defaultContext,
|
||||
registerTool: () => {},
|
||||
removeTool: () => {},
|
||||
updateContext: () => {}
|
||||
}
|
||||
|
||||
const CodeToolbarContext = createContext<CodeToolbarContextType>(defaultCodeToolbarContext)
|
||||
|
||||
export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [tools, setTools] = useState<CodeTool[]>([])
|
||||
const [context, setContext] = useState<CodeToolContext>(defaultContext)
|
||||
|
||||
// 注册工具,如果已存在同ID工具则替换
|
||||
const registerTool = useCallback((tool: CodeTool) => {
|
||||
setTools((prev) => {
|
||||
const filtered = prev.filter((t) => t.id !== tool.id)
|
||||
return [...filtered, tool].sort((a, b) => b.order - a.order)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 移除工具
|
||||
const removeTool = useCallback((id: string) => {
|
||||
setTools((prev) => prev.filter((tool) => tool.id !== id))
|
||||
}, [])
|
||||
|
||||
// 更新上下文
|
||||
const updateContext = useCallback((newContext: Partial<CodeToolContext>) => {
|
||||
setContext((prev) => ({ ...prev, ...newContext }))
|
||||
}, [])
|
||||
|
||||
const value: CodeToolbarContextType = useMemo(
|
||||
() => ({
|
||||
tools,
|
||||
context,
|
||||
registerTool,
|
||||
removeTool,
|
||||
updateContext
|
||||
}),
|
||||
[tools, context, registerTool, removeTool, updateContext]
|
||||
)
|
||||
|
||||
return <CodeToolbarContext value={value}>{children}</CodeToolbarContext>
|
||||
}
|
||||
|
||||
export const useCodeToolbar = () => {
|
||||
const context = use(CodeToolbarContext)
|
||||
if (!context) {
|
||||
throw new Error('useCodeToolbar must be used within a CodeToolbarProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
5
src/renderer/src/components/CodeToolbar/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './constants'
|
||||
export * from './context'
|
||||
export * from './toolbar'
|
||||
export * from './types'
|
||||
export * from './usePreviewTools'
|
||||
119
src/renderer/src/components/CodeToolbar/toolbar.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { Tooltip } from 'antd'
|
||||
import { EllipsisVertical } from 'lucide-react'
|
||||
import React, { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useCodeToolbar } from './context'
|
||||
import { CodeTool } from './types'
|
||||
|
||||
interface CodeToolButtonProps {
|
||||
tool: CodeTool
|
||||
}
|
||||
|
||||
const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => {
|
||||
const { context } = useCodeToolbar()
|
||||
|
||||
return (
|
||||
<Tooltip key={`${tool.id}-${tool.tooltip}`} title={tool.tooltip} mouseEnterDelay={0.5}>
|
||||
<ToolWrapper onClick={() => tool.onClick(context)}>{tool.icon}</ToolWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
export const CodeToolbar: React.FC = memo(() => {
|
||||
const { tools, context } = useCodeToolbar()
|
||||
const [showQuickTools, setShowQuickTools] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 根据条件显示工具
|
||||
const visibleTools = tools.filter((tool) => !tool.visible || tool.visible(context))
|
||||
|
||||
// 按类型分组
|
||||
const coreTools = visibleTools.filter((tool) => tool.type === 'core')
|
||||
const quickTools = visibleTools.filter((tool) => tool.type === 'quick')
|
||||
|
||||
// 点击了 more 按钮或者只有一个快捷工具时
|
||||
const quickToolButtons = useMemo(() => {
|
||||
if (quickTools.length === 1 || (quickTools.length > 1 && showQuickTools)) {
|
||||
return quickTools.map((tool) => <CodeToolButton key={tool.id} tool={tool} />)
|
||||
}
|
||||
|
||||
return null
|
||||
}, [quickTools, showQuickTools])
|
||||
|
||||
if (visibleTools.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StickyWrapper>
|
||||
<ToolbarWrapper className="code-toolbar">
|
||||
{/* 有多个快捷工具时通过 more 按钮展示 */}
|
||||
{quickToolButtons}
|
||||
{quickTools.length > 1 && (
|
||||
<Tooltip title={t('code_block.more')} mouseEnterDelay={0.5}>
|
||||
<ToolWrapper onClick={() => setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}>
|
||||
<EllipsisVertical className="icon" />
|
||||
</ToolWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 始终显示核心工具 */}
|
||||
{coreTools.map((tool) => (
|
||||
<CodeToolButton key={tool.id} tool={tool} />
|
||||
))}
|
||||
</ToolbarWrapper>
|
||||
</StickyWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
const StickyWrapper = styled.div`
|
||||
position: sticky;
|
||||
top: 28px;
|
||||
z-index: 10;
|
||||
`
|
||||
|
||||
const ToolbarWrapper = styled(HStack)`
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
bottom: 0.3rem;
|
||||
right: 0.5rem;
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const ToolWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.icon {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
.icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* For Lucide icons */
|
||||
.icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
`
|
||||
35
src/renderer/src/components/CodeToolbar/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 代码块工具基本信息
|
||||
*/
|
||||
export interface CodeToolSpec {
|
||||
id: string
|
||||
type: 'core' | 'quick'
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块工具定义接口
|
||||
* @param id 唯一标识符
|
||||
* @param type 工具类型
|
||||
* @param icon 按钮图标
|
||||
* @param tooltip 提示文本
|
||||
* @param condition 显示条件
|
||||
* @param onClick 点击动作
|
||||
* @param order 显示顺序,越小越靠右
|
||||
*/
|
||||
export interface CodeTool extends CodeToolSpec {
|
||||
icon: React.ReactNode
|
||||
tooltip: string
|
||||
visible?: (ctx?: CodeToolContext) => boolean
|
||||
onClick: (ctx?: CodeToolContext) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具上下文接口
|
||||
* @param code 代码内容
|
||||
* @param language 语言类型
|
||||
*/
|
||||
export interface CodeToolContext {
|
||||
code: string
|
||||
language: string
|
||||
}
|
||||
350
src/renderer/src/components/CodeToolbar/usePreviewTools.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { FileImage, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
|
||||
import { TOOL_SPECS } from './constants'
|
||||
import { useCodeToolbar } from './context'
|
||||
|
||||
// 预编译正则表达式用于查询位置
|
||||
const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
|
||||
|
||||
/**
|
||||
* 使用图像处理工具的自定义Hook
|
||||
* 提供图像缩放、复制和下载功能
|
||||
*/
|
||||
export const usePreviewToolHandlers = (
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
options: {
|
||||
prefix: string
|
||||
imgSelector: string
|
||||
enableWheelZoom?: boolean
|
||||
customDownloader?: (format: 'svg' | 'png') => void
|
||||
}
|
||||
) => {
|
||||
const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态
|
||||
const [renderTrigger, setRenderTrigger] = useState(0) // 仅用于触发组件重渲染的状态
|
||||
const { imgSelector, prefix, customDownloader, enableWheelZoom } = options
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 创建选择器函数
|
||||
const getImgElement = useCallback(() => {
|
||||
if (!containerRef.current) return null
|
||||
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
||||
}, [containerRef, imgSelector])
|
||||
|
||||
// 查询当前位置
|
||||
const getCurrentPosition = useCallback(() => {
|
||||
const imgElement = getImgElement()
|
||||
if (!imgElement) return { x: transformRef.current.x, y: transformRef.current.y }
|
||||
|
||||
const transform = imgElement.style.transform
|
||||
if (!transform || transform === 'none') return { x: transformRef.current.x, y: transformRef.current.y }
|
||||
|
||||
const match = transform.match(TRANSFORM_REGEX)
|
||||
if (match && match.length >= 3) {
|
||||
return {
|
||||
x: parseFloat(match[1]),
|
||||
y: parseFloat(match[2])
|
||||
}
|
||||
}
|
||||
|
||||
return { x: transformRef.current.x, y: transformRef.current.y }
|
||||
}, [getImgElement])
|
||||
|
||||
// 平移缩放变换
|
||||
const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => {
|
||||
if (!element) return
|
||||
element.style.transformOrigin = 'top left'
|
||||
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
|
||||
}, [])
|
||||
|
||||
// 拖拽平移支持
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let isDragging = false
|
||||
const startPos = { x: 0, y: 0 }
|
||||
const startOffset = { x: 0, y: 0 }
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return // 只响应左键
|
||||
|
||||
// 更新当前实际位置
|
||||
const position = getCurrentPosition()
|
||||
transformRef.current.x = position.x
|
||||
transformRef.current.y = position.y
|
||||
|
||||
isDragging = true
|
||||
startPos.x = e.clientX
|
||||
startPos.y = e.clientY
|
||||
startOffset.x = position.x
|
||||
startOffset.y = position.y
|
||||
|
||||
container.style.cursor = 'grabbing'
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const dx = e.clientX - startPos.x
|
||||
const dy = e.clientY - startPos.y
|
||||
const newX = startOffset.x + dx
|
||||
const newY = startOffset.y + dy
|
||||
|
||||
const imgElement = getImgElement()
|
||||
applyTransform(imgElement, newX, newY, transformRef.current.scale)
|
||||
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
if (!isDragging) return
|
||||
|
||||
// 更新位置但不立即触发状态变更
|
||||
const position = getCurrentPosition()
|
||||
transformRef.current.x = position.x
|
||||
transformRef.current.y = position.y
|
||||
|
||||
// 只触发一次渲染以保持组件状态同步
|
||||
setRenderTrigger((prev) => prev + 1)
|
||||
|
||||
isDragging = false
|
||||
container.style.cursor = 'default'
|
||||
}
|
||||
|
||||
// 绑定到document以确保拖拽可以在鼠标离开容器后继续
|
||||
container.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
}
|
||||
}, [containerRef, getCurrentPosition, getImgElement, applyTransform])
|
||||
|
||||
// 缩放处理函数
|
||||
const handleZoom = useCallback(
|
||||
(delta: number) => {
|
||||
const newScale = Math.max(0.1, Math.min(3, transformRef.current.scale + delta))
|
||||
transformRef.current.scale = newScale
|
||||
|
||||
const imgElement = getImgElement()
|
||||
applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale)
|
||||
|
||||
// 触发重渲染以保持组件状态同步
|
||||
setRenderTrigger((prev) => prev + 1)
|
||||
},
|
||||
[getImgElement, applyTransform]
|
||||
)
|
||||
|
||||
// 滚轮缩放支持
|
||||
useEffect(() => {
|
||||
if (!enableWheelZoom || !containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.target) {
|
||||
// 确认事件发生在容器内部
|
||||
if (container.contains(e.target as Node)) {
|
||||
const delta = e.deltaY < 0 ? 0.1 : -0.1
|
||||
handleZoom(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: true })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [containerRef, handleZoom, enableWheelZoom])
|
||||
|
||||
// 复制图像处理函数
|
||||
const handleCopyImage = useCallback(async () => {
|
||||
try {
|
||||
const imgElement = getImgElement()
|
||||
if (!imgElement) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
|
||||
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
|
||||
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
||||
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
|
||||
|
||||
img.onload = async () => {
|
||||
const scale = 3
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
|
||||
if (ctx) {
|
||||
ctx.scale(scale, scale)
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
window.message.success(t('message.copy.success'))
|
||||
}
|
||||
}
|
||||
img.src = svgBase64
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
}, [getImgElement, t])
|
||||
|
||||
// 下载处理函数
|
||||
const handleDownload = useCallback(
|
||||
(format: 'svg' | 'png') => {
|
||||
// 如果有自定义下载器,使用自定义实现
|
||||
if (customDownloader) {
|
||||
customDownloader(format)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const imgElement = getImgElement()
|
||||
if (!imgElement) return
|
||||
|
||||
const timestamp = Date.now()
|
||||
|
||||
if (format === 'svg') {
|
||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
download(url, `${prefix}-${timestamp}.svg`)
|
||||
URL.revokeObjectURL(url)
|
||||
} else if (format === 'png') {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
|
||||
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
|
||||
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
||||
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
|
||||
|
||||
img.onload = () => {
|
||||
const scale = 3
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
|
||||
if (ctx) {
|
||||
ctx.scale(scale, scale)
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
}
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const pngUrl = URL.createObjectURL(blob)
|
||||
download(pngUrl, `${prefix}-${timestamp}.png`)
|
||||
URL.revokeObjectURL(pngUrl)
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
img.src = svgBase64
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error)
|
||||
}
|
||||
},
|
||||
[getImgElement, prefix, customDownloader]
|
||||
)
|
||||
|
||||
return {
|
||||
scale: transformRef.current.scale,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload,
|
||||
renderTrigger // 导出渲染触发器,万一要用
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreviewToolsOptions {
|
||||
handleZoom?: (delta: number) => void
|
||||
handleCopyImage?: () => Promise<void>
|
||||
handleDownload?: (format: 'svg' | 'png') => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供预览组件通用工具栏功能的自定义Hook
|
||||
*/
|
||||
export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useCodeToolbar()
|
||||
|
||||
useEffect(() => {
|
||||
// 根据提供的功能有选择性地注册工具
|
||||
if (handleZoom) {
|
||||
// 放大工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['zoom-in'],
|
||||
icon: <ZoomIn className="icon" />,
|
||||
tooltip: t('code_block.preview.zoom_in'),
|
||||
onClick: () => handleZoom(0.1)
|
||||
})
|
||||
|
||||
// 缩小工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['zoom-out'],
|
||||
icon: <ZoomOut className="icon" />,
|
||||
tooltip: t('code_block.preview.zoom_out'),
|
||||
onClick: () => handleZoom(-0.1)
|
||||
})
|
||||
}
|
||||
|
||||
if (handleCopyImage) {
|
||||
// 复制图片工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['copy-image'],
|
||||
icon: <FileImage className="icon" />,
|
||||
tooltip: t('code_block.preview.copy.image'),
|
||||
onClick: handleCopyImage
|
||||
})
|
||||
}
|
||||
|
||||
if (handleDownload) {
|
||||
// 下载 SVG 工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['download-svg'],
|
||||
icon: <DownloadSvgIcon />,
|
||||
tooltip: t('code_block.download.svg'),
|
||||
onClick: () => handleDownload('svg')
|
||||
})
|
||||
|
||||
// 下载 PNG 工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['download-png'],
|
||||
icon: <DownloadPngIcon />,
|
||||
tooltip: t('code_block.download.png'),
|
||||
onClick: () => handleDownload('png')
|
||||
})
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (handleZoom) {
|
||||
removeTool(TOOL_SPECS['zoom-in'].id)
|
||||
removeTool(TOOL_SPECS['zoom-out'].id)
|
||||
}
|
||||
if (handleCopyImage) {
|
||||
removeTool(TOOL_SPECS['copy-image'].id)
|
||||
}
|
||||
if (handleDownload) {
|
||||
removeTool(TOOL_SPECS['download-svg'].id)
|
||||
removeTool(TOOL_SPECS['download-png'].id)
|
||||
}
|
||||
}
|
||||
}, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t])
|
||||
}
|
||||
718
src/renderer/src/components/ContentSearch.tsx
Normal file
@@ -0,0 +1,718 @@
|
||||
import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
|
||||
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||
import { Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const HIGHLIGHT_CLASS = 'highlight'
|
||||
const HIGHLIGHT_SELECT_CLASS = 'selected'
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode
|
||||
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
|
||||
/**
|
||||
* 过滤`node`,`node`只会是`Node.TEXT_NODE`类型的文本节点
|
||||
*
|
||||
* 返回`true`表示该`node`会被搜索
|
||||
*/
|
||||
filter: (node: Node) => boolean
|
||||
includeUser?: boolean
|
||||
onIncludeUserChange?: (value: boolean) => void
|
||||
}
|
||||
|
||||
enum SearchCompletedState {
|
||||
NotSearched,
|
||||
FirstSearched
|
||||
}
|
||||
|
||||
enum SearchTargetIndex {
|
||||
Next,
|
||||
Prev
|
||||
}
|
||||
|
||||
export interface ContentSearchRef {
|
||||
disable(): void
|
||||
enable(initialText?: string): void
|
||||
// 搜索下一个并定位
|
||||
searchNext(): void
|
||||
// 搜索上一个并定位
|
||||
searchPrev(): void
|
||||
// 搜索并定位
|
||||
search(): void
|
||||
// 搜索但不定位,或者说是更新
|
||||
silentSearch(): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
interface MatchInfo {
|
||||
index: number
|
||||
length: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const escapeRegExp = (string: string): string => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
||||
}
|
||||
|
||||
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
|
||||
if (!elementList || elementList.length === 0) {
|
||||
return null
|
||||
}
|
||||
let closestElementIndex: number | null = null
|
||||
let minVerticalDistance = Infinity
|
||||
const windowCenterY = window.innerHeight / 2
|
||||
for (let i = 0; i < elementList.length; i++) {
|
||||
const element = elementList[i]
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue
|
||||
}
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||
continue
|
||||
}
|
||||
const elementCenterY = rect.top + rect.height / 2
|
||||
const verticalDistance = Math.abs(elementCenterY - windowCenterY)
|
||||
if (verticalDistance < minVerticalDistance) {
|
||||
minVerticalDistance = verticalDistance
|
||||
closestElementIndex = i
|
||||
}
|
||||
}
|
||||
return closestElementIndex
|
||||
}
|
||||
|
||||
const highlightText = (
|
||||
textNode: Node,
|
||||
searchText: string,
|
||||
highlightClass: string,
|
||||
isCaseSensitive: boolean,
|
||||
isWholeWord: boolean
|
||||
): HTMLSpanElement[] | null => {
|
||||
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
|
||||
if (textNodeParentNode) {
|
||||
if (textNodeParentNode.classList.contains(highlightClass)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const textContent = textNode.textContent
|
||||
const escapedSearchText = escapeRegExp(searchText)
|
||||
|
||||
// 检查搜索文本是否仅包含拉丁字母
|
||||
const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText)
|
||||
|
||||
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
|
||||
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
|
||||
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
|
||||
const regex = new RegExp(regexPattern, regexFlags)
|
||||
|
||||
let match
|
||||
const matches: MatchInfo[] = []
|
||||
while ((match = regex.exec(textContent)) !== null) {
|
||||
if (typeof match.index === 'number' && typeof match[0] === 'string') {
|
||||
matches.push({ index: match.index, length: match[0].length, text: match[0] })
|
||||
} else {
|
||||
console.error('Unexpected match format:', match)
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parentNode = textNode.parentNode
|
||||
if (!parentNode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
let currentIndex = 0
|
||||
const highlightTextSet = new Set<HTMLSpanElement>()
|
||||
|
||||
matches.forEach(({ index, length, text }) => {
|
||||
if (index > currentIndex) {
|
||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
|
||||
}
|
||||
const highlightSpan = document.createElement('span')
|
||||
highlightSpan.className = highlightClass
|
||||
highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
|
||||
fragment.appendChild(highlightSpan)
|
||||
highlightTextSet.add(highlightSpan)
|
||||
currentIndex = index + length
|
||||
})
|
||||
|
||||
if (currentIndex < textContent.length) {
|
||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
|
||||
}
|
||||
|
||||
parentNode.replaceChild(fragment, textNode)
|
||||
return [...highlightTextSet]
|
||||
}
|
||||
|
||||
const mergeAdjacentTextNodes = (node: HTMLElement) => {
|
||||
const children = Array.from(node.childNodes)
|
||||
const groups: Array<Node | { text: string; nodes: Node[] }> = []
|
||||
let currentTextGroup: { text: string; nodes: Node[] } | null = null
|
||||
|
||||
for (const child of children) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (currentTextGroup === null) {
|
||||
currentTextGroup = {
|
||||
text: child.textContent ?? '',
|
||||
nodes: [child]
|
||||
}
|
||||
} else {
|
||||
currentTextGroup.text += child.textContent
|
||||
currentTextGroup.nodes.push(child)
|
||||
}
|
||||
} else {
|
||||
if (currentTextGroup !== null) {
|
||||
groups.push(currentTextGroup!)
|
||||
currentTextGroup = null
|
||||
}
|
||||
groups.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTextGroup !== null) {
|
||||
groups.push(currentTextGroup)
|
||||
}
|
||||
|
||||
const newChildren = groups.map((group) => {
|
||||
if (group instanceof Node) {
|
||||
return group
|
||||
} else {
|
||||
return document.createTextNode(group.text)
|
||||
}
|
||||
})
|
||||
|
||||
node.replaceChildren(...newChildren)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @eslint-react/no-forward-ref
|
||||
export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => {
|
||||
const target: HTMLElement | null = (() => {
|
||||
if (searchTarget instanceof HTMLElement) {
|
||||
return searchTarget
|
||||
} else {
|
||||
return (searchTarget.current as HTMLElement) ?? null
|
||||
}
|
||||
})()
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [searchResultIndex, setSearchResultIndex] = useState(0)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [enableContentSearch, setEnableContentSearch] = useState(false)
|
||||
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
|
||||
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||
const [isWholeWord, setIsWholeWord] = useState(false)
|
||||
const [shouldScroll, setShouldScroll] = useState(false)
|
||||
const highlightTextSet = useState(new Set<Node>())[0]
|
||||
const prevSearchText = useRef('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
const locateByIndex = (index: number, shouldScroll = true) => {
|
||||
if (target) {
|
||||
const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
|
||||
highlightTextNodes.sort((a, b) => {
|
||||
const { top: aTop } = a.getBoundingClientRect()
|
||||
const { top: bTop } = b.getBoundingClientRect()
|
||||
return aTop - bTop
|
||||
})
|
||||
for (const node of highlightTextNodes) {
|
||||
node.classList.remove(HIGHLIGHT_SELECT_CLASS)
|
||||
}
|
||||
setSearchResultIndex(index)
|
||||
if (highlightTextNodes.length > 0) {
|
||||
const highlightTextNode = highlightTextNodes[index] ?? null
|
||||
if (highlightTextNode) {
|
||||
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
|
||||
if (shouldScroll) {
|
||||
highlightTextNode.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
// inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const restoreHighlight = () => {
|
||||
const highlightTextParentNodeSet = new Set<HTMLElement>()
|
||||
// Make a copy because the set might be modified during iteration indirectly
|
||||
const nodesToRestore = [...highlightTextSet]
|
||||
for (const highlightTextNode of nodesToRestore) {
|
||||
if (highlightTextNode.textContent) {
|
||||
const textNode = document.createTextNode(highlightTextNode.textContent)
|
||||
const node = highlightTextNode as HTMLElement
|
||||
if (node.parentNode) {
|
||||
highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
|
||||
node.replaceWith(textNode) // This removes the node from the DOM
|
||||
}
|
||||
}
|
||||
}
|
||||
highlightTextSet.clear() // Clear the original set after processing
|
||||
for (const parentNode of highlightTextParentNodeSet) {
|
||||
mergeAdjacentTextNodes(parentNode)
|
||||
}
|
||||
// highlightTextSet.clear() // Already cleared
|
||||
}
|
||||
|
||||
const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
restoreHighlight()
|
||||
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
|
||||
let textNode: Node | null
|
||||
const textNodeSet: Set<Node> = new Set()
|
||||
while ((textNode = iter.nextNode())) {
|
||||
if (filter(textNode)) {
|
||||
textNodeSet.add(textNode)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightTextSetTemp = new Set<HTMLSpanElement>()
|
||||
for (const node of textNodeSet) {
|
||||
const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
|
||||
if (list) {
|
||||
list.forEach((node) => highlightTextSetTemp.add(node))
|
||||
}
|
||||
}
|
||||
const highlightTextList = [...highlightTextSetTemp]
|
||||
setTotalCount(highlightTextList.length)
|
||||
highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
|
||||
const changeIndex = () => {
|
||||
let index: number
|
||||
switch (searchTargetIndex) {
|
||||
case SearchTargetIndex.Next:
|
||||
{
|
||||
index = (searchResultIndex + 1) % highlightTextList.length
|
||||
}
|
||||
break
|
||||
case SearchTargetIndex.Prev:
|
||||
{
|
||||
index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
|
||||
}
|
||||
break
|
||||
default: {
|
||||
index = searchResultIndex
|
||||
}
|
||||
}
|
||||
return Math.max(index, 0)
|
||||
}
|
||||
|
||||
const targetIndex = (() => {
|
||||
switch (searchCompleted) {
|
||||
case SearchCompletedState.NotSearched: {
|
||||
setSearchCompleted(SearchCompletedState.FirstSearched)
|
||||
const index = findWindowVerticalCenterElementIndex(highlightTextList)
|
||||
if (index !== null) {
|
||||
setSearchResultIndex(index)
|
||||
return index
|
||||
} else {
|
||||
setSearchResultIndex(0)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
case SearchCompletedState.FirstSearched: {
|
||||
return changeIndex()
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
if (targetIndex === null) {
|
||||
return null
|
||||
} else {
|
||||
const totalCount = highlightTextSet.size
|
||||
if (targetIndex >= totalCount) {
|
||||
return totalCount - 1
|
||||
} else {
|
||||
return targetIndex
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const _searchHandlerDebounce = debounce(() => {
|
||||
implementation.search()
|
||||
}, 300)
|
||||
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
|
||||
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value.trim()
|
||||
if (value.length === 0) {
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
} else {
|
||||
// 用户输入时允许滚动
|
||||
setShouldScroll(true)
|
||||
searchHandler()
|
||||
}
|
||||
prevSearchText.current = value
|
||||
}
|
||||
|
||||
const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { code, key, shiftKey } = event
|
||||
if (key === 'Process') {
|
||||
return
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'Enter':
|
||||
{
|
||||
if (shiftKey) {
|
||||
implementation.searchPrev()
|
||||
} else {
|
||||
implementation.searchNext()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
{
|
||||
implementation.disable()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||
|
||||
const userOutlinedButtonOnClick = () => {
|
||||
if (onIncludeUserChange) {
|
||||
onIncludeUserChange(!includeUser)
|
||||
}
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const implementation = {
|
||||
disable() {
|
||||
setEnableContentSearch(false)
|
||||
restoreHighlight()
|
||||
setShouldScroll(false)
|
||||
},
|
||||
enable(initialText?: string) {
|
||||
setEnableContentSearch(true)
|
||||
setShouldScroll(false) // Default to false, search itself might set it to true
|
||||
if (searchInputRef.current) {
|
||||
const inputEl = searchInputRef.current
|
||||
if (initialText && initialText.trim().length > 0) {
|
||||
inputEl.value = initialText
|
||||
// Trigger search after setting initial text
|
||||
// Need to make sure search() uses the new value
|
||||
// and also to focus and select
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
setShouldScroll(true)
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex, true) // Ensure scrolling
|
||||
} else {
|
||||
// If search returns null (e.g., empty input or no matches with initial text), clear state
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
})
|
||||
// Only search if there's existing text and no new initialText
|
||||
if (inputEl.value.trim()) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
setSearchResultIndex(targetIndex)
|
||||
// locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
searchNext() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search(SearchTargetIndex.Next)
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
searchPrev() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search(SearchTargetIndex.Prev)
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
resetSearchState() {
|
||||
if (enableContentSearch) {
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
// Maybe also reset index? Depends on desired behavior
|
||||
// setSearchResultIndex(0);
|
||||
}
|
||||
},
|
||||
search() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex, shouldScroll)
|
||||
} else {
|
||||
// If search returns null (e.g., empty input), clear state
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}
|
||||
}
|
||||
},
|
||||
silentSearch() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
// 只更新索引,不触发滚动
|
||||
locateByIndex(targetIndex, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
focus() {
|
||||
searchInputFocus()
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disable() {
|
||||
implementation.disable()
|
||||
},
|
||||
enable(initialText?: string) {
|
||||
implementation.enable(initialText)
|
||||
},
|
||||
searchNext() {
|
||||
implementation.searchNext()
|
||||
},
|
||||
searchPrev() {
|
||||
implementation.searchPrev()
|
||||
},
|
||||
search() {
|
||||
implementation.search()
|
||||
},
|
||||
silentSearch() {
|
||||
implementation.silentSearch()
|
||||
},
|
||||
focus() {
|
||||
implementation.focus()
|
||||
}
|
||||
}))
|
||||
|
||||
// Re-run search when options change and search is active
|
||||
useEffect(() => {
|
||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||
implementation.search()
|
||||
}
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency
|
||||
|
||||
const prevButtonOnClick = () => {
|
||||
implementation.searchPrev()
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
const nextButtonOnClick = () => {
|
||||
implementation.searchNext()
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
const closeButtonOnClick = () => {
|
||||
implementation.disable()
|
||||
}
|
||||
|
||||
const caseSensitiveButtonOnClick = () => {
|
||||
setIsCaseSensitive(!isCaseSensitive)
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
const wholeWordButtonOnClick = () => {
|
||||
setIsWholeWord(!isWholeWord)
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
return (
|
||||
<Container ref={containerRef} style={enableContentSearch ? {} : { display: 'none' }}>
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<SearchBarContainer>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
onInput={userInputHandler}
|
||||
onKeyDown={keyDownHandler}
|
||||
placeholder={t('chat.assistant.search.placeholder')}
|
||||
style={{ lineHeight: '20px' }}
|
||||
/>
|
||||
<ToolBar>
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
|
||||
<CaseSensitive
|
||||
size={18}
|
||||
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
|
||||
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</ToolBar>
|
||||
</InputWrapper>
|
||||
<Separator></Separator>
|
||||
<SearchResults>
|
||||
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
||||
totalCount > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<NoResults>{t('common.no_results')}</NoResults>
|
||||
)
|
||||
) : (
|
||||
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
|
||||
)}
|
||||
</SearchResults>
|
||||
<ToolBar>
|
||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
|
||||
<ChevronUp size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
|
||||
<ChevronDown size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={closeButtonOnClick}>
|
||||
<X size={18} />
|
||||
</ToolbarButton>
|
||||
</ToolBar>
|
||||
</SearchBarContainer>
|
||||
</NarrowLayout>
|
||||
<Placeholder />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ContentSearch.displayName = 'ContentSearch'
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 2;
|
||||
`
|
||||
|
||||
const SearchBarContainer = styled.div`
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
margin-bottom: 5px;
|
||||
padding: 5px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background);
|
||||
flex: 1 1 auto; /* Take up input's previous space */
|
||||
`
|
||||
|
||||
const Placeholder = styled.div`
|
||||
width: 5px;
|
||||
`
|
||||
|
||||
const InputWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto; /* Take up input's previous space */
|
||||
`
|
||||
|
||||
const Input = styled.input`
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0 5px; /* Adjust padding, wrapper will handle spacing */
|
||||
flex: 1; /* Allow input to grow */
|
||||
font-size: 14px;
|
||||
font-family: Ubuntu;
|
||||
`
|
||||
|
||||
const ToolBar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: tpx;
|
||||
`
|
||||
|
||||
const Separator = styled.div`
|
||||
width: 1px;
|
||||
height: 1.5em;
|
||||
background-color: var(--color-border);
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
flex: 0 0 auto;
|
||||
`
|
||||
|
||||
const SearchResults = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
margin: 0 2px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-1);
|
||||
font-size: 14px;
|
||||
font-family: Ubuntu;
|
||||
`
|
||||
|
||||
const SearchResultsPlaceholder = styled.span`
|
||||
color: var(--color-text-1);
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
const NoResults = styled.span`
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const SearchResultCount = styled.span`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const SearchResultSeparator = styled.span`
|
||||
color: var(--color-text);
|
||||
margin: 0 4px;
|
||||
`
|
||||
|
||||
const SearchResultTotalCount = styled.span`
|
||||
color: var(--color-text);
|
||||
`
|
||||
94
src/renderer/src/components/ContextMenu/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Dropdown } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ContextMenuProps {
|
||||
children: React.ReactNode
|
||||
onContextMenu?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
|
||||
const { t } = useTranslation()
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
const quotedText =
|
||||
_selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------'
|
||||
setSelectedQuoteText(quotedText)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
onContextMenu?.(e)
|
||||
},
|
||||
[onContextMenu]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
setContextMenuPosition(null)
|
||||
}
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取右键菜单项
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => {
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedQuoteText) {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<ContextContainer onContextMenu={handleContextMenu}>
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
</Dropdown>
|
||||
)}
|
||||
{children}
|
||||
</ContextContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextContainer = styled.div``
|
||||
|
||||
export default ContextMenu
|
||||
68
src/renderer/src/components/Icons/DownloadIcons.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
// 基础下载图标
|
||||
export const DownloadIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.1em"
|
||||
height="1.1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<path d="M12 15V3" />
|
||||
<polygon points="12,15 9,11 15,11" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// 带有文件类型的下载图标基础组件
|
||||
const DownloadTypeIconBase = ({ type, ...props }: SVGProps<SVGSVGElement> & { type: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.1em"
|
||||
height="1.1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}>
|
||||
<text
|
||||
x="12"
|
||||
y="7"
|
||||
fontSize="8"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.3"
|
||||
letterSpacing="1"
|
||||
fontFamily="Arial Black, sans-serif"
|
||||
style={{
|
||||
paintOrder: 'stroke',
|
||||
fontStretch: 'expanded',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
}}>
|
||||
{type}
|
||||
</text>
|
||||
<path d="M21 16v3a2 2 0 01-2 2H5a2 2 0 01-2-2v-3" />
|
||||
<path d="M12 17V10" />
|
||||
<polygon points="12,17 9.5,14 14.5,14" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// JPG 文件下载图标
|
||||
export const DownloadJpgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="JPG" {...props} />
|
||||
|
||||
// PNG 文件下载图标
|
||||
export const DownloadPngIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="PNG" {...props} />
|
||||
|
||||
// SVG 文件下载图标
|
||||
export const DownloadSvgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="SVG" {...props} />
|
||||
@@ -149,15 +149,6 @@ export const BaseTypography = styled(Box)<{
|
||||
text-align: ${(props) => props.textAlign || 'left'};
|
||||
`
|
||||
|
||||
export const TypographyNormal = styled(BaseTypography)`
|
||||
font-family: 'Ubuntu';
|
||||
`
|
||||
|
||||
export const TypographyBold = styled(BaseTypography)`
|
||||
font-family: 'Ubuntu';
|
||||
font-weight: bold;
|
||||
`
|
||||
|
||||
export const Container = styled.main<ContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -35,7 +35,6 @@ const ListItemContainer = styled.div`
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
font-family: Ubuntu;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
ArrowRightOutlined,
|
||||
CloseOutlined,
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
@@ -241,6 +243,22 @@ const MinappPopupContainer: React.FC = () => {
|
||||
dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal))
|
||||
}
|
||||
|
||||
/** navigate back in webview history */
|
||||
const handleGoBack = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview && webview.canGoBack()) {
|
||||
webview.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
/** navigate forward in webview history */
|
||||
const handleGoForward = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview && webview.canGoForward()) {
|
||||
webview.goForward()
|
||||
}
|
||||
}
|
||||
|
||||
/** Title bar of the popup */
|
||||
const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
|
||||
if (!appInfo) return null
|
||||
@@ -286,6 +304,16 @@ const MinappPopupContainer: React.FC = () => {
|
||||
)}
|
||||
<Spacer />
|
||||
<ButtonsGroup className={isWindows || isLinux ? 'windows' : ''}>
|
||||
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleGoBack(appInfo.id)}>
|
||||
<ArrowLeftOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('minapp.popup.goForward')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleGoForward(appInfo.id)}>
|
||||
<ArrowRightOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleReload(appInfo.id)}>
|
||||
<ReloadOutlined />
|
||||
|
||||
@@ -67,6 +67,11 @@ const WebviewContainer = memo(
|
||||
style={WebviewStyle}
|
||||
allowpopups={'true' as any}
|
||||
partition="persist:webview"
|
||||
useragent={
|
||||
appid === 'google'
|
||||
? 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ const NameSpan = styled.span`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
font-family: 'Ubuntu';
|
||||
line-height: 30px;
|
||||
`
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Provider } from '@renderer/types'
|
||||
import { oauthWithAihubmix, oauthWithSiliconFlow } from '@renderer/utils/oauth'
|
||||
import { oauthWithAihubmix, oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth'
|
||||
import { Button, ButtonProps } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -27,6 +27,10 @@ const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
|
||||
if (provider.id === 'aihubmix') {
|
||||
oauthWithAihubmix(handleSuccess)
|
||||
}
|
||||
|
||||
if (provider.id === 'tokenflux') {
|
||||
oauthWithTokenFlux()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { backup } from '@renderer/services/BackupService'
|
||||
import store from '@renderer/store'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Modal, Progress } from 'antd'
|
||||
import Logger from 'electron-log'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -20,6 +22,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [progressData, setProgressData] = useState<ProgressData>()
|
||||
const { t } = useTranslation()
|
||||
const skipBackupFile = store.getState().settings.skipBackupFile
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => {
|
||||
@@ -32,7 +35,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
}, [])
|
||||
|
||||
const onOk = async () => {
|
||||
await backup()
|
||||
Logger.log('[BackupManager] ', skipBackupFile)
|
||||
await backup(skipBackupFile)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
|
||||
89
src/renderer/src/components/Popups/FloatingSidebar.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import HomeTabs from '@renderer/pages/home/Tabs/index'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Popover } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
activeAssistant: Assistant
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
activeTopic: Topic
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
position: 'left' | 'right'
|
||||
}
|
||||
|
||||
const FloatingSidebar: FC<Props> = ({
|
||||
children,
|
||||
activeAssistant,
|
||||
setActiveAssistant,
|
||||
activeTopic,
|
||||
setActiveTopic,
|
||||
position = 'left'
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
const [maxHeight, setMaxHeight] = useState(Math.floor(window.innerHeight * 0.75))
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMaxHeight(Math.floor(window.innerHeight * 0.75))
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const content = (
|
||||
<PopoverContent maxHeight={maxHeight}>
|
||||
<HomeTabs
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position={position}
|
||||
forceToSeeAllTab={true}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none'
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
content={content}
|
||||
trigger={['hover', 'click', 'contextMenu']}
|
||||
placement="bottomRight"
|
||||
showArrow
|
||||
mouseEnterDelay={0.8} // 800ms delay before showing
|
||||
mouseLeaveDelay={20}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
|
||||
max-height: ${(props) => props.maxHeight}px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
export default FloatingSidebar
|
||||
@@ -51,7 +51,6 @@ const MinAppsPopover: FC<Props> = ({ children }) => {
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Center>
|
||||
)}
|
||||
<App isLast app={minapps[0]} onClick={handleClose} size={50} />
|
||||
</AppsContainer>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
97
src/renderer/src/components/Popups/MultiSelectionPopup.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useChatContext } from '@renderer/hooks/useChatContext'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { Copy, Save, Trash, X } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
topic: Topic
|
||||
}
|
||||
|
||||
const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
|
||||
const { t } = useTranslation()
|
||||
const { toggleMultiSelectMode, selectedMessageIds, isMultiSelectMode, handleMultiSelectAction } =
|
||||
useChatContext(topic)
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
handleMultiSelectAction(action, selectedMessageIds)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
toggleMultiSelectMode(false)
|
||||
}
|
||||
|
||||
if (!isMultiSelectMode) return null
|
||||
|
||||
// TODO: 视情况调整
|
||||
// const isActionDisabled = selectedMessages.some((msg) => msg.role === 'user')
|
||||
const isActionDisabled = false
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ActionBar>
|
||||
<SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount>
|
||||
<ActionButtons>
|
||||
<Tooltip title={t('common.save')}>
|
||||
<ActionButton icon={<Save size={16} />} disabled={isActionDisabled} onClick={() => handleAction('save')} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.copy')}>
|
||||
<ActionButton icon={<Copy size={16} />} disabled={isActionDisabled} onClick={() => handleAction('copy')} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<ActionButton danger icon={<Trash size={16} />} onClick={() => handleAction('delete')} />
|
||||
</Tooltip>
|
||||
</ActionButtons>
|
||||
<Tooltip title={t('chat.navigation.close')}>
|
||||
<ActionButton icon={<X size={16} />} onClick={handleClose} />
|
||||
</Tooltip>
|
||||
</ActionBar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
padding: 36px 20px;
|
||||
background-color: var(--color-background);
|
||||
border-top: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
const ActionBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 50%;
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
const SelectionCount = styled.div`
|
||||
margin-right: 15px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
export default MultiSelectActionPopup
|
||||
@@ -647,7 +647,6 @@ const QuickPanelItem = styled.div`
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
font-family: Ubuntu;
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
&.focused {
|
||||
|
||||
@@ -2,12 +2,13 @@ import { throttle } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
right?: boolean
|
||||
ref?: any
|
||||
ref?: React.RefObject<HTMLDivElement | null>
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
|
||||
const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
||||
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
@@ -21,18 +22,31 @@ const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject
|
||||
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500)
|
||||
}, [])
|
||||
|
||||
const throttledHandleScroll = throttle(handleScroll, 200)
|
||||
const throttledInternalScrollHandler = throttle(handleScroll, 200)
|
||||
|
||||
// Combined scroll handler
|
||||
const combinedOnScroll = useCallback(() => {
|
||||
// Event is available if needed by internal handler
|
||||
throttledInternalScrollHandler() // Call internal logic
|
||||
if (externalOnScroll) {
|
||||
externalOnScroll() // Call external logic (from useScrollPosition)
|
||||
}
|
||||
}, [throttledInternalScrollHandler, externalOnScroll])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutRef.current && clearTimeout(timeoutRef.current)
|
||||
throttledHandleScroll.cancel()
|
||||
throttledInternalScrollHandler.cancel()
|
||||
}
|
||||
}, [throttledHandleScroll])
|
||||
}, [throttledInternalScrollHandler])
|
||||
|
||||
return (
|
||||
<Container {...props} isScrolling={isScrolling} onScroll={throttledHandleScroll} ref={ref}>
|
||||
{props.children}
|
||||
<Container
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
isScrolling={isScrolling}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
@@ -25,7 +26,12 @@ export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
|
||||
}
|
||||
|
||||
export const NavbarRight: FC<Props> = ({ children, ...props }) => {
|
||||
return <NavbarRightContainer {...props}>{children}</NavbarRightContainer>
|
||||
const isFullscreen = useFullscreen()
|
||||
return (
|
||||
<NavbarRightContainer {...props} $isFullscreen={isFullscreen}>
|
||||
{children}
|
||||
</NavbarRightContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const NavbarContainer = styled.div`
|
||||
@@ -58,11 +64,11 @@ const NavbarCenterContainer = styled.div`
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const NavbarRightContainer = styled.div`
|
||||
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
min-width: var(--topic-list-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
padding-right: ${isWindows ? '140px' : isLinux ? '120px' : '12px'};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isMac } from '@renderer/config/constant'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
@@ -68,8 +69,13 @@ const Sidebar: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const isFullscreen = useFullscreen()
|
||||
|
||||
return (
|
||||
<Container id="app-sidebar" style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
|
||||
<Container
|
||||
$isFullscreen={isFullscreen}
|
||||
id="app-sidebar"
|
||||
style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar onClick={onEditUser} className="sidebar-avatar" size={31} fontSize={18}>
|
||||
{avatar}
|
||||
@@ -311,7 +317,7 @@ const PinnedApps: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
const Container = styled.div<{ $isFullscreen: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -319,9 +325,9 @@ const Container = styled.div`
|
||||
padding-bottom: 12px;
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'};
|
||||
height: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'calc(100vh - var(--navbar-height))' : '100vh')};
|
||||
-webkit-app-region: drag !important;
|
||||
margin-top: ${isMac ? 'var(--navbar-height)' : 0};
|
||||
margin-top: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'var(--navbar-height)' : 0)};
|
||||
|
||||
.sidebar-avatar {
|
||||
margin-bottom: ${isMac ? '12px' : '12px'};
|
||||
|
||||
@@ -3,8 +3,6 @@ export const DEFAULT_CONTEXTCOUNT = 5
|
||||
export const DEFAULT_MAX_TOKENS = 4096
|
||||
export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6
|
||||
export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0
|
||||
export const FONT_FAMILY =
|
||||
"Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"
|
||||
|
||||
export const platform = window.electron?.process?.platform
|
||||
export const isMac = platform === 'darwin'
|
||||
@@ -12,6 +10,7 @@ export const isWindows = platform === 'win32' || platform === 'win64'
|
||||
export const isLinux = platform === 'linux'
|
||||
|
||||
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'
|
||||
export const TOKENFLUX_HOST = 'https://tokenflux.ai'
|
||||
|
||||
// Messages loading configuration
|
||||
export const INITIAL_MESSAGES_COUNT = 20
|
||||
|
||||
@@ -18,6 +18,7 @@ import FlowithAppLogo from '@renderer/assets/images/apps/flowith.svg?url'
|
||||
import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png?url'
|
||||
import GensparkLogo from '@renderer/assets/images/apps/genspark.jpg?url'
|
||||
import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?url'
|
||||
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
|
||||
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
|
||||
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
|
||||
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
|
||||
@@ -163,8 +164,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'minimax',
|
||||
name: '海螺',
|
||||
url: 'https://hailuoai.com/',
|
||||
logo: HailuoModelLogo
|
||||
url: 'https://chat.minimaxi.com/',
|
||||
logo: HailuoModelLogo,
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'groq',
|
||||
@@ -178,6 +180,16 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
url: 'https://claude.ai/',
|
||||
logo: ClaudeAppLogo
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
url: 'https://google.com/',
|
||||
logo: GoogleAppLogo,
|
||||
bodered: true,
|
||||
style: {
|
||||
padding: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'baidu-ai-chat',
|
||||
name: '文心一言',
|
||||
|
||||
@@ -34,8 +34,10 @@ import DianxinModelLogo from '@renderer/assets/images/models/dianxin.png'
|
||||
import DianxinModelLogoDark from '@renderer/assets/images/models/dianxin_dark.png'
|
||||
import DoubaoModelLogo from '@renderer/assets/images/models/doubao.png'
|
||||
import DoubaoModelLogoDark from '@renderer/assets/images/models/doubao_dark.png'
|
||||
import EmbeddingModelLogo from '@renderer/assets/images/models/embedding.png'
|
||||
import EmbeddingModelLogoDark from '@renderer/assets/images/models/embedding.png'
|
||||
import {
|
||||
default as EmbeddingModelLogo,
|
||||
default as EmbeddingModelLogoDark
|
||||
} from '@renderer/assets/images/models/embedding.png'
|
||||
import FlashaudioModelLogo from '@renderer/assets/images/models/flashaudio.png'
|
||||
import FlashaudioModelLogoDark from '@renderer/assets/images/models/flashaudio_dark.png'
|
||||
import FluxModelLogo from '@renderer/assets/images/models/flux.png'
|
||||
@@ -44,14 +46,15 @@ import GeminiModelLogo from '@renderer/assets/images/models/gemini.png'
|
||||
import GeminiModelLogoDark from '@renderer/assets/images/models/gemini_dark.png'
|
||||
import GemmaModelLogo from '@renderer/assets/images/models/gemma.png'
|
||||
import GemmaModelLogoDark from '@renderer/assets/images/models/gemma_dark.png'
|
||||
import GoogleModelLogo from '@renderer/assets/images/models/google.png'
|
||||
import GoogleModelLogoDark from '@renderer/assets/images/models/google.png'
|
||||
import { default as GoogleModelLogo, default as GoogleModelLogoDark } from '@renderer/assets/images/models/google.png'
|
||||
import ChatGPT35ModelLogo from '@renderer/assets/images/models/gpt_3.5.png'
|
||||
import ChatGPT4ModelLogo from '@renderer/assets/images/models/gpt_4.png'
|
||||
import ChatGptModelLogoDakr from '@renderer/assets/images/models/gpt_dark.png'
|
||||
import ChatGPT35ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
|
||||
import ChatGPT4ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
|
||||
import ChatGPTo1ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
|
||||
import {
|
||||
default as ChatGPT4ModelLogoDark,
|
||||
default as ChatGPT35ModelLogoDark,
|
||||
default as ChatGptModelLogoDakr,
|
||||
default as ChatGPTo1ModelLogoDark
|
||||
} from '@renderer/assets/images/models/gpt_dark.png'
|
||||
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
|
||||
import GrokModelLogo from '@renderer/assets/images/models/grok.png'
|
||||
import GrokModelLogoDark from '@renderer/assets/images/models/grok_dark.png'
|
||||
@@ -86,22 +89,28 @@ import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png'
|
||||
import MicrosoftModelLogoDark from '@renderer/assets/images/models/microsoft_dark.png'
|
||||
import MidjourneyModelLogo from '@renderer/assets/images/models/midjourney.png'
|
||||
import MidjourneyModelLogoDark from '@renderer/assets/images/models/midjourney_dark.png'
|
||||
import MinicpmModelLogo from '@renderer/assets/images/models/minicpm.webp'
|
||||
import MinicpmModelLogoDark from '@renderer/assets/images/models/minicpm.webp'
|
||||
import {
|
||||
default as MinicpmModelLogo,
|
||||
default as MinicpmModelLogoDark
|
||||
} from '@renderer/assets/images/models/minicpm.webp'
|
||||
import MinimaxModelLogo from '@renderer/assets/images/models/minimax.png'
|
||||
import MinimaxModelLogoDark from '@renderer/assets/images/models/minimax_dark.png'
|
||||
import MistralModelLogo from '@renderer/assets/images/models/mixtral.png'
|
||||
import MistralModelLogoDark from '@renderer/assets/images/models/mixtral_dark.png'
|
||||
import MoonshotModelLogo from '@renderer/assets/images/models/moonshot.png'
|
||||
import MoonshotModelLogoDark from '@renderer/assets/images/models/moonshot_dark.png'
|
||||
import NousResearchModelLogo from '@renderer/assets/images/models/nousresearch.png'
|
||||
import NousResearchModelLogoDark from '@renderer/assets/images/models/nousresearch.png'
|
||||
import {
|
||||
default as NousResearchModelLogo,
|
||||
default as NousResearchModelLogoDark
|
||||
} from '@renderer/assets/images/models/nousresearch.png'
|
||||
import NvidiaModelLogo from '@renderer/assets/images/models/nvidia.png'
|
||||
import NvidiaModelLogoDark from '@renderer/assets/images/models/nvidia_dark.png'
|
||||
import PalmModelLogo from '@renderer/assets/images/models/palm.png'
|
||||
import PalmModelLogoDark from '@renderer/assets/images/models/palm_dark.png'
|
||||
import PerplexityModelLogo from '@renderer/assets/images/models/perplexity.png'
|
||||
import PerplexityModelLogoDark from '@renderer/assets/images/models/perplexity.png'
|
||||
import {
|
||||
default as PerplexityModelLogo,
|
||||
default as PerplexityModelLogoDark
|
||||
} from '@renderer/assets/images/models/perplexity.png'
|
||||
import PixtralModelLogo from '@renderer/assets/images/models/pixtral.png'
|
||||
import PixtralModelLogoDark from '@renderer/assets/images/models/pixtral_dark.png'
|
||||
import QwenModelLogo from '@renderer/assets/images/models/qwen.png'
|
||||
@@ -118,6 +127,8 @@ import SunoModelLogo from '@renderer/assets/images/models/suno.png'
|
||||
import SunoModelLogoDark from '@renderer/assets/images/models/suno_dark.png'
|
||||
import TeleModelLogo from '@renderer/assets/images/models/tele.png'
|
||||
import TeleModelLogoDark from '@renderer/assets/images/models/tele_dark.png'
|
||||
import TokenFluxModelLogo from '@renderer/assets/images/models/tokenflux.png'
|
||||
import TokenFluxModelLogoDark from '@renderer/assets/images/models/tokenflux_dark.png'
|
||||
import UpstageModelLogo from '@renderer/assets/images/models/upstage.png'
|
||||
import UpstageModelLogoDark from '@renderer/assets/images/models/upstage_dark.png'
|
||||
import ViduModelLogo from '@renderer/assets/images/models/vidu.png'
|
||||
@@ -146,6 +157,8 @@ const visionAllowedModels = [
|
||||
'gemini-2\\.5',
|
||||
'gemini-exp',
|
||||
'claude-3',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4',
|
||||
'vision',
|
||||
'glm-4v',
|
||||
'qwen-vl',
|
||||
@@ -232,7 +245,7 @@ export const FUNCTION_CALLING_REGEX = new RegExp(
|
||||
)
|
||||
|
||||
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
||||
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+))\\b`,
|
||||
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
|
||||
'i'
|
||||
)
|
||||
|
||||
@@ -367,7 +380,8 @@ export function getModelLogo(modelId: string) {
|
||||
perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
|
||||
sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
|
||||
'bge-': BgeModelLogo,
|
||||
'voyage-': VoyageModelLogo
|
||||
'voyage-': VoyageModelLogo,
|
||||
tokenflux: isLight ? TokenFluxModelLogo : TokenFluxModelLogoDark
|
||||
}
|
||||
|
||||
for (const key in logoMap) {
|
||||
@@ -419,6 +433,30 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'Qwen'
|
||||
}
|
||||
],
|
||||
|
||||
burncloud: [
|
||||
{ id: 'claude-3-7-sonnet-20250219-thinking', provider: 'burncloud', name: 'Claude 3.7 thinking', group: 'Claude' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', provider: 'burncloud', name: 'Claude 3.7 Sonnet', group: 'Claude 3.7' },
|
||||
{ id: 'claude-3-5-sonnet-20241022', provider: 'burncloud', name: 'Claude 3.5 Sonnet', group: 'Claude 3.5' },
|
||||
{ id: 'claude-3-5-haiku-20241022', provider: 'burncloud', name: 'Claude 3.5 Haiku', group: 'Claude 3.5' },
|
||||
|
||||
{ id: 'gpt-4.5-preview', provider: 'burncloud', name: 'gpt-4.5-preview', group: 'gpt-4.5' },
|
||||
{ id: 'gpt-4o', provider: 'burncloud', name: 'GPT-4o', group: 'GPT 4o' },
|
||||
{ id: 'gpt-4o-mini', provider: 'burncloud', name: 'GPT-4o-mini', group: 'GPT 4o' },
|
||||
{ id: 'o3', provider: 'burncloud', name: 'GPT-o1-mini', group: 'o1' },
|
||||
{ id: 'o3-mini', provider: 'burncloud', name: 'GPT-o1-preview', group: 'o1' },
|
||||
{ id: 'o1-mini', provider: 'burncloud', name: 'GPT-o1-mini', group: 'o1' },
|
||||
|
||||
{ id: 'gemini-2.5-pro-preview-03-25', provider: 'burncloud', name: 'Gemini 2.5 Preview', group: 'Geminit 2.5' },
|
||||
{ id: 'gemini-2.5-pro-exp-03-25', provider: 'burncloud', name: 'Gemini 2.5 Pro Exp', group: 'Geminit 2.5' },
|
||||
{ id: 'gemini-2.0-flash-lite', provider: 'burncloud', name: 'Gemini 2.0 Flash Lite', group: 'Geminit 2.0' },
|
||||
{ id: 'gemini-2.0-flash-exp', provider: 'burncloud', name: 'Gemini 2.0 Flash Exp', group: 'Geminit 2.0' },
|
||||
{ id: 'gemini-2.0-flash', provider: 'burncloud', name: 'Gemini 2.0 Flash', group: 'Geminit 2.0' },
|
||||
|
||||
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
|
||||
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
|
||||
],
|
||||
|
||||
o3: [
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
@@ -686,6 +724,18 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
}
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude Sonnet 4',
|
||||
group: 'Claude 4'
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-20250514',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude Opus 4',
|
||||
group: 'Claude 4'
|
||||
},
|
||||
{
|
||||
id: 'claude-3-7-sonnet-20250219',
|
||||
provider: 'anthropic',
|
||||
@@ -725,46 +775,34 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
],
|
||||
'gitee-ai': [
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||
id: 'Qwen3-30B-A3B',
|
||||
name: 'Qwen3-30B-A3B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-1.5B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-1.5B',
|
||||
id: 'Qwen3-32B',
|
||||
name: 'Qwen3-32B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-14B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-14B',
|
||||
id: 'Qwen3-8B',
|
||||
name: 'Qwen3-8B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||
id: 'Qwen3-4B',
|
||||
name: 'Qwen3-4B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-V3',
|
||||
name: 'DeepSeek-V3',
|
||||
id: 'Qwen3-0.6B',
|
||||
name: 'Qwen3-0.6B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1',
|
||||
name: 'DeepSeek-R1',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-coder-33B-instruct',
|
||||
name: 'deepseek-coder-33B-instruct',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'Qwen2.5-72B-Instruct',
|
||||
@@ -803,11 +841,23 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'QwQ-32B-Preview',
|
||||
name: 'QwQ-32B-Preview',
|
||||
id: 'Qwen2.5-VL-32B-Instruct',
|
||||
name: 'Qwen2.5-VL-32B-Instruct',
|
||||
provider: 'gitee-ai',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'QwQ-32B',
|
||||
name: 'QwQ-32B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'Align-DS-V',
|
||||
name: 'Align-DS-V',
|
||||
provider: 'gitee-ai',
|
||||
group: 'Align'
|
||||
},
|
||||
{
|
||||
id: 'Yi-34B-Chat',
|
||||
name: 'Yi-34B-Chat',
|
||||
@@ -820,6 +870,12 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
provider: 'gitee-ai',
|
||||
group: 'THUDM'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-coder-33B-instruct',
|
||||
name: 'deepseek-coder-33B-instruct',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'codegeex4-all-9b',
|
||||
name: 'codegeex4-all-9b',
|
||||
@@ -844,6 +900,48 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
provider: 'gitee-ai',
|
||||
group: 'OpenGVLab'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-1.5B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-1.5B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-14B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-14B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-V3',
|
||||
name: 'DeepSeek-V3',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1',
|
||||
name: 'DeepSeek-R1',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'gemma-3-27b-it',
|
||||
name: 'gemma-3-27b-it',
|
||||
provider: 'gitee-ai',
|
||||
group: 'Gemma'
|
||||
},
|
||||
{
|
||||
id: 'bge-large-zh-v1.5',
|
||||
name: 'bge-large-zh-v1.5',
|
||||
@@ -2074,6 +2172,68 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'Qwen2.5 72B Instruct',
|
||||
group: 'Qwen'
|
||||
}
|
||||
],
|
||||
tokenflux: [
|
||||
{
|
||||
id: 'gpt-4.1',
|
||||
provider: 'tokenflux',
|
||||
name: 'GPT-4.1',
|
||||
group: 'GPT-4.1'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1-mini',
|
||||
provider: 'tokenflux',
|
||||
name: 'GPT-4.1 Mini',
|
||||
group: 'GPT-4.1'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4',
|
||||
provider: 'tokenflux',
|
||||
name: 'Claude Sonnet 4',
|
||||
group: 'Claude'
|
||||
},
|
||||
{
|
||||
id: 'claude-3-7-sonnet',
|
||||
provider: 'tokenflux',
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
group: 'Claude'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
provider: 'tokenflux',
|
||||
name: 'Gemini 2.5 Pro',
|
||||
group: 'Gemini'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
provider: 'tokenflux',
|
||||
name: 'Gemini 2.5 Flash',
|
||||
group: 'Gemini'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-r1',
|
||||
provider: 'tokenflux',
|
||||
name: 'DeepSeek R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3',
|
||||
provider: 'tokenflux',
|
||||
name: 'DeepSeek V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'qwen-max',
|
||||
provider: 'tokenflux',
|
||||
name: 'Qwen Max',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwen-plus',
|
||||
provider: 'tokenflux',
|
||||
name: 'Qwen Plus',
|
||||
group: 'Qwen'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2245,6 +2405,20 @@ export function isOpenAILLMModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isOpenAIModel(model: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
return model.id.includes('gpt') || isOpenAIReasoningModel(model)
|
||||
}
|
||||
|
||||
export function isSupportedFlexServiceTier(model: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
return (model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')
|
||||
}
|
||||
|
||||
export function isSupportedReasoningEffortOpenAIModel(model: Model): boolean {
|
||||
return (
|
||||
(model.id.includes('o1') && !(model.id.includes('o1-preview') || model.id.includes('o1-mini'))) ||
|
||||
@@ -2350,7 +2524,12 @@ export function isClaudeReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
return model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet')
|
||||
return (
|
||||
model.id.includes('claude-3-7-sonnet') ||
|
||||
model.id.includes('claude-3.7-sonnet') ||
|
||||
model.id.includes('claude-sonnet-4') ||
|
||||
model.id.includes('claude-opus-4')
|
||||
)
|
||||
}
|
||||
|
||||
export const isSupportedThinkingTokenClaudeModel = isClaudeReasoningModel
|
||||
@@ -2484,6 +2663,10 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
if (provider.id === 'grok') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2514,6 +2697,16 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
|
||||
if (assistant.enableWebSearch) {
|
||||
const webSearchTools = getWebSearchTools(model)
|
||||
|
||||
if (model.provider === 'grok') {
|
||||
return {
|
||||
search_parameters: {
|
||||
mode: 'auto',
|
||||
return_citations: true,
|
||||
sources: [{ type: 'web' }, { type: 'x' }, { type: 'news' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.provider === 'hunyuan') {
|
||||
return { enable_enhancement: true, citation: true, search_info: true }
|
||||
}
|
||||
@@ -2613,10 +2806,11 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
'qwen-turbo-.*$': { min: 0, max: 38912 },
|
||||
'qwen3-0\\.6b$': { min: 0, max: 30720 },
|
||||
'qwen3-1\\.7b$': { min: 0, max: 30720 },
|
||||
'qwen3-.*$': { min: 0, max: 38912 },
|
||||
'qwen3-.*$': { min: 1024, max: 38912 },
|
||||
|
||||
// Claude models
|
||||
'claude-3[.-]7.*sonnet.*$': { min: 0, max: 64000 }
|
||||
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
|
||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 64000 }
|
||||
}
|
||||
|
||||
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.p
|
||||
import BaichuanProviderLogo from '@renderer/assets/images/providers/baichuan.png'
|
||||
import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-cloud.svg'
|
||||
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
|
||||
import BurnCloudProviderLogo from '@renderer/assets/images/providers/burncloud.png'
|
||||
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
|
||||
import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png'
|
||||
import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.png'
|
||||
@@ -37,12 +38,15 @@ import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.p
|
||||
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
||||
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
|
||||
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
|
||||
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
|
||||
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
|
||||
import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png'
|
||||
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
|
||||
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
|
||||
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
|
||||
|
||||
import { TOKENFLUX_HOST } from './constant'
|
||||
|
||||
const PROVIDER_LOGO_MAP = {
|
||||
openai: OpenAiProviderLogo,
|
||||
silicon: SiliconFlowProviderLogo,
|
||||
@@ -61,6 +65,7 @@ const PROVIDER_LOGO_MAP = {
|
||||
xirang: XirangProviderLogo,
|
||||
anthropic: AnthropicProviderLogo,
|
||||
aihubmix: AiHubMixProviderLogo,
|
||||
burncloud: BurnCloudProviderLogo,
|
||||
gemini: GoogleProviderLogo,
|
||||
stepfun: StepProviderLogo,
|
||||
doubao: BytedanceProviderLogo,
|
||||
@@ -88,7 +93,8 @@ const PROVIDER_LOGO_MAP = {
|
||||
gpustack: GPUStackProviderLogo,
|
||||
alayanew: AlayaNewProviderLogo,
|
||||
voyageai: VoyageAIProviderLogo,
|
||||
qiniu: QiniuProviderLogo
|
||||
qiniu: QiniuProviderLogo,
|
||||
tokenflux: TokenFluxProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -121,6 +127,17 @@ export const PROVIDER_CONFIG = {
|
||||
models: 'https://docs.o3.fan/models'
|
||||
}
|
||||
},
|
||||
burncloud: {
|
||||
api: {
|
||||
url: 'https://ai.burncloud.com'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://ai.burncloud.com/',
|
||||
apiKey: 'https://ai.burncloud.com/token',
|
||||
docs: 'https://ai.burncloud.com/docs',
|
||||
models: 'https://ai.burncloud.com/pricing'
|
||||
}
|
||||
},
|
||||
ppio: {
|
||||
api: {
|
||||
url: 'https://api.ppinfra.com/v3/openai'
|
||||
@@ -584,5 +601,16 @@ export const PROVIDER_CONFIG = {
|
||||
docs: 'https://developer.qiniu.com/aitokenapi',
|
||||
models: 'https://developer.qiniu.com/aitokenapi/12883/model-list'
|
||||
}
|
||||
},
|
||||
tokenflux: {
|
||||
api: {
|
||||
url: TOKENFLUX_HOST
|
||||
},
|
||||
websites: {
|
||||
official: TOKENFLUX_HOST,
|
||||
apiKey: `${TOKENFLUX_HOST}/dashboard/api-keys`,
|
||||
docs: `${TOKENFLUX_HOST}/docs`,
|
||||
models: `${TOKENFLUX_HOST}/models`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,14 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
},
|
||||
Collapse: {
|
||||
headerBg: 'transparent'
|
||||
},
|
||||
Tooltip: {
|
||||
fontSize: 13
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: '#00b96b'
|
||||
colorPrimary: '#00b96b',
|
||||
fontFamily: 'var(--font-family)'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
|
||||
182
src/renderer/src/context/CodeStyleProvider.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/shiki'
|
||||
import * as cmThemes from '@uiw/codemirror-themes-all'
|
||||
import type React from 'react'
|
||||
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
interface CodeStyleContextType {
|
||||
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
cleanupTokenizers: (callerId: string) => void
|
||||
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
|
||||
highlightCode: (code: string, language: string) => Promise<string>
|
||||
shikiMarkdownIt: (code: string) => Promise<string>
|
||||
themeNames: string[]
|
||||
activeShikiTheme: string
|
||||
activeCmTheme: any
|
||||
languageMap: Record<string, string>
|
||||
}
|
||||
|
||||
const defaultCodeStyleContext: CodeStyleContextType = {
|
||||
highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
|
||||
cleanupTokenizers: () => {},
|
||||
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
|
||||
highlightCode: async () => '',
|
||||
shikiMarkdownIt: async () => '',
|
||||
themeNames: ['auto'],
|
||||
activeShikiTheme: 'auto',
|
||||
activeCmTheme: null,
|
||||
languageMap: {}
|
||||
}
|
||||
|
||||
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
|
||||
|
||||
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const { codeEditor, codePreview } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const [shikiThemes, setShikiThemes] = useState({})
|
||||
useMermaid()
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeEditor.enabled) {
|
||||
getShiki().then(({ bundledThemes }) => {
|
||||
setShikiThemes(bundledThemes)
|
||||
})
|
||||
}
|
||||
}, [codeEditor.enabled])
|
||||
|
||||
// 获取支持的主题名称列表
|
||||
const themeNames = useMemo(() => {
|
||||
// CodeMirror 主题
|
||||
// 更保险的做法可能是硬编码主题列表
|
||||
if (codeEditor.enabled) {
|
||||
return ['auto', 'light', 'dark']
|
||||
.concat(Object.keys(cmThemes))
|
||||
.filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function')
|
||||
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
|
||||
}
|
||||
|
||||
// Shiki 主题
|
||||
return ['auto', ...Object.keys(shikiThemes)]
|
||||
}, [codeEditor.enabled, shikiThemes])
|
||||
|
||||
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
|
||||
const activeShikiTheme = useMemo(() => {
|
||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||
const codeStyle = codePreview[field]
|
||||
if (!codeStyle || codeStyle === 'auto' || !themeNames.includes(codeStyle)) {
|
||||
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
|
||||
}
|
||||
return codeStyle
|
||||
}, [theme, codePreview, themeNames])
|
||||
|
||||
// 获取当前使用的 CodeMirror 主题对象(只用于编辑器)
|
||||
const activeCmTheme = useMemo(() => {
|
||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||
let themeName = codeEditor[field]
|
||||
if (!themeName || themeName === 'auto' || !themeNames.includes(themeName)) {
|
||||
themeName = theme === ThemeMode.light ? 'materialLight' : 'dark'
|
||||
}
|
||||
return cmThemes[themeName as keyof typeof cmThemes] || themeName
|
||||
}, [theme, codeEditor, themeNames])
|
||||
|
||||
// 一些语言的别名
|
||||
const languageMap = useMemo(() => {
|
||||
return {
|
||||
bash: 'shell',
|
||||
'objective-c++': 'objective-cpp',
|
||||
svg: 'xml',
|
||||
vab: 'vb'
|
||||
} as Record<string, string>
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// 在组件卸载时清理 Worker
|
||||
return () => {
|
||||
shikiStreamService.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 流式代码高亮,返回已高亮的 token lines
|
||||
const highlightCodeChunk = useCallback(
|
||||
async (trunk: string, language: string, callerId: string) => {
|
||||
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
return shikiStreamService.highlightCodeChunk(trunk, normalizedLang, activeShikiTheme, callerId)
|
||||
},
|
||||
[activeShikiTheme, languageMap]
|
||||
)
|
||||
|
||||
// 清理代码高亮资源
|
||||
const cleanupTokenizers = useCallback((callerId: string) => {
|
||||
shikiStreamService.cleanupTokenizers(callerId)
|
||||
}, [])
|
||||
|
||||
// 获取 Shiki pre 标签属性
|
||||
const getShikiPreProperties = useCallback(
|
||||
async (language: string) => {
|
||||
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
return shikiStreamService.getShikiPreProperties(normalizedLang, activeShikiTheme)
|
||||
},
|
||||
[activeShikiTheme, languageMap]
|
||||
)
|
||||
|
||||
const highlightCode = useCallback(
|
||||
async (code: string, language: string) => {
|
||||
const highlighter = await getHighlighter()
|
||||
await loadLanguageIfNeeded(highlighter, language)
|
||||
await loadThemeIfNeeded(highlighter, activeShikiTheme)
|
||||
return highlighter.codeToHtml(code, { lang: language, theme: activeShikiTheme })
|
||||
},
|
||||
[activeShikiTheme]
|
||||
)
|
||||
|
||||
// 使用 Shiki 和 Markdown-it 渲染代码
|
||||
const shikiMarkdownIt = useCallback(
|
||||
async (code: string) => {
|
||||
const renderer = await getMarkdownIt(activeShikiTheme, code)
|
||||
if (!renderer) {
|
||||
return code
|
||||
}
|
||||
return renderer.render(code)
|
||||
},
|
||||
[activeShikiTheme]
|
||||
)
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
highlightCodeChunk,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
shikiMarkdownIt,
|
||||
themeNames,
|
||||
activeShikiTheme,
|
||||
activeCmTheme,
|
||||
languageMap
|
||||
}),
|
||||
[
|
||||
highlightCodeChunk,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
shikiMarkdownIt,
|
||||
themeNames,
|
||||
activeShikiTheme,
|
||||
activeCmTheme,
|
||||
languageMap
|
||||
]
|
||||
)
|
||||
|
||||
return <CodeStyleContext value={contextValue}>{children}</CodeStyleContext>
|
||||
}
|
||||
|
||||
export const useCodeStyle = () => {
|
||||
const context = use(CodeStyleContext)
|
||||
if (!context) {
|
||||
throw new Error('useCodeStyle must be used within a CodeStyleProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
33
src/renderer/src/context/MessageEditingContext.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, ReactNode, use, useState } from 'react'
|
||||
|
||||
interface MessageEditingContextType {
|
||||
editingMessageId: string | null
|
||||
startEditing: (messageId: string) => void
|
||||
stopEditing: () => void
|
||||
}
|
||||
|
||||
const MessageEditingContext = createContext<MessageEditingContextType | null>(null)
|
||||
|
||||
export function MessageEditingProvider({ children }: { children: ReactNode }) {
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
|
||||
const startEditing = (messageId: string) => {
|
||||
setEditingMessageId(messageId)
|
||||
}
|
||||
|
||||
const stopEditing = () => {
|
||||
setEditingMessageId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageEditingContext value={{ editingMessageId, startEditing, stopEditing }}>{children}</MessageEditingContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMessageEditing() {
|
||||
const context = use(MessageEditingContext)
|
||||
if (!context) {
|
||||
throw new Error('useMessageEditing must be used within a MessageEditingProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
76
src/renderer/src/context/NotificationProvider.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NotificationQueue } from '@renderer/queue/NotificationQueue'
|
||||
import { Notification } from '@renderer/types/notification'
|
||||
import { isFocused } from '@renderer/utils/window'
|
||||
import { notification } from 'antd'
|
||||
import React, { createContext, use, useEffect, useMemo } from 'react'
|
||||
|
||||
type NotificationContextType = {
|
||||
open: typeof notification.open
|
||||
destroy: typeof notification.destroy
|
||||
}
|
||||
|
||||
const typeMap: Record<string, 'info' | 'success' | 'warning' | 'error'> = {
|
||||
error: 'error',
|
||||
success: 'success',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
progress: 'info',
|
||||
action: 'info'
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextType | undefined>(undefined)
|
||||
|
||||
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [api, contextHolder] = notification.useNotification({
|
||||
stack: {
|
||||
threshold: 3
|
||||
},
|
||||
showProgress: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const queue = NotificationQueue.getInstance()
|
||||
const listener = async (notification: Notification) => {
|
||||
// 判断是否需要系统通知
|
||||
if (notification.channel === 'system' || !isFocused()) {
|
||||
window.api.notification.send(notification)
|
||||
return
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
api.open({
|
||||
message: notification.title,
|
||||
description:
|
||||
notification.message.length > 50 ? notification.message.slice(0, 47) + '...' : notification.message,
|
||||
duration: 3,
|
||||
placement: 'topRight',
|
||||
type: typeMap[notification.type] || 'info',
|
||||
key: notification.id,
|
||||
onClose: resolve
|
||||
})
|
||||
})
|
||||
}
|
||||
queue.subscribe(listener)
|
||||
return () => queue.unsubscribe(listener)
|
||||
}, [api])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
open: api.open,
|
||||
destroy: api.destroy
|
||||
}),
|
||||
[api]
|
||||
)
|
||||
|
||||
return (
|
||||
<NotificationContext value={value}>
|
||||
{contextHolder}
|
||||
{children}
|
||||
</NotificationContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useNotification = () => {
|
||||
const ctx = use(NotificationContext)
|
||||
if (!ctx) throw new Error('useNotification must be used within a NotificationProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { CodeCacheService } from '@renderer/services/CodeCacheService'
|
||||
import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
|
||||
import type React from 'react'
|
||||
import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
|
||||
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null
|
||||
|
||||
async function getHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
langs: ['javascript', 'typescript', 'python', 'java', 'markdown'],
|
||||
themes: ['one-light', 'material-theme-darker']
|
||||
})
|
||||
}
|
||||
|
||||
return await highlighterPromise
|
||||
}
|
||||
|
||||
interface SyntaxHighlighterContextType {
|
||||
codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
|
||||
}
|
||||
|
||||
const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined)
|
||||
|
||||
export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const { theme } = useTheme()
|
||||
const { codeStyle } = useSettings()
|
||||
useMermaid()
|
||||
|
||||
const highlighterTheme = useMemo(() => {
|
||||
if (!codeStyle || codeStyle === 'auto') {
|
||||
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
|
||||
}
|
||||
|
||||
return codeStyle
|
||||
}, [theme, codeStyle])
|
||||
|
||||
const codeToHtml = useCallback(
|
||||
async (_code: string, language: string, enableCache: boolean) => {
|
||||
{
|
||||
if (!_code) return ''
|
||||
|
||||
const key = CodeCacheService.generateCacheKey(_code, language, highlighterTheme)
|
||||
const cached = enableCache ? CodeCacheService.getCachedResult(key) : null
|
||||
if (cached) return cached
|
||||
|
||||
const languageMap: Record<string, string> = {
|
||||
vab: 'vb'
|
||||
}
|
||||
|
||||
const mappedLanguage = languageMap[language] || language
|
||||
|
||||
const code = _code?.trimEnd() ?? ''
|
||||
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '<', '>': '>' })[char]!)
|
||||
|
||||
try {
|
||||
const highlighter = await getHighlighter()
|
||||
|
||||
if (!highlighter.getLoadedThemes().includes(highlighterTheme)) {
|
||||
const themeImportFn = bundledThemes[highlighterTheme]
|
||||
if (themeImportFn) {
|
||||
await highlighter.loadTheme(await themeImportFn())
|
||||
}
|
||||
}
|
||||
|
||||
if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) {
|
||||
const languageImportFn = bundledLanguages[mappedLanguage]
|
||||
if (languageImportFn) {
|
||||
await highlighter.loadLanguage(await languageImportFn())
|
||||
}
|
||||
}
|
||||
|
||||
// 生成高亮HTML
|
||||
const html = highlighter.codeToHtml(code, {
|
||||
lang: mappedLanguage,
|
||||
theme: highlighterTheme
|
||||
})
|
||||
|
||||
// 设置缓存
|
||||
if (enableCache) {
|
||||
CodeCacheService.setCachedResult(key, html, _code.length)
|
||||
}
|
||||
|
||||
return html
|
||||
} catch (error) {
|
||||
console.debug(`Error highlighting code for language '${mappedLanguage}':`, error)
|
||||
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
|
||||
}
|
||||
}
|
||||
},
|
||||
[highlighterTheme]
|
||||
)
|
||||
|
||||
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>
|
||||
}
|
||||
|
||||
export const useSyntaxHighlighter = () => {
|
||||
const context = use(SyntaxHighlighterContext)
|
||||
if (!context) {
|
||||
throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const codeThemes = ['auto', ...Object.keys(bundledThemes)] as CodeStyleVarious[]
|
||||
1
src/renderer/src/env.d.ts
vendored
@@ -19,7 +19,6 @@ declare global {
|
||||
message: MessageInstance
|
||||
modal: HookAPI
|
||||
keyv: KeyvStorage
|
||||
mermaid: any
|
||||
store: any
|
||||
navigate: NavigateFunction
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isLocalAi } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||
@@ -24,6 +25,11 @@ export function useAppInit() {
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById('spinner')?.remove()
|
||||
console.timeEnd('init')
|
||||
}, [])
|
||||
|
||||
useUpdateHandler()
|
||||
useFullScreenNotice()
|
||||
|
||||
@@ -32,7 +38,6 @@ export function useAppInit() {
|
||||
}, [avatar, dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById('spinner')?.remove()
|
||||
runAsyncFunction(async () => {
|
||||
const { isPackaged } = await window.api.getAppInfo()
|
||||
if (isPackaged && autoCheckUpdate) {
|
||||
@@ -88,7 +93,7 @@ export function useAppInit() {
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
import('@renderer/queue/KnowledgeQueue')
|
||||
KnowledgeQueue.checkAllBases()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
updateTopic,
|
||||
updateTopics
|
||||
} from '@renderer/store/assistants'
|
||||
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { setDefaultModel, setQuickAssistantModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
@@ -103,15 +103,17 @@ export function useDefaultAssistant() {
|
||||
}
|
||||
|
||||
export function useDefaultModel() {
|
||||
const { defaultModel, topicNamingModel, translateModel } = useAppSelector((state) => state.llm)
|
||||
const { defaultModel, topicNamingModel, translateModel, quickAssistantModel } = useAppSelector((state) => state.llm)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
defaultModel,
|
||||
topicNamingModel,
|
||||
translateModel,
|
||||
quickAssistantModel,
|
||||
setDefaultModel: (model: Model) => dispatch(setDefaultModel({ model })),
|
||||
setTopicNamingModel: (model: Model) => dispatch(setTopicNamingModel({ model })),
|
||||
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model }))
|
||||
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model })),
|
||||
setQuickAssistantModel: (model: Model) => dispatch(setQuickAssistantModel({ model }))
|
||||
}
|
||||
}
|
||||
|
||||
185
src/renderer/src/hooks/useChatContext.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { setActiveTopic, setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux'
|
||||
|
||||
export const useChatContext = (activeTopic: Topic) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useDispatch()
|
||||
const store = useStore<RootState>()
|
||||
const { deleteMessage } = useMessageOperations(activeTopic)
|
||||
|
||||
const [messageRefs, setMessageRefs] = useState<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
const isMultiSelectMode = useSelector((state: RootState) => state.runtime.chat.isMultiSelectMode)
|
||||
const selectedMessageIds = useSelector((state: RootState) => state.runtime.chat.selectedMessageIds)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = EventEmitter.on(EVENT_NAMES.CHANGE_TOPIC, () => {
|
||||
dispatch(toggleMultiSelectMode(false))
|
||||
})
|
||||
return () => unsubscribe()
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setActiveTopic(activeTopic))
|
||||
}, [dispatch, activeTopic])
|
||||
|
||||
const handleToggleMultiSelectMode = useCallback(
|
||||
(value: boolean) => {
|
||||
dispatch(toggleMultiSelectMode(value))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => {
|
||||
setMessageRefs((prev) => {
|
||||
const newRefs = new Map(prev)
|
||||
if (element) {
|
||||
newRefs.set(id, element)
|
||||
} else {
|
||||
newRefs.delete(id)
|
||||
}
|
||||
return newRefs
|
||||
})
|
||||
}, [])
|
||||
|
||||
const locateMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
const messageElement = messageRefs.get(messageId)
|
||||
if (messageElement) {
|
||||
// 检查消息是否可见
|
||||
const display = window.getComputedStyle(messageElement).display
|
||||
|
||||
if (display === 'none') {
|
||||
// 如果消息隐藏,需要处理显示逻辑
|
||||
// 查找消息并设置为选中状态
|
||||
const state = store.getState()
|
||||
const messages = selectMessagesForTopic(state, activeTopic.id)
|
||||
const message = messages.find((m) => m.id === messageId)
|
||||
if (message) {
|
||||
// 这里需要实现设置消息为选中状态的逻辑
|
||||
// 可能需要调用其他函数或修改状态
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到消息位置
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
},
|
||||
[messageRefs, store, activeTopic.id]
|
||||
)
|
||||
|
||||
const handleSelectMessage = useCallback(
|
||||
(messageId: string, selected: boolean) => {
|
||||
dispatch(
|
||||
setSelectedMessageIds(
|
||||
selected ? [...selectedMessageIds, messageId] : selectedMessageIds.filter((id) => id !== messageId)
|
||||
)
|
||||
)
|
||||
},
|
||||
[dispatch, selectedMessageIds]
|
||||
)
|
||||
|
||||
const handleMultiSelectAction = useCallback(
|
||||
async (actionType: string, messageIds: string[]) => {
|
||||
if (messageIds.length === 0) {
|
||||
window.message.warning(t('chat.multiple.select.empty'))
|
||||
return
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
const messages = selectMessagesForTopic(state, activeTopic.id)
|
||||
const messageBlocks = messageBlocksSelectors.selectEntities(state)
|
||||
|
||||
switch (actionType) {
|
||||
case 'delete':
|
||||
window.modal.confirm({
|
||||
title: t('message.delete.confirm.title'),
|
||||
content: t('message.delete.confirm.content', { count: messageIds.length }),
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await Promise.all(messageIds.map((messageId) => deleteMessage(messageId)))
|
||||
window.message.success(t('message.delete.success'))
|
||||
handleToggleMultiSelectMode(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete messages:', error)
|
||||
window.message.error(t('message.delete.failed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'save': {
|
||||
const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id))
|
||||
if (assistantMessages.length > 0) {
|
||||
const contentToSave = assistantMessages
|
||||
.map((msg) => {
|
||||
return msg.blocks
|
||||
.map((blockId) => {
|
||||
const block = messageBlocks[blockId]
|
||||
return block && 'content' in block ? block.content : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim()
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
const fileName = `chat_export_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.md`
|
||||
await window.api.file.save(fileName, contentToSave)
|
||||
window.message.success({ content: t('message.save.success.title'), key: 'save-messages' })
|
||||
handleToggleMultiSelectMode(false)
|
||||
} else {
|
||||
window.message.warning(t('message.save.no.assistant'))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'copy': {
|
||||
const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id))
|
||||
if (assistantMessages.length > 0) {
|
||||
const contentToCopy = assistantMessages
|
||||
.map((msg) => {
|
||||
return msg.blocks
|
||||
.map((blockId) => {
|
||||
const block = messageBlocks[blockId]
|
||||
return block && 'content' in block ? block.content : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim()
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
navigator.clipboard.writeText(contentToCopy)
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-messages' })
|
||||
handleToggleMultiSelectMode(false)
|
||||
} else {
|
||||
window.message.warning(t('message.copy.no.assistant'))
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
[t, store, activeTopic.id, deleteMessage, handleToggleMultiSelectMode]
|
||||
)
|
||||
|
||||
return {
|
||||
isMultiSelectMode,
|
||||
selectedMessageIds,
|
||||
toggleMultiSelectMode: handleToggleMultiSelectMode,
|
||||
handleMultiSelectAction,
|
||||
handleSelectMessage,
|
||||
activeTopic,
|
||||
locateMessage,
|
||||
messageRefs,
|
||||
registerMessageElement
|
||||
}
|
||||
}
|
||||
18
src/renderer/src/hooks/useFullscreen.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useFullscreen() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, fullscreen) => {
|
||||
setIsFullscreen(fullscreen)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanup()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isFullscreen
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const ipcRenderer = window.electron.ipcRenderer
|
||||
|
||||
// Listen for server changes from main process
|
||||
ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
|
||||
store.dispatch(setMCPServers(servers))
|
||||
})
|
||||
ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
|
||||
store.dispatch(addMCPServer(server))
|
||||
})
|
||||
|
||||
const selectMcpServers = (state) => state.mcp.servers
|
||||
const selectActiveMcpServers = createSelector([selectMcpServers], (servers) =>
|
||||
servers.filter((server) => server.isActive)
|
||||
)
|
||||
|
||||
export const useMCPServers = () => {
|
||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
||||
const activedMcpServers = useMemo(() => mcpServers.filter((server) => server.isActive), [mcpServers])
|
||||
const mcpServers = useAppSelector(selectMcpServers)
|
||||
const activedMcpServers = useAppSelector(selectActiveMcpServers)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,54 +1,76 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { EventEmitter } from '@renderer/services/EventService'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { loadScript, runAsyncFunction } from '@renderer/utils'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 跟踪 mermaid 模块状态,单例模式
|
||||
let mermaidModule: any = null
|
||||
let mermaidLoading = false
|
||||
let mermaidLoadPromise: Promise<any> | null = null
|
||||
|
||||
/**
|
||||
* 导入 mermaid 库
|
||||
*/
|
||||
const loadMermaidModule = async () => {
|
||||
if (mermaidModule) return mermaidModule
|
||||
if (mermaidLoading && mermaidLoadPromise) return mermaidLoadPromise
|
||||
|
||||
mermaidLoading = true
|
||||
mermaidLoadPromise = import('mermaid')
|
||||
.then((module) => {
|
||||
mermaidModule = module.default || module
|
||||
mermaidLoading = false
|
||||
return mermaidModule
|
||||
})
|
||||
.catch((error) => {
|
||||
mermaidLoading = false
|
||||
throw error
|
||||
})
|
||||
|
||||
return mermaidLoadPromise
|
||||
}
|
||||
|
||||
export const useMermaid = () => {
|
||||
const { theme } = useTheme()
|
||||
const mermaidLoaded = useRef(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 初始化 mermaid 并监听主题变化
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
if (!window.mermaid) {
|
||||
await loadScript('https://unpkg.com/mermaid@11.6.0/dist/mermaid.min.js')
|
||||
}
|
||||
let mounted = true
|
||||
|
||||
if (!mermaidLoaded.current) {
|
||||
await window.mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
const initialize = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const mermaid = await loadMermaidModule()
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false, // 禁用自动启动
|
||||
theme: theme === ThemeMode.dark ? 'dark' : 'default'
|
||||
})
|
||||
mermaidLoaded.current = true
|
||||
EventEmitter.emit('mermaid-loaded')
|
||||
}
|
||||
})
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const mermaidElement = (e.target as HTMLElement).closest('.mermaid')
|
||||
if (!mermaidElement) return
|
||||
|
||||
const svg = mermaidElement.querySelector('svg')
|
||||
if (!svg) return
|
||||
|
||||
const currentScale = parseFloat(svg.style.transform?.match(/scale\((.*?)\)/)?.[1] || '1')
|
||||
const delta = e.deltaY < 0 ? 0.1 : -0.1
|
||||
const newScale = Math.max(0.1, Math.min(3, currentScale + delta))
|
||||
|
||||
const container = svg.parentElement
|
||||
if (container) {
|
||||
container.style.overflow = 'auto'
|
||||
container.style.position = 'relative'
|
||||
svg.style.transformOrigin = 'top left'
|
||||
svg.style.transform = `scale(${newScale})`
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to initialize Mermaid')
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('wheel', handleWheel, { passive: true })
|
||||
return () => document.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
initialize()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
return {
|
||||
mermaid: mermaidModule,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Logger from '@renderer/config/logger'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
|
||||
import store, { type RootState, useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
|
||||
import { updateOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import {
|
||||
appendAssistantResponseThunk,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
deleteSingleMessageThunk,
|
||||
initiateTranslationThunk,
|
||||
regenerateAssistantResponseThunk,
|
||||
removeBlocksThunk,
|
||||
resendMessageThunk,
|
||||
resendUserMessageWithEditThunk,
|
||||
updateMessageAndBlocksThunk,
|
||||
@@ -22,21 +23,8 @@ import type { Assistant, Model, Topic } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { findFileBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const findMainTextBlockId = (message: Message): string | undefined => {
|
||||
if (!message || !message.blocks) return undefined
|
||||
const state = store.getState()
|
||||
for (const blockId of message.blocks) {
|
||||
const block = messageBlocksSelectors.selectById(state, String(blockId))
|
||||
if (block && block.type === MessageBlockType.MAIN_TEXT) {
|
||||
return block.id
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const selectMessagesState = (state: RootState) => state.messages
|
||||
|
||||
export const selectNewTopicLoading = createSelector(
|
||||
@@ -113,36 +101,6 @@ export function useMessageOperations(topic: Topic) {
|
||||
[dispatch, topic.id]
|
||||
)
|
||||
|
||||
/**
|
||||
* 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited.
|
||||
* Dispatches resendUserMessageWithEditThunk.
|
||||
*/
|
||||
const resendUserMessageWithEdit = useCallback(
|
||||
async (message: Message, editedContent: string, assistant: Assistant) => {
|
||||
const mainTextBlockId = findMainTextBlockId(message)
|
||||
if (!mainTextBlockId) {
|
||||
console.error('Cannot resend edited message: Main text block not found.')
|
||||
return
|
||||
}
|
||||
|
||||
const files = findFileBlocks(message).map((block) => block.file)
|
||||
|
||||
const usage = await estimateUserPromptUsage({ content: editedContent, files })
|
||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||
id: message.id,
|
||||
updatedAt: new Date().toISOString(),
|
||||
usage
|
||||
}
|
||||
|
||||
await dispatch(
|
||||
newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates })
|
||||
)
|
||||
// 对于message的修改会在下面的thunk中保存
|
||||
await dispatch(resendUserMessageWithEditThunk(topic.id, message, mainTextBlockId, editedContent, assistant))
|
||||
},
|
||||
[dispatch, topic.id]
|
||||
)
|
||||
|
||||
/**
|
||||
* 清除当前或指定主题的所有消息。 / Clears all messages for the current or specified topic.
|
||||
* Dispatches clearTopicMessagesThunk.
|
||||
@@ -309,29 +267,127 @@ export function useMessageOperations(topic: Topic) {
|
||||
)
|
||||
|
||||
/**
|
||||
* Updates properties of specific message blocks (e.g., content).
|
||||
* Uses the generalized thunk for persistence.
|
||||
* Updates message blocks by comparing original and edited blocks.
|
||||
* Handles adding, updating, and removing blocks in a single operation.
|
||||
* @param messageId The ID of the message to update
|
||||
* @param editedBlocks The complete set of blocks after editing
|
||||
*/
|
||||
const editMessageBlocks = useCallback(
|
||||
async (messageId: string, updates: Partial<MessageBlock>) => {
|
||||
async (messageId: string, editedBlocks: MessageBlock[]) => {
|
||||
if (!topic?.id) {
|
||||
console.error('[editMessageBlocks] Topic prop is not valid.')
|
||||
return
|
||||
}
|
||||
|
||||
const blockUpdatesListProcessed = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
...updates
|
||||
}
|
||||
try {
|
||||
// 1. Get the current state of the message and its blocks
|
||||
const state = store.getState()
|
||||
const message = state.messages.entities[messageId]
|
||||
if (!message) {
|
||||
console.error('[editMessageBlocks] Message not found:', messageId)
|
||||
return
|
||||
}
|
||||
|
||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||
id: messageId,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
// 2. Get all original blocks
|
||||
const originalBlocks = message.blocks
|
||||
? (message.blocks
|
||||
.map((blockId) => state.messageBlocks.entities[blockId])
|
||||
.filter((block) => block !== undefined) as MessageBlock[])
|
||||
: []
|
||||
|
||||
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [blockUpdatesListProcessed]))
|
||||
// 3. Create sets for efficient comparison
|
||||
const originalBlockIds = new Set(originalBlocks.map((block) => block.id))
|
||||
const editedBlockIds = new Set(editedBlocks.map((block) => block.id))
|
||||
|
||||
// 4. Identify blocks to remove, update, and add
|
||||
const blockIdsToRemove = originalBlocks
|
||||
.filter((block) => !editedBlockIds.has(block.id))
|
||||
.map((block) => block.id)
|
||||
|
||||
const blocksToUpdate = editedBlocks
|
||||
.filter((block) => originalBlockIds.has(block.id))
|
||||
.map((block) => ({
|
||||
...block,
|
||||
updatedAt: new Date().toISOString()
|
||||
}))
|
||||
|
||||
const blocksToAdd = editedBlocks
|
||||
.filter((block) => !originalBlockIds.has(block.id))
|
||||
.map((block) => ({
|
||||
...block,
|
||||
updatedAt: new Date().toISOString()
|
||||
}))
|
||||
|
||||
// 5. Prepare message update with new block IDs
|
||||
const updatedBlockIds = editedBlocks.map((block) => block.id)
|
||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||
id: messageId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
blocks: updatedBlockIds
|
||||
}
|
||||
|
||||
// 6. Log operations for debugging
|
||||
console.log('[editMessageBlocks] Operations:', {
|
||||
blocksToRemove: blockIdsToRemove.length,
|
||||
blocksToUpdate: blocksToUpdate.length,
|
||||
blocksToAdd: blocksToAdd.length
|
||||
})
|
||||
|
||||
// 7. Update Redux state and database
|
||||
// First update message and add/update blocks
|
||||
if (blocksToAdd.length > 0) {
|
||||
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToAdd))
|
||||
}
|
||||
|
||||
if (blocksToUpdate.length > 0) {
|
||||
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToUpdate))
|
||||
}
|
||||
|
||||
// Then remove blocks if needed
|
||||
if (blockIdsToRemove.length > 0) {
|
||||
await dispatch(removeBlocksThunk(topic.id, messageId, blockIdsToRemove))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[editMessageBlocks] Failed to update message blocks:', error)
|
||||
}
|
||||
},
|
||||
[dispatch, topic.id]
|
||||
[dispatch, topic?.id]
|
||||
)
|
||||
|
||||
/**
|
||||
* 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited.
|
||||
* Dispatches resendUserMessageWithEditThunk.
|
||||
*/
|
||||
const resendUserMessageWithEdit = useCallback(
|
||||
async (message: Message, editedBlocks: MessageBlock[], assistant: Assistant) => {
|
||||
await editMessageBlocks(message.id, editedBlocks)
|
||||
|
||||
const mainTextBlock = editedBlocks.find((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
if (!mainTextBlock) {
|
||||
console.error('[resendUserMessageWithEdit] Main text block not found in edited blocks')
|
||||
return
|
||||
}
|
||||
|
||||
const fileBlocks = editedBlocks.filter(
|
||||
(block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE
|
||||
)
|
||||
|
||||
const files = fileBlocks.map((block) => block.file).filter((file) => file !== undefined)
|
||||
|
||||
const usage = await estimateUserPromptUsage({ content: mainTextBlock.content, files })
|
||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||
id: message.id,
|
||||
updatedAt: new Date().toISOString(),
|
||||
usage
|
||||
}
|
||||
|
||||
await dispatch(
|
||||
newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates })
|
||||
)
|
||||
// 对于message的修改会在下面的thunk中保存
|
||||
await dispatch(resendUserMessageWithEditThunk(topic.id, message, assistant))
|
||||
},
|
||||
[dispatch, editMessageBlocks, topic.id]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
setOpenedOneOffMinapp
|
||||
} from '@renderer/store/runtime'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Usage:
|
||||
@@ -29,74 +30,86 @@ export const useMinappPopup = () => {
|
||||
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
|
||||
|
||||
/** Open a minapp (popup shows and minapp loaded) */
|
||||
const openMinapp = (app: MinAppType, keepAlive: boolean = false) => {
|
||||
if (keepAlive) {
|
||||
// 如果小程序已经打开,只切换显示
|
||||
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
|
||||
const openMinapp = useCallback(
|
||||
(app: MinAppType, keepAlive: boolean = false) => {
|
||||
if (keepAlive) {
|
||||
// 如果小程序已经打开,只切换显示
|
||||
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
|
||||
dispatch(setCurrentMinappId(app.id))
|
||||
dispatch(setMinappShow(true))
|
||||
return
|
||||
}
|
||||
|
||||
// 如果缓存数量未达上限,添加到缓存列表
|
||||
if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) {
|
||||
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
|
||||
} else {
|
||||
// 缓存数量达到上限,移除最后一个,添加新的
|
||||
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)]))
|
||||
}
|
||||
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
dispatch(setCurrentMinappId(app.id))
|
||||
dispatch(setMinappShow(true))
|
||||
return
|
||||
}
|
||||
|
||||
// 如果缓存数量未达上限,添加到缓存列表
|
||||
if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) {
|
||||
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
|
||||
} else {
|
||||
// 缓存数量达到上限,移除最后一个,添加新的
|
||||
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)]))
|
||||
}
|
||||
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
//if the minapp is not keep alive, open it as one-off minapp
|
||||
dispatch(setOpenedOneOffMinapp(app))
|
||||
dispatch(setCurrentMinappId(app.id))
|
||||
dispatch(setMinappShow(true))
|
||||
return
|
||||
}
|
||||
|
||||
//if the minapp is not keep alive, open it as one-off minapp
|
||||
dispatch(setOpenedOneOffMinapp(app))
|
||||
dispatch(setCurrentMinappId(app.id))
|
||||
dispatch(setMinappShow(true))
|
||||
return
|
||||
}
|
||||
},
|
||||
[dispatch, maxKeepAliveMinapps, openedKeepAliveMinapps]
|
||||
)
|
||||
|
||||
/** a wrapper of openMinapp(app, true) */
|
||||
const openMinappKeepAlive = (app: MinAppType) => {
|
||||
openMinapp(app, true)
|
||||
}
|
||||
const openMinappKeepAlive = useCallback(
|
||||
(app: MinAppType) => {
|
||||
openMinapp(app, true)
|
||||
},
|
||||
[openMinapp]
|
||||
)
|
||||
|
||||
/** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */
|
||||
const openMinappById = (id: string, keepAlive: boolean = false) => {
|
||||
import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => {
|
||||
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
|
||||
if (app) {
|
||||
openMinapp(app, keepAlive)
|
||||
}
|
||||
})
|
||||
}
|
||||
const openMinappById = useCallback(
|
||||
(id: string, keepAlive: boolean = false) => {
|
||||
import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => {
|
||||
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
|
||||
if (app) {
|
||||
openMinapp(app, keepAlive)
|
||||
}
|
||||
})
|
||||
},
|
||||
[openMinapp]
|
||||
)
|
||||
|
||||
/** Close a minapp immediately (popup hides and minapp unloaded) */
|
||||
const closeMinapp = (appid: string) => {
|
||||
if (openedKeepAliveMinapps.some((item) => item.id === appid)) {
|
||||
dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid)))
|
||||
} else if (openedOneOffMinapp?.id === appid) {
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
}
|
||||
const closeMinapp = useCallback(
|
||||
(appid: string) => {
|
||||
if (openedKeepAliveMinapps.some((item) => item.id === appid)) {
|
||||
dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid)))
|
||||
} else if (openedOneOffMinapp?.id === appid) {
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
}
|
||||
|
||||
dispatch(setCurrentMinappId(''))
|
||||
dispatch(setMinappShow(false))
|
||||
return
|
||||
}
|
||||
dispatch(setCurrentMinappId(''))
|
||||
dispatch(setMinappShow(false))
|
||||
return
|
||||
},
|
||||
[dispatch, openedKeepAliveMinapps, openedOneOffMinapp]
|
||||
)
|
||||
|
||||
/** Close all minapps (popup hides and all minapps unloaded) */
|
||||
const closeAllMinapps = () => {
|
||||
const closeAllMinapps = useCallback(() => {
|
||||
dispatch(setOpenedKeepAliveMinapps([]))
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
dispatch(setCurrentMinappId(''))
|
||||
dispatch(setMinappShow(false))
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
/** Hide the minapp popup (only one-off minapp unloaded) */
|
||||
const hideMinappPopup = () => {
|
||||
const hideMinappPopup = useCallback(() => {
|
||||
if (!minappShow) return
|
||||
|
||||
if (openedOneOffMinapp) {
|
||||
@@ -104,7 +117,7 @@ export const useMinappPopup = () => {
|
||||
dispatch(setCurrentMinappId(''))
|
||||
}
|
||||
dispatch(setMinappShow(false))
|
||||
}
|
||||
}, [dispatch, minappShow, openedOneOffMinapp])
|
||||
|
||||
return {
|
||||
openMinapp,
|
||||
|
||||