Compare commits

..

3 Commits

Author SHA1 Message Date
温州程序员劝退师 3499cd449b Merge pull request #3718 from GeekyWizKid/node-store
Node store
2025-03-21 13:33:01 +08:00
温州程序员劝退师 a97c3d9695 Merge branch 'main' into node-store 2025-03-21 13:28:23 +08:00
温州程序员劝退师 9145e998c4 feat: Implement Node.js app management features
- Added IPC handlers for managing Node.js applications, including listing, adding, installing, updating, starting, stopping, and uninstalling apps.
- Introduced deployment options for Node.js apps from ZIP files and Git repositories.
- Enhanced the process utility to support environment variables during script execution.
- Updated preload API to expose Node.js app management functionalities.
- Added new UI components and routes for Node.js app management in the renderer.
- Included internationalization support for Node.js app features in both English and Chinese.
2025-03-20 17:13:51 +08:00
201 changed files with 10603 additions and 16668 deletions
-1
View File
@@ -6,4 +6,3 @@ tsconfig.json
tsconfig.*.json tsconfig.*.json
CHANGELOG*.md CHANGELOG*.md
agents.json agents.json
src/renderer/src/integration/nutstore/sso/lib
@@ -0,0 +1,13 @@
diff --git a/src/markdown-loader.js b/src/markdown-loader.js
index eaf30b114a273e68abbb92c8b07018495e63f4cb..4b06519bdb51845e4693fe877da9de01c7a81039 100644
--- a/src/markdown-loader.js
+++ b/src/markdown-loader.js
@@ -21,7 +21,7 @@ export class MarkdownLoader extends BaseLoader {
? (await getSafe(this.filePathOrUrl, { format: 'buffer' })).body
: await streamToBuffer(fs.createReadStream(this.filePathOrUrl));
this.debug('MarkdownLoader stream created');
- const result = micromark(buffer, { extensions: [gfm(), mdxJsx()], htmlExtensions: [gfmHtml()] });
+ const result = micromark(buffer, { extensions: [gfm()], htmlExtensions: [gfmHtml()] });
this.debug('Markdown parsed...');
const webLoader = new WebLoader({
urlOrContent: result,
@@ -0,0 +1,158 @@
diff --git a/src/loaders/local-path-loader.d.ts b/src/loaders/local-path-loader.d.ts
index 48c20e68c469cd309be2dc8f28e44c1bd04a26e9..1c16d83bcbf9b7140292793d6cbb8c04281949d9 100644
--- a/src/loaders/local-path-loader.d.ts
+++ b/src/loaders/local-path-loader.d.ts
@@ -4,8 +4,10 @@ export declare class LocalPathLoader extends BaseLoader<{
}> {
private readonly debug;
private readonly path;
- constructor({ path }: {
+ constructor({ path, chunkSize, chunkOverlap }: {
path: string;
+ chunkSize?: number;
+ chunkOverlap?: number;
});
getUnfilteredChunks(): AsyncGenerator<{
metadata: {
diff --git a/src/loaders/local-path-loader.js b/src/loaders/local-path-loader.js
index 4cf8a6bd1d890244c8ec49d4a05ee3bd58861c79..ec8215b01195a21ef20f3c5d56ecc99f186bb596 100644
--- a/src/loaders/local-path-loader.js
+++ b/src/loaders/local-path-loader.js
@@ -8,8 +8,8 @@ import { BaseLoader } from '@llm-tools/embedjs-interfaces';
export class LocalPathLoader extends BaseLoader {
debug = createDebugMessages('embedjs:loader:LocalPathLoader');
path;
- constructor({ path }) {
- super(`LocalPathLoader_${md5(path)}`, { path });
+ constructor({ path, chunkSize, chunkOverlap }) {
+ super(`LocalPathLoader_${md5(path)}`, { path }, chunkSize ?? 1000, chunkOverlap ?? 0);
this.path = path;
}
async *getUnfilteredChunks() {
@@ -36,10 +36,12 @@ export class LocalPathLoader extends BaseLoader {
const extension = currentPath.split('.').pop().toLowerCase();
if (extension === 'md' || extension === 'mdx')
mime = 'text/markdown';
+ if (extension === 'txt')
+ mime = 'text/plain';
this.debug(`File '${this.path}' mime type updated to 'text/markdown'`);
}
try {
- const loader = await createLoaderFromMimeType(currentPath, mime);
+ const loader = await createLoaderFromMimeType(currentPath, mime, this.chunkSize, this.chunkOverlap);
for await (const result of await loader.getUnfilteredChunks()) {
yield {
pageContent: result.pageContent,
diff --git a/src/util/mime.d.ts b/src/util/mime.d.ts
index 57f56a1b8edc98366af9f84d671676c41c2f01ca..14be3b5727cff6eb1978838045e9a788f8f53bfb 100644
--- a/src/util/mime.d.ts
+++ b/src/util/mime.d.ts
@@ -1,2 +1,2 @@
import { BaseLoader } from '@llm-tools/embedjs-interfaces';
-export declare function createLoaderFromMimeType(loaderData: string, mimeType: string): Promise<BaseLoader>;
+export declare function createLoaderFromMimeType(loaderData: string, mimeType: string, chunkSize?: number, chunkOverlap?: number): Promise<BaseLoader>;
diff --git a/src/util/mime.js b/src/util/mime.js
index b6426a859968e2bf6206795f70333e90ae27aeb7..16ae2adb863f8d7abfa757f1c5cc39f6bb1c44fa 100644
--- a/src/util/mime.js
+++ b/src/util/mime.js
@@ -1,7 +1,9 @@
import mime from 'mime';
import createDebugMessages from 'debug';
import { TextLoader } from '../loaders/text-loader.js';
-export async function createLoaderFromMimeType(loaderData, mimeType) {
+import fs from 'node:fs'
+
+export async function createLoaderFromMimeType(loaderData, mimeType, chunkSize, chunkOverlap) {
createDebugMessages('embedjs:util:createLoaderFromMimeType')(`Incoming mime type '${mimeType}'`);
switch (mimeType) {
case 'application/msword':
@@ -10,7 +12,7 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load docx files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported DocxLoader');
- return new DocxLoader({ filePathOrUrl: loaderData });
+ return new DocxLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/vnd.ms-excel':
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
@@ -18,21 +20,21 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load excel files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported ExcelLoader');
- return new ExcelLoader({ filePathOrUrl: loaderData });
+ return new ExcelLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/pdf': {
const { PdfLoader } = await import('@llm-tools/embedjs-loader-pdf').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-pdf` needs to be installed to load PDF files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported PdfLoader');
- return new PdfLoader({ filePathOrUrl: loaderData });
+ return new PdfLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': {
const { PptLoader } = await import('@llm-tools/embedjs-loader-msoffice').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load pptx files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported PptLoader');
- return new PptLoader({ filePathOrUrl: loaderData });
+ return new PptLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/plain': {
const fineType = mime.getType(loaderData);
@@ -42,24 +44,24 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-csv` needs to be installed to load CSV files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported CsvLoader');
- return new CsvLoader({ filePathOrUrl: loaderData });
+ return new CsvLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
- else
- return new TextLoader({ text: loaderData });
+ const content = fs.readFileSync(loaderData, 'utf-8');
+ return new TextLoader({ text: content, chunkSize, chunkOverlap });
}
case 'application/csv': {
const { CsvLoader } = await import('@llm-tools/embedjs-loader-csv').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-csv` needs to be installed to load CSV files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported CsvLoader');
- return new CsvLoader({ filePathOrUrl: loaderData });
+ return new CsvLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/html': {
const { WebLoader } = await import('@llm-tools/embedjs-loader-web').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-web` needs to be installed to load web documents');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported WebLoader');
- return new WebLoader({ urlOrContent: loaderData });
+ return new WebLoader({ urlOrContent: loaderData, chunkSize, chunkOverlap });
}
case 'text/xml': {
const { SitemapLoader } = await import('@llm-tools/embedjs-loader-sitemap').catch(() => {
@@ -67,14 +69,14 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported SitemapLoader');
if (await SitemapLoader.test(loaderData)) {
- return new SitemapLoader({ url: loaderData });
+ return new SitemapLoader({ url: loaderData, chunkSize, chunkOverlap });
}
//This is not a Sitemap but is still XML
const { XmlLoader } = await import('@llm-tools/embedjs-loader-xml').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-xml` needs to be installed to load XML documents');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported XmlLoader');
- return new XmlLoader({ filePathOrUrl: loaderData });
+ return new XmlLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/x-markdown':
case 'text/markdown': {
@@ -82,7 +84,7 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-markdown` needs to be installed to load markdown files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported MarkdownLoader');
- return new MarkdownLoader({ filePathOrUrl: loaderData });
+ return new MarkdownLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'image/png':
case 'image/jpeg': {
@@ -0,0 +1,26 @@
diff --git a/dist/cjs/client/stdio.js b/dist/cjs/client/stdio.js
index 2ada8771c5f76673b5021d6453c6bdd7e0b88013..89f6ea9ca7de86294d3d966f3454b98e19bfe534 100644
--- a/dist/cjs/client/stdio.js
+++ b/dist/cjs/client/stdio.js
@@ -68,7 +68,7 @@ class StdioClientTransport {
this._process = (0, node_child_process_1.spawn)(this._serverParams.command, (_a = this._serverParams.args) !== null && _a !== void 0 ? _a : [], {
env: (_b = this._serverParams.env) !== null && _b !== void 0 ? _b : getDefaultEnvironment(),
stdio: ["pipe", "pipe", (_c = this._serverParams.stderr) !== null && _c !== void 0 ? _c : "inherit"],
- shell: false,
+ shell: process.platform === 'win32' ? true : false,
signal: this._abortController.signal,
windowsHide: node_process_1.default.platform === "win32" && isElectron(),
cwd: this._serverParams.cwd,
diff --git a/dist/esm/client/stdio.js b/dist/esm/client/stdio.js
index 387c982fd40fd8db9790a78e1a05c9ecb81501c0..7b7e60a306bca73149609015a27e904a0a68ca02 100644
--- a/dist/esm/client/stdio.js
+++ b/dist/esm/client/stdio.js
@@ -61,7 +61,7 @@ export class StdioClientTransport {
this._process = spawn(this._serverParams.command, (_a = this._serverParams.args) !== null && _a !== void 0 ? _a : [], {
env: (_b = this._serverParams.env) !== null && _b !== void 0 ? _b : getDefaultEnvironment(),
stdio: ["pipe", "pipe", (_c = this._serverParams.stderr) !== null && _c !== void 0 ? _c : "inherit"],
- shell: false,
+ shell: process.platform === 'win32' ? true : false,
signal: this._abortController.signal,
windowsHide: process.platform === "win32" && isElectron(),
cwd: this._serverParams.cwd,
-18
View File
@@ -1,18 +0,0 @@
diff --git a/dist/index.node.js b/dist/index.node.js
index bb108cbc210af5b99e864fd1dd8c555e948ecf7a..8ef8c1aab59215c21d161c0e52125724528ecab8 100644
--- a/dist/index.node.js
+++ b/dist/index.node.js
@@ -1,8 +1,11 @@
let crypto;
crypto =
globalThis.crypto?.webcrypto ?? // Node.js 16 REPL has globalThis.crypto as node:crypto
- globalThis.crypto ?? // Node.js 18+
- (await import("node:crypto")).webcrypto; // Node.js 16 non-REPL
+ globalThis.crypto ?? // Node.js 18+
+ (async() => {
+ const crypto = await import("node:crypto");
+ return crypto.webcrypto;
+ })();
/**
* Creates an array of length `size` of random bytes
* @param size
+5 -18
View File
@@ -6,7 +6,6 @@
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a><br></p> <p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a><br></p>
<div align="center"> <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://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div> </div>
# 🍒 Cherry Studio # 🍒 Cherry Studio
@@ -17,10 +16,6 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development! ❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
# 📖 Guide
https://docs.cherry-ai.com
# 🌠 Screenshot # 🌠 Screenshot
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600) ![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
@@ -82,14 +77,6 @@ https://docs.cherry-ai.com
- [ ] Voice input and output (AI call) - [ ] Voice input and output (AI call)
- [ ] Data backup supports custom backup content - [ ] Data backup supports custom backup content
# 🌈 Theme
- Theme Gallery: https://cherrycss.com
- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
Welcome PR for more themes
# 🖥️ Develop # 🖥️ Develop
Refer to the [development documentation](docs/dev.md) Refer to the [development documentation](docs/dev.md)
@@ -132,7 +119,11 @@ Thank you for your support and contributions!
# 🌐 Community # 🌐 Community
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao) [Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 Product Hunt
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
# ☕ Sponsor # ☕ Sponsor
@@ -142,10 +133,6 @@ Thank you for your support and contributions!
[LICENSE](./LICENSE) [LICENSE](./LICENSE)
# ✉️ Contact
yinsenho@cherry-ai.com
# ⭐️ Star History # ⭐️ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline) [![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
+5 -5
View File
@@ -1,8 +1,8 @@
# provider: generic # provider: generic
# url: http://127.0.0.1:8080 # url: http://127.0.0.1:8080
# updaterCacheDirName: cherry-studio-updater # updaterCacheDirName: cherry-studio-updater
provider: github # provider: github
repo: cherry-studio # repo: cherry-studio
owner: kangfenmao # owner: kangfenmao
# provider: generic provider: generic
# url: https://cherrystudio.ocool.online url: https://cherrystudio.ocool.online
+5 -17
View File
@@ -8,7 +8,6 @@
</p> </p>
<div align="center"> <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://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div> </div>
# 🍒 Cherry Studio # 🍒 Cherry Studio
@@ -18,10 +17,6 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️ ❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
# 📖 ガイド
https://docs.cherry-ai.com
# 🌠 スクリーンショット # 🌠 スクリーンショット
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600) ![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
@@ -83,13 +78,6 @@ https://docs.cherry-ai.com
- [ ] 音声入出力(AI コール) - [ ] 音声入出力(AI コール)
- [ ] データバックアップはカスタムバックアップコンテンツをサポート - [ ] データバックアップはカスタムバックアップコンテンツをサポート
# 🌈 テーマ
テーマギャラリー: https://cherrycss.com
Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
より多くのテーマのPRを歓迎します
# 🖥️ 開発 # 🖥️ 開発
参考[開発ドキュメント](dev.md) 参考[開発ドキュメント](dev.md)
@@ -129,7 +117,11 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
# コミュニティ # コミュニティ
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao) [Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 プロダクトハント
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
# スポンサー # スポンサー
@@ -139,10 +131,6 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
[LICENSE](../LICENSE) [LICENSE](../LICENSE)
# ✉️ お問い合わせ
yinsenho@cherry-ai.com
# ⭐️ スター履歴 # ⭐️ スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline) [![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
+5 -18
View File
@@ -7,9 +7,7 @@
<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"> <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://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div> </div>
# 🍒 Cherry Studio # 🍒 Cherry Studio
Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客户端,兼容 Windows、Mac 和 Linux 系统。 Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客户端,兼容 Windows、Mac 和 Linux 系统。
@@ -18,10 +16,6 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️ ❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
# 📖 使用教程
https://docs.cherry-ai.com
# 🌠 界面 # 🌠 界面
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600) ![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
@@ -83,13 +77,6 @@ https://docs.cherry-ai.com
- [ ] 语音输入输出(AI 通话) - [ ] 语音输入输出(AI 通话)
- [ ] 数据备份支持自定义备份内容 - [ ] 数据备份支持自定义备份内容
# 🌈 主题
主题库:https://cherrycss.com
Aero 主题:https://github.com/hakadao/CherryStudio-Aero
欢迎 PR 更多主题
# 🖥️ 开发 # 🖥️ 开发
参考[开发文档](dev.md) 参考[开发文档](dev.md)
@@ -130,7 +117,11 @@ Aero 主题:https://github.com/hakadao/CherryStudio-Aero
# 🌐 社区 # 🌐 社区
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao) [Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 产品猎人
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
# ☕ 赞助 # ☕ 赞助
@@ -140,10 +131,6 @@ Aero 主题:https://github.com/hakadao/CherryStudio-Aero
[LICENSE](../LICENSE) [LICENSE](../LICENSE)
# ✉️ 联系我们
yinsenho@cherry-ai.com
# ⭐️ Star 记录 # ⭐️ Star 记录
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline) [![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
+11 -12
View File
@@ -12,18 +12,17 @@ export default defineConfig({
plugins: [ plugins: [
externalizeDepsPlugin({ externalizeDepsPlugin({
exclude: [ exclude: [
'@cherrystudio/embedjs', '@llm-tools/embedjs',
'@cherrystudio/embedjs-openai', '@llm-tools/embedjs-openai',
'@cherrystudio/embedjs-loader-web', '@llm-tools/embedjs-loader-web',
'@cherrystudio/embedjs-loader-markdown', '@llm-tools/embedjs-loader-markdown',
'@cherrystudio/embedjs-loader-msoffice', '@llm-tools/embedjs-loader-msoffice',
'@cherrystudio/embedjs-loader-xml', '@llm-tools/embedjs-loader-xml',
'@cherrystudio/embedjs-loader-pdf', '@llm-tools/embedjs-loader-pdf',
'@cherrystudio/embedjs-loader-sitemap', '@llm-tools/embedjs-loader-sitemap',
'@cherrystudio/embedjs-libsql', '@llm-tools/embedjs-libsql',
'@cherrystudio/embedjs-loader-image', '@llm-tools/embedjs-loader-image',
'p-queue', 'p-queue'
'webdav'
] ]
}), }),
...visualizerPlugin('main') ...visualizerPlugin('main')
+1 -11
View File
@@ -53,16 +53,6 @@ export default defineConfig([
} }
], ],
{ {
ignores: [ ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js']
'node_modules/**',
'build/**',
'dist/**',
'out/**',
'local/**',
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**'
]
} }
]) ])
+32 -36
View File
@@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "1.1.11", "version": "1.1.8",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@@ -50,64 +50,61 @@
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@cherrystudio/embedjs": "^0.1.28", "@agentic/exa": "^7.3.3",
"@cherrystudio/embedjs-libsql": "^0.1.28", "@agentic/searxng": "^7.3.3",
"@cherrystudio/embedjs-loader-csv": "^0.1.28", "@agentic/tavily": "^7.3.3",
"@cherrystudio/embedjs-loader-image": "^0.1.28",
"@cherrystudio/embedjs-loader-markdown": "^0.1.28",
"@cherrystudio/embedjs-loader-msoffice": "^0.1.28",
"@cherrystudio/embedjs-loader-pdf": "^0.1.28",
"@cherrystudio/embedjs-loader-sitemap": "^0.1.28",
"@cherrystudio/embedjs-loader-web": "^0.1.28",
"@cherrystudio/embedjs-loader-xml": "^0.1.28",
"@cherrystudio/embedjs-openai": "^0.1.28",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0", "@electron/notarize": "^2.5.0",
"@emotion/is-prop-valid": "^1.3.1",
"@google/generative-ai": "^0.21.0", "@google/generative-ai": "^0.21.0",
"@langchain/community": "^0.3.36", "@llm-tools/embedjs": "patch:@llm-tools/embedjs@npm%3A0.1.28#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.28-8e4393fa2d.patch",
"@llm-tools/embedjs-libsql": "^0.1.28",
"@llm-tools/embedjs-loader-csv": "^0.1.28",
"@llm-tools/embedjs-loader-markdown": "patch:@llm-tools/embedjs-loader-markdown@npm%3A0.1.28#~/.yarn/patches/@llm-tools-embedjs-loader-markdown-npm-0.1.28-81647ffac6.patch",
"@llm-tools/embedjs-loader-msoffice": "^0.1.28",
"@llm-tools/embedjs-loader-pdf": "^0.1.28",
"@llm-tools/embedjs-loader-sitemap": "^0.1.28",
"@llm-tools/embedjs-loader-web": "^0.1.28",
"@llm-tools/embedjs-loader-xml": "^0.1.28",
"@llm-tools/embedjs-openai": "^0.1.28",
"@modelcontextprotocol/sdk": "patch:@modelcontextprotocol/sdk@npm%3A1.6.1#~/.yarn/patches/@modelcontextprotocol-sdk-npm-1.6.1-b46313efe7.patch",
"@notionhq/client": "^2.2.15", "@notionhq/client": "^2.2.15",
"@tryfabric/martian": "^1.2.4", "@tryfabric/martian": "^1.2.4",
"@types/react-infinite-scroll-component": "^5.0.0", "@types/react-infinite-scroll-component": "^5.0.0",
"@xyflow/react": "^12.4.4",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"apache-arrow": "^18.1.0",
"docx": "^9.0.2", "docx": "^9.0.2",
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-xml-parser": "^5.0.9",
"fetch-socks": "^1.3.2", "fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1", "officeparser": "^4.1.1",
"proxy-agent": "^6.5.0", "p-queue": "^8.1.0",
"socks-proxy-agent": "^8.0.3",
"tar": "^7.4.3", "tar": "^7.4.3",
"tokenx": "^0.4.1",
"undici": "^7.4.0", "undici": "^7.4.0",
"webdav": "^5.8.0", "webdav": "4.11.4",
"zipread": "^1.3.3" "zipread": "^1.3.3"
}, },
"devDependencies": { "devDependencies": {
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.38.0", "@anthropic-ai/sdk": "^0.38.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1", "@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@google/genai": "^0.4.0",
"@hello-pangea/dnd": "^16.6.0", "@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0", "@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.8.0", "@llm-tools/embedjs-loader-image": "^0.1.28",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch", "@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@tryfabric/martian": "^1.2.4",
"@types/adm-zip": "^0", "@types/adm-zip": "^0",
"@types/fs-extra": "^11", "@types/fs-extra": "^11",
"@types/lodash": "^4.17.5", "@types/lodash": "^4.17.5",
@@ -115,8 +112,8 @@
"@types/md5": "^2.3.5", "@types/md5": "^2.3.5",
"@types/node": "^18.19.9", "@types/node": "^18.19.9",
"@types/pako": "^1.0.2", "@types/pako": "^1.0.2",
"@types/react": "^19.0.12", "@types/react": "^18.2.48",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^18.2.18",
"@types/react-infinite-scroll-component": "^5.0.0", "@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1", "@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
@@ -146,13 +143,10 @@
"lint-staged": "^15.5.0", "lint-staged": "^15.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime": "^4.0.4", "mime": "^4.0.4",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch", "openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"p-queue": "^8.1.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"rc-virtual-list": "^3.18.5", "react": "^18.2.0",
"react": "^19.0.0", "react-dom": "^18.2.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.6.1", "react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2", "react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0", "react-infinite-scroll-component": "^6.1.0",
@@ -174,17 +168,19 @@
"string-width": "^7.2.0", "string-width": "^7.2.0",
"styled-components": "^6.1.11", "styled-components": "^6.1.11",
"tinycolor2": "^1.6.0", "tinycolor2": "^1.6.0",
"tokenx": "^0.4.1",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"vite": "^5.0.12" "vite": "^5.0.12"
}, },
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"resolutions": { "resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", "@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch", "openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch"
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
}, },
"packageManager": "yarn@4.6.0", "packageManager": "yarn@4.6.0",
"lint-staged": { "lint-staged": {
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@cherry-studio/artifacts",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cherry-studio/artifacts",
"version": "0.1.0",
"license": "ISC"
}
}
}
+1358
View File
File diff suppressed because it is too large Load Diff
+859 -1639
View File
File diff suppressed because it is too large Load Diff
+2 -27
View File
@@ -88,7 +88,7 @@ export const textExts = [
'.ctp', // CakePHP 视图文件 '.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件 '.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件 '.cfc', // ColdFusion 组件文件
'.m', // Objective-C 或 MATLAB 源文件 '.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件 '.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件 '.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件 '.groovy', // Gradle 构建文件
@@ -106,32 +106,7 @@ export const textExts = [
'.ixx', // C++20 模块实现文件 '.ixx', // C++20 模块实现文件
'.f90', // Fortran 90 源文件 '.f90', // Fortran 90 源文件
'.f', // Fortran 固定格式源代码文件 '.f', // Fortran 固定格式源代码文件
'.f03', // Fortran 2003+ 源代码文件 '.f03' // Fortran 2003+ 源代码文件
'.ahk', // AutoHotKey 语言文件
'.tcl', // Tcl 脚本
'.do', // Questa 或 Modelsim Tcl 脚本
'.v', // Verilog 源文件
'.sv', // SystemVerilog 源文件
'.svh', // SystemVerilog 头文件
'.vhd', // VHDL 源文件
'.vhdl', // VHDL 源文件
'.lef', // Library Exchange Format
'.def', // Design Exchange Format
'.edif', // Electronic Design Interchange Format
'.sdf', // Standard Delay Format
'.sdc', // Synopsys Design Constraints
'.xdc', // Xilinx Design Constraints
'.rpt', // 报告文件
'.lisp', // Lisp 脚本
'.il', // Cadence SKILL 脚本
'.ils', // Cadence SKILL++ 脚本
'.sp', // SPICE netlist 文件
'.spi', // SPICE netlist 文件
'.cir', // SPICE netlist 文件
'.net', // SPICE netlist 文件
'.scs', // Spectre netlist 文件
'.asc', // LTspice netlist schematic 文件
'.tf' // Technology File
] ]
export const ZOOM_SHORTCUTS = [ export const ZOOM_SHORTCUTS = [
-1
View File
@@ -1 +0,0 @@
export const NUTSTORE_HOST = 'https://dav.jianguoyun.com/dav'
+38 -21
View File
@@ -1,5 +1,8 @@
const { ProxyAgent } = require('undici')
const { SocksProxyAgent } = require('socks-proxy-agent')
const https = require('https') const https = require('https')
const fs = require('fs') const fs = require('fs')
const { pipeline } = require('stream/promises')
/** /**
* Downloads a file from a URL with redirect handling * Downloads a file from a URL with redirect handling
@@ -8,28 +11,42 @@ const fs = require('fs')
* @returns {Promise<void>} Promise that resolves when download is complete * @returns {Promise<void>} Promise that resolves when download is complete
*/ */
async function downloadWithRedirects(url, destinationPath) { async function downloadWithRedirects(url, destinationPath) {
return new Promise((resolve, reject) => { const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY
const request = (url) => { if (proxyUrl.startsWith('socks')) {
https const proxyAgent = new SocksProxyAgent(proxyUrl)
.get(url, (response) => { return new Promise((resolve, reject) => {
if (response.statusCode == 301 || response.statusCode == 302) { const request = (url) => {
request(response.headers.location) https
return .get(url, { agent: proxyAgent }, (response) => {
} if (response.statusCode == 301 || response.statusCode == 302) {
if (response.statusCode !== 200) { request(response.headers.location)
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`)) return
return }
} if (response.statusCode !== 200) {
const file = fs.createWriteStream(destinationPath) reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`))
response.pipe(file) return
file.on('finish', () => resolve()) }
}) const file = fs.createWriteStream(destinationPath)
.on('error', (err) => { response.pipe(file)
reject(err) file.on('finish', () => resolve())
}) })
.on('error', (err) => {
reject(err)
})
}
request(url)
})
} else {
const proxyAgent = new ProxyAgent(proxyUrl)
const response = await fetch(url, {
dispatcher: proxyAgent
})
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`)
} }
request(url) const file = fs.createWriteStream(destinationPath)
}) await pipeline(response.body, file)
}
} }
module.exports = { downloadWithRedirects } module.exports = { downloadWithRedirects }
+314
View File
@@ -0,0 +1,314 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const https = require('https')
const { execSync } = require('child_process')
// 配置
const NODE_VERSION = process.env.NODE_VERSION || '18.18.0' // 默认版本
const NODE_RELEASE_BASE_URL = 'https://nodejs.org/dist'
// 平台映射
const NODE_PACKAGES = {
'darwin-arm64': `node-v${NODE_VERSION}-darwin-arm64.tar.gz`,
'darwin-x64': `node-v${NODE_VERSION}-darwin-x64.tar.gz`,
'win32-x64': `node-v${NODE_VERSION}-win32-x64.zip`,
'win32-ia32': `node-v${NODE_VERSION}-win32-x86.zip`,
'linux-x64': `node-v${NODE_VERSION}-linux-x64.tar.gz`,
'linux-arm64': `node-v${NODE_VERSION}-linux-arm64.tar.gz`,
}
// 辅助函数 - 递归复制目录
function copyFolderRecursiveSync(source, target) {
// 检查目标目录是否存在,不存在则创建
if (!fs.existsSync(target)) {
fs.mkdirSync(target, { recursive: true });
}
// 读取源目录中的所有文件和文件夹
const files = fs.readdirSync(source);
// 循环处理每个文件/文件夹
for (const file of files) {
const sourcePath = path.join(source, file);
const targetPath = path.join(target, file);
// 检查是文件还是文件夹
if (fs.statSync(sourcePath).isDirectory()) {
// 如果是文件夹,递归复制
copyFolderRecursiveSync(sourcePath, targetPath);
} else {
// 如果是文件,直接复制
fs.copyFileSync(sourcePath, targetPath);
}
}
}
// 二进制文件存放目录
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
// 创建二进制文件存放目录
async function createBinariesDir() {
if (!fs.existsSync(binariesDir)) {
console.log(`Creating binaries directory at ${binariesDir}`)
fs.mkdirSync(binariesDir, { recursive: true })
}
}
// 获取当前平台对应的包名
function getPackageForPlatform() {
const platform = os.platform()
const arch = os.arch()
const key = `${platform}-${arch}`
console.log(`Current platform: ${platform}, architecture: ${arch}`)
if (!NODE_PACKAGES[key]) {
throw new Error(`Unsupported platform/architecture: ${key}`)
}
return NODE_PACKAGES[key]
}
// 下载 Node.js
async function downloadNodeJs() {
const packageName = getPackageForPlatform()
const downloadUrl = `${NODE_RELEASE_BASE_URL}/v${NODE_VERSION}/${packageName}`
const tempFilePath = path.join(os.tmpdir(), packageName)
console.log(`Downloading Node.js v${NODE_VERSION} from ${downloadUrl}`)
console.log(`Temp file path: ${tempFilePath}`)
// 如果临时文件已存在,先删除
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath)
}
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(tempFilePath)
https.get(downloadUrl, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`))
return
}
console.log(`Download started, status code: ${response.statusCode}`)
response.pipe(file)
file.on('finish', () => {
file.close()
console.log('Download completed')
resolve(tempFilePath)
})
file.on('error', (err) => {
fs.unlinkSync(tempFilePath)
reject(err)
})
}).on('error', (err) => {
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath)
}
reject(err)
})
})
}
// 解压 Node.js 包
async function extractNodeJs(filePath) {
const platform = os.platform()
const extractDir = path.join(os.tmpdir(), `node-v${NODE_VERSION}-extract`)
if (fs.existsSync(extractDir)) {
console.log(`Removing existing extract directory: ${extractDir}`)
fs.rmSync(extractDir, { recursive: true, force: true })
}
console.log(`Creating extract directory: ${extractDir}`)
fs.mkdirSync(extractDir, { recursive: true })
console.log(`Extracting to ${extractDir}`)
if (platform === 'win32') {
// Windows 使用内置的解压工具
try {
const AdmZip = require('adm-zip')
console.log(`Using adm-zip to extract ${filePath}`)
const zip = new AdmZip(filePath)
zip.extractAllTo(extractDir, true)
console.log(`Extraction completed using adm-zip`)
} catch (error) {
console.error(`Error using adm-zip: ${error}`)
throw error
}
} else {
// Linux/Mac 使用 tar
try {
console.log(`Using tar to extract ${filePath} to ${extractDir}`)
execSync(`tar -xzf "${filePath}" -C "${extractDir}"`, { stdio: 'inherit' })
console.log(`Extraction completed using tar`)
} catch (error) {
console.error(`Error using tar: ${error}`)
throw error
}
}
return extractDir
}
// 安装 Node.js
async function installNodeJs(extractDir) {
const platform = os.platform()
console.log(`Finding extracted Node.js directory in ${extractDir}`)
const items = fs.readdirSync(extractDir)
console.log(`Found items in extract directory: ${items.join(', ')}`)
// 找到包含"node-v"的目录名
const folderName = items.find(item => item.startsWith('node-v'))
if (!folderName) {
throw new Error(`Could not find Node.js directory in ${extractDir}`)
}
console.log(`Found Node.js directory: ${folderName}`)
const nodeBinPath = path.join(extractDir, folderName, 'bin')
console.log(`Node.js bin path: ${nodeBinPath}`)
// 复制 node 和 npm
if (platform === 'win32') {
// Windows
console.log('Installing Node.js binaries for Windows')
fs.copyFileSync(
path.join(extractDir, folderName, 'node.exe'),
path.join(binariesDir, 'node.exe')
)
console.log(`Copied node.exe to ${path.join(binariesDir, 'node.exe')}`)
fs.copyFileSync(
path.join(extractDir, folderName, 'npm.cmd'),
path.join(binariesDir, 'npm.cmd')
)
console.log(`Copied npm.cmd to ${path.join(binariesDir, 'npm.cmd')}`)
fs.copyFileSync(
path.join(extractDir, folderName, 'npx.cmd'),
path.join(binariesDir, 'npx.cmd')
)
console.log(`Copied npx.cmd to ${path.join(binariesDir, 'npx.cmd')}`)
} else {
// Linux/Mac
console.log('Installing Node.js binaries for Linux/Mac')
fs.copyFileSync(
path.join(nodeBinPath, 'node'),
path.join(binariesDir, 'node')
)
console.log(`Copied node to ${path.join(binariesDir, 'node')}`)
// 创建npm脚本,指向正确路径
const npmScript = `#!/usr/bin/env node
require("./node_modules/npm/lib/cli.js")(process)`;
fs.writeFileSync(path.join(binariesDir, 'npm'), npmScript);
console.log(`Created npm script at ${path.join(binariesDir, 'npm')}`);
// 创建npx脚本,指向正确路径
const npxScript = `#!/usr/bin/env node
require("./node_modules/npm/bin/npx-cli.js")`;
fs.writeFileSync(path.join(binariesDir, 'npx'), npxScript);
console.log(`Created npx script at ${path.join(binariesDir, 'npx')}`);
// 设置执行权限
execSync(`chmod +x "${path.join(binariesDir, 'node')}"`)
execSync(`chmod +x "${path.join(binariesDir, 'npm')}"`)
execSync(`chmod +x "${path.join(binariesDir, 'npx')}"`)
console.log('Set executable permissions for Node.js binaries')
}
// 复制 npm 相关文件和目录
const npmDir = path.join(binariesDir, 'node_modules', 'npm')
fs.mkdirSync(npmDir, { recursive: true })
console.log(`Created npm directory at ${npmDir}`)
// 复制 npm 目录的内容
const srcNpmDir = path.join(extractDir, folderName, 'lib', 'node_modules', 'npm')
console.log(`Copying npm files from ${srcNpmDir} to ${npmDir}`)
const files = fs.readdirSync(srcNpmDir)
for (const file of files) {
const srcPath = path.join(srcNpmDir, file)
const destPath = path.join(npmDir, file)
if (fs.lstatSync(srcPath).isDirectory()) {
// 使用自定义函数代替fs.cpSync,确保兼容性
console.log(`Copying directory: ${file}`)
copyFolderRecursiveSync(srcPath, destPath)
} else {
console.log(`Copying file: ${file}`)
fs.copyFileSync(srcPath, destPath)
}
}
console.log('Node.js installation completed successfully')
}
// 清理临时文件
async function cleanup(filePath, extractDir) {
try {
if (fs.existsSync(filePath)) {
console.log(`Cleaning up temp file: ${filePath}`)
fs.unlinkSync(filePath)
}
if (fs.existsSync(extractDir)) {
console.log(`Cleaning up extract directory: ${extractDir}`)
fs.rmSync(extractDir, { recursive: true, force: true })
}
console.log('Cleaned up temporary files')
} catch (error) {
console.error('Error during cleanup:', error)
}
}
// 主安装函数
async function install() {
try {
console.log(`Starting Node.js v${NODE_VERSION} installation...`)
await createBinariesDir()
console.log('Binary directory created/verified')
const filePath = await downloadNodeJs()
console.log(`Downloaded Node.js to ${filePath}`)
const extractDir = await extractNodeJs(filePath)
console.log(`Extracted Node.js to ${extractDir}`)
await installNodeJs(extractDir)
console.log('Installed Node.js binaries')
await cleanup(filePath, extractDir)
console.log('Cleanup completed')
console.log(`Node.js v${NODE_VERSION} has been installed successfully at ${binariesDir}`)
return true
} catch (error) {
console.error('Installation failed:', error)
throw error
}
}
// 执行安装
install()
.then(() => {
console.log('Installation process completed successfully')
process.exit(0)
})
.catch((error) => {
console.error('Fatal error during installation:', error)
process.exit(1)
})
-130
View File
@@ -1,130 +0,0 @@
/**
* OCOOL_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts
*/
// OCOOL API KEY
const OCOOL_API_KEY = process.env.OCOOL_API_KEY
const INDEX = [
// 语言的名称 代码 用来翻译的模型
{ name: 'France', code: 'fr-fr', model: 'qwen2.5-32b-instruct' },
{ name: 'Spanish', code: 'es-es', model: 'qwen2.5-32b-instruct' },
{ name: 'Portuguese', code: 'pt-pt', model: 'qwen2.5-72b-instruct' },
{ name: 'Greek', code: 'el-gr', model: 'qwen-turbo' }
]
const fs = require('fs')
import OpenAI from 'openai'
const zh = JSON.parse(fs.readFileSync('src/renderer/src/i18n/locales/zh-cn.json', 'utf8')) as object
const openai = new OpenAI({
apiKey: OCOOL_API_KEY,
baseURL: 'https://one.ocoolai.com/v1'
})
// 递归遍历翻译
async function translate(zh: object, obj: object, target: string, model: string, updateFile) {
const texts: { [key: string]: string } = {}
for (const e in zh) {
if (typeof zh[e] == 'object') {
// 遍历下一层
if (!obj[e] || typeof obj[e] != 'object') obj[e] = {}
await translate(zh[e], obj[e], target, model, updateFile)
} else {
// 加入到本层待翻译列表
if (!obj[e] || typeof obj[e] != 'string') {
texts[e] = zh[e]
}
}
}
if (Object.keys(texts).length > 0) {
const completion = await openai.chat.completions.create({
model: model,
response_format: { type: 'json_object' },
messages: [
{
role: 'user',
content: `
You are a robot specifically designed for translation tasks. As a model that has been extensively fine-tuned on Russian language corpora, you are proficient in using the Russian language.
Now, please output the translation based on the input content. The input will include both Chinese and English key values, and you should output the corresponding key values in the Russian language.
When translating, ensure that no key value is omitted, and maintain the accuracy and fluency of the translation. Pay attention to the capitalization rules in the output to match the source text, and especially pay attention to whether to capitalize the first letter of each word except for prepositions. For strings containing \`{{value}}\`, ensure that the format is not disrupted.
Output in JSON.
######################################################
INPUT
######################################################
${JSON.stringify({
confirm: '确定要备份数据吗?',
select_model: '选择模型',
title: '文件',
deeply_thought: '已深度思考(用时 {{secounds}} 秒)'
})}
######################################################
MAKE SURE TO OUTPUT IN Russian. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
######################################################
`
},
{
role: 'assistant',
content: JSON.stringify({
confirm: 'Подтвердите резервное копирование данных?',
select_model: 'Выберите Модель',
title: 'Файл',
deeply_thought: 'Глубоко продумано (заняло {{seconds}} секунд)'
})
},
{
role: 'user',
content: `
You are a robot specifically designed for translation tasks. As a model that has been extensively fine-tuned on ${target} language corpora, you are proficient in using the ${target} language.
Now, please output the translation based on the input content. The input will include both Chinese and English key values, and you should output the corresponding key values in the ${target} language.
When translating, ensure that no key value is omitted, and maintain the accuracy and fluency of the translation. Pay attention to the capitalization rules in the output to match the source text, and especially pay attention to whether to capitalize the first letter of each word except for prepositions. For strings containing \`{{value}}\`, ensure that the format is not disrupted.
Output in JSON.
######################################################
INPUT
######################################################
${JSON.stringify(texts)}
######################################################
MAKE SURE TO OUTPUT IN ${target}. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
######################################################
`
}
]
})
// 添加翻译后的键值,并打印错译漏译内容
try {
const result = JSON.parse(completion.choices[0].message.content!)
for (const e in texts) {
if (result[e] && typeof result[e] === 'string') {
obj[e] = result[e]
} else {
console.log('[warning]', `missing value "${e}" in ${target} translation`)
}
}
} catch (e) {
console.log('[error]', e)
for (const e in texts) {
console.log('[warning]', `missing value "${e}" in ${target} translation`)
}
}
}
// 删除多余的键值
for (const e in obj) {
if (!zh[e]) {
delete obj[e]
}
}
// 更新文件
updateFile()
}
;(async () => {
for (const { name, code, model } of INDEX) {
const obj = fs.existsSync(`src/renderer/src/i18n/translate/${code}.json`)
? JSON.parse(fs.readFileSync(`src/renderer/src/i18n/translate/${code}.json`, 'utf8'))
: {}
await translate(zh, obj, name, model, () => {
fs.writeFileSync(`src/renderer/src/i18n/translate/${code}.json`, JSON.stringify(obj, null, 2), 'utf8')
})
}
})()
+16
View File
@@ -0,0 +1,16 @@
export interface NodeAppType {
id: string
name: string
type: string
description?: string
author?: string
homepage?: string
repositoryUrl?: string
port?: number
installCommand?: string
buildCommand?: string
startCommand?: string
isInstalled: boolean
isRunning: boolean
url?: string
}
-24
View File
@@ -1,24 +0,0 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import EmbeddingsFactory from './EmbeddingsFactory'
export default class Embeddings {
private sdk: BaseEmbeddings
constructor({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) {
this.sdk = EmbeddingsFactory.create({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
}
public async init(): Promise<void> {
return this.sdk.init()
}
public async getDimensions(): Promise<number> {
return this.sdk.getDimensions()
}
public async embedDocuments(texts: string[]): Promise<number[][]> {
return this.sdk.embedDocuments(texts)
}
public async embedQuery(text: string): Promise<number[]> {
return this.sdk.embedQuery(text)
}
}
-38
View File
@@ -1,38 +0,0 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import { getInstanceName } from '@main/utils'
import { KnowledgeBaseParams } from '@types'
import VoyageEmbeddings from './VoyageEmbeddings'
export default class EmbeddingsFactory {
static create({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
const batchSize = 10
if (model.includes('voyage')) {
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: dimensions,
batchSize: 8
})
}
if (apiVersion !== undefined) {
return new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIApiInstanceName: getInstanceName(baseURL),
dimensions,
batchSize
})
}
return new OpenAiEmbeddings({
model,
apiKey,
dimensions,
batchSize,
configuration: { baseURL }
})
}
}
-30
View File
@@ -1,30 +0,0 @@
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
export default class VoyageEmbeddings extends BaseEmbeddings {
private model: _VoyageEmbeddings
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
super()
if (!this.configuration) this.configuration = {}
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
if (!this.configuration.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
this.model = new _VoyageEmbeddings(this.configuration)
}
override async getDimensions(): Promise<number> {
if (!this.configuration?.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
return this.configuration?.outputDimension
}
override async embedDocuments(texts: string[]): Promise<number[][]> {
return this.model.embedDocuments(texts)
}
override async embedQuery(text: string): Promise<number[]> {
return this.model.embedQuery(text)
}
}
+1 -30
View File
@@ -4,8 +4,6 @@ import { app, ipcMain } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc' import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
import { registerShortcuts } from './services/ShortcutService' import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService' import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService' import { windowService } from './services/WindowService'
@@ -23,12 +21,6 @@ if (!app.requestSingleInstanceLock()) {
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
// Mac: Hide dock icon before window creation when launch to tray is set
const isLaunchToTray = configManager.getLaunchToTray()
if (isLaunchToTray) {
app.dock?.hide()
}
const mainWindow = windowService.createMainWindow() const mainWindow = windowService.createMainWindow()
new TrayService() new TrayService()
@@ -57,30 +49,9 @@ if (!app.requestSingleInstanceLock()) {
}) })
}) })
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
// Listen for second instance // Listen for second instance
app.on('second-instance', (_event, argv) => { app.on('second-instance', () => {
windowService.showMainWindow() windowService.showMainWindow()
// Protocol handler for Windows/Linux
// The commandLine is an array of strings where the last item might be the URL
const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
if (url) handleProtocolUrl(url)
}) })
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
-8
View File
@@ -1,8 +0,0 @@
declare function decrypt(app: string, s: string): string;
interface Secret {
app: string;
}
declare function createOAuthUrl(secret: Secret): string;
export { type Secret, createOAuthUrl, decrypt };
File diff suppressed because one or more lines are too long
+83 -42
View File
@@ -1,8 +1,7 @@
import fs from 'node:fs' import fs from 'node:fs'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { Shortcut, ThemeMode } from '@types' import { MCPServer, Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, session, shell } from 'electron' import { BrowserWindow, ipcMain, session, shell } from 'electron'
import log from 'electron-log' import log from 'electron-log'
@@ -16,9 +15,7 @@ import FileService from './services/FileService'
import FileStorage from './services/FileStorage' import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService' import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService' import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService' import MCPService from './services/MCPService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager' import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService' import { TrayService } from './services/TrayService'
@@ -27,11 +24,12 @@ import { getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes' import { decrypt, encrypt } from './utils/aes'
import { getFilesDir } from './utils/file' import { getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip' import { compress, decompress } from './utils/zip'
import NodeAppService from './services/NodeAppService'
const fileManager = new FileStorage() const fileManager = new FileStorage()
const backupManager = new BackupManager() const backupManager = new BackupManager()
const exportService = new ExportService(fileManager) const exportService = new ExportService(fileManager)
const obsidianVaultService = new ObsidianVaultService() const mcpService = new MCPService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow) const appUpdater = new AppUpdater(mainWindow)
@@ -71,30 +69,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setLanguage(language) configManager.setLanguage(language)
}) })
// launch on boot
ipcMain.handle('app:set-launch-on-boot', (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
app.setLoginItemSettings({ openAtLogin })
}
})
// launch to tray
ipcMain.handle('app:set-launch-to-tray', (_, isActive: boolean) => {
configManager.setLaunchToTray(isActive)
})
// tray // tray
ipcMain.handle('app:set-tray', (_, isActive: boolean) => { ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
configManager.setTray(isActive) configManager.setTray(isActive)
}) })
// to tray on close
ipcMain.handle('app:set-tray-on-close', (_, isActive: boolean) => {
configManager.setTrayOnClose(isActive)
})
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray()) ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
ipcMain.handle('config:set', (_, key: string, value: any) => { ipcMain.handle('config:set', (_, key: string, value: any) => {
@@ -166,8 +145,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav) ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav) ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles) ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
ipcMain.handle('backup:checkConnection', backupManager.checkConnection)
ipcMain.handle('backup:createDirectory', backupManager.createDirectory)
// file // file
ipcMain.handle('file:open', fileManager.open) ipcMain.handle('file:open', fileManager.open)
@@ -263,16 +240,47 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
) )
// Register MCP handlers // Register MCP handlers
ipcMain.handle('mcp:remove-server', mcpService.removeServer) ipcMain.on('mcp:servers-from-renderer', (_, servers) => mcpService.setServers(servers))
ipcMain.handle('mcp:list-tools', mcpService.listTools) ipcMain.handle('mcp:list-servers', async () => mcpService.listAvailableServices())
ipcMain.handle('mcp:call-tool', mcpService.callTool) ipcMain.handle('mcp:add-server', async (_, server: MCPServer) => mcpService.addServer(server))
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo) ipcMain.handle('mcp:update-server', async (_, server: MCPServer) => mcpService.updateServer(server))
ipcMain.handle('mcp:delete-server', async (_, serverName: string) => mcpService.deleteServer(serverName))
ipcMain.handle('mcp:set-server-active', async (_, { name, isActive }) =>
mcpService.setServerActive({ name, isActive })
)
// According to preload, this should take no parameters, but our implementation accepts
// an optional serverName for better flexibility
ipcMain.handle('mcp:list-tools', async (_, serverName?: string) => mcpService.listTools(serverName))
ipcMain.handle('mcp:call-tool', async (_, params: { client: string; name: string; args: any }) =>
mcpService.callTool(params)
)
ipcMain.handle('mcp:cleanup', async () => mcpService.cleanup())
// Shell API
ipcMain.handle('shell:openExternal', async (_, url: string) => {
try {
log.info(`Opening external URL: ${url}`)
return await shell.openExternal(url)
} catch (error) {
log.error('Error opening external URL:', error)
throw error
}
})
ipcMain.handle('app:is-binary-exist', (_, name: string) => isBinaryExists(name)) ipcMain.handle('app:is-binary-exist', (_, name: string) => isBinaryExists(name))
ipcMain.handle('app:get-binary-path', (_, name: string) => getBinaryPath(name)) ipcMain.handle('app:get-binary-path', (_, name: string) => getBinaryPath(name))
ipcMain.handle('app:install-uv-binary', () => runInstallScript('install-uv.js')) ipcMain.handle('app:install-uv-binary', () => runInstallScript('install-uv.js'))
ipcMain.handle('app:install-bun-binary', () => runInstallScript('install-bun.js')) ipcMain.handle('app:install-bun-binary', () => runInstallScript('install-bun.js'))
// Listen for changes in MCP servers and notify renderer
mcpService.on('servers-updated', (servers) => {
mainWindow?.webContents.send('mcp:servers-updated', servers)
})
app.on('before-quit', () => mcpService.cleanup())
//copilot //copilot
ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage) ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage)
ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken) ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken)
@@ -281,19 +289,52 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('copilot:logout', CopilotService.logout) ipcMain.handle('copilot:logout', CopilotService.logout)
ipcMain.handle('copilot:get-user', CopilotService.getUser) ipcMain.handle('copilot:get-user', CopilotService.getUser)
// Obsidian service // Node app management
ipcMain.handle('obsidian:get-vaults', () => { const nodeAppService = NodeAppService.getInstance()
return obsidianVaultService.getVaults()
ipcMain.handle('nodeapp:list', async () => await nodeAppService.getAllApps())
ipcMain.handle('nodeapp:add', async (_, app) => await nodeAppService.addApp(app))
ipcMain.handle('nodeapp:install', async (_, appId) => await nodeAppService.installApp(appId))
ipcMain.handle('nodeapp:update', async (_, appId) => await nodeAppService.updateApp(appId))
ipcMain.handle('nodeapp:start', async (_, appId) => await nodeAppService.startApp(appId))
ipcMain.handle('nodeapp:stop', async (_, appId) => await nodeAppService.stopApp(appId))
ipcMain.handle('nodeapp:uninstall', async (_, appId) => await nodeAppService.uninstallApp(appId))
ipcMain.handle('nodeapp:deploy-zip', async (_, zipPath, options) => await nodeAppService.deployFromZip(zipPath, options))
ipcMain.handle('nodeapp:deploy-git', async (_, repoUrl, options) => await nodeAppService.deployFromGit(repoUrl, options))
ipcMain.handle('nodeapp:check-node', async () => {
const isNodeInstalled = await isBinaryExists('node')
return isNodeInstalled
}) })
ipcMain.handle('obsidian:get-files', (_event, vaultName) => { ipcMain.handle('nodeapp:install-node', async () => {
return obsidianVaultService.getFilesByVaultName(vaultName) return await nodeAppService.installNodeJs()
}) })
// nutstore // Listen for changes in Node.js apps and notify renderer
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl) nodeAppService.on('apps-updated', (apps) => {
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token)) mainWindow?.webContents.send('nodeapp:updated', apps)
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) => })
NutstoreService.getDirectoryContents(token, path)
) app.on('before-quit', () => nodeAppService.cleanup())
// 运行简单命令
ipcMain.handle('app:run-command', async (_, command: string) => {
try {
const { execSync } = require('child_process')
const result = execSync(command).toString()
return result
} catch (error) {
log.error('Error running command:', error)
throw error
}
})
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import * as fs from 'node:fs' import * as fs from 'node:fs'
import { JsonLoader } from '@cherrystudio/embedjs' import { JsonLoader } from '@llm-tools/embedjs'
/** /**
* Drafts 应用导出的笔记文件加载器 * Drafts 应用导出的笔记文件加载器
+2 -2
View File
@@ -1,6 +1,6 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters' import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { BaseLoader } from '@llm-tools/embedjs-interfaces'
import { cleanString } from '@llm-tools/embedjs-utils'
import { getTempDir } from '@main/utils/file' import { getTempDir } from '@main/utils/file'
import Logger from 'electron-log' import Logger from 'electron-log'
import EPub from 'epub' import EPub from 'epub'
+3 -3
View File
@@ -1,8 +1,8 @@
import * as fs from 'node:fs' import * as fs from 'node:fs'
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs' import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@llm-tools/embedjs'
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces' import type { AddLoaderReturn } from '@llm-tools/embedjs-interfaces'
import { WebLoader } from '@cherrystudio/embedjs-loader-web' import { WebLoader } from '@llm-tools/embedjs-loader-web'
import { LoaderReturn } from '@shared/config/types' import { LoaderReturn } from '@shared/config/types'
import { FileType, KnowledgeBaseParams } from '@types' import { FileType, KnowledgeBaseParams } from '@types'
import Logger from 'electron-log' import Logger from 'electron-log'
+2 -2
View File
@@ -1,6 +1,6 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters' import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { BaseLoader } from '@llm-tools/embedjs-interfaces'
import { cleanString } from '@llm-tools/embedjs-utils'
import md5 from 'md5' import md5 from 'md5'
import { OfficeParserConfig, parseOfficeAsync } from 'officeparser' import { OfficeParserConfig, parseOfficeAsync } from 'officeparser'
+1 -12
View File
@@ -1,4 +1,4 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types' import { KnowledgeBaseParams } from '@types'
export default abstract class BaseReranker { export default abstract class BaseReranker {
@@ -17,15 +17,4 @@ export default abstract class BaseReranker {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
} }
public formatErrorMessage(url: string, error: any, requestBody: any) {
const errorDetails = {
url: url,
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
requestBody: requestBody
}
return JSON.stringify(errorDetails, null, 2)
}
} }
+1 -2
View File
@@ -1,4 +1,4 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types' import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker' import BaseReranker from './BaseReranker'
@@ -7,7 +7,6 @@ export default class DefaultReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) { constructor(base: KnowledgeBaseParams) {
super(base) super(base)
} }
async rerank(): Promise<ExtractChunkData[]> { async rerank(): Promise<ExtractChunkData[]> {
throw new Error('Method not implemented.') throw new Error('Method not implemented.')
} }
+5 -13
View File
@@ -1,4 +1,4 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types' import { KnowledgeBaseParams } from '@types'
import axios from 'axios' import axios from 'axios'
@@ -10,15 +10,9 @@ export default class JinaReranker extends BaseReranker {
} }
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => { public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/') const baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1) ? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL : this.base.rerankBaseURL
// 必须携带/v1,否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank` const url = `${baseURL}/rerank`
const requestBody = { const requestBody = {
@@ -46,11 +40,9 @@ export default class JinaReranker extends BaseReranker {
}) })
.filter((doc): doc is ExtractChunkData => doc !== undefined) .filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
} catch (error: any) { } catch (error) {
const errorDetails = this.formatErrorMessage(url, error, requestBody) console.error('Jina Reranker API 错误:', error)
throw error
console.error('Jina Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
} }
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types' import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker' import BaseReranker from './BaseReranker'
-3
View File
@@ -4,7 +4,6 @@ import BaseReranker from './BaseReranker'
import DefaultReranker from './DefaultReranker' import DefaultReranker from './DefaultReranker'
import JinaReranker from './JinaReranker' import JinaReranker from './JinaReranker'
import SiliconFlowReranker from './SiliconFlowReranker' import SiliconFlowReranker from './SiliconFlowReranker'
import VoyageReranker from './VoyageReranker'
export default class RerankerFactory { export default class RerankerFactory {
static create(base: KnowledgeBaseParams): BaseReranker { static create(base: KnowledgeBaseParams): BaseReranker {
@@ -12,8 +11,6 @@ export default class RerankerFactory {
return new SiliconFlowReranker(base) return new SiliconFlowReranker(base)
} else if (base.rerankModelProvider === 'jina') { } else if (base.rerankModelProvider === 'jina') {
return new JinaReranker(base) return new JinaReranker(base)
} else if (base.rerankModelProvider === 'voyageai') {
return new VoyageReranker(base)
} }
return new DefaultReranker(base) return new DefaultReranker(base)
} }
+5 -13
View File
@@ -1,4 +1,4 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types' import { KnowledgeBaseParams } from '@types'
import axios from 'axios' import axios from 'axios'
@@ -10,15 +10,9 @@ export default class SiliconFlowReranker extends BaseReranker {
} }
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => { public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/') const baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1) ? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL : this.base.rerankBaseURL
// 必须携带/v1,否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank` const url = `${baseURL}/rerank`
const requestBody = { const requestBody = {
@@ -48,11 +42,9 @@ export default class SiliconFlowReranker extends BaseReranker {
}) })
.filter((doc): doc is ExtractChunkData => doc !== undefined) .filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
} catch (error: any) { } catch (error) {
const errorDetails = this.formatErrorMessage(url, error, requestBody) console.error('SiliconFlow Reranker API 错误:', error)
throw error
console.error('SiliconFlow Reranker API 错误:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
} }
} }
} }
-62
View File
@@ -1,62 +0,0 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
export default class VoyageReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
query,
documents: searchResults.map((doc) => doc.pageContent),
top_k: this.base.topN,
return_documents: false,
truncation: true
}
try {
const { data } = await axios.post(url, requestBody, {
headers: {
...this.defaultHeaders()
}
})
const rerankResults = data.data
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('Voyage Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

+1 -17
View File
@@ -5,7 +5,7 @@ import { app } from 'electron'
import Logger from 'electron-log' import Logger from 'electron-log'
import * as fs from 'fs-extra' import * as fs from 'fs-extra'
import * as path from 'path' import * as path from 'path'
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav' import { createClient, FileStat } from 'webdav'
import WebDav from './WebDav' import WebDav from './WebDav'
import { windowService } from './WindowService' import { windowService } from './WindowService'
@@ -15,7 +15,6 @@ class BackupManager {
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup') private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
constructor() { constructor() {
this.checkConnection = this.checkConnection.bind(this)
this.backup = this.backup.bind(this) this.backup = this.backup.bind(this)
this.restore = this.restore.bind(this) this.restore = this.restore.bind(this)
this.backupToWebdav = this.backupToWebdav.bind(this) this.backupToWebdav = this.backupToWebdav.bind(this)
@@ -279,21 +278,6 @@ class BackupManager {
} }
} }
} }
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.checkConnection()
}
async createDirectory(
_: Electron.IpcMainInvokeEvent,
webdavConfig: WebDavConfig,
path: string,
options?: CreateDirectoryOptions
) {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.createDirectory(path, options)
}
} }
export default BackupManager export default BackupManager
-16
View File
@@ -30,14 +30,6 @@ export class ConfigManager {
this.store.set('theme', theme) this.store.set('theme', theme)
} }
getLaunchToTray(): boolean {
return !!this.store.get('launchToTray', false)
}
setLaunchToTray(value: boolean) {
this.store.set('launchToTray', value)
}
getTray(): boolean { getTray(): boolean {
return !!this.store.get('tray', true) return !!this.store.get('tray', true)
} }
@@ -47,14 +39,6 @@ export class ConfigManager {
this.notifySubscribers('tray', value) this.notifySubscribers('tray', value)
} }
getTrayOnClose(): boolean {
return !!this.store.get('trayOnClose', true)
}
setTrayOnClose(value: boolean) {
this.store.set('trayOnClose', value)
}
getZoomFactor(): number { getZoomFactor(): number {
return this.store.get('zoomFactor', 1) as number return this.store.get('zoomFactor', 1) as number
} }
+5
View File
@@ -3,12 +3,14 @@ import { FileType } from '@types'
import fs from 'fs' import fs from 'fs'
import { CacheService } from './CacheService' import { CacheService } from './CacheService'
import { proxyManager } from './ProxyManager'
export class GeminiService { export class GeminiService {
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list' private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
private static readonly CACHE_DURATION = 3000 private static readonly CACHE_DURATION = 3000
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string) { static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string) {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey) const fileManager = new GoogleAIFileManager(apiKey)
const uploadResult = await fileManager.uploadFile(file.path, { const uploadResult = await fileManager.uploadFile(file.path, {
mimeType: 'application/pdf', mimeType: 'application/pdf',
@@ -29,6 +31,7 @@ export class GeminiService {
file: FileType, file: FileType,
apiKey: string apiKey: string
): Promise<FileMetadataResponse | undefined> { ): Promise<FileMetadataResponse | undefined> {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey) const fileManager = new GoogleAIFileManager(apiKey)
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY) const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
@@ -52,11 +55,13 @@ export class GeminiService {
} }
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string) { static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string) {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey) const fileManager = new GoogleAIFileManager(apiKey)
return await fileManager.listFiles() return await fileManager.listFiles()
} }
static async deleteFile(_: Electron.IpcMainInvokeEvent, apiKey: string, fileId: string) { static async deleteFile(_: Electron.IpcMainInvokeEvent, apiKey: string, fileId: string) {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey) const fileManager = new GoogleAIFileManager(apiKey)
await fileManager.deleteFile(fileId) await fileManager.deleteFile(fileId)
} }
+33 -23
View File
@@ -16,15 +16,17 @@
import * as fs from 'node:fs' import * as fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@cherrystudio/embedjs' import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { LibSqlDb } from '@cherrystudio/embedjs-libsql' import { LibSqlDb } from '@llm-tools/embedjs-libsql'
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap' import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap'
import { WebLoader } from '@cherrystudio/embedjs-loader-web' import { WebLoader } from '@llm-tools/embedjs-loader-web'
import Embeddings from '@main/embeddings/Embeddings' import { AzureOpenAiEmbeddings, OpenAiEmbeddings } from '@llm-tools/embedjs-openai'
import { addFileLoader } from '@main/loader' import { addFileLoader } from '@main/loader'
import Reranker from '@main/reranker/Reranker' import Reranker from '@main/reranker/Reranker'
import { proxyManager } from '@main/services/ProxyManager'
import { windowService } from '@main/services/WindowService' import { windowService } from '@main/services/WindowService'
import { getInstanceName } from '@main/utils'
import { getAllFiles } from '@main/utils/file' import { getAllFiles } from '@main/utils/file'
import type { LoaderReturn } from '@shared/config/types' import type { LoaderReturn } from '@shared/config/types'
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types' import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
@@ -113,20 +115,30 @@ class KnowledgeService {
baseURL, baseURL,
dimensions dimensions
}: KnowledgeBaseParams): Promise<RAGApplication> => { }: KnowledgeBaseParams): Promise<RAGApplication> => {
let ragApplication: RAGApplication const batchSize = 10
const embeddings = new Embeddings({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams) return new RAGApplicationBuilder()
try { .setModel('NO_MODEL')
ragApplication = await new RAGApplicationBuilder() .setEmbeddingModel(
.setModel('NO_MODEL') apiVersion
.setEmbeddingModel(embeddings) ? new AzureOpenAiEmbeddings({
.setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) })) azureOpenAIApiKey: apiKey,
.build() azureOpenAIApiVersion: apiVersion,
} catch (e) { azureOpenAIApiDeploymentName: model,
Logger.error(e) azureOpenAIApiInstanceName: getInstanceName(baseURL),
throw new Error(`Failed to create RAGApplication: ${e}`) configuration: { httpAgent: proxyManager.getProxyAgent() },
} dimensions,
batchSize
return ragApplication })
: new OpenAiEmbeddings({
model,
apiKey,
configuration: { baseURL, httpAgent: proxyManager.getProxyAgent() },
dimensions,
batchSize
})
)
.setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) }))
.build()
} }
public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => { public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
@@ -414,6 +426,7 @@ class KnowledgeService {
} }
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => { public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
proxyManager.setGlobalProxy()
return new Promise((resolve) => { return new Promise((resolve) => {
const { base, item, forceReload = false } = options const { base, item, forceReload = false } = options
const optionsNonNullableAttribute = { base, item, forceReload } const optionsNonNullableAttribute = { base, item, forceReload }
@@ -475,9 +488,6 @@ class KnowledgeService {
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] } { search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }
): Promise<ExtractChunkData[]> => { ): Promise<ExtractChunkData[]> => {
if (results.length === 0) {
return results
}
return await new Reranker(base).rerank(search, results) return await new Reranker(base).rerank(search, results)
} }
} }
+545 -99
View File
@@ -1,169 +1,615 @@
import os from 'node:os' import { isLinux, isMac, isWin } from '@main/constant'
import path from 'node:path' import { getBinaryPath } from '@main/utils/process'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { MCPServer, MCPTool } from '@types'
import log from 'electron-log'
import { EventEmitter } from 'events'
import { v4 as uuidv4 } from 'uuid'
import { getBinaryName, getBinaryPath } from '@main/utils/process' import { CacheService } from './CacheService'
import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { windowService } from './WindowService'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { MCPServer } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
class McpService { /**
private client: Client | null = null * Service for managing Model Context Protocol servers and tools
private clients: Map<string, Client> = new Map() */
export default class MCPService extends EventEmitter {
private servers: MCPServer[] = []
private activeServers: Map<string, any> = new Map()
private clients: { [key: string]: any } = {}
private Client: typeof Client | undefined
private stdioTransport: typeof StdioClientTransport | undefined
private sseTransport: typeof SSEClientTransport | undefined
private initialized = false
private initPromise: Promise<void> | null = null
private getServerKey(server: MCPServer): string { // Simplified server loading state management
return JSON.stringify({ private readyState = {
baseUrl: server.baseUrl, serversLoaded: false,
command: server.command, promise: null as Promise<void> | null,
args: server.args, resolve: null as ((value: void) => void) | null
env: server.env,
id: server.id
})
} }
constructor() { constructor() {
this.initClient = this.initClient.bind(this) super()
this.listTools = this.listTools.bind(this) this.createServerLoadingPromise()
this.callTool = this.callTool.bind(this) this.init().catch((err) => this.logError('Failed to initialize MCP service', err))
this.closeClient = this.closeClient.bind(this)
this.removeServer = this.removeServer.bind(this)
} }
async initClient(server: MCPServer) { /**
const serverKey = this.getServerKey(server) * Create a promise that resolves when servers are loaded
*/
private createServerLoadingPromise(): void {
this.readyState.promise = new Promise<void>((resolve) => {
this.readyState.resolve = resolve
})
}
// Check if we already have a client for this server configuration /**
const existingClient = this.clients.get(serverKey) * Set servers received from Redux and trigger initialization if needed
if (existingClient) { */
this.client = existingClient public setServers(servers: MCPServer[]): void {
this.servers = servers
log.info(`[MCP] Received ${servers.length} servers from Redux`)
// Mark servers as loaded and resolve the waiting promise
if (!this.readyState.serversLoaded && this.readyState.resolve) {
this.readyState.serversLoaded = true
this.readyState.resolve()
this.readyState.resolve = null
}
// Initialize if not already initialized
if (!this.initialized) {
this.init().catch((err) => this.logError('Failed to initialize MCP service', err))
}
}
/**
* Initialize the MCP service if not already initialized
*/
public async init(): Promise<void> {
// If already initialized, return immediately
if (this.initialized) return
// If initialization is in progress, return that promise
if (this.initPromise) return this.initPromise
this.initPromise = (async () => {
try {
log.info('[MCP] Starting initialization')
// Wait for servers to be loaded from Redux
await this.waitForServers()
// Load SDK components in parallel for better performance
const [Client, StdioTransport, SSETransport] = await Promise.all([
this.importClient(),
this.importStdioClientTransport(),
this.importSSEClientTransport()
])
this.Client = Client
this.stdioTransport = StdioTransport
this.sseTransport = SSETransport
// Mark as initialized before loading servers
this.initialized = true
// Load active servers
await this.loadActiveServers()
log.info('[MCP] Initialization successfully')
return
} catch (err) {
this.initialized = false // Reset flag on error
log.error('[MCP] Failed to initialize:', err)
throw err
} finally {
this.initPromise = null
}
})()
return this.initPromise
}
/**
* Wait for servers to be loaded from Redux
*/
private async waitForServers(): Promise<void> {
if (!this.readyState.serversLoaded && this.readyState.promise) {
log.info('[MCP] Waiting for servers data from Redux...')
await this.readyState.promise
log.info('[MCP] Servers received, continuing initialization')
}
}
/**
* Helper to create consistent error logging functions
*/
private logError(message: string, err?: any): void {
log.error(`[MCP] ${message}`, err)
}
/**
* Import the MCP client SDK
*/
private async importClient() {
try {
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
return Client
} catch (err) {
this.logError('Failed to import Client:', err)
throw err
}
}
/**
* Import the stdio transport
*/
private async importStdioClientTransport() {
try {
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
return StdioClientTransport
} catch (err) {
log.error('[MCP] Failed to import StdioTransport:', err)
throw err
}
}
/**
* Import the SSE transport
*/
private async importSSEClientTransport() {
try {
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js')
return SSEClientTransport
} catch (err) {
log.error('[MCP] Failed to import SSETransport:', err)
throw err
}
}
/**
* List all available MCP servers
*/
public async listAvailableServices(): Promise<MCPServer[]> {
await this.ensureInitialized()
return this.servers
}
/**
* Ensure the service is initialized before operations
*/
private async ensureInitialized() {
if (!this.initialized) {
log.debug('[MCP] Ensuring initialization')
await this.init()
}
}
/**
* Add a new MCP server
*/
public async addServer(server: MCPServer): Promise<void> {
await this.ensureInitialized()
// Check for duplicate name
if (this.servers.some((s) => s.name === server.name)) {
throw new Error(`Server with name ${server.name} already exists`)
}
// Activate if needed
if (server.isActive) {
await this.activate(server)
}
// Add to servers list
this.servers = [...this.servers, server]
this.notifyReduxServersChanged(this.servers)
}
/**
* Update an existing MCP server
*/
public async updateServer(server: MCPServer): Promise<void> {
await this.ensureInitialized()
const index = this.servers.findIndex((s) => s.name === server.name)
if (index === -1) {
throw new Error(`Server ${server.name} not found`)
}
// Check activation status change
const wasActive = this.servers[index].isActive
if (wasActive && !server.isActive) {
await this.deactivate(server.name)
} else if (!wasActive && server.isActive) {
await this.activate(server)
} else {
await this.restartServer(server)
}
// Update servers list
const updatedServers = [...this.servers]
updatedServers[index] = server
this.servers = updatedServers
// Notify Redux
this.notifyReduxServersChanged(updatedServers)
}
public async restartServer(_server: MCPServer): Promise<void> {
await this.ensureInitialized()
const server = this.servers.find((s) => s.name === _server.name)
if (server) {
if (server.isActive) {
await this.deactivate(server.name)
}
await this.activate(server)
}
}
/**
* Delete an MCP server
*/
public async deleteServer(serverName: string): Promise<void> {
await this.ensureInitialized()
// Deactivate if running
if (this.clients[serverName]) {
await this.deactivate(serverName)
}
// Update servers list
const filteredServers = this.servers.filter((s) => s.name !== serverName)
this.servers = filteredServers
this.notifyReduxServersChanged(filteredServers)
}
/**
* Set a server's active state
*/
public async setServerActive(params: { name: string; isActive: boolean }): Promise<void> {
await this.ensureInitialized()
const { name, isActive } = params
const server = this.servers.find((s) => s.name === name)
if (!server) {
throw new Error(`Server ${name} not found`)
}
// Activate or deactivate as needed
if (isActive) {
await this.activate(server)
} else {
await this.deactivate(name)
}
// Update server status
server.isActive = isActive
this.notifyReduxServersChanged([...this.servers])
}
/**
* Notify Redux in the renderer process about server changes
*/
private notifyReduxServersChanged(servers: MCPServer[]): void {
const mainWindow = windowService.getMainWindow()
if (mainWindow) {
mainWindow.webContents.send('mcp:servers-changed', servers)
}
}
/**
* Activate an MCP server
*/
public async activate(server: MCPServer): Promise<void> {
await this.ensureInitialized()
const { name, baseUrl, command, env } = server
const args = [...(server.args || [])]
// Skip if already running
if (this.clients[name]) {
log.info(`[MCP] Server ${name} is already running`)
return return
} }
// If there's an existing client for a different server, close it
if (this.client) {
await this.closeClient()
}
// Create new client instance for each connection
this.client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
const args = [...(server.args || [])]
let transport: StdioClientTransport | SSEClientTransport let transport: StdioClientTransport | SSEClientTransport
try { try {
// Create appropriate transport based on configuration // Create appropriate transport based on configuration
if (server.baseUrl) { if (baseUrl) {
transport = new SSEClientTransport(new URL(server.baseUrl)) transport = new this.sseTransport!(new URL(baseUrl))
} else if (server.command) { } else if (command) {
let cmd = server.command let cmd: string = command
if (command === 'npx') {
if (server.command === 'npx') {
cmd = await getBinaryPath('bun') cmd = await getBinaryPath('bun')
if (cmd === 'bun') { if (cmd === 'bun') {
cmd = 'npx' cmd = 'npx'
} }
Logger.info(`[MCP] Using command: ${cmd}`) log.info(`[MCP] Using command: ${cmd}`)
// add -x to args if args exist // add -x to args if args exist
if (args && args.length > 0) { if (args && args.length > 0) {
if (!args.includes('-y')) { if (!args.includes('-y')) {
!args.includes('-y') && args.unshift('-y') args.unshift('-y')
} }
if (cmd.includes('bun') && !args.includes('x')) { if (cmd.includes('bun') && !args.includes('x')) {
args.unshift('x') args.unshift('x')
} }
} }
} } else if (command === 'uvx') {
if (server.command === 'uvx') {
cmd = await getBinaryPath('uvx') cmd = await getBinaryPath('uvx')
} }
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`) log.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
transport = new StdioClientTransport({ transport = new this.stdioTransport!({
command: cmd, command: cmd,
args, args,
env: server.env stderr: 'pipe',
env: {
PATH: this.getEnhancedPath(process.env.PATH || ''),
...env
}
}) })
} else { } else {
throw new Error('Either baseUrl or command must be provided') throw new Error('Either baseUrl or command must be provided')
} }
await this.client.connect(transport) // Create and connect client
const client = new this.Client!({ name, version: '1.0.0' }, { capabilities: {} })
// Store the new client in the cache await client.connect(transport)
this.clients.set(serverKey, this.client)
Logger.info(`[MCP] Activated server: ${server.name}`) // Store client and server info
} catch (error: any) { this.clients[name] = client
Logger.error(`[MCP] Error activating server ${server.name}:`, error) this.activeServers.set(name, { client, server })
log.info(`[MCP] Activated server: ${server.name}`)
this.emit('server-started', { name })
} catch (error) {
log.error(`[MCP] Error activating server ${name}:`, error)
this.setServerActive({ name, isActive: false })
throw error throw error
} }
} }
async closeClient() { /**
if (this.client) { * Deactivate an MCP server
// Remove the client from the cache */
for (const [key, client] of this.clients.entries()) { public async deactivate(name: string): Promise<void> {
if (client === this.client) { await this.ensureInitialized()
this.clients.delete(key)
break
}
}
await this.client.close() if (!this.clients[name]) {
this.client = null log.warn(`[MCP] Server ${name} is not running`)
return
}
try {
log.info(`[MCP] Stopping server: ${name}`)
await this.clients[name].close()
delete this.clients[name]
this.activeServers.delete(name)
this.emit('server-stopped', { name })
} catch (error) {
log.error(`[MCP] Error deactivating server ${name}:`, error)
throw error
} }
} }
async removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) { /**
await this.closeClient() * List available tools from active MCP servers
this.clients.delete(this.getServerKey(server)) */
public async listTools(serverName?: string): Promise<MCPTool[]> {
await this.ensureInitialized()
log.info(`[MCP] Listing tools from ${serverName || 'all active servers'}`)
try {
// If server name provided, list tools for that server only
if (serverName) {
return await this.listToolsFromServer(serverName)
}
// Otherwise list tools from all active servers
let allTools: MCPTool[] = []
for (const clientName in this.clients) {
log.info(`[MCP] Listing tools from ${clientName}`)
try {
const tools = await this.listToolsFromServer(clientName)
allTools = allTools.concat(tools)
} catch (error) {
this.logError(`Error listing tools for ${clientName}`, error)
}
}
log.info(`[MCP] Total tools listed: ${allTools.length}`)
return allTools
} catch (error) {
this.logError('Error listing tools:', error)
return []
}
} }
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { /**
await this.initClient(server) * Helper method to list tools from a specific server
const { tools } = await this.client!.listTools() */
return tools.map((tool) => ({ private async listToolsFromServer(serverName: string): Promise<MCPTool[]> {
log.info(`[MCP] start list tools from ${serverName}:`)
if (!this.clients[serverName]) {
throw new Error(`MCP Client ${serverName} not found`)
}
const cacheKey = `mcp:list_tool:${serverName}`
if (CacheService.has(cacheKey)) {
log.info(`[MCP] Tools from ${serverName} loaded from cache`)
// Check if cache is still valid
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
if (cachedTools && cachedTools.length > 0) {
return cachedTools
}
CacheService.remove(cacheKey)
}
const { tools } = await this.clients[serverName].listTools()
const transformedTools = tools.map((tool: any) => ({
...tool, ...tool,
serverId: server.id, serverName,
serverName: server.name id: 'f' + uuidv4().replace(/-/g, '')
})) }))
// Cache the tools for 5 minutes
if (transformedTools.length > 0) {
CacheService.set(cacheKey, transformedTools, 5 * 60 * 1000)
}
log.info(`[MCP] Tools from ${serverName}:`, transformedTools)
return transformedTools
} }
/** /**
* Call a tool on an MCP server * Call a tool on an MCP server
*/ */
public async callTool( public async callTool(params: { client: string; name: string; args: any }): Promise<any> {
_: Electron.IpcMainInvokeEvent, await this.ensureInitialized()
{ server, name, args }: { server: MCPServer; name: string; args: any }
): Promise<any> { const { client, name, args } = params
await this.initClient(server)
if (!this.clients[client]) {
throw new Error(`MCP Client ${client} not found`)
}
log.info('[MCP] Calling:', client, name, args)
try { try {
Logger.info('[MCP] Calling:', server.name, name, args) return await this.clients[client].callTool({
const result = await this.client!.callTool({ name, arguments: args }) name,
return result arguments: args
})
} catch (error) { } catch (error) {
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error) log.error(`[MCP] Error calling tool ${name} on ${client}:`, error)
throw error throw error
} }
} }
public async getInstallInfo() { /**
const dir = path.join(os.homedir(), '.cherrystudio', 'bin') * Clean up all MCP resources
const uvName = await getBinaryName('uv') */
const bunName = await getBinaryName('bun') public async cleanup(): Promise<void> {
const uvPath = path.join(dir, uvName) const clientNames = Object.keys(this.clients)
const bunPath = path.join(dir, bunName)
return { dir, uvPath, bunPath } if (clientNames.length === 0) {
log.info('[MCP] No active servers to clean up')
return
}
log.info(`[MCP] Cleaning up ${clientNames.length} active servers`)
// Deactivate all clients
await Promise.allSettled(
clientNames.map((name) =>
this.deactivate(name).catch((err) => {
log.error(`[MCP] Error during cleanup of ${name}:`, err)
})
)
)
this.clients = {}
this.activeServers.clear()
log.info('[MCP] All servers cleaned up')
}
/**
* Load all active servers
*/
private async loadActiveServers(): Promise<void> {
const activeServers = this.servers.filter((server) => server.isActive)
if (activeServers.length === 0) {
log.info('[MCP] No active servers to load')
return
}
log.info(`[MCP] Start loading ${activeServers.length} active servers`)
// Activate servers in parallel for better performance
await Promise.allSettled(
activeServers.map(async (server) => {
try {
await this.activate(server)
} catch (error) {
this.logError(`Failed to activate server ${server.name}`, error)
this.emit('server-error', { name: server.name, error })
}
})
)
log.info(`[MCP] End loading ${Object.keys(this.clients).length} active servers`)
}
/**
* Get enhanced PATH including common tool locations
*/
private getEnhancedPath(originalPath: string): string {
// 将原始 PATH 按分隔符分割成数组
const pathSeparator = process.platform === 'win32' ? ';' : ':'
const existingPaths = new Set(originalPath.split(pathSeparator).filter(Boolean))
const homeDir = process.env.HOME || process.env.USERPROFILE || ''
// 定义要添加的新路径
const newPaths: string[] = []
if (isMac) {
newPaths.push(
'/bin',
'/usr/bin',
'/usr/local/bin',
'/usr/local/sbin',
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/opt/node/bin',
`${homeDir}/.nvm/current/bin`,
`${homeDir}/.npm-global/bin`,
`${homeDir}/.yarn/bin`,
`${homeDir}/.cargo/bin`,
'/opt/local/bin'
)
}
if (isLinux) {
newPaths.push(
'/bin',
'/usr/bin',
'/usr/local/bin',
`${homeDir}/.nvm/current/bin`,
`${homeDir}/.npm-global/bin`,
`${homeDir}/.yarn/bin`,
`${homeDir}/.cargo/bin`,
'/snap/bin'
)
}
if (isWin) {
newPaths.push(`${process.env.APPDATA}\\npm`, `${homeDir}\\AppData\\Local\\Yarn\\bin`, `${homeDir}\\.cargo\\bin`)
}
// 只添加不存在的路径
newPaths.forEach((path) => {
if (path && !existingPaths.has(path)) {
existingPaths.add(path)
}
})
// 转换回字符串
return Array.from(existingPaths).join(pathSeparator)
} }
} }
export default new McpService()
File diff suppressed because it is too large Load Diff
-134
View File
@@ -1,134 +0,0 @@
import path from 'node:path'
import { NUTSTORE_HOST } from '@shared/config/nutstore'
import { XMLParser } from 'fast-xml-parser'
import { isNil, partial } from 'lodash'
import { type FileStat } from 'webdav'
interface OAuthResponse {
username: string
userid: string
access_token: string
}
interface WebDAVResponse {
multistatus: {
response: Array<{
href: string
propstat: {
prop: {
displayname: string
resourcetype: { collection?: any }
getlastmodified?: string
getcontentlength?: string
getcontenttype?: string
}
status: string
}
}>
}
}
export async function getNutstoreSSOUrl() {
const { createOAuthUrl } = await import('../integration/nutstore/sso/lib')
const url = createOAuthUrl({
app: 'cherrystudio'
})
return url
}
export async function decryptToken(token: string) {
const { decrypt } = await import('../integration/nutstore/sso/lib')
try {
const decrypted = decrypt('cherrystudio', token)
return JSON.parse(decrypted) as OAuthResponse
} catch (error) {
console.error('解密失败:', error)
return null
}
}
export async function getDirectoryContents(token: string, target: string): Promise<FileStat[]> {
const contents: FileStat[] = []
if (!target.startsWith('/')) {
target = '/' + target
}
let currentUrl = `${NUTSTORE_HOST}${target}`
while (true) {
const response = await fetch(currentUrl, {
method: 'PROPFIND',
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/xml',
Depth: '1'
},
body: `<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<displayname/>
<resourcetype/>
<getlastmodified/>
<getcontentlength/>
<getcontenttype/>
</prop>
</propfind>`
})
const text = await response.text()
const result = parseXml<WebDAVResponse>(text)
const items = Array.isArray(result.multistatus.response)
? result.multistatus.response
: [result.multistatus.response]
// 跳过第一个条目(当前目录)
contents.push(...items.slice(1).map(partial(convertToFileStat, '/dav')))
const linkHeader = response.headers['link'] || response.headers['Link']
if (!linkHeader) {
break
}
const nextLink = extractNextLink(linkHeader)
if (!nextLink) {
break
}
currentUrl = decodeURI(nextLink)
}
return contents
}
function extractNextLink(linkHeader: string): string | null {
const matches = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return matches ? matches[1] : null
}
function convertToFileStat(serverBase: string, item: WebDAVResponse['multistatus']['response'][number]): FileStat {
const props = item.propstat.prop
const isDir = !isNil(props.resourcetype?.collection)
const href = decodeURIComponent(item.href)
const filename = serverBase === '/' ? href : path.join('/', href.replace(serverBase, ''))
return {
filename,
basename: path.basename(filename),
lastmod: props.getlastmodified || '',
size: props.getcontentlength ? parseInt(props.getcontentlength, 10) : 0,
type: isDir ? 'directory' : 'file',
etag: null,
mime: props.getcontenttype
}
}
function parseXml<T>(xml: string) {
const parser = new XMLParser({
attributeNamePrefix: '',
removeNSPrefix: true
})
return parser.parse(xml) as T
}
-167
View File
@@ -1,167 +0,0 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
interface VaultInfo {
path: string
name: string
}
interface FileInfo {
path: string
type: 'folder' | 'markdown'
name: string
}
class ObsidianVaultService {
private obsidianConfigPath: string
constructor() {
// 根据操作系统获取Obsidian配置文件路径
if (process.platform === 'win32') {
this.obsidianConfigPath = path.join(app.getPath('appData'), 'obsidian', 'obsidian.json')
} else if (process.platform === 'darwin') {
this.obsidianConfigPath = path.join(
app.getPath('home'),
'Library',
'Application Support',
'obsidian',
'obsidian.json'
)
} else {
// Linux
this.obsidianConfigPath = path.join(app.getPath('home'), '.config', 'obsidian', 'obsidian.json')
}
}
/**
* 获取所有的Obsidian Vault
*/
getVaults(): VaultInfo[] {
try {
if (!fs.existsSync(this.obsidianConfigPath)) {
return []
}
const configContent = fs.readFileSync(this.obsidianConfigPath, 'utf8')
const config = JSON.parse(configContent)
if (!config.vaults) {
return []
}
return Object.entries(config.vaults).map(([, vault]: [string, any]) => ({
path: vault.path,
name: vault.name || path.basename(vault.path)
}))
} catch (error) {
console.error('获取Obsidian Vault失败:', error)
return []
}
}
/**
* 获取Vault中的文件夹和Markdown文件结构
*/
getVaultStructure(vaultPath: string): FileInfo[] {
const results: FileInfo[] = []
try {
// 检查vault路径是否存在
if (!fs.existsSync(vaultPath)) {
console.error('Vault路径不存在:', vaultPath)
return []
}
// 检查是否是目录
const stats = fs.statSync(vaultPath)
if (!stats.isDirectory()) {
console.error('Vault路径不是一个目录:', vaultPath)
return []
}
this.traverseDirectory(vaultPath, '', results)
} catch (error) {
console.error('读取Vault文件夹结构失败:', error)
}
return results
}
/**
* 递归遍历目录获取所有文件夹和Markdown文件
*/
private traverseDirectory(dirPath: string, relativePath: string, results: FileInfo[]) {
try {
// 首先添加当前文件夹
if (relativePath) {
results.push({
path: relativePath,
type: 'folder',
name: path.basename(relativePath)
})
}
// 确保目录存在且可访问
if (!fs.existsSync(dirPath)) {
console.error('目录不存在:', dirPath)
return
}
let items
try {
items = fs.readdirSync(dirPath, { withFileTypes: true })
} catch (err) {
console.error(`无法读取目录 ${dirPath}:`, err)
return
}
for (const item of items) {
// 忽略以.开头的隐藏文件夹和文件
if (item.name.startsWith('.')) {
continue
}
const newRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
this.traverseDirectory(fullPath, newRelativePath, results)
} else if (item.isFile() && item.name.endsWith('.md')) {
// 收集.md文件
results.push({
path: newRelativePath,
type: 'markdown',
name: item.name
})
}
}
} catch (error) {
console.error(`遍历目录出错 ${dirPath}:`, error)
}
}
/**
* 获取指定Vault的文件夹和Markdown文件结构
* @param vaultName vault名称
*/
getFilesByVaultName(vaultName: string): FileInfo[] {
try {
const vaults = this.getVaults()
const vault = vaults.find((v) => v.name === vaultName)
if (!vault) {
console.error('未找到指定名称的Vault:', vaultName)
return []
}
console.log('获取Vault文件结构:', vault.name, vault.path)
return this.getVaultStructure(vault.path)
} catch (error) {
console.error('获取Vault文件结构时发生错误:', error)
return []
}
}
}
export default ObsidianVaultService
-34
View File
@@ -1,34 +0,0 @@
import { windowService } from './WindowService'
export const CHERRY_STUDIO_PROTOCOL = 'cherrystudio'
export function registerProtocolClient(app: Electron.App) {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL, process.execPath, [process.argv[1]])
}
}
app.setAsDefaultProtocolClient('cherrystudio')
}
export function handleProtocolUrl(url: string) {
if (!url) return
// Process the URL that was used to open the app
// The url will be in the format: cherrystudio://data?param1=value1&param2=value2
console.log('Received URL:', url)
// Parse the URL and extract parameters
const urlObj = new URL(url)
const params = new URLSearchParams(urlObj.search)
// You can send the data to your renderer process
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('protocol-data', {
url,
params: Object.fromEntries(params.entries())
})
}
}
+43 -28
View File
@@ -1,23 +1,25 @@
import { ProxyConfig as _ProxyConfig, session } from 'electron' import { ProxyConfig as _ProxyConfig, session } from 'electron'
import { socksDispatcher } from 'fetch-socks' import { socksDispatcher } from 'fetch-socks'
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent'
import { ProxyAgent, setGlobalDispatcher } from 'undici' import { ProxyAgent, setGlobalDispatcher } from 'undici'
type ProxyMode = 'system' | 'custom' | 'none' type ProxyMode = 'system' | 'custom' | 'none'
export interface ProxyConfig { export interface ProxyConfig {
mode: ProxyMode mode: ProxyMode
url?: string url?: string | null
} }
export class ProxyManager { export class ProxyManager {
private config: ProxyConfig private config: ProxyConfig
private proxyAgent: GeneralProxyAgent | null = null private proxyAgent: HttpsProxyAgent | null = null
private proxyUrl: string | null = null
private systemProxyInterval: NodeJS.Timeout | null = null private systemProxyInterval: NodeJS.Timeout | null = null
constructor() { constructor() {
this.config = { this.config = {
mode: 'none' mode: 'none',
url: ''
} }
} }
@@ -49,7 +51,7 @@ export class ProxyManager {
if (this.config.mode === 'system') { if (this.config.mode === 'system') {
await this.setSystemProxy() await this.setSystemProxy()
this.monitorSystemProxy() this.monitorSystemProxy()
} else if (this.config.mode === 'custom') { } else if (this.config.mode == 'custom') {
await this.setCustomProxy() await this.setCustomProxy()
} else { } else {
await this.clearProxy() await this.clearProxy()
@@ -71,13 +73,11 @@ export class ProxyManager {
private async setSystemProxy(): Promise<void> { private async setSystemProxy(): Promise<void> {
try { try {
await this.setSessionsProxy({ mode: 'system' }) await this.setSessionsProxy({ mode: 'system' })
const proxyString = await session.defaultSession.resolveProxy('https://dummy.com') const url = await this.resolveSystemProxy()
const [protocol, address] = proxyString.split(';')[0].split(' ') if (url && url !== this.proxyUrl) {
const url = protocol === 'PROXY' ? `http://${address}` : null this.proxyUrl = url.toLowerCase()
if (url && url !== this.config.url) { this.proxyAgent = new HttpsProxyAgent(this.proxyUrl)
this.config.url = url.toLowerCase() this.setEnvironment(this.proxyUrl)
this.setEnvironment(this.config.url)
this.proxyAgent = new GeneralProxyAgent()
} }
} catch (error) { } catch (error) {
console.error('Failed to set system proxy:', error) console.error('Failed to set system proxy:', error)
@@ -88,9 +88,10 @@ export class ProxyManager {
private async setCustomProxy(): Promise<void> { private async setCustomProxy(): Promise<void> {
try { try {
if (this.config.url) { if (this.config.url) {
this.setEnvironment(this.config.url) this.proxyUrl = this.config.url.toLowerCase()
this.proxyAgent = new GeneralProxyAgent() this.proxyAgent = new HttpsProxyAgent(this.proxyUrl)
await this.setSessionsProxy({ proxyRules: this.config.url }) this.setEnvironment(this.proxyUrl)
await this.setSessionsProxy({ proxyRules: this.proxyUrl })
} }
} catch (error) { } catch (error) {
console.error('Failed to set custom proxy:', error) console.error('Failed to set custom proxy:', error)
@@ -98,31 +99,45 @@ export class ProxyManager {
} }
} }
private clearEnvironment(): void { private async clearProxy(): Promise<void> {
delete process.env.HTTP_PROXY delete process.env.HTTP_PROXY
delete process.env.HTTPS_PROXY delete process.env.HTTPS_PROXY
delete process.env.grpc_proxy await this.setSessionsProxy({})
delete process.env.http_proxy
delete process.env.https_proxy
}
private async clearProxy(): Promise<void> {
this.clearEnvironment()
await this.setSessionsProxy({ mode: 'direct' })
this.config = { mode: 'none' } this.config = { mode: 'none' }
this.proxyAgent = null this.proxyAgent = null
this.proxyUrl = null
} }
getProxyAgent(): GeneralProxyAgent | null { private async resolveSystemProxy(): Promise<string | null> {
try {
return await this.resolveElectronProxy()
} catch (error) {
console.error('Failed to resolve system proxy:', error)
return null
}
}
private async resolveElectronProxy(): Promise<string | null> {
try {
const proxyString = await session.defaultSession.resolveProxy('https://dummy.com')
const [protocol, address] = proxyString.split(';')[0].split(' ')
return protocol === 'PROXY' ? `http://${address}` : null
} catch (error) {
console.error('Failed to resolve electron proxy:', error)
return null
}
}
getProxyAgent(): HttpsProxyAgent | null {
return this.proxyAgent return this.proxyAgent
} }
getProxyUrl(): string { getProxyUrl(): string | null {
return this.config.url || '' return this.proxyUrl
} }
setGlobalProxy() { setGlobalProxy() {
const proxyUrl = this.config.url const proxyUrl = this.proxyUrl
if (proxyUrl) { if (proxyUrl) {
const [protocol, address] = proxyUrl.split('://') const [protocol, address] = proxyUrl.split('://')
const [host, port] = address.split(':') const [host, port] = address.split(':')
+3 -25
View File
@@ -115,20 +115,7 @@ const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutForm
} }
export function registerShortcuts(window: BrowserWindow) { export function registerShortcuts(window: BrowserWindow) {
window.once('ready-to-show', () => { const register = () => {
if (configManager.getLaunchToTray()) {
registerOnlyUniversalShortcuts()
}
})
//only for clearer code
const registerOnlyUniversalShortcuts = () => {
register(true)
}
//onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window
//onlyUniversalShortcuts is needed when we launch to tray
const register = (onlyUniversalShortcuts: boolean = false) => {
if (window.isDestroyed()) return if (window.isDestroyed()) return
const shortcuts = configManager.getShortcuts() const shortcuts = configManager.getShortcuts()
@@ -145,11 +132,6 @@ export function registerShortcuts(window: BrowserWindow) {
return return
} }
// only register universal shortcuts when needed
if (onlyUniversalShortcuts && !['show_app', 'mini_window'].includes(shortcut.key)) {
return
}
const handler = getShortcutHandler(shortcut) const handler = getShortcutHandler(shortcut)
if (!handler) { if (!handler) {
return return
@@ -221,13 +203,9 @@ export function registerShortcuts(window: BrowserWindow) {
// only register the event handlers once // only register the event handlers once
if (undefined === windowOnHandlers.get(window)) { if (undefined === windowOnHandlers.get(window)) {
// pass register() directly to listener, the func will receive Event as argument, it's not expected window.on('focus', register)
const registerHandler = () => {
register()
}
window.on('focus', registerHandler)
window.on('blur', unregister) window.on('blur', unregister)
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister }) windowOnHandlers.set(window, { onFocusHandler: register, onBlurHandler: unregister })
} }
if (!window.isDestroyed() && window.isFocused()) { if (!window.isDestroyed() && window.isFocused()) {
+7 -36
View File
@@ -1,31 +1,28 @@
import { proxyManager } from '@main/services/ProxyManager'
import { WebDavConfig } from '@types' import { WebDavConfig } from '@types'
import Logger from 'electron-log' import Logger from 'electron-log'
import { HttpProxyAgent } from 'http-proxy-agent'
import Stream from 'stream' import Stream from 'stream'
import { import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
BufferLike,
createClient,
CreateDirectoryOptions,
GetFileContentsOptions,
PutFileContentsOptions,
WebDAVClient
} from 'webdav'
export default class WebDav { export default class WebDav {
public instance: WebDAVClient | undefined public instance: WebDAVClient | undefined
private webdavPath: string private webdavPath: string
constructor(params: WebDavConfig) { constructor(params: WebDavConfig) {
this.webdavPath = params.webdavPath this.webdavPath = params.webdavPath
const url = proxyManager.getProxyUrl()
this.instance = createClient(params.webdavHost, { this.instance = createClient(params.webdavHost, {
username: params.webdavUser, username: params.webdavUser,
password: params.webdavPass, password: params.webdavPass,
maxBodyLength: Infinity, maxBodyLength: Infinity,
maxContentLength: Infinity maxContentLength: Infinity,
httpAgent: url ? new HttpProxyAgent(url) : undefined,
httpsAgent: proxyManager.getProxyAgent()
}) })
this.putFileContents = this.putFileContents.bind(this) this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this) this.getFileContents = this.getFileContents.bind(this)
this.createDirectory = this.createDirectory.bind(this)
} }
public putFileContents = async ( public putFileContents = async (
@@ -72,30 +69,4 @@ export default class WebDav {
throw error throw error
} }
} }
public checkConnection = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.exists('/')
} catch (error) {
Logger.error('[WebDAV] Error checking connection:', error)
throw error
}
}
public createDirectory = async (path: string, options?: CreateDirectoryOptions) => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.createDirectory(path, options)
} catch (error) {
Logger.error('[WebDAV] Error creating directory on WebDAV:', error)
throw error
}
}
} }
+14 -27
View File
@@ -1,5 +1,5 @@
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
import { isDev, isLinux, isMac, isWin } from '@main/constant' import { isDev, isLinux, isWin } from '@main/constant'
import { getFilesDir } from '@main/utils/file' import { getFilesDir } from '@main/utils/file'
import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron' import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
import Logger from 'electron-log' import Logger from 'electron-log'
@@ -39,6 +39,8 @@ export class WindowService {
}) })
const theme = configManager.getTheme() const theme = configManager.getTheme()
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
this.mainWindow = new BrowserWindow({ this.mainWindow = new BrowserWindow({
x: mainWindowState.x, x: mainWindowState.x,
@@ -144,12 +146,7 @@ export class WindowService {
private setupWindowEvents(mainWindow: BrowserWindow) { private setupWindowEvents(mainWindow: BrowserWindow) {
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
mainWindow.show()
// show window only when laucn to tray not set
const isLaunchToTray = configManager.getLaunchToTray()
if (!isLaunchToTray) {
mainWindow.show()
}
}) })
// 处理全屏相关事件 // 处理全屏相关事件
@@ -258,20 +255,12 @@ export class WindowService {
return app.quit() return app.quit()
} }
// 托盘及关闭行为设置 // 没有开启托盘,且是Windows或Linux系统,直接退出
const isShowTray = configManager.getTray() const notInTray = !configManager.getTray()
const isTrayOnClose = configManager.getTrayOnClose() if ((isWin || isLinux) && notInTray) {
return app.quit()
// 没有开启托盘,或者开启了托盘,但设置了直接关闭,应执行直接退出
if (!isShowTray || (isShowTray && !isTrayOnClose)) {
// 如果是Windows或Linux,直接退出
// mac按照系统默认行为,不退出
if (isWin || isLinux) {
return app.quit()
}
} }
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
// 如果是Windows或Linux,且处于全屏状态,则退出应用 // 如果是Windows或Linux,且处于全屏状态,则退出应用
if (this.wasFullScreen) { if (this.wasFullScreen) {
if (isWin || isLinux) { if (isWin || isLinux) {
@@ -282,13 +271,8 @@ export class WindowService {
return return
} }
} }
event.preventDefault() event.preventDefault()
mainWindow.hide() mainWindow.hide()
if (isMac && isTrayOnClose) {
app.dock?.hide() //for mac to hide to tray
}
}) })
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
@@ -317,9 +301,6 @@ export class WindowService {
this.mainWindow = this.createMainWindow() this.mainWindow = this.createMainWindow()
this.mainWindow.focus() this.mainWindow.focus()
} }
//for mac users, when window is shown, should show dock icon (dock may be set to hide when launch)
app.dock?.show()
} }
public showMiniWindow() { public showMiniWindow() {
@@ -329,6 +310,9 @@ export class WindowService {
return return
} }
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.hide()
}
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) { if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.hide() this.selectionMenuWindow.hide()
} }
@@ -343,6 +327,8 @@ export class WindowService {
return return
} }
const isMac = process.platform === 'darwin'
this.miniWindow = new BrowserWindow({ this.miniWindow = new BrowserWindow({
width: 500, width: 500,
height: 520, height: 520,
@@ -417,6 +403,7 @@ export class WindowService {
} }
const theme = configManager.getTheme() const theme = configManager.getTheme()
const isMac = process.platform === 'darwin'
this.selectionMenuWindow = new BrowserWindow({ this.selectionMenuWindow = new BrowserWindow({
width: 280, width: 280,
-4
View File
@@ -42,7 +42,3 @@ export function dumpPersistState() {
} }
return JSON.stringify(persistState) return JSON.stringify(persistState)
} }
export const runAsyncFunction = async (fn: () => void) => {
await fn()
}
+5 -11
View File
@@ -6,13 +6,13 @@ import path from 'path'
import { getResourcePath } from '.' import { getResourcePath } from '.'
export function runInstallScript(scriptPath: string): Promise<void> { export function runInstallScript(scriptPath: string, env?: NodeJS.ProcessEnv): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const installScriptPath = path.join(getResourcePath(), 'scripts', scriptPath) const installScriptPath = path.join(getResourcePath(), 'scripts', scriptPath)
log.info(`Running script at: ${installScriptPath}`) log.info(`Running script at: ${installScriptPath}`)
const nodeProcess = spawn(process.execPath, [installScriptPath], { const nodeProcess = spawn(process.execPath, [installScriptPath], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', ...env }
}) })
nodeProcess.stdout.on('data', (data) => { nodeProcess.stdout.on('data', (data) => {
@@ -35,18 +35,12 @@ export function runInstallScript(scriptPath: string): Promise<void> {
}) })
} }
export async function getBinaryName(name: string): Promise<string> {
if (process.platform === 'win32') {
return `${name}.exe`
}
return name
}
export async function getBinaryPath(name: string): Promise<string> { export async function getBinaryPath(name: string): Promise<string> {
const binaryName = await getBinaryName(name) let cmd = process.platform === 'win32' ? `${name}.exe` : name
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin') const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binariesDirExists = await fs.existsSync(binariesDir) const binariesDirExists = await fs.existsSync(binariesDir)
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName cmd = binariesDirExists ? path.join(binariesDir, cmd) : name
return cmd
} }
export async function isBinaryExists(name: string): Promise<boolean> { export async function isBinaryExists(name: string): Promise<boolean> {
+39 -18
View File
@@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { ElectronAPI } from '@electron-toolkit/preload' import { ElectronAPI } from '@electron-toolkit/preload'
import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server' import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server'
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import type { MCPServer, MCPTool } from '@renderer/types' import type { MCPServer, MCPTool } from '@renderer/types'
import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types' import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
import type { LoaderReturn } from '@shared/config/types' import type { LoaderReturn } from '@shared/config/types'
@@ -23,10 +23,7 @@ declare global {
openWebsite: (url: string) => void openWebsite: (url: string) => void
setProxy: (proxy: string | undefined) => void setProxy: (proxy: string | undefined) => void
setLanguage: (theme: LanguageVarious) => void setLanguage: (theme: LanguageVarious) => void
setLaunchOnBoot: (isActive: boolean) => void
setLaunchToTray: (isActive: boolean) => void
setTray: (isActive: boolean) => void setTray: (isActive: boolean) => void
setTrayOnClose: (isActive: boolean) => void
restartTray: () => void restartTray: () => void
setTheme: (theme: 'light' | 'dark') => void setTheme: (theme: 'light' | 'dark') => void
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
@@ -45,8 +42,6 @@ declare global {
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean> backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string> restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]> listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
checkConnection: (webdavConfig: WebDavConfig) => Promise<boolean>
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => Promise<void>
} }
file: { file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null> select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
@@ -146,10 +141,17 @@ declare global {
openExternal: (url: string, options?: OpenExternalOptions) => Promise<void> openExternal: (url: string, options?: OpenExternalOptions) => Promise<void>
} }
mcp: { mcp: {
removeServer: (server: MCPServer) => Promise<void> // servers
listTools: (server: MCPServer) => Promise<MCPTool[]> listServers: () => Promise<MCPServer[]>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any> addServer: (server: MCPServer) => Promise<void>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }> updateServer: (server: MCPServer) => Promise<void>
deleteServer: (serverName: string) => Promise<void>
setServerActive: (name: string, isActive: boolean) => Promise<void>
// tools
listTools: () => Promise<MCPTool[]>
callTool: ({ client, name, args }: { client: string; name: string; args: any }) => Promise<any>
// status
cleanup: () => Promise<void>
} }
copilot: { copilot: {
getAuthMessage: ( getAuthMessage: (
@@ -161,18 +163,37 @@ declare global {
logout: () => Promise<void> logout: () => Promise<void>
getUser: (token: string) => Promise<{ login: string; avatar: string }> getUser: (token: string) => Promise<{ login: string; avatar: string }>
} }
nodeapp: {
list: () => Promise<any[]>
add: (app: any) => Promise<any>
install: (appId: string) => Promise<any | null>
update: (appId: string) => Promise<any | null>
start: (appId: string) => Promise<{ port: number; url: string } | null>
stop: (appId: string) => Promise<boolean>
uninstall: (appId: string) => Promise<boolean>
deployZip: (zipPath: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => Promise<{ port: number; url: string } | null>
deployGit: (repoUrl: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => Promise<{ port: number; url: string } | null>
checkNode: () => Promise<boolean>
installNode: () => Promise<boolean>
onUpdated: (callback: (apps: any[]) => void) => () => void
}
isBinaryExist: (name: string) => Promise<boolean> isBinaryExist: (name: string) => Promise<boolean>
getBinaryPath: (name: string) => Promise<string> getBinaryPath: (name: string) => Promise<string>
installUVBinary: () => Promise<void> installUVBinary: () => Promise<void>
installBunBinary: () => Promise<void> installBunBinary: () => Promise<void>
protocol: { run: (command: string) => Promise<string>
onReceiveData: (callback: (data: { url: string; params: any }) => void) => () => void
}
nutstore: {
getSSOUrl: () => Promise<string>
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
getDirectoryContents: (token: string, path: string) => Promise<any>
}
} }
} }
} }
+45 -37
View File
@@ -1,8 +1,7 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types' import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron' import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
import { CreateDirectoryOptions } from 'webdav'
// Custom APIs for renderer // Custom APIs for renderer
const api = { const api = {
@@ -12,10 +11,7 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'), checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
showUpdateDialog: () => ipcRenderer.invoke('app:show-update-dialog'), showUpdateDialog: () => ipcRenderer.invoke('app:show-update-dialog'),
setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang), setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang),
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-on-boot', isActive),
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-to-tray', isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive), setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke('app:set-tray-on-close', isActive),
restartTray: () => ipcRenderer.invoke('app:restart-tray'), restartTray: () => ipcRenderer.invoke('app:restart-tray'),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
@@ -35,10 +31,39 @@ const api = {
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig), ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig), restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig),
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig), listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig)
checkConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:checkConnection', webdavConfig), },
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => nodeapp: {
ipcRenderer.invoke('backup:createDirectory', webdavConfig, path, options) list: () => ipcRenderer.invoke('nodeapp:list'),
add: (app: any) => ipcRenderer.invoke('nodeapp:add', app),
install: (appId: string) => ipcRenderer.invoke('nodeapp:install', appId),
update: (appId: string) => ipcRenderer.invoke('nodeapp:update', appId),
start: (appId: string) => ipcRenderer.invoke('nodeapp:start', appId),
stop: (appId: string) => ipcRenderer.invoke('nodeapp:stop', appId),
uninstall: (appId: string) => ipcRenderer.invoke('nodeapp:uninstall', appId),
deployZip: (zipPath: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => ipcRenderer.invoke('nodeapp:deploy-zip', zipPath, options),
deployGit: (repoUrl: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => ipcRenderer.invoke('nodeapp:deploy-git', repoUrl, options),
checkNode: () => ipcRenderer.invoke('nodeapp:check-node'),
installNode: () => ipcRenderer.invoke('nodeapp:install-node'),
onUpdated: (callback: (apps: any[]) => void) => {
const eventListener = (_: any, apps: any[]) => callback(apps)
ipcRenderer.on('nodeapp:updated', eventListener)
return () => {
ipcRenderer.removeListener('nodeapp:updated', eventListener)
}
}
}, },
file: { file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
@@ -120,14 +145,18 @@ const api = {
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey) ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
}, },
mcp: { mcp: {
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server), listServers: () => ipcRenderer.invoke('mcp:list-servers'),
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server), addServer: (server: MCPServer) => ipcRenderer.invoke('mcp:add-server', server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => updateServer: (server: MCPServer) => ipcRenderer.invoke('mcp:update-server', server),
ipcRenderer.invoke('mcp:call-tool', { server, name, args }), deleteServer: (serverName: string) => ipcRenderer.invoke('mcp:delete-server', serverName),
getInstallInfo: () => ipcRenderer.invoke('mcp:get-install-info') setServerActive: (name: string, isActive: boolean) =>
ipcRenderer.invoke('mcp:set-server-active', { name, isActive }),
listTools: (serverName?: string) => ipcRenderer.invoke('mcp:list-tools', serverName),
callTool: (params: { client: string; name: string; args: any }) => ipcRenderer.invoke('mcp:call-tool', params),
cleanup: () => ipcRenderer.invoke('mcp:cleanup')
}, },
shell: { shell: {
openExternal: shell.openExternal openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url)
}, },
copilot: { copilot: {
getAuthMessage: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-auth-message', headers), getAuthMessage: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-auth-message', headers),
@@ -144,23 +173,7 @@ const api = {
getBinaryPath: (name: string) => ipcRenderer.invoke('app:get-binary-path', name), getBinaryPath: (name: string) => ipcRenderer.invoke('app:get-binary-path', name),
installUVBinary: () => ipcRenderer.invoke('app:install-uv-binary'), installUVBinary: () => ipcRenderer.invoke('app:install-uv-binary'),
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'), installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'),
protocol: { run: (command: string) => ipcRenderer.invoke('app:run-command', command)
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
callback(data)
}
ipcRenderer.on('protocol-data', listener)
return () => {
ipcRenderer.off('protocol-data', listener)
}
}
},
nutstore: {
getSSOUrl: () => ipcRenderer.invoke('nutstore:get-sso-url'),
decryptToken: (token: string) => ipcRenderer.invoke('nutstore:decrypt-token', token),
getDirectoryContents: (token: string, path: string) =>
ipcRenderer.invoke('nutstore:get-directory-contents', token, path)
}
} }
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to
@@ -170,11 +183,6 @@ if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('obsidian', {
getVaults: () => ipcRenderer.invoke('obsidian:get-vaults'),
getFolders: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName),
getFiles: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName)
})
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
+2 -1
View File
@@ -39,4 +39,5 @@
<script type="module" src="/src/init.ts"></script> <script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html>
</html>
+3 -1
View File
@@ -17,11 +17,12 @@ import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage' import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage' import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage' import KnowledgePage from './pages/knowledge/KnowledgePage'
import NodeAppsPage from './pages/nodeapps/NodeAppsPage'
import PaintingsPage from './pages/paintings/PaintingsPage' import PaintingsPage from './pages/paintings/PaintingsPage'
import SettingsPage from './pages/settings/SettingsPage' import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage' import TranslatePage from './pages/translate/TranslatePage'
function App(): React.ReactElement { function App(): JSX.Element {
return ( return (
<Provider store={store}> <Provider store={store}>
<StyleSheetManager> <StyleSheetManager>
@@ -41,6 +42,7 @@ function App(): React.ReactElement {
<Route path="/files" element={<FilesPage />} /> <Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} /> <Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} /> <Route path="/apps" element={<AppsPage />} />
<Route path="/nodeapps" element={<NodeAppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} /> <Route path="/settings/*" element={<SettingsPage />} />
</Routes> </Routes>
</HashRouter> </HashRouter>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

@@ -1,18 +0,0 @@
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
}
70% {
box-shadow: 0 0 0 var(--pulse-size) rgba(var(--pulse-color), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0);
}
}
// 电磁波扩散效果
.animation-pulse {
--pulse-color: 59, 130, 246;
--pulse-size: 8px;
animation: pulse 1.5s infinite;
}
+1 -2
View File
@@ -2,7 +2,6 @@
@use './ant.scss'; @use './ant.scss';
@use './scrollbar.scss'; @use './scrollbar.scss';
@use './container.scss'; @use './container.scss';
@use './animation.scss';
@import '../fonts/icon-fonts/iconfont.css'; @import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css'; @import '../fonts/ubuntu/ubuntu.css';
@@ -19,7 +18,7 @@
--color-gray-2: #414853; --color-gray-2: #414853;
--color-gray-3: #32363f; --color-gray-3: #32363f;
--color-text-1: rgba(255, 255, 245, 0.9); --color-text-1: rgba(255, 255, 245, 0.86);
--color-text-2: rgba(235, 235, 245, 0.6); --color-text-2: rgba(235, 235, 245, 0.6);
--color-text-3: rgba(235, 235, 245, 0.38); --color-text-3: rgba(235, 235, 245, 0.38);
@@ -294,11 +294,3 @@
emoji-picker { emoji-picker {
--border-size: 0; --border-size: 0;
} }
.katex-display{
overflow-x: auto;
overflow-y: hidden;
}
mjx-container{
overflow-x: auto;
}
@@ -8,10 +8,9 @@ interface Props {
model: Model model: Model
size: number size: number
props?: AvatarProps props?: AvatarProps
className?: string
} }
const ModelAvatar: FC<Props> = ({ model, size, props, className }) => { const ModelAvatar: FC<Props> = ({ model, size, props }) => {
return ( return (
<Avatar <Avatar
src={getModelLogo(model?.id || '')} src={getModelLogo(model?.id || '')}
@@ -24,8 +23,7 @@ const ModelAvatar: FC<Props> = ({ model, size, props, className }) => {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
}} }}
{...props} {...props}>
className={className}>
{first(model?.name)} {first(model?.name)}
</Avatar> </Avatar>
) )
@@ -9,7 +9,6 @@ import {
ResponderProvided ResponderProvided
} from '@hello-pangea/dnd' } from '@hello-pangea/dnd'
import { droppableReorder } from '@renderer/utils' import { droppableReorder } from '@renderer/utils'
import VirtualList from 'rc-virtual-list'
import { FC } from 'react' import { FC } from 'react'
interface Props<T> { interface Props<T> {
@@ -48,28 +47,26 @@ const DragableList: FC<Props<any>> = ({
<Droppable droppableId="droppable" {...droppableProps}> <Droppable droppableId="droppable" {...droppableProps}>
{(provided) => ( {(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} style={style}> <div {...provided.droppableProps} ref={provided.innerRef} style={style}>
<VirtualList data={list} itemKey="id"> {list.map((item, index) => {
{(item, index) => { const id = item.id || item
const id = item.id || item return (
return ( <Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}> {(provided) => (
{(provided) => ( <div
<div ref={provided.innerRef}
ref={provided.innerRef} {...provided.draggableProps}
{...provided.draggableProps} {...provided.dragHandleProps}
{...provided.dragHandleProps} style={{
style={{ ...listStyle,
...listStyle, ...provided.draggableProps.style,
...provided.draggableProps.style, marginBottom: 8
marginBottom: 8 }}>
}}> {children(item, index)}
{children(item, index)} </div>
</div> )}
)} </Draggable>
</Draggable> )
) })}
}}
</VirtualList>
{provided.placeholder} {provided.placeholder}
</div> </div>
)} )}
@@ -1,50 +0,0 @@
import styled from 'styled-components'
const IconSpan = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 100%;
`
export function NutstoreIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<IconSpan>
<svg
{...props}
width="16px"
height="16px"
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink">
<title>线</title>
<g id="线性单坚果" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<path
d="M10.1590439,0.886175571 C10.1753674,0.890326544 10.291709,0.910777855 10.428428,0.935202765 L10.6388345,0.973279488 C10.7074276,0.985937901 10.77116,0.998048871 10.8200766,1.00807156 C11.2437905,1.09488771 11.6662387,1.21011472 12.1133986,1.37210166 C13.2580363,1.78675499 14.3714894,2.43940777 15.4224927,3.39703693 L15.621,3.584 L15.6351722,3.57092562 C16.53166,2.76294504 17.6751418,2.31986999 18.4291849,2.58060734 L18.5580792,2.63399481 C18.9455012,2.81584984 19.2328582,3.16284846 19.437028,3.61729231 C19.5709871,3.91546021 19.6526725,4.21929758 19.6985752,4.50662941 C19.7148596,4.80478115 19.5904581,5.0358501 19.4098118,5.1582622 C19.3815042,5.17858714 19.3523426,5.19648783 19.3224017,5.21197531 C19.1152073,5.31915066 18.9086763,5.30466603 18.6939183,5.22086872 C18.6620576,5.20843687 18.6328325,5.19564599 18.6006654,5.18105502 C18.4394695,5.11546938 18.2846309,5.06753532 18.1365915,5.04232952 C17.7415971,4.96197402 17.3578102,5.06378907 17.051656,5.32621284 L17.046624,5.33098744 L17.1856424,5.55157847 C18.0964209,7.0577136 18.6880009,8.98631362 18.5914984,10.988329 L18.5672508,11.3423168 C18.518886,12.3590196 18.336046,13.2889191 17.9959883,14.1391815 C17.4227031,15.6418626 16.5311196,16.5912538 15.4105898,16.2529712 L15.278,16.207 C15.204042,16.2889459 15.1247235,16.3618831 15.0410669,16.4278107 L14.9126231,16.5212291 C13.2906651,17.9150353 10.9315401,19.0281897 7.99389616,19.2 L7.17106258,19.2 C3.43360072,19.2 1.02132454,17.63803 0.534391412,16.0333683 L0.513,15.954 L0.504265285,15.9449232 C-0.110228462,15.1972878 0.264421351,10.4760569 2.09599684,6.99794495 L2.22026541,6.76796973 C2.29571954,6.63016882 2.43695112,6.39220857 2.63659846,6.08729923 C2.9688861,5.57981633 3.34471126,5.07232148 3.75709487,4.59788661 C4.2749895,4.0020645 4.81413532,3.50121679 5.3386949,3.15177019 C5.36355777,3.12648036 5.4278064,3.07827062 5.50910569,3.02364741 L5.559,2.991 L5.5530361,2.96941337 C5.48899059,2.69876461 5.47862138,2.4784725 5.54146387,2.2521942 L5.58811106,2.11525813 C5.68308256,1.86409186 5.94349142,1.57994703 6.25873284,1.38755406 C6.58654657,1.18748816 7.23187921,0.95895859 7.69473739,0.883035787 C8.37505518,0.763266442 9.38159553,0.78076773 10.1590439,0.886175571 Z M6.59801776,3.85068129 C6.46732353,3.85068129 6.2240354,3.97828097 6.07844768,4.1001814 C5.59811888,4.42589962 5.12194443,4.87010868 4.65860433,5.40361803 C4.52372819,5.55892011 4.37448327,5.74624534 4.22515758,5.94252901 L4.04684241,6.18089332 C3.57610889,6.82012555 3.16307203,7.45661922 3.27592159,7.33459023 C1.39280393,10.7336939 1.18786427,14.1190682 1.66513528,15.5784041 C1.72944314,15.8645824 2.24255786,16.4352772 2.98506717,16.8902532 C4.03558482,17.5339627 5.43381914,17.9303112 7.15636912,17.9630362 L7.95282724,17.9633776 C10.5671194,17.8104156 12.6011819,16.8513512 14.1270746,15.5866906 L14.2005419,15.5269075 L14.2189125,15.5136158 C14.591184,15.2751975 14.6855045,14.9945722 14.5299888,14.3127204 C14.1480256,12.8500475 13.2023047,10.9705228 11.4802274,8.76564869 C10.6761315,7.73569508 9.84271439,6.77270459 8.9812637,5.88185595 C8.26651717,5.13999817 7.48191474,4.46126051 6.65303256,3.86947602 C6.6343697,3.85523851 6.62003281,3.85068129 6.59801776,3.85068129 Z M8.0520431,2.14478343 C7.34750556,2.24716005 6.81392621,2.48276912 6.75769294,2.58286729 C6.75315545,2.59094425 6.75172186,2.59912409 6.75788522,2.63367631 L6.761,2.653 C6.92447955,2.67441039 7.07755879,2.72514333 7.22081781,2.80306173 L7.36053304,2.88992896 C8.25106173,3.52400396 9.08393795,4.2496146 9.84209216,5.05104835 C10.7498631,5.98954517 11.620838,6.99715009 12.4127624,8.02643665 C14.2357617,10.3660968 15.255676,12.4067536 15.6810213,14.0171728 C15.7810435,14.3986973 15.8140553,14.7531702 15.7838468,15.0855202 L15.779624,15.1139874 L15.7923351,15.1170186 C16.0195271,15.1453183 16.2337261,14.9383655 16.4514,14.5090146 L16.5168229,14.3735502 C16.5998938,14.1934825 16.8522658,13.5389313 16.8131724,13.6336744 L16.800624,13.6629874 L16.8933423,13.4088509 C17.1021765,12.7846983 17.2487406,12.0003637 17.2861365,11.2776414 C17.4525549,9.34169753 16.8847303,7.51332101 15.9618076,5.9792161 C15.8725231,5.8278532 15.7620551,5.66138642 15.6942132,5.57820575 C14.7595226,4.31701776 13.5999579,3.42705248 12.3136888,2.84260842 C11.4827868,2.46507019 10.794487,2.2853603 10.1559862,2.18983638 C9.43796126,2.09113972 8.59553714,2.05880421 8.0520431,2.14478343 Z M16.4823653,4.32067121 L16.364,4.418 L16.393,4.454 L16.5100007,4.3621392 C17.0306065,3.97118443 17.6106194,3.7900296 18.1665334,3.88918284 L18.233,3.904 L18.2063581,3.87419362 C18.1376794,3.79892884 18.0675642,3.72412847 18.0165076,3.68190508 L17.972563,3.65173005 C17.800955,3.56958653 17.0606024,3.86572493 16.4823653,4.32067121 Z"
id="形状结合"
fill="currentColor"
fillRule="nonzero"></path>
</g>
</svg>
</IconSpan>
)
}
export function FolderIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<IconSpan>
<svg width="16px" height="16px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" {...props}>
<title>folder</title>
<path
d="M396.5,185.7l22.7,27.2a36.1,36.1,0,0,0,27.7,12.7H906.8c29.4,0,53.2,22.8,53.2,50.9V800.1c0,28.1-23.8,50.9-53.2,50.9H117.2C87.8,851,64,828.2,64,800.1V223.9c0-28.1,23.8-50.9,53.2-50.9H368.8A36.1,36.1,0,0,1,396.5,185.7Z"
style={{ fill: '#9fddff' }}
/>
<path
d="M64,342.5V797.8c0,29.4,24,53.2,53.6,53.2H906.4c29.6,0,53.6-23.8,53.6-53.2V342.5Z"
style={{ fill: '#74c6ff' }}
/>
</svg>
</IconSpan>
)
}
@@ -8,7 +8,7 @@ const ReasoningIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement
return ( return (
<Container> <Container>
<Tooltip title={t('models.type.reasoning')} placement="top"> <Tooltip title={t('models.reasoning')} placement="top">
<Icon className="iconfont icon-thinking" {...(props as any)} /> <Icon className="iconfont icon-thinking" {...(props as any)} />
</Tooltip> </Tooltip>
</Container> </Container>
@@ -23,7 +23,7 @@ const Container = styled.div`
` `
const Icon = styled(ToolOutlined)` const Icon = styled(ToolOutlined)`
color: var(--color-primary); color: #d97757;
font-size: 15px; font-size: 15px;
margin-right: 6px; margin-right: 6px;
` `
@@ -9,7 +9,7 @@ const VisionIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>,
return ( return (
<Container> <Container>
<Tooltip title={t('models.type.vision')} placement="top"> <Tooltip title={t('models.vision')} placement="top">
<Icon {...(props as any)} /> <Icon {...(props as any)} />
</Tooltip> </Tooltip>
</Container> </Container>
@@ -9,7 +9,7 @@ const WebSearchIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement
return ( return (
<Container> <Container>
<Tooltip title={t('models.type.websearch')} placement="top"> <Tooltip title={t('models.websearch')} placement="top">
<Icon {...(props as any)} /> <Icon {...(props as any)} />
</Tooltip> </Tooltip>
</Container> </Container>
+7 -17
View File
@@ -4,25 +4,15 @@ import styled from 'styled-components'
interface IndicatorLightProps { interface IndicatorLightProps {
color: string color: string
size?: number
shadow?: boolean
style?: React.CSSProperties
animation?: boolean
} }
const Light = styled.div<{ const Light = styled.div<{ color: string }>`
color: string width: 8px;
size: number height: 8px;
shadow?: boolean
style?: React.CSSProperties
animation?: boolean
}>`
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
border-radius: 50%; border-radius: 50%;
background-color: ${({ color }) => color}; background-color: ${({ color }) => color};
box-shadow: ${({ shadow, color }) => (shadow ? `0 0 6px ${color}` : 'none')}; box-shadow: 0 0 6px ${({ color }) => color};
animation: ${({ animation }) => (animation ? 'pulse 2s infinite' : 'none')}; animation: pulse 2s infinite;
@keyframes pulse { @keyframes pulse {
0% { 0% {
@@ -37,9 +27,9 @@ const Light = styled.div<{
} }
` `
const IndicatorLight: React.FC<IndicatorLightProps> = ({ color, size = 8, shadow = true, style, animation = true }) => { const IndicatorLight: React.FC<IndicatorLightProps> = ({ color }) => {
const actualColor = color === 'green' ? '#22c55e' : color const actualColor = color === 'green' ? '#22c55e' : color
return <Light color={actualColor} size={size} shadow={shadow} style={style} animation={animation} /> return <Light color={actualColor} />
} }
export default IndicatorLight export default IndicatorLight
@@ -8,20 +8,17 @@ interface ListItemProps {
subtitle?: string subtitle?: string
titleStyle?: React.CSSProperties titleStyle?: React.CSSProperties
onClick?: () => void onClick?: () => void
rightContent?: ReactNode
style?: React.CSSProperties
} }
const ListItem = ({ active, icon, title, subtitle, titleStyle, onClick, rightContent, style }: ListItemProps) => { const ListItem = ({ active, icon, title, subtitle, titleStyle, onClick }: ListItemProps) => {
return ( return (
<ListItemContainer className={active ? 'active' : ''} onClick={onClick} style={style}> <ListItemContainer className={active ? 'active' : ''} onClick={onClick}>
<ListItemContent> <ListItemContent>
{icon && <IconWrapper>{icon}</IconWrapper>} {icon && <IconWrapper>{icon}</IconWrapper>}
<TextContainer> <TextContainer>
<TitleText style={titleStyle}>{title}</TitleText> <TitleText style={titleStyle}>{title}</TitleText>
{subtitle && <SubtitleText>{subtitle}</SubtitleText>} {subtitle && <SubtitleText>{subtitle}</SubtitleText>}
</TextContainer> </TextContainer>
{rightContent && <RightContentWrapper>{rightContent}</RightContentWrapper>}
</ListItemContent> </ListItemContent>
</ListItemContainer> </ListItemContainer>
) )
@@ -87,8 +84,4 @@ const SubtitleText = styled.div`
color: var(--color-text-3); color: var(--color-text-3);
` `
const RightContentWrapper = styled.div`
margin-left: auto;
`
export default ListItem export default ListItem
+2 -4
View File
@@ -2,7 +2,6 @@ import {
isEmbeddingModel, isEmbeddingModel,
isFunctionCallingModel, isFunctionCallingModel,
isReasoningModel, isReasoningModel,
isRerankModel,
isVisionModel, isVisionModel,
isWebSearchModel isWebSearchModel
} from '@renderer/config/models' } from '@renderer/config/models'
@@ -33,9 +32,8 @@ const ModelTags: FC<ModelTagsProps> = ({ model, showFree = true, showReasoning =
{isWebSearchModel(model) && <WebSearchIcon />} {isWebSearchModel(model) && <WebSearchIcon />}
{showReasoning && isReasoningModel(model) && <ReasoningIcon />} {showReasoning && isReasoningModel(model) && <ReasoningIcon />}
{showToolsCalling && isFunctionCallingModel(model) && <ToolsCallingIcon />} {showToolsCalling && isFunctionCallingModel(model) && <ToolsCallingIcon />}
{isEmbeddingModel(model) && <Tag color="orange">{t('models.type.embedding')}</Tag>} {isEmbeddingModel(model) && <Tag color="orange">{t('models.embedding')}</Tag>}
{showFree && isFreeModel(model) && <Tag color="green">{t('models.type.free')}</Tag>} {showFree && isFreeModel(model) && <Tag color="green">{t('models.free')}</Tag>}
{isRerankModel(model) && <Tag color="geekblue">{t('models.type.rerank')}</Tag>}
</Container> </Container>
) )
} }
@@ -1,250 +0,0 @@
import { FolderIcon as NutstoreFolderIcon } from '@renderer/components/Icons/NutstoreIcons'
import { Button, Input } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { HStack } from './Layout'
interface NewFolderProps {
onConfirm: (name: string) => void
onCancel: () => void
className?: string
}
const NewFolderContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
`
const FolderIcon = styled(NutstoreFolderIcon)`
width: 40px;
height: 40px;
`
function NewFolder(props: NewFolderProps) {
const { onConfirm, onCancel } = props
const [name, setName] = useState('')
const { t } = useTranslation()
return (
<NewFolderContainer>
<FolderIcon className={props.className}></FolderIcon>
<Input type="text" style={{ flex: 1 }} autoFocus value={name} onChange={(e) => setName(e.target.value)} />
<Button type="primary" size="small" onClick={() => onConfirm(name)}>
{t('settings.data.nutstore.new_folder.button.confirm')}
</Button>
<Button type="default" size="small" onClick={() => onCancel()}>
{t('settings.data.nutstore.new_folder.button.cancel')}
</Button>
</NewFolderContainer>
)
}
interface FolderProps {
name: string
path: string
onClick: (path: string) => void
}
const FolderContainer = styled.div`
display: flex;
gap: 8px;
align-items: center;
max-width: 100%;
padding: 0 4px;
&:hover {
background-color: var(--color-background-soft);
}
.nutstore-pathname {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
`
function Folder(props: FolderProps) {
return (
<FolderContainer onClick={() => props.onClick(props.path)}>
<FolderIcon></FolderIcon>
<span className="nutstore-pathname">{props.name}</span>
</FolderContainer>
)
}
interface FileListProps {
path: string
fs: Nutstore.Fs
onClick: (file: Nutstore.FileStat) => void
}
function FileList(props: FileListProps) {
const [files, setFiles] = useState<Nutstore.FileStat[]>([])
const folders = files.filter((file) => file.isDir).sort((a, b) => a.basename.localeCompare(b.basename, ['zh']))
useEffect(() => {
async function fetchFiles() {
try {
const items = await props.fs.ls(props.path)
setFiles(items)
} catch (error) {
if (error instanceof Error) {
console.error(error)
window.modal.error({
content: error.message,
centered: true
})
}
}
}
fetchFiles()
}, [props.path, props.fs])
return (
<>
{folders.map((folder) => (
<Folder key={folder.path} name={folder.basename} path={folder.path} onClick={() => props.onClick(folder)} />
))}
</>
)
}
const SingleFileListContainer = styled.div`
height: 300px;
overflow: hidden;
.scroll-container {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.new-folder {
margin-top: 4px;
}
`
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
.nutstore-current-path-container {
display: flex;
align-items: center;
gap: 8px;
.nutstore-current-path {
word-break: break-all;
}
}
.nutstore-path-operater {
display: flex;
align-items: center;
gap: 8px;
}
`
interface Props {
fs: Nutstore.Fs
onConfirm: (path: string) => void
onCancel: () => void
}
export function NutstorePathSelector(props: Props) {
const { t } = useTranslation()
const [stack, setStack] = useState<string[]>(['/'])
const [showNewFolder, setShowNewFolder] = useState(false)
const cwd = stack.at(-1)
const enter = useCallback((path: string) => {
setStack((prev) => [...prev, path])
}, [])
const pop = useCallback(() => {
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev))
}, [])
const handleNewFolder = useCallback(
async (name: string) => {
const target = (cwd ?? '/') + (cwd && cwd !== '/' ? '/' : '') + name
await props.fs.mkdirs(target)
setShowNewFolder(false)
enter(target)
},
[cwd, props.fs, enter]
)
return (
<>
<Container>
<SingleFileListContainer>
<div className="scroll-container">
{showNewFolder && (
<NewFolder className="new-folder" onConfirm={handleNewFolder} onCancel={() => setShowNewFolder(false)} />
)}
<FileList path={cwd ?? ''} fs={props.fs} onClick={(f) => enter(f.path)} />
</div>
</SingleFileListContainer>
<div className="nutstore-current-path-container">
<span>{t('settings.data.nutstore.pathSelector.currentPath')}</span>
<span className="nutstore-current-path">{cwd ?? '/'}</span>
</div>
</Container>
<NustorePathSelectorFooter
returnPrev={pop}
mkdir={() => setShowNewFolder(true)}
cancel={props.onCancel}
confirm={() => props.onConfirm(cwd ?? '')}
/>
</>
)
}
const FooterContainer = styled(HStack)`
background: transparent;
margin-top: 12px;
padding: 0;
border-top: none;
border-radius: 0;
`
interface FooterProps {
returnPrev: () => void
mkdir: () => void
cancel: () => void
confirm: () => void
}
export function NustorePathSelectorFooter(props: FooterProps) {
const { t } = useTranslation()
return (
<FooterContainer justifyContent="space-between">
<HStack gap={8} alignItems="center">
<Button onClick={props.returnPrev}>{t('settings.data.nutstore.pathSelector.return')}</Button>
<Button size="small" type="link" onClick={props.mkdir}>
{t('settings.data.nutstore.new_folder.button')}
</Button>
</HStack>
<HStack gap={8} alignItems="center">
<Button type="default" onClick={props.cancel}>
{t('settings.data.nutstore.new_folder.button.cancel')}
</Button>
<Button type="primary" onClick={props.confirm}>
{t('backup.confirm.button')}
</Button>
</HStack>
</FooterContainer>
)
}
@@ -1,403 +0,0 @@
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { exportMarkdownToObsidian } from '@renderer/utils/export'
import { Alert, Empty, Form, Input, Modal, Select, Spin, TreeSelect } from 'antd'
import React, { useEffect, useState } from 'react'
const { Option } = Select
interface ObsidianExportDialogProps {
title: string
markdown: string
open: boolean
onClose: (success: boolean) => void
obsidianTags: string | null
processingMethod: string | '3' //默认新增(存在就覆盖)
}
interface FileInfo {
path: string
type: 'folder' | 'markdown'
name: string
}
// 转换文件信息数组为树形结构
const convertToTreeData = (files: FileInfo[]) => {
const treeData: any[] = [
{
title: i18n.t('chat.topics.export.obsidian_root_directory'),
value: '',
isLeaf: false,
selectable: true
}
]
// 记录已创建的节点路径
const pathMap: Record<string, any> = {
'': treeData[0]
}
// 先按类型分组,确保先处理文件夹
const folders = files.filter((file) => file.type === 'folder')
const mdFiles = files.filter((file) => file.type === 'markdown')
// 按路径排序,确保父文件夹先被创建
const sortedFolders = [...folders].sort((a, b) => a.path.split('/').length - b.path.split('/').length)
// 先处理所有文件夹,构建目录结构
for (const folder of sortedFolders) {
const parts = folder.path.split('/')
let currentPath = ''
let parentPath = ''
// 遍历文件夹路径的每一部分,确保创建完整路径
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
// 构建当前路径
currentPath = currentPath ? `${currentPath}/${part}` : part
// 如果这个路径节点还没创建
if (!pathMap[currentPath]) {
const node = {
title: part,
value: currentPath,
key: currentPath,
isLeaf: false,
selectable: true,
children: []
}
// 获取父节点,将当前节点添加到父节点的children中
const parentNode = pathMap[parentPath]
if (parentNode) {
if (!parentNode.children) {
parentNode.children = []
}
parentNode.children.push(node)
}
pathMap[currentPath] = node
}
// 更新父路径为当前路径,为下一级做准备
parentPath = currentPath
}
}
// 然后处理md文件
for (const file of mdFiles) {
const fullPath = file.path
const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/'))
const fileName = file.name
// 获取父文件夹节点
const parentNode = pathMap[dirPath] || pathMap['']
// 创建文件节点
const fileNode = {
title: fileName,
value: fullPath,
isLeaf: true,
selectable: true,
icon: <span style={{ marginRight: 4 }}>📄</span>
}
// 添加到父节点
if (!parentNode.children) {
parentNode.children = []
}
parentNode.children.push(fileNode)
}
return treeData
}
const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
title,
markdown,
open,
onClose,
obsidianTags,
processingMethod
}) => {
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
const [state, setState] = useState({
title,
tags: obsidianTags || '',
createdAt: new Date().toISOString().split('T')[0],
source: 'Cherry Studio',
processingMethod: processingMethod,
folder: ''
})
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
const [files, setFiles] = useState<FileInfo[]>([])
const [fileTreeData, setFileTreeData] = useState<any[]>([])
const [selectedVault, setSelectedVault] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
// 处理文件数据转为树形结构
useEffect(() => {
if (files.length > 0) {
const treeData = convertToTreeData(files)
setFileTreeData(treeData)
} else {
setFileTreeData([
{
title: i18n.t('chat.topics.export.obsidian_root_directory'),
value: '',
isLeaf: false,
selectable: true
}
])
}
}, [files])
// 组件加载时获取Vault列表
useEffect(() => {
const fetchVaults = async () => {
try {
setLoading(true)
setError(null)
const vaultsData = await window.obsidian.getVaults()
if (vaultsData.length === 0) {
setError(i18n.t('chat.topics.export.obsidian_no_vaults'))
setLoading(false)
return
}
setVaults(vaultsData)
// 如果没有选择的vault,使用默认值或第一个
const vaultToUse = defaultObsidianVault || vaultsData[0]?.name
if (vaultToUse) {
setSelectedVault(vaultToUse)
// 获取选中vault的文件和文件夹
const filesData = await window.obsidian.getFiles(vaultToUse)
setFiles(filesData)
}
} catch (error) {
console.error('获取Obsidian Vault失败:', error)
setError(i18n.t('chat.topics.export.obsidian_fetch_error'))
} finally {
setLoading(false)
}
}
fetchVaults()
}, [defaultObsidianVault])
// 当选择的vault变化时,获取其文件和文件夹
useEffect(() => {
if (selectedVault) {
const fetchFiles = async () => {
try {
setLoading(true)
setError(null)
const filesData = await window.obsidian.getFiles(selectedVault)
setFiles(filesData)
} catch (error) {
console.error('获取Obsidian文件失败:', error)
setError(i18n.t('chat.topics.export.obsidian_fetch_folders_error'))
} finally {
setLoading(false)
}
}
fetchFiles()
}
}, [selectedVault])
const handleOk = async () => {
if (!selectedVault) {
setError(i18n.t('chat.topics.export.obsidian_no_vault_selected'))
return
}
//构建content 并复制到粘贴板
let content = ''
if (state.processingMethod !== '3') {
content = `\n---\n${markdown}`
} else {
content = `---
\ntitle: ${state.title}
\ncreated: ${state.createdAt}
\nsource: ${state.source}
\ntags: ${state.tags}
\n---\n${markdown}`
}
if (content === '') {
window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
return
}
await navigator.clipboard.writeText(content)
// 导出到Obsidian
exportMarkdownToObsidian({
...state,
folder: state.folder,
vault: selectedVault
})
onClose(true)
}
const handleCancel = () => {
onClose(false)
}
const handleChange = (key: string, value: any) => {
setState((prevState) => ({ ...prevState, [key]: value }))
}
const handleVaultChange = (value: string) => {
setSelectedVault(value)
// 文件夹会通过useEffect自动获取
setState((prevState) => ({
...prevState,
folder: ''
}))
}
// 处理文件选择
const handleFileSelect = (value: string) => {
// 更新folder值
handleChange('folder', value)
// 检查是否选中md文件
if (value) {
const selectedFile = files.find((file) => file.path === value)
if (selectedFile) {
if (selectedFile.type === 'markdown') {
// 如果是md文件,自动设置标题为文件名并设置处理方式为1(追加)
const fileName = selectedFile.name
const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName
handleChange('title', titleWithoutExt)
handleChange('processingMethod', '1')
} else {
// 如果是文件夹,自动设置标题为话题名并设置处理方式为3(新建)
handleChange('processingMethod', '3')
handleChange('title', title)
}
}
}
}
return (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
open={open}
onOk={handleOk}
onCancel={handleCancel}
width={600}
closable
maskClosable
centered
okButtonProps={{
type: 'primary',
disabled: vaults.length === 0 || loading || !!error
}}
okText={i18n.t('chat.topics.export.obsidian_btn')}>
{error && <Alert message={error} type="error" showIcon style={{ marginBottom: 16 }} />}
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign="left">
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
<Input
value={state.title}
onChange={(e) => handleChange('title', e.target.value)}
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_vault')}>
{vaults.length > 0 ? (
<Select
loading={loading}
value={selectedVault}
onChange={handleVaultChange}
placeholder={i18n.t('chat.topics.export.obsidian_vault_placeholder')}
style={{ width: '100%' }}>
{vaults.map((vault) => (
<Option key={vault.name} value={vault.name}>
{vault.name}
</Option>
))}
</Select>
) : (
<Empty
description={
loading
? i18n.t('chat.topics.export.obsidian_loading')
: i18n.t('chat.topics.export.obsidian_no_vaults')
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_path')}>
<Spin spinning={loading}>
{selectedVault ? (
<TreeSelect
value={state.folder}
onChange={handleFileSelect}
placeholder={i18n.t('chat.topics.export.obsidian_path_placeholder')}
style={{ width: '100%' }}
showSearch
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
treeDefaultExpandAll={false}
treeNodeFilterProp="title"
treeData={fileTreeData}></TreeSelect>
) : (
<Empty
description={i18n.t('chat.topics.export.obsidian_select_vault_first')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Spin>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_tags')}>
<Input
value={state.tags}
onChange={(e) => handleChange('tags', e.target.value)}
placeholder={i18n.t('chat.topics.export.obsidian_tags_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_created')}>
<Input
value={state.createdAt}
onChange={(e) => handleChange('createdAt', e.target.value)}
placeholder={i18n.t('chat.topics.export.obsidian_created_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_source')}>
<Input
value={state.source}
onChange={(e) => handleChange('source', e.target.value)}
placeholder={i18n.t('chat.topics.export.obsidian_source_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_operate')}>
<Select
value={state.processingMethod}
onChange={(value) => handleChange('processingMethod', value)}
placeholder={i18n.t('chat.topics.export.obsidian_operate_placeholder')}
allowClear>
<Option value="1">{i18n.t('chat.topics.export.obsidian_operate_append')}</Option>
<Option value="2">{i18n.t('chat.topics.export.obsidian_operate_prepend')}</Option>
<Option value="3">{i18n.t('chat.topics.export.obsidian_operate_new_or_overwrite')}</Option>
</Select>
</Form.Item>
</Form>
</Modal>
)
}
export default ObsidianExportDialog
@@ -0,0 +1,228 @@
import { FileOutlined, FolderOutlined } from '@ant-design/icons'
import { Spin, Switch, Tree } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
defaultPath: string
obsidianUrl: string
obsidianApiKey: string
onPathChange: (path: string, isMdFile: boolean) => void
}
interface TreeNode {
title: string
key: string
isLeaf: boolean
isMdFile?: boolean
children?: TreeNode[]
}
const ObsidianFolderSelector: FC<Props> = ({ defaultPath, obsidianUrl, obsidianApiKey, onPathChange }) => {
const { t } = useTranslation()
const [treeData, setTreeData] = useState<TreeNode[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [expandedKeys, setExpandedKeys] = useState<string[]>(['/'])
const [showMdFiles, setShowMdFiles] = useState<boolean>(false)
// 当前选中的节点信息
const [currentSelection, setCurrentSelection] = useState({
path: defaultPath,
isMdFile: false
})
// 使用key强制Tree组件重新渲染
const [treeKey, setTreeKey] = useState<number>(0)
// 只初始化根节点,不立即加载内容
useEffect(() => {
initializeRootNode()
}, [showMdFiles])
// 初始化根节点,但不自动加载子节点
const initializeRootNode = () => {
const rootNode: TreeNode = {
title: '/',
key: '/',
isLeaf: false
}
setTreeData([rootNode])
}
// 异步加载子节点
const loadData = async (node: any) => {
if (node.isLeaf) return // 如果是叶子节点(md文件),不加载子节点
setLoading(true)
try {
// 确保路径末尾有斜杠
const path = node.key === '/' ? '' : node.key
const requestPath = path.endsWith('/') ? path : `${path}/`
const response = await fetch(`${obsidianUrl}vault${requestPath}`, {
headers: {
Authorization: `Bearer ${obsidianApiKey}`
}
})
const data = await response.json()
if (!response.ok || (!data?.files && data?.errorCode !== 40400)) {
throw new Error('获取文件夹失败')
}
const childNodes: TreeNode[] = (data.files || [])
.filter((file: string) => file.endsWith('/') || (showMdFiles && file.endsWith('.md'))) // 根据开关状态决定是否显示md文件
.map((file: string) => {
// 修复路径问题,避免重复的斜杠
const normalizedFile = file.replace('/', '')
const isMdFile = file.endsWith('.md')
const childPath = requestPath.endsWith('/')
? `${requestPath}${normalizedFile}${isMdFile ? '' : '/'}`
: `${requestPath}/${normalizedFile}${isMdFile ? '' : '/'}`
return {
title: normalizedFile,
key: childPath,
isLeaf: isMdFile,
isMdFile
}
})
// 更新节点的子节点
setTreeData((origin) => {
const loop = (data: TreeNode[], key: string, children: TreeNode[]): TreeNode[] => {
return data.map((item) => {
if (item.key === key) {
return {
...item,
children
}
}
if (item.children) {
return {
...item,
children: loop(item.children, key, children)
}
}
return item
})
}
return loop(origin, node.key, childNodes)
})
} catch (error) {
window.message.error(t('chat.topics.export.obsidian_fetch_failed'))
} finally {
setLoading(false)
}
}
// 处理开关切换
const handleSwitchChange = (checked: boolean) => {
setShowMdFiles(checked)
// 重置选择
setCurrentSelection({
path: defaultPath,
isMdFile: false
})
onPathChange(defaultPath, false)
// 重置Tree状态并强制重新渲染
setTreeData([])
setExpandedKeys(['/'])
// 递增key值以强制Tree组件完全重新渲染
setTreeKey((prev) => prev + 1)
// 延迟初始化根节点,让状态完全清除
setTimeout(() => {
initializeRootNode()
}, 50)
}
// 自定义图标,为md文件和文件夹显示不同的图标
const renderIcon = (props: any) => {
const { data } = props
if (data.isMdFile) {
return <FileOutlined />
}
return <FolderOutlined />
}
return (
<Container>
<SwitchContainer>
<span>{t('chat.topics.export.obsidian_show_md_files')}</span>
<Switch checked={showMdFiles} onChange={handleSwitchChange} />
</SwitchContainer>
<Spin spinning={loading}>
<TreeContainer>
<Tree
key={treeKey} // 使用key来强制重新渲染
defaultSelectedKeys={[defaultPath]}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys as string[])}
treeData={treeData}
loadData={loadData}
onSelect={(selectedKeys, info) => {
if (selectedKeys.length > 0) {
const path = selectedKeys[0] as string
const isMdFile = !!(info.node as any).isMdFile
setCurrentSelection({
path,
isMdFile
})
onPathChange?.(path, isMdFile)
}
}}
showLine
showIcon
icon={renderIcon}
/>
</TreeContainer>
</Spin>
<div>
{currentSelection.path !== defaultPath && (
<SelectedPath>
{t('chat.topics.export.obsidian_selected_path')}: {currentSelection.path}
</SelectedPath>
)}
</div>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 400px;
`
const TreeContainer = styled.div`
flex: 1;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
height: 320px;
`
const SwitchContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding: 0 10px;
`
const SelectedPath = styled.div`
font-size: 12px;
color: var(--color-text-2);
margin-top: 5px;
padding: 0 10px;
word-break: break-all;
`
export default ObsidianFolderSelector
@@ -1,60 +0,0 @@
import { Modal } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NutstorePathSelector } from '../NutstorePathSelector'
import { TopView } from '../TopView'
interface Props {
fs: Nutstore.Fs
resolve: (data: string | null) => void
}
const PopupContainer: React.FC<Props> = ({ resolve, fs }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
return (
<Modal
open={open}
title={t('settings.data.nutstore.pathSelector.title')}
transitionName="ant-move-down"
afterClose={onClose}
onCancel={onClose}
footer={null}
centered>
<NutstorePathSelector fs={fs} onConfirm={resolve} onCancel={onCancel} />
</Modal>
)
}
const TopViewKey = 'NutstorePathPopup'
export default class NutstorePathPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(fs: Nutstore.Fs) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
fs={fs}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
@@ -1,44 +1,72 @@
import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog' import ObsidianFolderSelector from '@renderer/components/ObsidianFolderSelector'
import { createRoot } from 'react-dom/client' import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { exportMarkdownToObsidian } from '@renderer/utils/export'
interface ObsidianExportOptions { interface ObsidianExportOptions {
title: string title: string
markdown: string markdown: string
processingMethod: string | '3' // 默认新增(存在就覆盖)
} }
/** // 用于显示 Obsidian 导出对话框
* 配置Obsidian 笔记属性弹窗
* @param options.title 标题
* @param options.markdown markdown内容
* @param options.processingMethod 处理方式
* @returns
*/
const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise<boolean> => { const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => { const { title, markdown } = options
const div = document.createElement('div') const obsidianUrl = store.getState().settings.obsidianUrl
document.body.appendChild(div) const obsidianApiKey = store.getState().settings.obsidianApiKey
const root = createRoot(div)
const handleClose = (success: boolean) => { if (!obsidianUrl || !obsidianApiKey) {
root.unmount() window.message.error(i18n.t('chat.topics.export.obsidian_not_configured'))
document.body.removeChild(div) return false
resolve(success) }
}
// 不再从store中获取tag配置 try {
root.render( // 创建一个状态变量来存储选择的路径
<ObsidianExportDialog let selectedPath = '/'
title={options.title} let selectedIsMdFile = false
markdown={options.markdown}
obsidianTags="" // 显示文件夹选择对话框
processingMethod={options.processingMethod} return new Promise<boolean>((resolve) => {
open={true} window.modal.confirm({
onClose={handleClose} title: i18n.t('chat.topics.export.obsidian_select_folder'),
/> content: (
) <ObsidianFolderSelector
}) defaultPath={selectedPath}
obsidianUrl={obsidianUrl}
obsidianApiKey={obsidianApiKey}
onPathChange={(path, isMdFile) => {
selectedPath = path
selectedIsMdFile = isMdFile
}}
/>
),
width: 600,
icon: null,
closable: true,
maskClosable: true,
centered: true,
okButtonProps: { type: 'primary' },
okText: i18n.t('chat.topics.export.obsidian_select_folder.btn'),
onOk: () => {
// 如果选择的是md文件,则使用选择的文件名而不是传入的标题
const fileName = selectedIsMdFile ? selectedPath.split('/').pop()?.replace('.md', '') : title
exportMarkdownToObsidian(fileName as string, markdown, selectedPath, selectedIsMdFile)
resolve(true)
},
onCancel: () => {
resolve(false)
}
})
})
} catch (error) {
window.message.error(i18n.t('chat.topics.export.obsidian_fetch_failed'))
console.error(error)
return false
}
} }
export default { const ObsidianExportPopup = {
show: showObsidianExportDialog show: showObsidianExportDialog
} }
export default ObsidianExportPopup
@@ -1,6 +1,6 @@
import { PushpinOutlined, SearchOutlined } from '@ant-design/icons' import { PushpinOutlined, SearchOutlined } from '@ant-design/icons'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { getModelLogo, isEmbeddingModel } from '@renderer/config/models'
import db from '@renderer/databases' import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'
@@ -76,7 +76,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
// 根据输入的文本筛选模型 // 根据输入的文本筛选模型
const getFilteredModels = useCallback( const getFilteredModels = useCallback(
(provider) => { (provider) => {
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) let models = provider.models.filter((m) => !isEmbeddingModel(m))
if (searchText.trim()) { if (searchText.trim()) {
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean) const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
@@ -1,5 +1,5 @@
import { throttle } from 'lodash' import { throttle } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react' import { FC, forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
interface Props extends React.HTMLAttributes<HTMLDivElement> { interface Props extends React.HTMLAttributes<HTMLDivElement> {
@@ -7,7 +7,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
ref?: any ref?: any
} }
const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject<HTMLDivElement | null> }) => { const Scrollbar: FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
const [isScrolling, setIsScrolling] = useState(false) const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -35,7 +35,7 @@ const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject
{props.children} {props.children}
</Container> </Container>
) )
} })
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>` const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
overflow-y: auto; overflow-y: auto;
@@ -1,237 +0,0 @@
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Input, Modal, Select, Spin } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
interface WebdavModalProps {
isModalVisible: boolean
handleBackup: () => void
handleCancel: () => void
backuping: boolean
customFileName: string
setCustomFileName: (value: string) => void
}
export function useWebdavBackupModal({ backupMethod }: { backupMethod?: typeof backupToWebdav } = {}) {
const [customFileName, setCustomFileName] = useState('')
const [isModalVisible, setIsModalVisible] = useState(false)
const [backuping, setBackuping] = useState(false)
const handleBackup = async () => {
setBackuping(true)
try {
await (backupMethod ?? backupToWebdav)({ showMessage: true, customFileName })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
const handleCancel = () => {
setIsModalVisible(false)
}
const showBackupModal = useCallback(async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}, [])
return {
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName,
showBackupModal
}
}
export function WebdavBackupModal({
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName
}: WebdavModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.webdav.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')}
/>
</Modal>
)
}
interface WebdavRestoreModalProps {
isRestoreModalVisible: boolean
handleRestore: () => void
handleCancel: () => void
restoring: boolean
selectedFile: string | null
setSelectedFile: (value: string | null) => void
loadingFiles: boolean
backupFiles: BackupFile[]
}
interface UseWebdavRestoreModalProps {
webdavHost: string | undefined
webdavUser: string | undefined
webdavPass: string | undefined
webdavPath: string | undefined
restoreMethod?: typeof restoreFromWebdav
}
export function useWebdavRestoreModal({
webdavHost,
webdavUser,
webdavPass,
webdavPath,
restoreMethod
}: UseWebdavRestoreModalProps) {
const { t } = useTranslation()
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [restoring, setRestoring] = useState(false)
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [loadingFiles, setLoadingFiles] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const showRestoreModal = useCallback(async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listWebdavFiles({
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({ content: error.message, key: 'list-files-error' })
} finally {
setLoadingFiles(false)
}
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
const handleRestore = useCallback(async () => {
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
content: t('settings.data.webdav.restore.confirm.content'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await (restoreMethod ?? restoreFromWebdav)(selectedFile)
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({ content: error.message, key: 'restore-error' })
} finally {
setRestoring(false)
}
}
})
}, [selectedFile, webdavHost, webdavUser, webdavPass, webdavPath, t, restoreMethod])
const handleCancel = () => {
setIsRestoreModalVisible(false)
}
return {
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles,
showRestoreModal
}
}
export function WebdavRestoreModal({
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles
}: WebdavRestoreModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.webdav.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={handleCancel}
okButtonProps={{ loading: restoring }}
width={600}>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
)
}
function formatFileOption(file: BackupFile) {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = formatFileSize(file.size)
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}
+3 -6
View File
@@ -1,10 +1,9 @@
import { isMac, isWindows } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import type { FC, PropsWithChildren } from 'react' import { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement> type Props = PropsWithChildren & JSX.IntrinsicElements['div']
export const Navbar: FC<Props> = ({ children, ...props }) => { export const Navbar: FC<Props> = ({ children, ...props }) => {
const backgroundColor = useNavBackgroundColor() const backgroundColor = useNavBackgroundColor()
@@ -63,6 +62,4 @@ const NavbarRightContainer = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
padding-right: ${isWindows ? '140px' : 12};
justify-content: flex-end;
` `
+4 -5
View File
@@ -35,7 +35,7 @@ const Sidebar: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { sidebarIcons } = useSettings() const { sidebarIcons } = useSettings()
const { theme, settingTheme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const { pinned } = useMinapps() const { pinned } = useMinapps()
const onEditUser = () => UserPopup.show() const onEditUser = () => UserPopup.show()
@@ -87,10 +87,7 @@ const Sidebar: FC = () => {
<QuestionCircleOutlined /> <QuestionCircleOutlined />
</Icon> </Icon>
</Tooltip> </Tooltip>
<Tooltip <Tooltip title={t('settings.theme.title')} mouseEnterDelay={0.8} placement="right">
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settingTheme}`)}
mouseEnterDelay={0.8}
placement="right">
<Icon theme={theme} onClick={() => toggleTheme()}> <Icon theme={theme} onClick={() => toggleTheme()}>
{theme === 'dark' ? ( {theme === 'dark' ? (
<i className="iconfont icon-theme icon-dark1" /> <i className="iconfont icon-theme icon-dark1" />
@@ -133,6 +130,7 @@ const MainMenus: FC = () => {
paintings: <PictureOutlined style={{ fontSize: 16 }} />, paintings: <PictureOutlined style={{ fontSize: 16 }} />,
translate: <TranslationOutlined />, translate: <TranslationOutlined />,
minapp: <i className="iconfont icon-appstore" />, minapp: <i className="iconfont icon-appstore" />,
nodeapps: <i className="iconfont icon-code" />,
knowledge: <FileSearchOutlined />, knowledge: <FileSearchOutlined />,
files: <FolderOutlined /> files: <FolderOutlined />
} }
@@ -143,6 +141,7 @@ const MainMenus: FC = () => {
paintings: '/paintings', paintings: '/paintings',
translate: '/translate', translate: '/translate',
minapp: '/apps', minapp: '/apps',
nodeapps: '/nodeapps',
knowledge: '/knowledge', knowledge: '/knowledge',
files: '/files' files: '/files'
} }
-52
View File
@@ -242,58 +242,6 @@ export const EMBEDDING_MODELS = [
{ {
id: 'mistral-embed', id: 'mistral-embed',
max_context: 8000 max_context: 8000
},
{
id: 'voyage-3-large',
max_context: 1024
},
{
id: 'voyage-3-large',
max_context: 256
},
{
id: 'voyage-3-large',
max_context: 512
},
{
id: 'voyage-3-large',
max_context: 2048
},
{
id: 'voyage-3',
max_context: 1024
},
{
id: 'voyage-3-lite',
max_context: 512
},
{
id: 'voyage-code-3',
max_context: 1024
},
{
id: 'voyage-code-3',
max_context: 256
},
{
id: 'voyage-code-3',
max_context: 512
},
{
id: 'voyage-code-3',
max_context: 2048
},
{
id: 'voyage-finance-2',
max_context: 1024
},
{
id: 'voyage-law-2',
max_context: 1024
},
{
id: 'voyage-code-2',
max_context: 1536
} }
] ]
+13 -116
View File
@@ -122,7 +122,6 @@ import UpstageModelLogo from '@renderer/assets/images/models/upstage.png'
import UpstageModelLogoDark from '@renderer/assets/images/models/upstage_dark.png' import UpstageModelLogoDark from '@renderer/assets/images/models/upstage_dark.png'
import ViduModelLogo from '@renderer/assets/images/models/vidu.png' import ViduModelLogo from '@renderer/assets/images/models/vidu.png'
import ViduModelLogoDark from '@renderer/assets/images/models/vidu_dark.png' import ViduModelLogoDark from '@renderer/assets/images/models/vidu_dark.png'
import VoyageModelLogo from '@renderer/assets/images/models/voyageai.png'
import WenxinModelLogo from '@renderer/assets/images/models/wenxin.png' import WenxinModelLogo from '@renderer/assets/images/models/wenxin.png'
import WenxinModelLogoDark from '@renderer/assets/images/models/wenxin_dark.png' import WenxinModelLogoDark from '@renderer/assets/images/models/wenxin_dark.png'
import XirangModelLogo from '@renderer/assets/images/models/xirang.png' import XirangModelLogo from '@renderer/assets/images/models/xirang.png'
@@ -149,7 +148,6 @@ const visionAllowedModels = [
'qwen-vl', 'qwen-vl',
'qwen2-vl', 'qwen2-vl',
'qwen2.5-vl', 'qwen2.5-vl',
'qwen2.5-omni',
'qvq', 'qvq',
'internvl2', 'internvl2',
'grok-vision-beta', 'grok-vision-beta',
@@ -174,11 +172,10 @@ export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|
// Reasoning models // Reasoning models
export const REASONING_REGEX = export const REASONING_REGEX =
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*)$/i /^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*)$/i
// Embedding models // Embedding models
export const EMBEDDING_REGEX = export const EMBEDDING_REGEX = /(?:^text-|embed|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings)/i
/(?:^text-|embed|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings|voyage-)/i
// Rerank models // Rerank models
export const RERANKING_REGEX = /(?:rerank|re-rank|re-ranker|re-ranking|retrieval|retriever)/i export const RERANKING_REGEX = /(?:rerank|re-rank|re-ranker|re-ranking|retrieval|retriever)/i
@@ -330,8 +327,7 @@ export function getModelLogo(modelId: string) {
embedding: isLight ? EmbeddingModelLogo : EmbeddingModelLogoDark, embedding: isLight ? EmbeddingModelLogo : EmbeddingModelLogoDark,
perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark, perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark, sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
'bge-': BgeModelLogo, 'bge-': BgeModelLogo
'voyage-': VoyageModelLogo
} }
for (const key in logoMap) { for (const key in logoMap) {
@@ -1106,12 +1102,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'GLM 4V', name: 'GLM 4V',
group: 'GLM-4v' group: 'GLM-4v'
}, },
{
id: 'glm-4v-flash',
provider: 'zhipu',
name: 'GLM-4V-Flash',
group: 'GLM-4v'
},
{ {
id: 'glm-4v-plus', id: 'glm-4v-plus',
provider: 'zhipu', provider: 'zhipu',
@@ -1542,15 +1532,15 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Llama3' group: 'Llama3'
}, },
{ {
id: 'mistral-saba-24b', id: 'mixtral-8x7b-32768',
provider: 'groq', provider: 'groq',
name: 'Mistral Saba 24B', name: 'Mixtral 8x7B',
group: 'Mistral' group: 'Mixtral'
}, },
{ {
id: 'gemma-9b-it', id: 'gemma-7b-it',
provider: 'groq', provider: 'groq',
name: 'Gemma 9B', name: 'Gemma 7B',
group: 'Gemma' group: 'Gemma'
} }
], ],
@@ -1805,63 +1795,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'DeepSeek' group: 'DeepSeek'
} }
], ],
gpustack: [], gpustack: []
voyageai: [
{
id: 'voyage-3-large',
provider: 'voyageai',
name: 'voyage-3-large',
group: 'Voyage Embeddings V3'
},
{
id: 'voyage-3',
provider: 'voyageai',
name: 'voyage-3',
group: 'Voyage Embeddings V3'
},
{
id: 'voyage-3-lite',
provider: 'voyageai',
name: 'voyage-3-lite',
group: 'Voyage Embeddings V3'
},
{
id: 'voyage-code-3',
provider: 'voyageai',
name: 'voyage-code-3',
group: 'Voyage Embeddings V3'
},
{
id: 'voyage-finance-3',
provider: 'voyageai',
name: 'voyage-finance-3',
group: 'Voyage Embeddings V2'
},
{
id: 'voyage-law-2',
provider: 'voyageai',
name: 'voyage-law-2',
group: 'Voyage Embeddings V2'
},
{
id: 'voyage-code-2',
provider: 'voyageai',
name: 'voyage-code-2',
group: 'Voyage Embeddings V2'
},
{
id: 'rerank-2',
provider: 'voyageai',
name: 'rerank-2',
group: 'Voyage Rerank V2'
},
{
id: 'rerank-2-lite',
provider: 'voyageai',
name: 'rerank-2-lite',
group: 'Voyage Rerank V2'
}
]
} }
export const TEXT_TO_IMAGES_MODELS = [ export const TEXT_TO_IMAGES_MODELS = [
@@ -1938,8 +1872,6 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
'stabilityai/stable-diffusion-xl-base-1.0' 'stabilityai/stable-diffusion-xl-base-1.0'
] ]
export const GENERATE_IMAGE_MODELS = ['gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-exp']
export function isTextToImageModel(model: Model): boolean { export function isTextToImageModel(model: Model): boolean {
return TEXT_TO_IMAGE_REGEX.test(model.id) return TEXT_TO_IMAGE_REGEX.test(model.id)
} }
@@ -1957,15 +1889,14 @@ export function isEmbeddingModel(model: Model): boolean {
return EMBEDDING_REGEX.test(model.name) return EMBEDDING_REGEX.test(model.name)
} }
if (isRerankModel(model)) {
return false
}
return EMBEDDING_REGEX.test(model.id) || model.type?.includes('embedding') || false return EMBEDDING_REGEX.test(model.id) || model.type?.includes('embedding') || false
} }
export function isRerankModel(model: Model): boolean { export function isRerankModel(model: Model): boolean {
return model ? RERANKING_REGEX.test(model.id) || false : false if (!model) {
return false
}
return RERANKING_REGEX.test(model.id) || false
} }
export function isVisionModel(model: Model): boolean { export function isVisionModel(model: Model): boolean {
@@ -1987,18 +1918,6 @@ export function isOpenAIoSeries(model: Model): boolean {
return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3') return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3')
} }
export function isSupportedResoningEffortModel(model?: Model): boolean {
if (!model) {
return false
}
if (model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet') || isOpenAIoSeries(model)) {
return true
}
return false
}
export function isReasoningModel(model?: Model): boolean { export function isReasoningModel(model?: Model): boolean {
if (!model) { if (!model) {
return false return false
@@ -2083,28 +2002,6 @@ export function isWebSearchModel(model: Model): boolean {
return false return false
} }
export function isGenerateImageModel(model: Model): boolean {
if (!model) {
return false
}
const provider = getProviderByModel(model)
if (!provider) {
return false
}
const isEmbedding = isEmbeddingModel(model)
if (isEmbedding) {
return false
}
if (GENERATE_IMAGE_MODELS.includes(model.id)) {
return true
}
return false
}
export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Record<string, any> { export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Record<string, any> {
if (isWebSearchModel(model)) { if (isWebSearchModel(model)) {
if (assistant.enableWebSearch) { if (assistant.enableWebSearch) {
+1 -1
View File
@@ -93,7 +93,7 @@ export const REFERENCE_PROMPT = `Please answer the question based on the referen
Please respond in the same language as the user's question. Please respond in the same language as the user's question.
` `
export const FOOTNOTE_PROMPT = `Please answer the question based on the reference materials and use footnote format to cite your sources. Please ignore irrelevant reference materials. If the reference material is not relevant to the question, please answer the question based on your knowledge. The answer should be clearly structured and complete. export const FOOTNOTE_PROMPT = `Please answer the question based on the reference materials and use footnote format to cite your sources. Please ignore irrelevant reference materials.
## Footnote Format: ## Footnote Format:
+1 -16
View File
@@ -38,7 +38,6 @@ import StepProviderLogo from '@renderer/assets/images/providers/step.png'
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png' import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png' import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.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 XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png' import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
@@ -87,16 +86,13 @@ const PROVIDER_LOGO_MAP = {
o3: O3ProviderLogo, o3: O3ProviderLogo,
'tencent-cloud-ti': TencentCloudProviderLogo, 'tencent-cloud-ti': TencentCloudProviderLogo,
gpustack: GPUStackProviderLogo, gpustack: GPUStackProviderLogo,
alayanew: AlayaNewProviderLogo, alayanew: AlayaNewProviderLogo
voyageai: VoyageAIProviderLogo
} as const } as const
export function getProviderLogo(providerId: string) { export function getProviderLogo(providerId: string) {
return PROVIDER_LOGO_MAP[providerId as keyof typeof PROVIDER_LOGO_MAP] return PROVIDER_LOGO_MAP[providerId as keyof typeof PROVIDER_LOGO_MAP]
} }
export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina', 'voyageai']
export const PROVIDER_CONFIG = { export const PROVIDER_CONFIG = {
openai: { openai: {
api: { api: {
@@ -562,16 +558,5 @@ export const PROVIDER_CONFIG = {
docs: 'https://docs.gpustack.ai/latest/', docs: 'https://docs.gpustack.ai/latest/',
models: 'https://docs.gpustack.ai/latest/overview/#supported-models' models: 'https://docs.gpustack.ai/latest/overview/#supported-models'
} }
},
voyageai: {
api: {
url: 'https://api.voyageai.com'
},
websites: {
official: 'https://www.voyageai.com/',
apiKey: 'https://dashboard.voyageai.com/organization/api-keys',
docs: 'https://docs.voyageai.com/docs',
models: 'https://docs.voyageai.com/docs'
}
} }
} }
+1 -12
View File
@@ -1,12 +1,8 @@
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { LanguageVarious } from '@renderer/types' import { LanguageVarious } from '@renderer/types'
import { ConfigProvider, theme } from 'antd' import { ConfigProvider, theme } from 'antd'
import elGR from 'antd/locale/el_GR'
import enUS from 'antd/locale/en_US' import enUS from 'antd/locale/en_US'
import esES from 'antd/locale/es_ES'
import frFR from 'antd/locale/fr_FR'
import jaJP from 'antd/locale/ja_JP' import jaJP from 'antd/locale/ja_JP'
import ptPT from 'antd/locale/pt_PT'
import ruRU from 'antd/locale/ru_RU' import ruRU from 'antd/locale/ru_RU'
import zhCN from 'antd/locale/zh_CN' import zhCN from 'antd/locale/zh_CN'
import zhTW from 'antd/locale/zh_TW' import zhTW from 'antd/locale/zh_TW'
@@ -57,14 +53,7 @@ function getAntdLocale(language: LanguageVarious) {
return ruRU return ruRU
case 'ja-JP': case 'ja-JP':
return jaJP return jaJP
case 'el-GR':
return elGR
case 'es-ES':
return esES
case 'fr-FR':
return frFR
case 'pt-PT':
return ptPT
default: default:
return zhCN return zhCN
} }
@@ -1,12 +1,12 @@
import isPropValid from '@emotion/is-prop-valid' import isPropValid from '@emotion/is-prop-valid'
import type { ReactNode } from 'react' import { ReactNode } from 'react'
import { StyleSheetManager as StyledComponentsStyleSheetManager } from 'styled-components' import { StyleSheetManager as StyledComponentsStyleSheetManager } from 'styled-components'
interface StyleSheetManagerProps { interface StyleSheetManagerProps {
children: ReactNode children: ReactNode
} }
const StyleSheetManager = ({ children }: StyleSheetManagerProps): React.ReactElement => { const StyleSheetManager = ({ children }: StyleSheetManagerProps): JSX.Element => {
return ( return (
<StyledComponentsStyleSheetManager <StyledComponentsStyleSheetManager
shouldForwardProp={(prop, element) => { shouldForwardProp={(prop, element) => {
@@ -1,11 +1,16 @@
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid' import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { type CodeStyleVarious, ThemeMode } from '@renderer/types' import { CodeStyleVarious, ThemeMode } from '@renderer/types'
import type React from 'react' import React, { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react' import {
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki' BundledLanguage,
import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki' bundledLanguages,
BundledTheme,
bundledThemes,
createHighlighter,
HighlighterGeneric
} from 'shiki'
interface SyntaxHighlighterContextType { interface SyntaxHighlighterContextType {
codeToHtml: (code: string, language: string) => Promise<string> codeToHtml: (code: string, language: string) => Promise<string>
@@ -46,47 +51,42 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
initHighlighter() initHighlighter()
}, [highlighterTheme]) }, [highlighterTheme])
const codeToHtml = useCallback( const codeToHtml = async (code: string, language: string) => {
async (_code: string, language: string) => { if (!highlighter) return ''
{
if (!highlighter) return ''
const languageMap: Record<string, string> = { const languageMap: Record<string, string> = {
vab: 'vb' vab: 'vb'
} }
const mappedLanguage = languageMap[language] || language const mappedLanguage = languageMap[language] || language
const code = _code?.trimEnd() ?? '' code = code?.trimEnd() ?? ''
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!) const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!)
try { try {
if (!highlighter.getLoadedLanguages().includes(mappedLanguage as BundledLanguage)) { if (!highlighter.getLoadedLanguages().includes(mappedLanguage as BundledLanguage)) {
if (mappedLanguage in bundledLanguages || mappedLanguage === 'text') { if (mappedLanguage in bundledLanguages || mappedLanguage === 'text') {
await highlighter.loadLanguage(mappedLanguage as BundledLanguage) await highlighter.loadLanguage(mappedLanguage as BundledLanguage)
} else { } else {
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
return highlighter.codeToHtml(code, {
lang: mappedLanguage,
theme: highlighterTheme
})
} catch (error) {
console.warn(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>` return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
} }
} }
},
[highlighter, highlighterTheme]
)
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext> return highlighter.codeToHtml(code, {
lang: mappedLanguage,
theme: highlighterTheme
})
} catch (error) {
console.warn(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
return <SyntaxHighlighterContext.Provider value={{ codeToHtml }}>{children}</SyntaxHighlighterContext.Provider>
} }
export const useSyntaxHighlighter = () => { export const useSyntaxHighlighter = () => {
const context = use(SyntaxHighlighterContext) const context = useContext(SyntaxHighlighterContext)
if (!context) { if (!context) {
throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider') throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider')
} }
+3 -5
View File
@@ -1,17 +1,15 @@
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { ThemeMode } from '@renderer/types' import { ThemeMode } from '@renderer/types'
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react' import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
interface ThemeContextType { interface ThemeContextType {
theme: ThemeMode theme: ThemeMode
settingTheme: ThemeMode
toggleTheme: () => void toggleTheme: () => void
} }
const ThemeContext = createContext<ThemeContextType>({ const ThemeContext = createContext<ThemeContextType>({
theme: ThemeMode.light, theme: ThemeMode.light,
settingTheme: ThemeMode.light,
toggleTheme: () => {} toggleTheme: () => {}
}) })
@@ -57,7 +55,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
} }
}) })
return <ThemeContext value={{ theme: _theme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext> return <ThemeContext.Provider value={{ theme: _theme, toggleTheme }}>{children}</ThemeContext.Provider>
} }
export const useTheme = () => use(ThemeContext) export const useTheme = () => useContext(ThemeContext)
+2
View File
@@ -20,5 +20,7 @@ declare global {
keyv: KeyvStorage keyv: KeyvStorage
mermaid: any mermaid: any
store: any store: any
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<{ data: string; success: boolean }>
} }
} }
+5 -3
View File
@@ -11,13 +11,14 @@ import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant' import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice' import useFullScreenNotice from './useFullScreenNotice'
import { useInitMCPServers } from './useMCPServers'
import { useRuntime } from './useRuntime' import { useRuntime } from './useRuntime'
import { useSettings } from './useSettings' import { useSettings } from './useSettings'
import useUpdateHandler from './useUpdateHandler' import useUpdateHandler from './useUpdateHandler'
export function useAppInit() { export function useAppInit() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss } = useSettings() const { proxyUrl, language, windowStyle, manualUpdateCheck, proxyMode, customCss } = useSettings()
const { minappShow } = useRuntime() const { minappShow } = useRuntime()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel() const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar')) const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
@@ -25,6 +26,7 @@ export function useAppInit() {
useUpdateHandler() useUpdateHandler()
useFullScreenNotice() useFullScreenNotice()
useInitMCPServers()
useEffect(() => { useEffect(() => {
avatar?.value && dispatch(setAvatar(avatar.value)) avatar?.value && dispatch(setAvatar(avatar.value))
@@ -34,13 +36,13 @@ export function useAppInit() {
document.getElementById('spinner')?.remove() document.getElementById('spinner')?.remove()
runAsyncFunction(async () => { runAsyncFunction(async () => {
const { isPackaged } = await window.api.getAppInfo() const { isPackaged } = await window.api.getAppInfo()
if (isPackaged && autoCheckUpdate) { if (isPackaged && !manualUpdateCheck) {
await delay(2) await delay(2)
const { updateInfo } = await window.api.checkForUpdate() const { updateInfo } = await window.api.checkForUpdate()
dispatch(setUpdateState({ info: updateInfo })) dispatch(setUpdateState({ info: updateInfo }))
} }
}) })
}, [dispatch, autoCheckUpdate]) }, [dispatch, manualUpdateCheck])
useEffect(() => { useEffect(() => {
if (proxyMode === 'system') { if (proxyMode === 'system') {
+76 -9
View File
@@ -1,6 +1,7 @@
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp' import { setMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
import { useEffect } from 'react'
const ipcRenderer = window.electron.ipcRenderer const ipcRenderer = window.electron.ipcRenderer
@@ -12,16 +13,82 @@ ipcRenderer.on('mcp:servers-changed', (_event, servers) => {
export const useMCPServers = () => { export const useMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers) const mcpServers = useAppSelector((state) => state.mcp.servers)
const activedMcpServers = useAppSelector((state) => state.mcp.servers?.filter((server) => server.isActive)) const activedMcpServers = useAppSelector((state) => state.mcp.servers?.filter((server) => server.isActive))
const dispatch = useAppDispatch()
const addMCPServer = async (server: MCPServer) => {
try {
await window.api.mcp.addServer(server)
// Main process will send back updated servers via mcp:servers-changed
} catch (error) {
console.error('Failed to add MCP server:', error)
throw error
}
}
const updateMCPServer = async (server: MCPServer) => {
try {
await window.api.mcp.updateServer(server)
// Main process will send back updated servers via mcp:servers-changed
} catch (error) {
console.error('Failed to update MCP server:', error)
throw error
}
}
const deleteMCPServer = async (name: string) => {
try {
await window.api.mcp.deleteServer(name)
// Main process will send back updated servers via mcp:servers-changed
} catch (error) {
console.error('Failed to delete MCP server:', error)
throw error
}
}
const setMCPServerActive = async (name: string, isActive: boolean) => {
try {
await window.api.mcp.setServerActive(name, isActive)
// Main process will send back updated servers via mcp:servers-changed
} catch (error) {
console.error('Failed to set MCP server active status:', error)
throw error
}
}
const getActiveMCPServers = () => {
return mcpServers.filter((server) => server.isActive)
}
return { return {
mcpServers, mcpServers,
activedMcpServers, activedMcpServers,
addMCPServer: (server: MCPServer) => dispatch(addMCPServer(server)), addMCPServer,
updateMCPServer: (server: MCPServer) => dispatch(updateMCPServer(server)), updateMCPServer,
deleteMCPServer: (id: string) => dispatch(deleteMCPServer(id)), deleteMCPServer,
setMCPServerActive: (server: MCPServer, isActive: boolean) => dispatch(updateMCPServer({ ...server, isActive })), setMCPServerActive,
getActiveMCPServers: () => mcpServers.filter((server) => server.isActive), getActiveMCPServers
updateMcpServers: (servers: MCPServer[]) => dispatch(setMCPServers(servers))
} }
} }
export const useInitMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers)
// const dispatch = useAppDispatch()
// Send servers to main process when they change in Redux
useEffect(() => {
ipcRenderer.send('mcp:servers-from-renderer', mcpServers)
}, [mcpServers])
// Initial load of MCP servers from main process
// useEffect(() => {
// const loadServers = async () => {
// try {
// const servers = await window.api.mcp.listServers()
// dispatch(setMCPServers(servers))
// } catch (error) {
// console.error('Failed to load MCP servers:', error)
// }
// }
// loadServers()
// }, [dispatch])
}
+20 -30
View File
@@ -1,19 +1,17 @@
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { estimateMessageUsage } from '@renderer/services/TokenService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { import {
clearStreamMessage, clearStreamMessage,
clearTopicMessages, clearTopicMessages,
commitStreamMessage, commitStreamMessage,
deleteMessageAction,
resendMessage, resendMessage,
selectDisplayCount, selectDisplayCount,
selectTopicLoading, selectTopicLoading,
selectTopicMessages, selectTopicMessages,
setStreamMessage, setStreamMessage,
setTopicLoading, setTopicLoading,
updateMessages, updateMessage,
updateMessageThunk updateMessages
} from '@renderer/store/messages' } from '@renderer/store/messages'
import type { Assistant, Message, Topic } from '@renderer/types' import type { Assistant, Message, Topic } from '@renderer/types'
import { abortCompletion } from '@renderer/utils/abortController' import { abortCompletion } from '@renderer/utils/abortController'
@@ -28,15 +26,17 @@ import { TopicManager } from './useTopic'
*/ */
export function useMessageOperations(topic: Topic) { export function useMessageOperations(topic: Topic) {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const messages = useAppSelector((state) => selectTopicMessages(state, topic.id))
/** /**
* 删除单个消息 * 删除单个消息
*/ */
const deleteMessage = useCallback( const deleteMessage = useCallback(
async (id: string) => { async (message: Message) => {
await dispatch(deleteMessageAction(topic, id)) const newMessages = messages.filter((m) => m.id !== message.id)
await dispatch(updateMessages(topic, newMessages))
}, },
[dispatch, topic] [dispatch, topic, messages]
) )
/** /**
@@ -44,9 +44,10 @@ export function useMessageOperations(topic: Topic) {
*/ */
const deleteGroupMessages = useCallback( const deleteGroupMessages = useCallback(
async (askId: string) => { async (askId: string) => {
await dispatch(deleteMessageAction(topic, askId, 'askId')) const newMessages = messages.filter((m) => m.askId !== askId)
await dispatch(updateMessages(topic, newMessages))
}, },
[dispatch, topic] [dispatch, topic, messages]
) )
/** /**
@@ -54,17 +55,13 @@ export function useMessageOperations(topic: Topic) {
*/ */
const editMessage = useCallback( const editMessage = useCallback(
async (messageId: string, updates: Partial<Message>) => { async (messageId: string, updates: Partial<Message>) => {
// 如果更新包含内容变更,重新计算 token await dispatch(
if ('content' in updates) { updateMessage({
const messages = store.getState().messages.messagesByTopic[topic.id] topicId: topic.id,
const message = messages?.find((m) => m.id === messageId) messageId,
if (message) { updates
const updatedMessage = { ...message, ...updates } })
const usage = await estimateMessageUsage(updatedMessage) )
updates.usage = usage
}
}
await dispatch(updateMessageThunk(topic.id, messageId, updates))
}, },
[dispatch, topic.id] [dispatch, topic.id]
) )
@@ -151,6 +148,7 @@ export function useMessageOperations(topic: Topic) {
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT) EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
}, []) }, [])
const loading = useAppSelector((state) => selectTopicLoading(state, topic.id))
const displayCount = useAppSelector(selectDisplayCount) const displayCount = useAppSelector(selectDisplayCount)
// /** // /**
// * 获取当前消息列表 // * 获取当前消息列表
@@ -202,6 +200,8 @@ export function useMessageOperations(topic: Topic) {
) )
return { return {
messages,
loading,
displayCount, displayCount,
updateMessages: updateMessagesAction, updateMessages: updateMessagesAction,
deleteMessage, deleteMessage,
@@ -219,13 +219,3 @@ export function useMessageOperations(topic: Topic) {
resumeMessage resumeMessage
} }
} }
export const useTopicMessages = (topic: Topic) => {
const messages = useAppSelector((state) => selectTopicMessages(state, topic.id))
return messages
}
export const useTopicLoading = (topic: Topic) => {
const loading = useAppSelector((state) => selectTopicLoading(state, topic.id))
return loading
}
+88
View File
@@ -0,0 +1,88 @@
import { NodeAppType } from '@renderer/types'
import { useCallback, useEffect, useState } from 'react'
export function useNodeApps() {
const [apps, setApps] = useState<NodeAppType[]>([])
const [loading, setLoading] = useState(true)
// Load apps
const loadApps = useCallback(async () => {
try {
setLoading(true)
const result = await window.api.nodeapp.list()
setApps(result || [])
} catch (error) {
console.error('Error loading node apps:', error)
} finally {
setLoading(false)
}
}, [])
// Add app
const addApp = useCallback(async (app: NodeAppType) => {
const result = await window.api.nodeapp.add(app)
await loadApps()
return result
}, [loadApps])
// Install app
const installApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.install(appId)
await loadApps()
return result
}, [loadApps])
// Update app
const updateApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.update(appId)
await loadApps()
return result
}, [loadApps])
// Start app
const startApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.start(appId)
await loadApps()
return result
}, [loadApps])
// Stop app
const stopApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.stop(appId)
await loadApps()
return result
}, [loadApps])
// Uninstall app
const uninstallApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.uninstall(appId)
await loadApps()
return result
}, [loadApps])
// Initialize
useEffect(() => {
loadApps()
// Subscribe to app updates
const unsubscribe = window.api.nodeapp.onUpdated((updatedApps) => {
setApps(updatedApps || [])
})
return () => {
unsubscribe()
}
}, [loadApps])
return {
apps,
loading,
addApp,
installApp,
updateApp,
startApp,
stopApp,
uninstallApp,
refresh: loadApps
}
}
-24
View File
@@ -1,24 +0,0 @@
import { useCallback } from 'react'
export function useNutstoreSSO() {
const nutstoreSSOHandler = useCallback(() => {
return new Promise<string>((resolve, reject) => {
const removeListener = window.api.protocol.onReceiveData(async (data) => {
try {
const url = new URL(data.url)
const params = new URLSearchParams(url.search)
const encryptedToken = params.get('s')
if (!encryptedToken) return reject(null)
resolve(encryptedToken)
} catch (error) {
console.error('解析URL失败:', error)
reject(null)
} finally {
removeListener()
}
})
})
}, [])
return nutstoreSSOHandler
}
+4 -27
View File
@@ -1,8 +1,6 @@
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { import {
SendMessageShortcut, SendMessageShortcut,
setLaunchOnBoot,
setLaunchToTray,
setSendMessageShortcut as _setSendMessageShortcut, setSendMessageShortcut as _setSendMessageShortcut,
setShowAssistantIcon, setShowAssistantIcon,
setSidebarIcons, setSidebarIcons,
@@ -10,8 +8,7 @@ import {
setTheme, setTheme,
SettingsState, SettingsState,
setTopicPosition, setTopicPosition,
setTray as _setTray, setTray,
setTrayOnClose,
setWindowStyle setWindowStyle
} from '@renderer/store/settings' } from '@renderer/store/settings'
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types' import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
@@ -25,30 +22,10 @@ export function useSettings() {
setSendMessageShortcut(shortcut: SendMessageShortcut) { setSendMessageShortcut(shortcut: SendMessageShortcut) {
dispatch(_setSendMessageShortcut(shortcut)) dispatch(_setSendMessageShortcut(shortcut))
}, },
setTray(isActive: boolean) {
setLaunch(isLaunchOnBoot: boolean | undefined, isLaunchToTray: boolean | undefined = undefined) { dispatch(setTray(isActive))
if (isLaunchOnBoot !== undefined) { window.api.setTray(isActive)
dispatch(setLaunchOnBoot(isLaunchOnBoot))
window.api.setLaunchOnBoot(isLaunchOnBoot)
}
if (isLaunchToTray !== undefined) {
dispatch(setLaunchToTray(isLaunchToTray))
window.api.setLaunchToTray(isLaunchToTray)
}
}, },
setTray(isShowTray: boolean | undefined, isTrayOnClose: boolean | undefined = undefined) {
if (isShowTray !== undefined) {
dispatch(_setTray(isShowTray))
window.api.setTray(isShowTray)
}
if (isTrayOnClose !== undefined) {
dispatch(setTrayOnClose(isTrayOnClose))
window.api.setTrayOnClose(isTrayOnClose)
}
},
setTheme(theme: ThemeMode) { setTheme(theme: ThemeMode) {
dispatch(setTheme(theme)) dispatch(setTheme(theme))
}, },
+3 -13
View File
@@ -1,28 +1,18 @@
import i18n from 'i18next' import i18n from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
// Original translation
import enUS from './locales/en-us.json' import enUS from './locales/en-us.json'
import jaJP from './locales/ja-jp.json' import jaJP from './locales/ja-jp.json'
import ruRU from './locales/ru-ru.json' import ruRU from './locales/ru-ru.json'
import zhCN from './locales/zh-cn.json' import zhCN from './locales/zh-cn.json'
import zhTW from './locales/zh-tw.json' import zhTW from './locales/zh-tw.json'
// Machine translation
import elGR from './translate/el-gr.json'
import esES from './translate/es-es.json'
import frFR from './translate/fr-fr.json'
import ptPT from './translate/pt-pt.json'
const resources = { const resources = {
'el-GR': elGR,
'en-US': enUS, 'en-US': enUS,
'es-ES': esES,
'fr-FR': frFR,
'ja-JP': jaJP,
'pt-PT': ptPT,
'ru-RU': ruRU,
'zh-CN': zhCN, 'zh-CN': zhCN,
'zh-TW': zhTW 'zh-TW': zhTW,
'ja-JP': jaJP,
'ru-RU': ruRU
} }
export const getLanguage = () => { export const getLanguage = () => {

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