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
255 changed files with 12220 additions and 21911 deletions

View File

@@ -6,4 +6,3 @@ tsconfig.json
tsconfig.*.json
CHANGELOG*.md
agents.json
src/renderer/src/integration/nutstore/sso/lib

View File

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

View File

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

View File

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

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

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>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
# 🍒 Cherry Studio
@@ -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!
# 📖 Guide
https://docs.cherry-ai.com
# 🌠 Screenshot
![](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)
- [ ] 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
Refer to the [development documentation](docs/dev.md)
@@ -132,7 +119,11 @@ Thank you for your support and contributions!
# 🌐 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
@@ -142,10 +133,6 @@ Thank you for your support and contributions!
[LICENSE](./LICENSE)
# ✉️ Contact
yinsenho@cherry-ai.com
# ⭐️ 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)

View File

@@ -1,8 +1,8 @@
# provider: generic
# url: http://127.0.0.1:8080
# updaterCacheDirName: cherry-studio-updater
provider: github
repo: cherry-studio
owner: kangfenmao
# provider: generic
# url: https://cherrystudio.ocool.online
# provider: github
# repo: cherry-studio
# owner: kangfenmao
provider: generic
url: https://cherrystudio.ocool.online

View File

@@ -8,7 +8,6 @@
</p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
# 🍒 Cherry Studio
@@ -18,10 +17,6 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
# 📖 ガイド
https://docs.cherry-ai.com
# 🌠 スクリーンショット
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
@@ -83,13 +78,6 @@ https://docs.cherry-ai.com
- [ ] 音声入出力AI コール)
- [ ] データバックアップはカスタムバックアップコンテンツをサポート
# 🌈 テーマ
テーマギャラリー: https://cherrycss.com
Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
より多くのテーマのPRを歓迎します
# 🖥️ 開発
参考[開発ドキュメント](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)
# ✉️ お問い合わせ
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)

View File

@@ -7,9 +7,7 @@
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br></p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
# 🍒 Cherry Studio
Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客户端兼容 Windows、Mac 和 Linux 系统。
@@ -18,10 +16,6 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
# 📖 使用教程
https://docs.cherry-ai.com
# 🌠 界面
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
@@ -83,13 +77,6 @@ https://docs.cherry-ai.com
- [ ] 语音输入输出AI 通话)
- [ ] 数据备份支持自定义备份内容
# 🌈 主题
主题库https://cherrycss.com
Aero 主题https://github.com/hakadao/CherryStudio-Aero
欢迎 PR 更多主题
# 🖥️ 开发
参考[开发文档](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)
# ✉️ 联系我们
yinsenho@cherry-ai.com
# ⭐️ Star 记录
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

@@ -83,7 +83,8 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
小程序支持多开
支持 GPT-4o 图像生成
修复 MCP 服务器无法使用问题
修复升级导致旧版本数据丢失问题
知识库设置增加重排模型,提升知识库的准确性
自定义服务商增加兼容模式
增加 Github Copilot 服务商
PlantUML 预览支持放大和缩小
联网模式支持增强模式

View File

@@ -12,18 +12,17 @@ export default defineConfig({
plugins: [
externalizeDepsPlugin({
exclude: [
'@cherrystudio/embedjs',
'@cherrystudio/embedjs-openai',
'@cherrystudio/embedjs-loader-web',
'@cherrystudio/embedjs-loader-markdown',
'@cherrystudio/embedjs-loader-msoffice',
'@cherrystudio/embedjs-loader-xml',
'@cherrystudio/embedjs-loader-pdf',
'@cherrystudio/embedjs-loader-sitemap',
'@cherrystudio/embedjs-libsql',
'@cherrystudio/embedjs-loader-image',
'p-queue',
'webdav'
'@llm-tools/embedjs',
'@llm-tools/embedjs-openai',
'@llm-tools/embedjs-loader-web',
'@llm-tools/embedjs-loader-markdown',
'@llm-tools/embedjs-loader-msoffice',
'@llm-tools/embedjs-loader-xml',
'@llm-tools/embedjs-loader-pdf',
'@llm-tools/embedjs-loader-sitemap',
'@llm-tools/embedjs-libsql',
'@llm-tools/embedjs-loader-image',
'p-queue'
]
}),
...visualizerPlugin('main')

View File

@@ -53,16 +53,6 @@ export default defineConfig([
}
],
{
ignores: [
'node_modules/**',
'build/**',
'dist/**',
'out/**',
'local/**',
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**'
]
ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js']
}
])

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.1.17",
"version": "1.1.8",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -50,64 +50,61 @@
"prepare": "husky"
},
"dependencies": {
"@cherrystudio/embedjs": "^0.1.28",
"@cherrystudio/embedjs-libsql": "^0.1.28",
"@cherrystudio/embedjs-loader-csv": "^0.1.28",
"@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",
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@google/generative-ai": "^0.24.0",
"@langchain/community": "^0.3.36",
"@emotion/is-prop-valid": "^1.3.1",
"@google/generative-ai": "^0.21.0",
"@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",
"@tryfabric/martian": "^1.2.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@xyflow/react": "^12.4.4",
"adm-zip": "^0.5.16",
"apache-arrow": "^18.1.0",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.9",
"electron-window-state": "^5.0.3",
"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",
"fs-extra": "^11.2.0",
"markdown-it": "^14.1.0",
"npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1",
"proxy-agent": "^6.5.0",
"p-queue": "^8.1.0",
"socks-proxy-agent": "^8.0.3",
"tar": "^7.4.3",
"tokenx": "^0.4.1",
"undici": "^7.4.0",
"webdav": "^5.8.0",
"webdav": "4.11.4",
"zipread": "^1.3.3"
},
"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",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
"@google/genai": "^0.4.0",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.8.0",
"@notionhq/client": "^2.2.15",
"@llm-tools/embedjs-loader-image": "^0.1.28",
"@reduxjs/toolkit": "^2.2.5",
"@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/fs-extra": "^11",
"@types/lodash": "^4.17.5",
@@ -115,8 +112,8 @@
"@types/md5": "^2.3.5",
"@types/node": "^18.19.9",
"@types/pako": "^1.0.2",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1",
@@ -146,13 +143,10 @@
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"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",
"p-queue": "^8.1.0",
"prettier": "^3.5.3",
"rc-virtual-list": "^3.18.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
@@ -166,7 +160,6 @@
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.0.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
@@ -175,17 +168,19 @@
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tinycolor2": "^1.6.0",
"tokenx": "^0.4.1",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"vite": "^5.0.12"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"resolutions": {
"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.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",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch"
},
"packageManager": "yarn@4.6.0",
"lint-staged": {

13
packages/artifacts/package-lock.json generated Normal file
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
packages/database/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -88,7 +88,7 @@ export const textExts = [
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 或 MATLAB 源文件
'.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
@@ -106,32 +106,7 @@ export const textExts = [
'.ixx', // C++20 模块实现文件
'.f90', // Fortran 90 源文件
'.f', // Fortran 固定格式源代码文件
'.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
'.f03' // Fortran 2003+ 源代码文件
]
export const ZOOM_SHORTCUTS = [

View File

@@ -1 +0,0 @@
export const NUTSTORE_HOST = 'https://dav.jianguoyun.com/dav'

View File

@@ -1,5 +1,8 @@
const { ProxyAgent } = require('undici')
const { SocksProxyAgent } = require('socks-proxy-agent')
const https = require('https')
const fs = require('fs')
const { pipeline } = require('stream/promises')
/**
* 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
*/
async function downloadWithRedirects(url, destinationPath) {
return new Promise((resolve, reject) => {
const request = (url) => {
https
.get(url, (response) => {
if (response.statusCode == 301 || response.statusCode == 302) {
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`))
return
}
const file = fs.createWriteStream(destinationPath)
response.pipe(file)
file.on('finish', () => resolve())
})
.on('error', (err) => {
reject(err)
})
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY
if (proxyUrl.startsWith('socks')) {
const proxyAgent = new SocksProxyAgent(proxyUrl)
return new Promise((resolve, reject) => {
const request = (url) => {
https
.get(url, { agent: proxyAgent }, (response) => {
if (response.statusCode == 301 || response.statusCode == 302) {
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`))
return
}
const file = fs.createWriteStream(destinationPath)
response.pipe(file)
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 }

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

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
src/@types/index.d.ts vendored Normal file
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
}

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

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

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

View File

@@ -1,11 +1,9 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app, ipcMain } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
@@ -23,12 +21,6 @@ if (!app.requestSingleInstanceLock()) {
// Set app user model id for windows
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()
new TrayService()
@@ -48,7 +40,7 @@ if (!app.requestSingleInstanceLock()) {
replaceDevtoolsFont(mainWindow)
if (process.env.NODE_ENV === 'development') {
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
installExtension(REDUX_DEVTOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
@@ -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
app.on('second-instance', (_event, argv) => {
app.on('second-instance', () => {
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) => {

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

View File

@@ -1,8 +1,7 @@
import fs from 'node:fs'
import { isMac, isWin } from '@main/constant'
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 log from 'electron-log'
@@ -16,9 +15,7 @@ import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import MCPService from './services/MCPService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
@@ -27,11 +24,12 @@ import { getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
import NodeAppService from './services/NodeAppService'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
const obsidianVaultService = new ObsidianVaultService()
const mcpService = new MCPService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
@@ -71,30 +69,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
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
ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
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('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:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
ipcMain.handle('backup:checkConnection', backupManager.checkConnection)
ipcMain.handle('backup:createDirectory', backupManager.createDirectory)
// file
ipcMain.handle('file:open', fileManager.open)
@@ -255,7 +232,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
ipcMain.handle('miniwindow:set-pin', (_, isPinned) => windowService.setPinMiniWindow(isPinned))
// aes
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
@@ -264,18 +240,47 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
)
// Register MCP handlers
ipcMain.handle('mcp:remove-server', mcpService.removeServer)
ipcMain.handle('mcp:restart-server', mcpService.restartServer)
ipcMain.handle('mcp:stop-server', mcpService.stopServer)
ipcMain.handle('mcp:list-tools', mcpService.listTools)
ipcMain.handle('mcp:call-tool', mcpService.callTool)
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
ipcMain.on('mcp:servers-from-renderer', (_, servers) => mcpService.setServers(servers))
ipcMain.handle('mcp:list-servers', async () => mcpService.listAvailableServices())
ipcMain.handle('mcp:add-server', async (_, server: MCPServer) => mcpService.addServer(server))
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:get-binary-path', (_, name: string) => getBinaryPath(name))
ipcMain.handle('app:install-uv-binary', () => runInstallScript('install-uv.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
ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage)
ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken)
@@ -284,19 +289,52 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('copilot:logout', CopilotService.logout)
ipcMain.handle('copilot:get-user', CopilotService.getUser)
// Obsidian service
ipcMain.handle('obsidian:get-vaults', () => {
return obsidianVaultService.getVaults()
// Node app management
const nodeAppService = NodeAppService.getInstance()
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) => {
return obsidianVaultService.getFilesByVaultName(vaultName)
ipcMain.handle('nodeapp:install-node', async () => {
return await nodeAppService.installNodeJs()
})
// nutstore
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl)
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
// Listen for changes in Node.js apps and notify renderer
nodeAppService.on('apps-updated', (apps) => {
mainWindow?.webContents.send('nodeapp:updated', apps)
})
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
}
})
}

View File

@@ -1,6 +1,6 @@
import * as fs from 'node:fs'
import { JsonLoader } from '@cherrystudio/embedjs'
import { JsonLoader } from '@llm-tools/embedjs'
/**
* Drafts 应用导出的笔记文件加载器

View File

@@ -1,6 +1,6 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
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 Logger from 'electron-log'
import EPub from 'epub'

View File

@@ -1,8 +1,8 @@
import * as fs from 'node:fs'
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs'
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@llm-tools/embedjs'
import type { AddLoaderReturn } from '@llm-tools/embedjs-interfaces'
import { WebLoader } from '@llm-tools/embedjs-loader-web'
import { LoaderReturn } from '@shared/config/types'
import { FileType, KnowledgeBaseParams } from '@types'
import Logger from 'electron-log'

View File

@@ -1,6 +1,6 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { BaseLoader } from '@llm-tools/embedjs-interfaces'
import { cleanString } from '@llm-tools/embedjs-utils'
import md5 from 'md5'
import { OfficeParserConfig, parseOfficeAsync } from 'officeparser'

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'
export default abstract class BaseReranker {
@@ -17,15 +17,4 @@ export default abstract class BaseReranker {
'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)
}
}

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 BaseReranker from './BaseReranker'
@@ -7,7 +7,6 @@ export default class DefaultReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
async rerank(): Promise<ExtractChunkData[]> {
throw new Error('Method not implemented.')
}

View File

@@ -1,4 +1,4 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
@@ -10,15 +10,9 @@ export default class JinaReranker extends BaseReranker {
}
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
// 必须携带/v1否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const requestBody = {
@@ -46,11 +40,9 @@ export default class JinaReranker extends BaseReranker {
})
.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('Jina Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
} catch (error) {
console.error('Jina Reranker API 错误:', error)
throw error
}
}
}

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 BaseReranker from './BaseReranker'

View File

@@ -4,7 +4,6 @@ import BaseReranker from './BaseReranker'
import DefaultReranker from './DefaultReranker'
import JinaReranker from './JinaReranker'
import SiliconFlowReranker from './SiliconFlowReranker'
import VoyageReranker from './VoyageReranker'
export default class RerankerFactory {
static create(base: KnowledgeBaseParams): BaseReranker {
@@ -12,8 +11,6 @@ export default class RerankerFactory {
return new SiliconFlowReranker(base)
} else if (base.rerankModelProvider === 'jina') {
return new JinaReranker(base)
} else if (base.rerankModelProvider === 'voyageai') {
return new VoyageReranker(base)
}
return new DefaultReranker(base)
}

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 axios from 'axios'
@@ -10,15 +10,9 @@ export default class SiliconFlowReranker extends BaseReranker {
}
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
// 必须携带/v1否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const requestBody = {
@@ -48,11 +42,9 @@ export default class SiliconFlowReranker extends BaseReranker {
})
.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('SiliconFlow Reranker API 错误:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
} catch (error) {
console.error('SiliconFlow Reranker API 错误:', error)
throw error
}
}
}

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

BIN
src/main/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -5,7 +5,7 @@ import { app } from 'electron'
import Logger from 'electron-log'
import * as fs from 'fs-extra'
import * as path from 'path'
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
import { createClient, FileStat } from 'webdav'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -15,7 +15,6 @@ class BackupManager {
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
constructor() {
this.checkConnection = this.checkConnection.bind(this)
this.backup = this.backup.bind(this)
this.restore = this.restore.bind(this)
this.backupToWebdav = this.backupToWebdav.bind(this)
@@ -87,16 +86,9 @@ class BackupManager {
await fs.ensureDir(this.tempDir)
onProgress({ stage: 'preparing', progress: 0, total: 100 })
// 使用流的方式写入 data.json
// 将 data 写入临时文件
const tempDataPath = path.join(this.tempDir, 'data.json')
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(tempDataPath)
writeStream.write(data)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
await fs.writeFile(tempDataPath, data)
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
// 复制 Data 目录到临时目录
@@ -215,15 +207,8 @@ class BackupManager {
fs.mkdirSync(this.backupDir, { recursive: true })
}
// 使用流的方式写入文件
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(backupedFilePath)
writeStream.write(retrievedFile as Buffer)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
// sync为同步写无须await
fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
return await this.restore(_, backupedFilePath)
} catch (error: any) {
@@ -293,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

View File

@@ -30,14 +30,6 @@ export class ConfigManager {
this.store.set('theme', theme)
}
getLaunchToTray(): boolean {
return !!this.store.get('launchToTray', false)
}
setLaunchToTray(value: boolean) {
this.store.set('launchToTray', value)
}
getTray(): boolean {
return !!this.store.get('tray', true)
}
@@ -47,14 +39,6 @@ export class ConfigManager {
this.notifySubscribers('tray', value)
}
getTrayOnClose(): boolean {
return !!this.store.get('trayOnClose', true)
}
setTrayOnClose(value: boolean) {
this.store.set('trayOnClose', value)
}
getZoomFactor(): number {
return this.store.get('zoomFactor', 1) as number
}

View File

@@ -3,12 +3,14 @@ import { FileType } from '@types'
import fs from 'fs'
import { CacheService } from './CacheService'
import { proxyManager } from './ProxyManager'
export class GeminiService {
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
private static readonly CACHE_DURATION = 3000
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string) {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey)
const uploadResult = await fileManager.uploadFile(file.path, {
mimeType: 'application/pdf',
@@ -29,6 +31,7 @@ export class GeminiService {
file: FileType,
apiKey: string
): Promise<FileMetadataResponse | undefined> {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey)
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
@@ -52,11 +55,13 @@ export class GeminiService {
}
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string) {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey)
return await fileManager.listFiles()
}
static async deleteFile(_: Electron.IpcMainInvokeEvent, apiKey: string, fileId: string) {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey)
await fileManager.deleteFile(fileId)
}

View File

@@ -16,15 +16,17 @@
import * as fs from 'node:fs'
import path from 'node:path'
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@cherrystudio/embedjs'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import Embeddings from '@main/embeddings/Embeddings'
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs'
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { LibSqlDb } from '@llm-tools/embedjs-libsql'
import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap'
import { WebLoader } from '@llm-tools/embedjs-loader-web'
import { AzureOpenAiEmbeddings, OpenAiEmbeddings } from '@llm-tools/embedjs-openai'
import { addFileLoader } from '@main/loader'
import Reranker from '@main/reranker/Reranker'
import { proxyManager } from '@main/services/ProxyManager'
import { windowService } from '@main/services/WindowService'
import { getInstanceName } from '@main/utils'
import { getAllFiles } from '@main/utils/file'
import type { LoaderReturn } from '@shared/config/types'
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
@@ -113,20 +115,30 @@ class KnowledgeService {
baseURL,
dimensions
}: KnowledgeBaseParams): Promise<RAGApplication> => {
let ragApplication: RAGApplication
const embeddings = new Embeddings({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
try {
ragApplication = await new RAGApplicationBuilder()
.setModel('NO_MODEL')
.setEmbeddingModel(embeddings)
.setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) }))
.build()
} catch (e) {
Logger.error(e)
throw new Error(`Failed to create RAGApplication: ${e}`)
}
return ragApplication
const batchSize = 10
return new RAGApplicationBuilder()
.setModel('NO_MODEL')
.setEmbeddingModel(
apiVersion
? new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIApiInstanceName: getInstanceName(baseURL),
configuration: { httpAgent: proxyManager.getProxyAgent() },
dimensions,
batchSize
})
: 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> => {
@@ -414,6 +426,7 @@ class KnowledgeService {
}
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
proxyManager.setGlobalProxy()
return new Promise((resolve) => {
const { base, item, forceReload = false } = options
const optionsNonNullableAttribute = { base, item, forceReload }
@@ -475,9 +488,6 @@ class KnowledgeService {
_: Electron.IpcMainInvokeEvent,
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }
): Promise<ExtractChunkData[]> => {
if (results.length === 0) {
return results
}
return await new Reranker(base).rerank(search, results)
}
}

View File

@@ -1,228 +1,559 @@
import os from 'node:os'
import path from 'node:path'
import { isLinux, isMac, isWin } from '@main/constant'
import { makeSureDirExists } from '@main/utils'
import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { nanoid } from '@reduxjs/toolkit'
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 { app } from 'electron'
import Logger from 'electron-log'
import log from 'electron-log'
import { EventEmitter } from 'events'
import { v4 as uuidv4 } from 'uuid'
import { CacheService } from './CacheService'
import { windowService } from './WindowService'
class McpService {
private clients: Map<string, Client> = new Map()
/**
* Service for managing Model Context Protocol servers and tools
*/
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 {
return JSON.stringify({
baseUrl: server.baseUrl,
command: server.command,
args: server.args,
registryUrl: server.registryUrl,
env: server.env,
id: server.id
})
// Simplified server loading state management
private readyState = {
serversLoaded: false,
promise: null as Promise<void> | null,
resolve: null as ((value: void) => void) | null
}
constructor() {
this.initClient = this.initClient.bind(this)
this.listTools = this.listTools.bind(this)
this.callTool = this.callTool.bind(this)
this.closeClient = this.closeClient.bind(this)
this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this)
this.stopServer = this.stopServer.bind(this)
super()
this.createServerLoadingPromise()
this.init().catch((err) => this.logError('Failed to initialize MCP service', err))
}
async initClient(server: MCPServer): Promise<Client> {
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)
if (existingClient) {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
this.clients.delete(serverKey)
} else {
return existingClient
}
/**
* Set servers received from Redux and trigger initialization if needed
*/
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
}
// Create new client instance for each connection
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
// 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
}
let transport: StdioClientTransport | SSEClientTransport
try {
// Create appropriate transport based on configuration
if (server.baseUrl) {
transport = new SSEClientTransport(new URL(server.baseUrl))
} else if (server.command) {
let cmd = server.command
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
if (baseUrl) {
transport = new this.sseTransport!(new URL(baseUrl))
} else if (command) {
let cmd: string = command
if (command === 'npx') {
cmd = await getBinaryPath('bun')
Logger.info(`[MCP] Using command: ${cmd}`)
if (cmd === 'bun') {
cmd = 'npx'
}
log.info(`[MCP] Using command: ${cmd}`)
// add -x to args if args exist
if (args && args.length > 0) {
if (!args.includes('-y')) {
!args.includes('-y') && args.unshift('-y')
args.unshift('-y')
}
if (!args.includes('x')) {
if (cmd.includes('bun') && !args.includes('x')) {
args.unshift('x')
}
}
if (server.registryUrl) {
server.env = {
...server.env,
NPM_CONFIG_REGISTRY: server.registryUrl
}
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name === 'mcp-auto-install') {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json')
}
}
} else if (server.command === 'uvx' || server.command === 'uv') {
cmd = await getBinaryPath(server.command)
if (server.registryUrl) {
server.env = {
...server.env,
UV_DEFAULT_INDEX: server.registryUrl,
PIP_INDEX_URL: server.registryUrl
}
}
} else if (command === 'uvx') {
cmd = await getBinaryPath('uvx')
}
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
// Logger.info(`[MCP] Environment variables for server:`, server.env)
log.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
transport = new StdioClientTransport({
transport = new this.stdioTransport!({
command: cmd,
args,
stderr: 'pipe',
env: {
...getDefaultEnvironment(),
PATH: this.getEnhancedPath(process.env.PATH || ''),
...server.env
...env
}
})
} else {
throw new Error('Either baseUrl or command must be provided')
}
// Create and connect client
const client = new this.Client!({ name, version: '1.0.0' }, { capabilities: {} })
await client.connect(transport)
// Store the new client in the cache
this.clients.set(serverKey, client)
// Store client and server info
this.clients[name] = client
this.activeServers.set(name, { client, server })
Logger.info(`[MCP] Activated server: ${server.name}`)
return client
} catch (error: any) {
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
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
}
}
async closeClient(serverKey: string) {
const client = this.clients.get(serverKey)
if (client) {
// Remove the client from the cache
await client.close()
Logger.info(`[MCP] Closed server: ${serverKey}`)
this.clients.delete(serverKey)
CacheService.remove(`mcp:list_tool:${serverKey}`)
Logger.info(`[MCP] Cleared cache for server: ${serverKey}`)
} else {
Logger.warn(`[MCP] No client found for server: ${serverKey}`)
/**
* Deactivate an MCP server
*/
public async deactivate(name: string): Promise<void> {
await this.ensureInitialized()
if (!this.clients[name]) {
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 stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
Logger.info(`[MCP] Stopping server: ${server.name}`)
await this.closeClient(serverKey)
}
/**
* List available tools from active MCP servers
*/
public async listTools(serverName?: string): Promise<MCPTool[]> {
await this.ensureInitialized()
log.info(`[MCP] Listing tools from ${serverName || 'all active servers'}`)
async removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
const existingClient = this.clients.get(serverKey)
if (existingClient) {
await this.closeClient(serverKey)
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 restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
Logger.info(`[MCP] Restarting server: ${server.name}`)
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
await this.initClient(server)
}
/**
* Helper method to list tools from a specific server
*/
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}`
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const client = await this.initClient(server)
const serverKey = this.getServerKey(server)
const cacheKey = `mcp:list_tool:${serverKey}`
if (CacheService.has(cacheKey)) {
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
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)
}
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
id: `f${nanoid()}`,
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
CacheService.set(cacheKey, serverTools, 5 * 60 * 1000)
return serverTools
const { tools } = await this.clients[serverName].listTools()
const transformedTools = tools.map((tool: any) => ({
...tool,
serverName,
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
*/
public async callTool(
_: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args: any }
): Promise<any> {
public async callTool(params: { client: string; name: string; args: any }): Promise<any> {
await this.ensureInitialized()
const { client, name, args } = params
if (!this.clients[client]) {
throw new Error(`MCP Client ${client} not found`)
}
log.info('[MCP] Calling:', client, name, args)
try {
Logger.info('[MCP] Calling:', server.name, name, args)
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args })
return result
return await this.clients[client].callTool({
name,
arguments: args
})
} 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
}
}
public async getInstallInfo() {
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const uvName = await getBinaryName('uv')
const bunName = await getBinaryName('bun')
const uvPath = path.join(dir, uvName)
const bunPath = path.join(dir, bunName)
return { dir, uvPath, bunPath }
/**
* Clean up all MCP resources
*/
public async cleanup(): Promise<void> {
const clientNames = Object.keys(this.clients)
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`)
}
/**
@@ -250,7 +581,6 @@ class McpService {
`${homeDir}/.npm-global/bin`,
`${homeDir}/.yarn/bin`,
`${homeDir}/.cargo/bin`,
`${homeDir}/.cherrystudio/bin`,
'/opt/local/bin'
)
}
@@ -264,18 +594,12 @@ class McpService {
`${homeDir}/.npm-global/bin`,
`${homeDir}/.yarn/bin`,
`${homeDir}/.cargo/bin`,
`${homeDir}/.cherrystudio/bin`,
'/snap/bin'
)
}
if (isWin) {
newPaths.push(
`${process.env.APPDATA}\\npm`,
`${homeDir}\\AppData\\Local\\Yarn\\bin`,
`${homeDir}\\.cargo\\bin`,
`${homeDir}\\.cherrystudio\\bin`
)
newPaths.push(`${process.env.APPDATA}\\npm`, `${homeDir}\\AppData\\Local\\Yarn\\bin`, `${homeDir}\\.cargo\\bin`)
}
// 只添加不存在的路径
@@ -289,5 +613,3 @@ class McpService {
return Array.from(existingPaths).join(pathSeparator)
}
}
export default new McpService()

File diff suppressed because it is too large Load Diff

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.posix.join('/', href.replace(serverBase, ''))
return {
filename: filename.endsWith('/') ? filename.slice(0, -1) : 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
}

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

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

View File

@@ -1,23 +1,25 @@
import { ProxyConfig as _ProxyConfig, session } from 'electron'
import { socksDispatcher } from 'fetch-socks'
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
import { HttpsProxyAgent } from 'https-proxy-agent'
import { ProxyAgent, setGlobalDispatcher } from 'undici'
type ProxyMode = 'system' | 'custom' | 'none'
export interface ProxyConfig {
mode: ProxyMode
url?: string
url?: string | null
}
export class ProxyManager {
private config: ProxyConfig
private proxyAgent: GeneralProxyAgent | null = null
private proxyAgent: HttpsProxyAgent | null = null
private proxyUrl: string | null = null
private systemProxyInterval: NodeJS.Timeout | null = null
constructor() {
this.config = {
mode: 'none'
mode: 'none',
url: ''
}
}
@@ -49,7 +51,7 @@ export class ProxyManager {
if (this.config.mode === 'system') {
await this.setSystemProxy()
this.monitorSystemProxy()
} else if (this.config.mode === 'custom') {
} else if (this.config.mode == 'custom') {
await this.setCustomProxy()
} else {
await this.clearProxy()
@@ -71,13 +73,11 @@ export class ProxyManager {
private async setSystemProxy(): Promise<void> {
try {
await this.setSessionsProxy({ mode: 'system' })
const proxyString = await session.defaultSession.resolveProxy('https://dummy.com')
const [protocol, address] = proxyString.split(';')[0].split(' ')
const url = protocol === 'PROXY' ? `http://${address}` : null
if (url && url !== this.config.url) {
this.config.url = url.toLowerCase()
this.setEnvironment(this.config.url)
this.proxyAgent = new GeneralProxyAgent()
const url = await this.resolveSystemProxy()
if (url && url !== this.proxyUrl) {
this.proxyUrl = url.toLowerCase()
this.proxyAgent = new HttpsProxyAgent(this.proxyUrl)
this.setEnvironment(this.proxyUrl)
}
} catch (error) {
console.error('Failed to set system proxy:', error)
@@ -88,9 +88,10 @@ export class ProxyManager {
private async setCustomProxy(): Promise<void> {
try {
if (this.config.url) {
this.setEnvironment(this.config.url)
this.proxyAgent = new GeneralProxyAgent()
await this.setSessionsProxy({ proxyRules: this.config.url })
this.proxyUrl = this.config.url.toLowerCase()
this.proxyAgent = new HttpsProxyAgent(this.proxyUrl)
this.setEnvironment(this.proxyUrl)
await this.setSessionsProxy({ proxyRules: this.proxyUrl })
}
} catch (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.HTTPS_PROXY
delete process.env.grpc_proxy
delete process.env.http_proxy
delete process.env.https_proxy
}
private async clearProxy(): Promise<void> {
this.clearEnvironment()
await this.setSessionsProxy({ mode: 'direct' })
await this.setSessionsProxy({})
this.config = { mode: 'none' }
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
}
getProxyUrl(): string {
return this.config.url || ''
getProxyUrl(): string | null {
return this.proxyUrl
}
setGlobalProxy() {
const proxyUrl = this.config.url
const proxyUrl = this.proxyUrl
if (proxyUrl) {
const [protocol, address] = proxyUrl.split('://')
const [host, port] = address.split(':')

View File

@@ -23,8 +23,17 @@ function getShortcutHandler(shortcut: Shortcut) {
configManager.setZoomFactor(1)
}
case 'show_app':
return () => {
windowService.toggleMainWindow()
return (window: BrowserWindow) => {
if (window.isVisible()) {
if (window.isFocused()) {
window.hide()
} else {
window.focus()
}
} else {
window.show()
window.focus()
}
}
case 'mini_window':
return () => {
@@ -106,20 +115,7 @@ const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutForm
}
export function registerShortcuts(window: BrowserWindow) {
window.once('ready-to-show', () => {
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) => {
const register = () => {
if (window.isDestroyed()) return
const shortcuts = configManager.getShortcuts()
@@ -136,11 +132,6 @@ export function registerShortcuts(window: BrowserWindow) {
return
}
// only register universal shortcuts when needed
if (onlyUniversalShortcuts && !['show_app', 'mini_window'].includes(shortcut.key)) {
return
}
const handler = getShortcutHandler(shortcut)
if (!handler) {
return
@@ -212,13 +203,9 @@ export function registerShortcuts(window: BrowserWindow) {
// only register the event handlers once
if (undefined === windowOnHandlers.get(window)) {
// pass register() directly to listener, the func will receive Event as argument, it's not expected
const registerHandler = () => {
register()
}
window.on('focus', registerHandler)
window.on('focus', register)
window.on('blur', unregister)
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
windowOnHandlers.set(window, { onFocusHandler: register, onBlurHandler: unregister })
}
if (!window.isDestroyed() && window.isFocused()) {

View File

@@ -1,31 +1,28 @@
import { proxyManager } from '@main/services/ProxyManager'
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import { HttpProxyAgent } from 'http-proxy-agent'
import Stream from 'stream'
import {
BufferLike,
createClient,
CreateDirectoryOptions,
GetFileContentsOptions,
PutFileContentsOptions,
WebDAVClient
} from 'webdav'
import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
export default class WebDav {
public instance: WebDAVClient | undefined
private webdavPath: string
constructor(params: WebDavConfig) {
this.webdavPath = params.webdavPath
const url = proxyManager.getProxyUrl()
this.instance = createClient(params.webdavHost, {
username: params.webdavUser,
password: params.webdavPass,
maxBodyLength: Infinity,
maxContentLength: Infinity
maxContentLength: Infinity,
httpAgent: url ? new HttpProxyAgent(url) : undefined,
httpsAgent: proxyManager.getProxyAgent()
})
this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this)
this.createDirectory = this.createDirectory.bind(this)
}
public putFileContents = async (
@@ -72,30 +69,4 @@ export default class WebDav {
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
}
}
}

View File

@@ -1,5 +1,5 @@
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 { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
import Logger from 'electron-log'
@@ -15,11 +15,7 @@ export class WindowService {
private static instance: WindowService | null = null
private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null
private isPinnedMiniWindow: boolean = false
private wasFullScreen: boolean = false
//hacky-fix: store the focused status of mainWindow before miniWindow shows
//to restore the focus status when miniWindow hides
private wasMainWindowFocused: boolean = false
private selectionMenuWindow: BrowserWindow | null = null
private lastSelectedText: string = ''
private contextMenu: Menu | null = null
@@ -34,7 +30,6 @@ export class WindowService {
public createMainWindow(): BrowserWindow {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.show()
this.mainWindow.focus()
return this.mainWindow
}
@@ -44,6 +39,8 @@ export class WindowService {
})
const theme = configManager.getTheme()
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
this.mainWindow = new BrowserWindow({
x: mainWindowState.x,
@@ -61,7 +58,7 @@ export class WindowService {
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
trafficLightPosition: { x: 8, y: 12 },
...(isLinux ? { icon } : {}),
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -73,12 +70,6 @@ export class WindowService {
this.setupMainWindow(this.mainWindow, mainWindowState)
//preload miniWindow to resolve series of issues about miniWindow in Mac
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (enableQuickAssistant && !this.miniWindow) {
this.miniWindow = this.createMiniWindow(true)
}
return this.mainWindow
}
@@ -155,14 +146,7 @@ export class WindowService {
private setupWindowEvents(mainWindow: BrowserWindow) {
mainWindow.once('ready-to-show', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
// show window only when laucn to tray not set
const isLaunchToTray = configManager.getLaunchToTray()
if (!isLaunchToTray) {
//[mac]hacky-fix: miniWindow set visibleOnFullScreen:true will cause dock icon disappeared
app.dock?.show()
mainWindow.show()
}
mainWindow.show()
})
// 处理全屏相关事件
@@ -176,25 +160,6 @@ export class WindowService {
mainWindow.webContents.send('fullscreen-status-changed', false)
})
// set the zoom factor again when the window is going to resize
//
// this is a workaround for the known bug that
// the zoom factor is reset to cached value when window is resized after routing to other page
// see: https://github.com/electron/electron/issues/10572
//
mainWindow.on('will-resize', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
// ARCH: as `will-resize` is only for Win & Mac,
// linux has the same problem, use `resize` listener instead
// but `resize` will fliker the ui
if (isLinux) {
mainWindow.on('resize', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
}
// 添加Escape键退出全屏的支持
mainWindow.webContents.on('before-input-event', (event, input) => {
// 当按下Escape键且窗口处于全屏状态时退出全屏
@@ -290,20 +255,12 @@ export class WindowService {
return app.quit()
}
// 托盘及关闭行为设置
const isShowTray = configManager.getTray()
const isTrayOnClose = configManager.getTrayOnClose()
// 没有开启托盘,或者开启了托盘,但设置了直接关闭,应执行直接退出
if (!isShowTray || (isShowTray && !isTrayOnClose)) {
// 如果是Windows或Linux直接退出
// mac按照系统默认行为不退出
if (isWin || isLinux) {
return app.quit()
}
// 没有开启托盘且是Windows或Linux系统直接退出
const notInTray = !configManager.getTray()
if ((isWin || isLinux) && notInTray) {
return app.quit()
}
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
// 如果是Windows或Linux且处于全屏状态则退出应用
if (this.wasFullScreen) {
if (isWin || isLinux) {
@@ -314,12 +271,8 @@ export class WindowService {
return
}
}
event.preventDefault()
mainWindow.hide()
//for mac users, should hide dock icon if close to tray
app.dock?.hide()
})
mainWindow.on('closed', () => {
@@ -340,52 +293,46 @@ export class WindowService {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
if (this.mainWindow.isMinimized()) {
this.mainWindow.restore()
return
return this.mainWindow.restore()
}
//[macOS] Known Issue
// setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
// AppleScript may be a solution, but it's not worth
this.mainWindow.setVisibleOnAllWorkspaces(true)
this.mainWindow.show()
this.mainWindow.focus()
this.mainWindow.setVisibleOnAllWorkspaces(false)
} else {
this.mainWindow = this.createMainWindow()
this.mainWindow.focus()
}
}
public toggleMainWindow() {
// should not toggle main window when in full screen
if (this.wasFullScreen) {
public showMiniWindow() {
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (!enableQuickAssistant) {
return
}
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) {
if (this.mainWindow.isFocused()) {
// if tray is enabled, hide the main window, else do nothing
if (configManager.getTray()) {
this.mainWindow.hide()
app.dock?.hide()
}
} else {
this.mainWindow.focus()
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.hide()
}
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.hide()
}
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
if (this.miniWindow.isMinimized()) {
this.miniWindow.restore()
}
this.miniWindow.show()
this.miniWindow.center()
this.miniWindow.focus()
return
}
this.showMainWindow()
}
const isMac = process.platform === 'darwin'
public createMiniWindow(isPreload: boolean = false): BrowserWindow {
this.miniWindow = new BrowserWindow({
width: 550,
height: 400,
minWidth: 350,
minHeight: 380,
maxWidth: 1024,
maxHeight: 768,
show: false,
width: 500,
height: 520,
show: true,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'under-window',
@@ -393,13 +340,8 @@ export class WindowService {
center: true,
frame: false,
alwaysOnTop: true,
resizable: true,
resizable: false,
useContentSize: true,
...(isMac ? { type: 'panel' } : {}),
skipTaskbar: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -408,25 +350,8 @@ export class WindowService {
}
})
//miniWindow should show in current desktop
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
//make miniWindow always on top of fullscreen apps with level set
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
this.miniWindow.on('ready-to-show', () => {
if (isPreload) {
return
}
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
this.miniWindow?.center()
this.miniWindow?.show()
})
this.miniWindow.on('blur', () => {
if (!this.isPinnedMiniWindow) {
this.hideMiniWindow()
}
this.miniWindow?.hide()
})
this.miniWindow.on('closed', () => {
@@ -452,48 +377,9 @@ export class WindowService {
hash: '#/mini'
})
}
return this.miniWindow
}
public showMiniWindow() {
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (!enableQuickAssistant) {
return
}
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.hide()
}
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
if (this.miniWindow.isMinimized()) {
this.miniWindow.restore()
}
this.miniWindow.show()
return
}
this.miniWindow = this.createMiniWindow()
}
public hideMiniWindow() {
//hacky-fix:[mac/win] previous window(not self-app) should be focused again after miniWindow hide
if (isWin) {
this.miniWindow?.minimize()
this.miniWindow?.hide()
return
} else if (isMac) {
this.miniWindow?.hide()
if (!this.wasMainWindowFocused) {
app.hide()
}
return
}
this.miniWindow?.hide()
}
@@ -502,16 +388,11 @@ export class WindowService {
}
public toggleMiniWindow() {
if (this.miniWindow && !this.miniWindow.isDestroyed() && this.miniWindow.isVisible()) {
this.hideMiniWindow()
return
if (this.miniWindow) {
this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
} else {
this.showMiniWindow()
}
this.showMiniWindow()
}
public setPinMiniWindow(isPinned) {
this.isPinnedMiniWindow = isPinned
}
public showSelectionMenu(bounds: { x: number; y: number }) {
@@ -522,6 +403,7 @@ export class WindowService {
}
const theme = configManager.getTheme()
const isMac = process.platform === 'darwin'
this.selectionMenuWindow = new BrowserWindow({
width: 280,

View File

@@ -42,13 +42,3 @@ export function dumpPersistState() {
}
return JSON.stringify(persistState)
}
export const runAsyncFunction = async (fn: () => void) => {
await fn()
}
export function makeSureDirExists(dir: string) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}

View File

@@ -6,13 +6,13 @@ import path from 'path'
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) => {
const installScriptPath = path.join(getResourcePath(), 'scripts', scriptPath)
log.info(`Running script at: ${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) => {
@@ -35,22 +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> {
if (!name) {
return path.join(os.homedir(), '.cherrystudio', 'bin')
}
const binaryName = await getBinaryName(name)
export async function getBinaryPath(name: string): Promise<string> {
let cmd = process.platform === 'win32' ? `${name}.exe` : name
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
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> {

View File

@@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { ElectronAPI } from '@electron-toolkit/preload'
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 { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
import type { LoaderReturn } from '@shared/config/types'
@@ -23,10 +23,7 @@ declare global {
openWebsite: (url: string) => void
setProxy: (proxy: string | undefined) => void
setLanguage: (theme: LanguageVarious) => void
setLaunchOnBoot: (isActive: boolean) => void
setLaunchToTray: (isActive: boolean) => void
setTray: (isActive: boolean) => void
setTrayOnClose: (isActive: boolean) => void
restartTray: () => void
setTheme: (theme: 'light' | 'dark') => void
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
@@ -45,8 +42,6 @@ declare global {
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
checkConnection: (webdavConfig: WebDavConfig) => Promise<boolean>
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => Promise<void>
}
file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
@@ -137,7 +132,6 @@ declare global {
hide: () => Promise<void>
close: () => Promise<void>
toggle: () => Promise<void>
setPin: (isPinned: boolean) => Promise<void>
}
aes: {
encrypt: (text: string, secretKey: string, iv: string) => Promise<{ iv: string; encryptedData: string }>
@@ -147,12 +141,17 @@ declare global {
openExternal: (url: string, options?: OpenExternalOptions) => Promise<void>
}
mcp: {
removeServer: (server: MCPServer) => Promise<void>
restartServer: (server: MCPServer) => Promise<void>
stopServer: (server: MCPServer) => Promise<void>
listTools: (server: MCPServer) => Promise<MCPTool[]>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
// servers
listServers: () => Promise<MCPServer[]>
addServer: (server: MCPServer) => Promise<void>
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: {
getAuthMessage: (
@@ -164,18 +163,37 @@ declare global {
logout: () => Promise<void>
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>
getBinaryPath: (name: string) => Promise<string>
installUVBinary: () => Promise<void>
installBunBinary: () => Promise<void>
protocol: {
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>
}
run: (command: string) => Promise<string>
}
}
}

View File

@@ -1,8 +1,7 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
import { CreateDirectoryOptions } from 'webdav'
// Custom APIs for renderer
const api = {
@@ -12,10 +11,7 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
showUpdateDialog: () => ipcRenderer.invoke('app:show-update-dialog'),
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),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke('app:set-tray-on-close', isActive),
restartTray: () => ipcRenderer.invoke('app:restart-tray'),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
@@ -35,10 +31,39 @@ const api = {
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig),
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig),
checkConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:checkConnection', webdavConfig),
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke('backup:createDirectory', webdavConfig, path, options)
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig)
},
nodeapp: {
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: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
@@ -112,8 +137,7 @@ const api = {
show: () => ipcRenderer.invoke('miniwindow:show'),
hide: () => ipcRenderer.invoke('miniwindow:hide'),
close: () => ipcRenderer.invoke('miniwindow:close'),
toggle: () => ipcRenderer.invoke('miniwindow:toggle'),
setPin: (isPinned: boolean) => ipcRenderer.invoke('miniwindow:set-pin', isPinned)
toggle: () => ipcRenderer.invoke('miniwindow:toggle')
},
aes: {
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),
@@ -121,16 +145,18 @@ const api = {
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
},
mcp: {
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server),
restartServer: (server: MCPServer) => ipcRenderer.invoke('mcp:restart-server', server),
stopServer: (server: MCPServer) => ipcRenderer.invoke('mcp:stop-server', server),
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
ipcRenderer.invoke('mcp:call-tool', { server, name, args }),
getInstallInfo: () => ipcRenderer.invoke('mcp:get-install-info')
listServers: () => ipcRenderer.invoke('mcp:list-servers'),
addServer: (server: MCPServer) => ipcRenderer.invoke('mcp:add-server', server),
updateServer: (server: MCPServer) => ipcRenderer.invoke('mcp:update-server', server),
deleteServer: (serverName: string) => ipcRenderer.invoke('mcp:delete-server', serverName),
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: {
openExternal: shell.openExternal
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url)
},
copilot: {
getAuthMessage: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-auth-message', headers),
@@ -147,23 +173,7 @@ const api = {
getBinaryPath: (name: string) => ipcRenderer.invoke('app:get-binary-path', name),
installUVBinary: () => ipcRenderer.invoke('app:install-uv-binary'),
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'),
protocol: {
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)
}
run: (command: string) => ipcRenderer.invoke('app:run-command', command)
}
// Use `contextBridge` APIs to expose Electron APIs to
@@ -173,11 +183,6 @@ if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
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) {
console.error(error)
}

View File

@@ -39,4 +39,5 @@
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View File

@@ -17,11 +17,12 @@ import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import NodeAppsPage from './pages/nodeapps/NodeAppsPage'
import PaintingsPage from './pages/paintings/PaintingsPage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
function App(): React.ReactElement {
function App(): JSX.Element {
return (
<Provider store={store}>
<StyleSheetManager>
@@ -41,6 +42,7 @@ function App(): React.ReactElement {
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/nodeapps" element={<NodeAppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,18 +0,0 @@
@keyframes animation-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: animation-pulse 1.5s infinite;
}

View File

@@ -192,10 +192,3 @@
}
}
}
.ant-dropdown-menu .ant-dropdown-menu-sub {
max-height: 350px;
width: max-content;
overflow-y: auto;
overflow-x: hidden;
}

View File

@@ -2,7 +2,6 @@
@use './ant.scss';
@use './scrollbar.scss';
@use './container.scss';
@use './animation.scss';
@import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css';
@@ -19,7 +18,7 @@
--color-gray-2: #414853;
--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-3: rgba(235, 235, 245, 0.38);

View File

@@ -294,11 +294,3 @@
emoji-picker {
--border-size: 0;
}
.katex-display{
overflow-x: auto;
overflow-y: hidden;
}
mjx-container{
overflow-x: auto;
}

View File

@@ -8,10 +8,9 @@ interface Props {
model: Model
size: number
props?: AvatarProps
className?: string
}
const ModelAvatar: FC<Props> = ({ model, size, props, className }) => {
const ModelAvatar: FC<Props> = ({ model, size, props }) => {
return (
<Avatar
src={getModelLogo(model?.id || '')}
@@ -24,8 +23,7 @@ const ModelAvatar: FC<Props> = ({ model, size, props, className }) => {
alignItems: 'center',
justifyContent: 'center'
}}
{...props}
className={className}>
{...props}>
{first(model?.name)}
</Avatar>
)

View File

@@ -1,43 +0,0 @@
import { Collapse } from 'antd'
import { FC, memo } from 'react'
interface CustomCollapseProps {
label: React.ReactNode
extra: React.ReactNode
children: React.ReactNode
}
const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) => {
const CollapseStyle = {
background: 'transparent',
border: '0.5px solid var(--color-border)'
}
const CollapseItemStyles = {
header: {
padding: '8px 16px',
alignItems: 'center',
justifyContent: 'space-between'
},
body: {
borderTop: '0.5px solid var(--color-border)'
}
}
return (
<Collapse
bordered={false}
style={CollapseStyle}
defaultActiveKey={['1']}
items={[
{
styles: CollapseItemStyles,
key: '1',
label,
extra,
children
}
]}
/>
)
}
export default memo(CustomCollapse)

View File

@@ -9,7 +9,6 @@ import {
ResponderProvided
} from '@hello-pangea/dnd'
import { droppableReorder } from '@renderer/utils'
import VirtualList from 'rc-virtual-list'
import { FC } from 'react'
interface Props<T> {
@@ -48,28 +47,26 @@ const DragableList: FC<Props<any>> = ({
<Droppable droppableId="droppable" {...droppableProps}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
<VirtualList data={list} itemKey="id">
{(item, index) => {
const id = item.id || item
return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...listStyle,
...provided.draggableProps.style,
marginBottom: 8
}}>
{children(item, index)}
</div>
)}
</Draggable>
)
}}
</VirtualList>
{list.map((item, index) => {
const id = item.id || item
return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...listStyle,
...provided.draggableProps.style,
marginBottom: 8
}}>
{children(item, index)}
</div>
)}
</Draggable>
)
})}
{provided.placeholder}
</div>
)}

View File

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

View File

@@ -8,7 +8,7 @@ const ReasoningIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement
return (
<Container>
<Tooltip title={t('models.type.reasoning')} placement="top">
<Tooltip title={t('models.reasoning')} placement="top">
<Icon className="iconfont icon-thinking" {...(props as any)} />
</Tooltip>
</Container>

View File

@@ -23,7 +23,7 @@ const Container = styled.div`
`
const Icon = styled(ToolOutlined)`
color: var(--color-primary);
color: #d97757;
font-size: 15px;
margin-right: 6px;
`

View File

@@ -9,7 +9,7 @@ const VisionIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>,
return (
<Container>
<Tooltip title={t('models.type.vision')} placement="top">
<Tooltip title={t('models.vision')} placement="top">
<Icon {...(props as any)} />
</Tooltip>
</Container>

View File

@@ -9,7 +9,7 @@ const WebSearchIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement
return (
<Container>
<Tooltip title={t('models.type.websearch')} placement="top">
<Tooltip title={t('models.websearch')} placement="top">
<Icon {...(props as any)} />
</Tooltip>
</Container>

View File

@@ -4,25 +4,15 @@ import styled from 'styled-components'
interface IndicatorLightProps {
color: string
size?: number
shadow?: boolean
style?: React.CSSProperties
animation?: boolean
}
const Light = styled.div<{
color: string
size: number
shadow?: boolean
style?: React.CSSProperties
animation?: boolean
}>`
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
const Light = styled.div<{ color: string }>`
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${({ color }) => color};
box-shadow: ${({ shadow, color }) => (shadow ? `0 0 6px ${color}` : 'none')};
animation: ${({ animation }) => (animation ? 'pulse 2s infinite' : 'none')};
box-shadow: 0 0 6px ${({ color }) => color};
animation: pulse 2s infinite;
@keyframes pulse {
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
return <Light color={actualColor} size={size} shadow={shadow} style={style} animation={animation} />
return <Light color={actualColor} />
}
export default IndicatorLight

View File

@@ -8,20 +8,17 @@ interface ListItemProps {
subtitle?: string
titleStyle?: React.CSSProperties
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 (
<ListItemContainer className={active ? 'active' : ''} onClick={onClick} style={style}>
<ListItemContainer className={active ? 'active' : ''} onClick={onClick}>
<ListItemContent>
{icon && <IconWrapper>{icon}</IconWrapper>}
<TextContainer>
<TitleText style={titleStyle}>{title}</TitleText>
{subtitle && <SubtitleText>{subtitle}</SubtitleText>}
</TextContainer>
{rightContent && <RightContentWrapper>{rightContent}</RightContentWrapper>}
</ListItemContent>
</ListItemContainer>
)
@@ -87,8 +84,4 @@ const SubtitleText = styled.div`
color: var(--color-text-3);
`
const RightContentWrapper = styled.div`
margin-left: auto;
`
export default ListItem

View File

@@ -1,429 +0,0 @@
import {
CloseOutlined,
CodeOutlined,
CopyOutlined,
ExportOutlined,
MinusOutlined,
PushpinOutlined,
ReloadOutlined
} from '@ant-design/icons'
import { isMac, isWindows } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { Avatar, Drawer, Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
import WebviewContainer from './WebviewContainer'
interface AppExtraInfo {
canPinned: boolean
isPinned: boolean
canOpenExternalLink: boolean
}
type AppInfo = MinAppType & AppExtraInfo
/** The main container for MinApp popup */
const MinappPopupContainer: React.FC = () => {
const { openedKeepAliveMinapps, openedOneOffMinapp, currentMinappId, minappShow } = useRuntime()
const { closeMinapp, hideMinappPopup } = useMinappPopup()
const { pinned, updatePinnedMinapps } = useMinapps()
const { t } = useTranslation()
/** control the drawer open or close */
const [isPopupShow, setIsPopupShow] = useState(true)
/** whether the current minapp is ready */
const [isReady, setIsReady] = useState(false)
/** the current REAL url of the minapp
* different from the app preset url, because user may navigate in minapp */
const [currentUrl, setCurrentUrl] = useState<string | null>(null)
/** store the last minapp id and show status */
const lastMinappId = useRef<string | null>(null)
const lastMinappShow = useRef<boolean>(false)
/** store the webview refs, one of the key to make them keepalive */
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
/** indicate whether the webview has loaded */
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
const isInDevelopment = process.env.NODE_ENV === 'development'
useBridge()
/** set the popup display status */
useEffect(() => {
if (minappShow) {
// init the current url
if (currentMinappId && currentAppInfo) {
setCurrentUrl(currentAppInfo.url)
}
setIsPopupShow(true)
if (webviewLoadedRefs.current.get(currentMinappId)) {
setIsReady(true)
/** the case that open the minapp from sidebar */
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
setIsReady(false)
}
} else {
setIsPopupShow(false)
setIsReady(false)
}
return () => {
/** renew the last minapp id and show status */
lastMinappId.current = currentMinappId
lastMinappShow.current = minappShow
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minappShow, currentMinappId])
useEffect(() => {
if (!webviewRefs.current) return
/** set the webview display status
* DO NOT use the state to set the display status,
* to AVOID the re-render of the webview container
*/
webviewRefs.current.forEach((webviewRef, appid) => {
if (!webviewRef) return
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
})
//delete the extra webviewLoadedRefs
webviewLoadedRefs.current.forEach((_, appid) => {
if (!webviewRefs.current.has(appid)) {
webviewLoadedRefs.current.delete(appid)
}
})
}, [currentMinappId])
/** only the keepalive minapp can be minimized */
const canMinimize = !(openedOneOffMinapp && openedOneOffMinapp.id == currentMinappId)
/** combine the openedKeepAliveMinapps and openedOneOffMinapp */
const combinedApps = useMemo(() => {
return [...openedKeepAliveMinapps, ...(openedOneOffMinapp ? [openedOneOffMinapp] : [])]
}, [openedKeepAliveMinapps, openedOneOffMinapp])
/** get the extra info of the apps */
const appsExtraInfo = useMemo(() => {
return combinedApps.reduce(
(acc, app) => ({
...acc,
[app.id]: {
canPinned: DEFAULT_MIN_APPS.some((item) => item.id === app.id),
isPinned: pinned.some((item) => item.id === app.id),
canOpenExternalLink: app.url.startsWith('http://') || app.url.startsWith('https://')
}
}),
{} as Record<string, AppExtraInfo>
)
}, [combinedApps, pinned])
/** get the current app info with extra info */
let currentAppInfo: AppInfo | null = null
if (currentMinappId) {
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
}
/** will close the popup and delete the webview */
const handlePopupClose = async (appid: string) => {
setIsPopupShow(false)
await delay(0.3)
webviewLoadedRefs.current.delete(appid)
closeMinapp(appid)
}
/** will hide the popup and remain the webviews */
const handlePopupMinimize = async () => {
setIsPopupShow(false)
await delay(0.3)
hideMinappPopup()
}
/** the callback function to set the webviews ref */
const handleWebviewSetRef = (appid: string, element: WebviewTag | null) => {
webviewRefs.current.set(appid, element)
if (!webviewRefs.current.has(appid)) {
webviewRefs.current.set(appid, null)
return
}
if (element) {
webviewRefs.current.set(appid, element)
} else {
webviewRefs.current.delete(appid)
}
}
/** the callback function to set the webviews loaded indicator */
const handleWebviewLoaded = (appid: string) => {
webviewLoadedRefs.current.set(appid, true)
if (appid == currentMinappId) {
setTimeout(() => setIsReady(true), 200)
}
}
/** the callback function to handle the webview navigate to new url */
const handleWebviewNavigate = (appid: string, url: string) => {
if (appid === currentMinappId) {
setCurrentUrl(url)
}
}
/** will open the devtools of the minapp */
const handleOpenDevTools = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview) {
webview.openDevTools()
}
}
/** only reload the original url */
const handleReload = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview) {
const url = combinedApps.find((item) => item.id === appid)?.url
if (url) {
webview.src = url
}
}
}
/** open the giving url in browser */
const handleOpenLink = (url: string) => {
window.api.openWebsite(url)
}
/** toggle the pin status of the minapp */
const handleTogglePin = (appid: string) => {
const app = combinedApps.find((item) => item.id === appid)
if (!app) return
const newPinned = appsExtraInfo[appid].isPinned ? pinned.filter((item) => item.id !== appid) : [...pinned, app]
updatePinnedMinapps(newPinned)
}
/** Title bar of the popup */
const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
if (!appInfo) return null
const handleCopyUrl = (event: any, url: string) => {
//don't show app-wide context menu
event.preventDefault()
navigator.clipboard
.writeText(url)
.then(() => {
window.message.success('URL ' + t('message.copy.success'))
})
.catch(() => {
window.message.error('URL ' + t('message.copy.failed'))
})
}
return (
<TitleContainer style={{ justifyContent: 'space-between' }}>
<Tooltip
title={
<TitleTextTooltip>
{url ?? appInfo.url} <br />
<CopyOutlined className="icon-copy" />
{t('minapp.popup.rightclick_copyurl')}
</TitleTextTooltip>
}
mouseEnterDelay={0.8}
placement="rightBottom"
styles={{
root: {
maxWidth: '400px'
}
}}>
<TitleText onContextMenu={(e) => handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name}</TitleText>
</Tooltip>
<ButtonsGroup className={isWindows ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleReload(appInfo.id)}>
<ReloadOutlined />
</Button>
</Tooltip>
{appInfo.canPinned && (
<Tooltip
title={appInfo.isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title')}
mouseEnterDelay={0.8}
placement="bottom">
<Button onClick={() => handleTogglePin(appInfo.id)} className={appInfo.isPinned ? 'pinned' : ''}>
<PushpinOutlined style={{ fontSize: 16 }} />
</Button>
</Tooltip>
)}
{appInfo.canOpenExternalLink && (
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
<ExportOutlined />
</Button>
</Tooltip>
)}
{isInDevelopment && (
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleOpenDevTools(appInfo.id)}>
<CodeOutlined />
</Button>
</Tooltip>
)}
{canMinimize && (
<Tooltip title={t('minapp.popup.minimize')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handlePopupMinimize()}>
<MinusOutlined />
</Button>
</Tooltip>
)}
<Tooltip title={t('minapp.popup.close')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handlePopupClose(appInfo.id)}>
<CloseOutlined />
</Button>
</Tooltip>
</ButtonsGroup>
</TitleContainer>
)
}
/** group the webview containers with Memo, one of the key to make them keepalive */
const WebviewContainerGroup = useMemo(() => {
return combinedApps.map((app) => (
<WebviewContainer
key={app.id}
appid={app.id}
url={app.url}
onSetRefCallback={handleWebviewSetRef}
onLoadedCallback={handleWebviewLoaded}
onNavigateCallback={handleWebviewNavigate}
/>
))
// because the combinedApps is enough
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [combinedApps])
return (
<Drawer
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
placement="bottom"
onClose={handlePopupMinimize}
open={isPopupShow}
destroyOnClose={false}
mask={false}
rootClassName="minapp-drawer"
maskClassName="minapp-mask"
height={'100%'}
maskClosable={false}
closeIcon={null}
style={{ marginLeft: 'var(--sidebar-width)' }}>
{!isReady && (
<EmptyView>
<Avatar
src={currentAppInfo?.logo}
size={80}
style={{ border: '1px solid var(--color-border)', marginTop: -150 }}
/>
<BeatLoader color="var(--color-text-2)" size="10px" style={{ marginTop: 15 }} />
</EmptyView>
)}
{WebviewContainerGroup}
</Drawer>
)
}
const TitleContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding-left: ${isMac ? '20px' : '10px'};
padding-right: 10px;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
`
const TitleText = styled.div`
font-weight: bold;
font-size: 14px;
color: var(--color-text-1);
margin-right: 10px;
-webkit-app-region: no-drag;
`
const TitleTextTooltip = styled.span`
font-size: 0.8rem;
.icon-copy {
font-size: 0.7rem;
padding-right: 5px;
}
`
const ButtonsGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
-webkit-app-region: no-drag;
&.windows {
margin-right: ${isWindows ? '130px' : 0};
background-color: var(--color-background-mute);
border-radius: 50px;
padding: 0 3px;
overflow: hidden;
}
`
const Button = styled.div`
cursor: pointer;
width: 30px;
height: 30px;
border-radius: 5px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
color: var(--color-text-2);
transition: all 0.2s ease;
font-size: 14px;
&:hover {
color: var(--color-text-1);
background-color: var(--color-background-mute);
}
&.pinned {
color: var(--color-primary);
background-color: var(--color-primary-bg);
}
`
const EmptyView = styled.div`
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: var(--color-background);
`
export default MinappPopupContainer

View File

@@ -1,11 +0,0 @@
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
import { useRuntime } from '@renderer/hooks/useRuntime'
const TopViewMinappContainer = () => {
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
return <>{isCreate && <MinappPopupContainer />}</>
}
export default TopViewMinappContainer

View File

@@ -1,92 +0,0 @@
import { WebviewTag } from 'electron'
import { memo, useEffect, useRef } from 'react'
/**
* WebviewContainer is a component that renders a webview element.
* It is used in the MinAppPopupContainer component.
* The webcontent can be remain in memory
*/
const WebviewContainer = memo(
({
appid,
url,
onSetRefCallback,
onLoadedCallback,
onNavigateCallback
}: {
appid: string
url: string
onSetRefCallback: (appid: string, element: WebviewTag | null) => void
onLoadedCallback: (appid: string) => void
onNavigateCallback: (appid: string, url: string) => void
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
return (element: WebviewTag | null) => {
onSetRefCallback(appid, element)
if (element) {
webviewRef.current = element
} else {
webviewRef.current = null
}
}
}
useEffect(() => {
if (!webviewRef.current) return
const handleNewWindow = (event: any) => {
event.preventDefault()
if (webviewRef.current?.loadURL) {
webviewRef.current.loadURL(event.url)
}
}
const handleLoaded = () => {
onLoadedCallback(appid)
}
const handleNavigate = (event: any) => {
onNavigateCallback(appid, event.url)
}
webviewRef.current.addEventListener('new-window', handleNewWindow)
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
// we set the url when the webview is ready
webviewRef.current.src = url
return () => {
webviewRef.current?.removeEventListener('new-window', handleNewWindow)
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
}
// because the appid and url are enough, no need to add onLoadedCallback
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
return (
<webview
key={appid}
ref={setRef(appid)}
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
nodeintegration={'true' as any}
/>
)
}
)
const WebviewStyle: React.CSSProperties = {
width: 'calc(100vw - var(--sidebar-width))',
height: 'calc(100vh - var(--navbar-height))',
backgroundColor: 'white',
display: 'inline-flex'
}
export default WebviewContainer

View File

@@ -0,0 +1,282 @@
import { CloseOutlined, CodeOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons'
import { isMac, isWindows } from '@renderer/config/constant'
import { AppLogo } from '@renderer/config/env'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinapps } from '@renderer/hooks/useMinapps'
import store from '@renderer/store'
import { setMinappShow } from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { Avatar, Drawer } from 'antd'
import { WebviewTag } from 'electron'
import { useEffect, useRef, useState } from 'react'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
import { TopView } from '../TopView'
interface Props {
app: MinAppType
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
const { pinned, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id)
const [open, setOpen] = useState(true)
const [opened, setOpened] = useState(false)
const [isReady, setIsReady] = useState(false)
const webviewRef = useRef<WebviewTag | null>(null)
useBridge()
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
const canPinned = DEFAULT_MIN_APPS.some((i) => i.id === app?.id)
const onClose = async (_delay = 0.3) => {
setOpen(false)
await delay(_delay)
resolve({})
}
MinApp.onClose = onClose
const openDevTools = () => {
if (webviewRef.current) {
webviewRef.current.openDevTools()
}
}
const onReload = () => {
if (webviewRef.current) {
webviewRef.current.src = app.url
}
}
const onOpenLink = () => {
if (webviewRef.current) {
const currentUrl = webviewRef.current.getURL()
window.api.openWebsite(currentUrl)
}
}
const onTogglePin = () => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
updatePinnedMinapps(newPinned)
}
const isInDevelopment = process.env.NODE_ENV === 'development'
const Title = () => {
return (
<TitleContainer style={{ justifyContent: 'space-between' }}>
<TitleText>{app.name}</TitleText>
<ButtonsGroup className={isWindows ? 'windows' : ''}>
<Button onClick={onReload}>
<ReloadOutlined />
</Button>
{canPinned && (
<Button onClick={onTogglePin} className={isPinned ? 'pinned' : ''}>
<PushpinOutlined style={{ fontSize: 16 }} />
</Button>
)}
{canOpenExternalLink && (
<Button onClick={onOpenLink}>
<ExportOutlined />
</Button>
)}
{isInDevelopment && (
<Button onClick={openDevTools}>
<CodeOutlined />
</Button>
)}
<Button onClick={() => onClose()}>
<CloseOutlined />
</Button>
</ButtonsGroup>
</TitleContainer>
)
}
useEffect(() => {
const webview = webviewRef.current
if (webview) {
const handleNewWindow = (event: any) => {
event.preventDefault()
if (webview.loadURL) {
webview.loadURL(event.url)
}
}
const onLoaded = () => setIsReady(true)
webview.addEventListener('new-window', handleNewWindow)
webview.addEventListener('did-finish-load', onLoaded)
return () => {
webview.removeEventListener('new-window', handleNewWindow)
webview.removeEventListener('did-finish-load', onLoaded)
}
}
return () => {}
}, [opened])
useEffect(() => {
setTimeout(() => setOpened(true), 350)
}, [])
return (
<Drawer
title={<Title />}
placement="bottom"
onClose={() => onClose()}
open={open}
mask={true}
rootClassName="minapp-drawer"
maskClassName="minapp-mask"
height={'100%'}
maskClosable={false}
closeIcon={null}
style={{ marginLeft: 'var(--sidebar-width)' }}>
{!isReady && (
<EmptyView>
<Avatar src={app.logo} size={80} style={{ border: '1px solid var(--color-border)', marginTop: -150 }} />
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginTop: 15 }} />
</EmptyView>
)}
{opened && (
<webview
src={app.url}
ref={webviewRef}
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
nodeintegration={true}
/>
)}
</Drawer>
)
}
const WebviewStyle: React.CSSProperties = {
width: 'calc(100vw - var(--sidebar-width))',
height: 'calc(100vh - var(--navbar-height))',
backgroundColor: 'white',
display: 'inline-flex'
}
const TitleContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding-left: ${isMac ? '20px' : '10px'};
padding-right: 10px;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
`
const TitleText = styled.div`
font-weight: bold;
font-size: 14px;
color: var(--color-text-1);
margin-right: 10px;
user-select: none;
`
const ButtonsGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
-webkit-app-region: no-drag;
&.windows {
margin-right: ${isWindows ? '130px' : 0};
background-color: var(--color-background-mute);
border-radius: 50px;
padding: 0 3px;
overflow: hidden;
}
`
const Button = styled.div`
cursor: pointer;
width: 30px;
height: 30px;
border-radius: 5px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
color: var(--color-text-2);
transition: all 0.2s ease;
font-size: 14px;
&:hover {
color: var(--color-text-1);
background-color: var(--color-background-mute);
}
&.pinned {
color: var(--color-primary);
background-color: var(--color-primary-bg);
}
`
const EmptyView = styled.div`
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: var(--color-background);
`
export default class MinApp {
static topviewId = 0
static onClose = () => {}
static app: MinAppType | null = null
static async start(app: MinAppType) {
if (app?.id && MinApp.app?.id === app?.id) {
return
}
if (MinApp.app) {
// @ts-ignore delay params
await MinApp.onClose(0)
await delay(0)
}
if (!app.logo) {
app.logo = AppLogo
}
MinApp.app = app
store.dispatch(setMinappShow(true))
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
app={app}
resolve={(v) => {
resolve(v)
this.close()
}}
/>,
'MinApp'
)
})
}
static close() {
TopView.hide('MinApp')
store.dispatch(setMinappShow(false))
MinApp.app = null
MinApp.onClose = () => {}
}
}

View File

@@ -2,7 +2,6 @@ import {
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
@@ -33,9 +32,8 @@ const ModelTags: FC<ModelTagsProps> = ({ model, showFree = true, showReasoning =
{isWebSearchModel(model) && <WebSearchIcon />}
{showReasoning && isReasoningModel(model) && <ReasoningIcon />}
{showToolsCalling && isFunctionCallingModel(model) && <ToolsCallingIcon />}
{isEmbeddingModel(model) && <Tag color="orange">{t('models.type.embedding')}</Tag>}
{showFree && isFreeModel(model) && <Tag color="green">{t('models.type.free')}</Tag>}
{isRerankModel(model) && <Tag color="geekblue">{t('models.type.rerank')}</Tag>}
{isEmbeddingModel(model) && <Tag color="orange">{t('models.embedding')}</Tag>}
{showFree && isFreeModel(model) && <Tag color="green">{t('models.free')}</Tag>}
</Container>
)
}

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import { Agent, Assistant } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
import { take } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -30,8 +30,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const inputRef = useRef<InputRef>(null)
const systemAgents = useSystemAgents()
const loadingRef = useRef(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
@@ -54,80 +52,25 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return filtered
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
// 重置选中索引当搜索或列表内容变更时
useEffect(() => {
setSelectedIndex(0)
}, [agents.length, searchText])
const onCreateAssistant = useCallback(
async (agent: Agent) => {
if (loadingRef.current) {
return
}
loadingRef.current = true
let assistant: Assistant
if (agent.id === 'default') {
assistant = { ...agent, id: uuid() }
addAssistant(assistant)
} else {
assistant = await createAssistantFromAgent(agent)
}
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant)
setOpen(false)
},
[resolve, addAssistant, setOpen]
) // 添加函数内使用的依赖项
// 键盘导航处理
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
const displayedAgents = take(agents, 100)
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
break
case 'Enter':
// 如果焦点在输入框且有搜索内容,则默认选择第一项
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
e.preventDefault()
onCreateAssistant(displayedAgents[selectedIndex])
}
// 否则选择当前选中项
else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) {
e.preventDefault()
onCreateAssistant(displayedAgents[selectedIndex])
}
break
}
const onCreateAssistant = async (agent: Agent) => {
if (loadingRef.current) {
return
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, selectedIndex, agents, searchText, onCreateAssistant])
loadingRef.current = true
let assistant: Assistant
// 确保选中项在可视区域
useEffect(() => {
if (containerRef.current) {
const agentItems = containerRef.current.querySelectorAll('.agent-item')
if (agentItems[selectedIndex]) {
agentItems[selectedIndex].scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
if (agent.id === 'default') {
assistant = { ...agent, id: uuid() }
addAssistant(assistant)
} else {
assistant = await createAssistantFromAgent(agent)
}
}, [selectedIndex])
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant)
setOpen(false)
}
const onCancel = () => {
setOpen(false)
@@ -178,13 +121,12 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
/>
</HStack>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
<Container ref={containerRef}>
{take(agents, 100).map((agent, index) => (
<Container>
{take(agents, 100).map((agent) => (
<AgentItem
key={agent.id}
onClick={() => onCreateAssistant(agent)}
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
onMouseEnter={() => setSelectedIndex(index)}>
className={agent.id === 'default' ? 'default' : ''}>
<HStack
alignItems="center"
gap={5}
@@ -219,14 +161,9 @@ const AgentItem = styled.div`
margin-bottom: 8px;
cursor: pointer;
overflow: hidden;
border: 1px solid transparent;
&.default {
background-color: var(--color-background-mute);
}
&.keyboard-selected {
background-color: var(--color-background-mute);
border: 1px solid var(--color-primary);
}
.anticon {
font-size: 16px;
color: var(--color-icon);

View File

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

View File

@@ -1,44 +1,72 @@
import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog'
import { createRoot } from 'react-dom/client'
import ObsidianFolderSelector from '@renderer/components/ObsidianFolderSelector'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { exportMarkdownToObsidian } from '@renderer/utils/export'
interface ObsidianExportOptions {
title: string
markdown: string
processingMethod: string | '3' // 默认新增(存在就覆盖)
}
/**
* 配置Obsidian 笔记属性弹窗
* @param options.title 标题
* @param options.markdown markdown内容
* @param options.processingMethod 处理方式
* @returns
*/
// 用于显示 Obsidian 导出对话框
const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
const div = document.createElement('div')
document.body.appendChild(div)
const root = createRoot(div)
const { title, markdown } = options
const obsidianUrl = store.getState().settings.obsidianUrl
const obsidianApiKey = store.getState().settings.obsidianApiKey
const handleClose = (success: boolean) => {
root.unmount()
document.body.removeChild(div)
resolve(success)
}
// 不再从store中获取tag配置
root.render(
<ObsidianExportDialog
title={options.title}
markdown={options.markdown}
obsidianTags=""
processingMethod={options.processingMethod}
open={true}
onClose={handleClose}
/>
)
})
if (!obsidianUrl || !obsidianApiKey) {
window.message.error(i18n.t('chat.topics.export.obsidian_not_configured'))
return false
}
try {
// 创建一个状态变量来存储选择的路径
let selectedPath = '/'
let selectedIsMdFile = false
// 显示文件夹选择对话框
return new Promise<boolean>((resolve) => {
window.modal.confirm({
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
}
export default ObsidianExportPopup

View File

@@ -1,6 +1,6 @@
import { PushpinOutlined, SearchOutlined } from '@ant-design/icons'
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 { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
@@ -76,7 +76,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
// 根据输入的文本筛选模型
const getFilteredModels = useCallback(
(provider) => {
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
let models = provider.models.filter((m) => !isEmbeddingModel(m))
if (searchText.trim()) {
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)

View File

@@ -1,5 +1,5 @@
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'
interface Props extends React.HTMLAttributes<HTMLDivElement> {
@@ -7,7 +7,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
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 timeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -35,7 +35,7 @@ const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject
{props.children}
</Container>
)
}
})
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
overflow-y: auto;

View File

@@ -1,4 +1,3 @@
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { message, Modal } from 'antd'
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
@@ -77,7 +76,6 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
{children}
{messageContextHolder}
{modalContextHolder}
<TopViewMinappContainer />
{elements.map(({ element: Element, id }) => (
<FullScreenContainer key={`TOPVIEW_${id}`}>
{typeof Element === 'function' ? <Element /> : Element}

View File

@@ -1,103 +0,0 @@
import { DeleteOutlined, ImportOutlined } from '@ant-design/icons'
import { VStack } from '@renderer/components/Layout'
import { Variable } from '@renderer/types'
import { Button, Input, Tooltip } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface VariableListProps {
variables: Variable[]
setVariables: (variables: Variable[]) => void
onUpdate?: (variables: Variable[]) => void
onInsertVariable?: (name: string) => void
}
const VariableList: React.FC<VariableListProps> = ({ variables, setVariables, onUpdate, onInsertVariable }) => {
const { t } = useTranslation()
const deleteVariable = (id: string) => {
const updatedVariables = variables.filter((v) => v.id !== id)
setVariables(updatedVariables)
if (onUpdate) {
onUpdate(updatedVariables)
}
}
const updateVariable = (id: string, field: 'name' | 'value', value: string) => {
// Only update the local state when typing, don't call the parent's onUpdate
const updatedVariables = variables.map((v) => (v.id === id ? { ...v, [field]: value } : v))
setVariables(updatedVariables)
}
// This function will be called when input loses focus
const handleInputBlur = () => {
if (onUpdate) {
onUpdate(variables)
}
}
return (
<VariablesContainer>
{variables.length === 0 ? (
<EmptyText>{t('common.no_variables_added')}</EmptyText>
) : (
<VStack gap={8} width="100%">
{variables.map((variable) => (
<VariableItem key={variable.id}>
<Input
placeholder={t('common.variable_name')}
value={variable.name}
onChange={(e) => updateVariable(variable.id, 'name', e.target.value)}
onBlur={handleInputBlur}
style={{ width: '30%' }}
/>
<Input
placeholder={t('common.value')}
value={variable.value}
onChange={(e) => updateVariable(variable.id, 'value', e.target.value)}
onBlur={handleInputBlur}
style={{ flex: 1 }}
/>
{onInsertVariable && (
<Tooltip title={t('common.insert_variable_into_prompt')}>
<Button type="text" onClick={() => onInsertVariable(variable.name)}>
<ImportOutlined />
</Button>
</Tooltip>
)}
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => deleteVariable(variable.id)} />
</VariableItem>
))}
</VStack>
)}
</VariablesContainer>
)
}
const VariablesContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
overflow-y: auto;
max-height: 200px;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 12px;
`
const VariableItem = styled.div`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
`
const EmptyText = styled.div`
color: var(--color-text-2);
opacity: 0.6;
font-style: italic;
`
export default VariableList

View File

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

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 type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import { FC, PropsWithChildren } from 'react'
import styled from 'styled-components'
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
type Props = PropsWithChildren & JSX.IntrinsicElements['div']
export const Navbar: FC<Props> = ({ children, ...props }) => {
const backgroundColor = useNavBackgroundColor()
@@ -63,6 +62,4 @@ const NavbarRightContainer = styled.div`
display: flex;
align-items: center;
padding: 0 12px;
padding-right: ${isWindows ? '140px' : 12};
justify-content: flex-end;
`

View File

@@ -9,35 +9,34 @@ import { isMac } from '@renderer/config/constant'
import { AppLogo, UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { isEmoji } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Avatar, Dropdown, Tooltip } from 'antd'
import { FC, useEffect } from 'react'
import { Tooltip } from 'antd'
import { Avatar } from 'antd'
import { Dropdown } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import DragableList from '../DragableList'
import MinAppIcon from '../Icons/MinAppIcon'
import MinApp from '../MinApp'
import UserPopup from '../Popups/UserPopup'
const Sidebar: FC = () => {
const { hideMinappPopup, openMinapp } = useMinappPopup()
const { minappShow, currentMinappId } = useRuntime()
const { sidebarIcons } = useSettings()
const { pinned } = useMinapps()
const { pathname } = useLocation()
const navigate = useNavigate()
const { theme, settingTheme, toggleTheme } = useTheme()
const avatar = useAvatar()
const { minappShow } = useRuntime()
const { t } = useTranslation()
const navigate = useNavigate()
const { sidebarIcons } = useSettings()
const { theme, toggleTheme } = useTheme()
const { pinned } = useMinapps()
const onEditUser = () => UserPopup.show()
@@ -50,10 +49,9 @@ const Sidebar: FC = () => {
navigate(path)
}
const docsId = 'cherrystudio-docs'
const onOpenDocs = () => {
openMinapp({
id: docsId,
MinApp.start({
id: 'docs',
name: t('docs.title'),
url: 'https://docs.cherry-ai.com/',
logo: AppLogo
@@ -68,10 +66,9 @@ const Sidebar: FC = () => {
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
)}
<MainMenusContainer>
<Menus onClick={hideMinappPopup}>
<Menus onClick={MinApp.onClose}>
<MainMenus />
</Menus>
<SidebarOpenedMinappTabs />
{showPinnedApps && (
<AppsContainer>
<Divider />
@@ -83,14 +80,14 @@ const Sidebar: FC = () => {
</MainMenusContainer>
<Menus>
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
<Icon
theme={theme}
onClick={onOpenDocs}
className={minappShow && MinApp.app?.url === 'https://docs.cherry-ai.com/' ? 'active' : ''}>
<QuestionCircleOutlined />
</Icon>
</Tooltip>
<Tooltip
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settingTheme}`)}
mouseEnterDelay={0.8}
placement="right">
<Tooltip title={t('settings.theme.title')} mouseEnterDelay={0.8} placement="right">
<Icon theme={theme} onClick={() => toggleTheme()}>
{theme === 'dark' ? (
<i className="iconfont icon-theme icon-dark1" />
@@ -102,7 +99,7 @@ const Sidebar: FC = () => {
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink
onClick={async () => {
hideMinappPopup()
minappShow && (await MinApp.close())
await modelGenerating()
await to('/settings/provider')
}}>
@@ -117,7 +114,6 @@ const Sidebar: FC = () => {
}
const MainMenus: FC = () => {
const { hideMinappPopup } = useMinappPopup()
const { t } = useTranslation()
const { pathname } = useLocation()
const { sidebarIcons } = useSettings()
@@ -134,6 +130,7 @@ const MainMenus: FC = () => {
paintings: <PictureOutlined style={{ fontSize: 16 }} />,
translate: <TranslationOutlined />,
minapp: <i className="iconfont icon-appstore" />,
nodeapps: <i className="iconfont icon-code" />,
knowledge: <FileSearchOutlined />,
files: <FolderOutlined />
}
@@ -144,6 +141,7 @@ const MainMenus: FC = () => {
paintings: '/paintings',
translate: '/translate',
minapp: '/apps',
nodeapps: '/nodeapps',
knowledge: '/knowledge',
files: '/files'
}
@@ -156,7 +154,7 @@ const MainMenus: FC = () => {
<Tooltip key={icon} title={t(`${icon}.title`)} mouseEnterDelay={0.8} placement="right">
<StyledLink
onClick={async () => {
hideMinappPopup()
minappShow && (await MinApp.close())
await modelGenerating()
navigate(path)
}}>
@@ -169,103 +167,11 @@ const MainMenus: FC = () => {
})
}
/** Tabs of opened minapps in sidebar */
const SidebarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
const { showOpenedMinappsInSidebar } = useSettings() // 获取控制显示的设置
const { theme } = useTheme()
const { t } = useTranslation()
const handleOnClick = (app) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
} else {
openMinappKeepAlive(app)
}
}
// animation for minapp switch indicator
useEffect(() => {
//hacky way to get the height of the icon
const iconDefaultHeight = 40
const iconDefaultOffset = 17
const container = document.querySelector('.TabsContainer') as HTMLElement
const activeIcon = document.querySelector('.TabsContainer .opened-active') as HTMLElement
let indicatorTop = 0,
indicatorRight = 0
if (minappShow && activeIcon && container) {
indicatorTop = activeIcon.offsetTop + activeIcon.offsetHeight / 2 - 4 // 4 is half of the indicator's height (8px)
indicatorRight = 0
} else {
indicatorTop =
((openedKeepAliveMinapps.length > 0 ? openedKeepAliveMinapps.length : 1) / 2) * iconDefaultHeight +
iconDefaultOffset -
4
indicatorRight = -50
}
container.style.setProperty('--indicator-top', `${indicatorTop}px`)
container.style.setProperty('--indicator-right', `${indicatorRight}px`)
}, [currentMinappId, openedKeepAliveMinapps, minappShow])
// 检查是否需要显示已打开小程序组件
const isShowOpened = showOpenedMinappsInSidebar && openedKeepAliveMinapps.length > 0
// 如果不需要显示,返回空容器保持动画效果但不显示内容
if (!isShowOpened) return <TabsContainer className="TabsContainer" />
return (
<TabsContainer className="TabsContainer">
<Divider />
<TabsWrapper>
<Menus>
{openedKeepAliveMinapps.map((app) => {
const menuItems: MenuProps['items'] = [
{
key: 'closeApp',
label: t('minapp.sidebar.close.title'),
onClick: () => {
closeMinapp(app.id)
}
},
{
key: 'closeAllApp',
label: t('minapp.sidebar.closeall.title'),
onClick: () => {
closeAllMinapps()
}
}
]
const isActive = minappShow && currentMinappId === app.id
return (
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
<StyledLink>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<Icon
theme={theme}
onClick={() => handleOnClick(app)}
className={`${isActive ? 'opened-active' : ''}`}>
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
</Icon>
</Dropdown>
</StyledLink>
</Tooltip>
)
})}
</Menus>
</TabsWrapper>
</TabsContainer>
)
}
const PinnedApps: FC = () => {
const { pinned, updatePinnedMinapps } = useMinapps()
const { t } = useTranslation()
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { minappShow } = useRuntime()
const { theme } = useTheme()
const { openMinappKeepAlive } = useMinappPopup()
return (
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
@@ -280,15 +186,12 @@ const PinnedApps: FC = () => {
}
}
]
const isActive = minappShow && currentMinappId === app.id
const isActive = minappShow && MinApp.app?.id === app.id
return (
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
<StyledLink>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<Icon
theme={theme}
onClick={() => openMinappKeepAlive(app)}
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-animation' : ''}`}>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Icon theme={theme} onClick={() => MinApp.start(app)} className={isActive ? 'active' : ''}>
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
</Icon>
</Dropdown>
@@ -361,7 +264,6 @@ const Icon = styled.div<{ theme: string }>`
justify-content: center;
align-items: center;
border-radius: 50%;
box-sizing: border-box;
-webkit-app-region: none;
border: 0.5px solid transparent;
.iconfont,
@@ -390,39 +292,6 @@ const Icon = styled.div<{ theme: string }>`
color: var(--color-icon-white);
}
}
@keyframes borderBreath {
0% {
opacity: 0.1;
}
50% {
opacity: 1;
}
100% {
opacity: 0.1;
}
}
&.opened-animation {
position: relative;
}
&.opened-animation::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border-radius: inherit;
opacity: 0;
will-change: opacity;
border: 0.5px solid var(--color-primary);
/* NOTICE: although we have optimized for the performance,
* the infinite animation will still consume a little GPU resources,
* it's a trade-off balance between performance and animation smoothness*/
animation: borderBreath 4s ease-in-out infinite;
}
`
const StyledLink = styled.div`
@@ -453,37 +322,4 @@ const Divider = styled.div`
border-bottom: 0.5px solid var(--color-border);
`
const TabsContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
-webkit-app-region: none;
position: relative;
width: 100%;
&::after {
content: '';
position: absolute;
right: var(--indicator-right, 0);
top: var(--indicator-top, 0);
width: 4px;
height: 8px;
background-color: var(--color-primary);
transition:
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
right 0.3s ease-in-out;
border-radius: 2px;
}
&::-webkit-scrollbar {
display: none;
}
`
const TabsWrapper = styled.div`
background-color: rgba(128, 128, 128, 0.1);
border-radius: 20px;
overflow: hidden;
`
export default Sidebar

View File

@@ -242,58 +242,6 @@ export const EMBEDDING_MODELS = [
{
id: 'mistral-embed',
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
}
]

View File

@@ -49,7 +49,9 @@ import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
import MinApp from '@renderer/components/MinApp'
import { MinAppType } from '@renderer/types'
export const DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'openai',
@@ -393,3 +395,8 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
bodered: true
}
]
export function startMinAppById(id: string) {
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
app && MinApp.start(app)
}

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 ViduModelLogo from '@renderer/assets/images/models/vidu.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 WenxinModelLogoDark from '@renderer/assets/images/models/wenxin_dark.png'
import XirangModelLogo from '@renderer/assets/images/models/xirang.png'
@@ -142,7 +141,6 @@ const visionAllowedModels = [
'minicpm',
'gemini-1\\.5',
'gemini-2\\.0',
'gemini-2\\.5',
'gemini-exp',
'claude-3',
'vision',
@@ -150,14 +148,12 @@ const visionAllowedModels = [
'qwen-vl',
'qwen2-vl',
'qwen2.5-vl',
'qwen2.5-omni',
'qvq',
'internvl2',
'grok-vision-beta',
'pixtral',
'gpt-4(?:-[\\w-]+)',
'gpt-4o(?:-[\\w-]+)?',
'gpt-4.5(?:-[\\w-]+)',
'chatgpt-4o(?:-[\\w-]+)?',
'o1(?:-[\\w-]+)?',
'deepseek-vl(?:[\\w-]+)?',
@@ -165,15 +161,7 @@ const visionAllowedModels = [
'gemma-3(?:-[\\w-]+)'
]
const visionExcludedModels = [
'gpt-4-\\d+-preview',
'gpt-4-turbo-preview',
'gpt-4-32k',
'gpt-4-\\d+',
'o1-mini',
'o1-preview',
'AIDC-AI/Marco-o1'
]
const visionExcludedModels = ['gpt-4-\\d+-preview', 'gpt-4-turbo-preview', 'gpt-4-32k', 'gpt-4-\\d+']
export const VISION_REGEX = new RegExp(
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
'i'
@@ -184,11 +172,10 @@ export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|
// Reasoning models
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
export const EMBEDDING_REGEX =
/(?:^text-|embed|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings|voyage-)/i
export const EMBEDDING_REGEX = /(?:^text-|embed|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings)/i
// Rerank models
export const RERANKING_REGEX = /(?:rerank|re-rank|re-ranker|re-ranking|retrieval|retriever)/i
@@ -201,23 +188,15 @@ export const FUNCTION_CALLING_MODELS = [
'gpt-4o-mini',
'gpt-4',
'gpt-4.5',
'o1(?:-[\\w-]+)?',
'claude',
'qwen',
'hunyuan',
'deepseek',
'glm-4(?:-[\\w-]+)?',
'learnlm(?:-[\\w-]+)?',
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
]
const FUNCTION_CALLING_EXCLUDED_MODELS = [
'aqa(?:-[\\w-]+)?',
'imagen(?:-[\\w-]+)?',
'o1-mini',
'o1-preview',
'AIDC-AI/Marco-o1'
]
const FUNCTION_CALLING_EXCLUDED_MODELS = ['aqa(?:-[\\w-]+)?', 'imagen(?:-[\\w-]+)?']
export const FUNCTION_CALLING_REGEX = new RegExp(
`\\b(?!(?:${FUNCTION_CALLING_EXCLUDED_MODELS.join('|')})\\b)(?:${FUNCTION_CALLING_MODELS.join('|')})\\b`,
@@ -348,8 +327,7 @@ export function getModelLogo(modelId: string) {
embedding: isLight ? EmbeddingModelLogo : EmbeddingModelLogoDark,
perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
'bge-': BgeModelLogo,
'voyage-': VoyageModelLogo
'bge-': BgeModelLogo
}
for (const key in logoMap) {
@@ -1124,12 +1102,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'GLM 4V',
group: 'GLM-4v'
},
{
id: 'glm-4v-flash',
provider: 'zhipu',
name: 'GLM-4V-Flash',
group: 'GLM-4v'
},
{
id: 'glm-4v-plus',
provider: 'zhipu',
@@ -1231,140 +1203,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Step 1'
}
],
doubao: [
{
id: 'doubao-1-5-vision-pro-32k-250115',
provider: 'doubao',
name: 'doubao-1.5-vision-pro',
group: 'Doubao-1.5-vision-pro'
},
{
id: 'doubao-1-5-pro-32k-250115',
provider: 'doubao',
name: 'doubao-1.5-pro-32k',
group: 'Doubao-1.5-pro'
},
{
id: 'doubao-1-5-pro-32k-character-250228',
provider: 'doubao',
name: 'doubao-1.5-pro-32k-character',
group: 'Doubao-1.5-pro'
},
{
id: 'doubao-1-5-pro-256k-250115',
provider: 'doubao',
name: 'Doubao-1.5-pro-256k',
group: 'Doubao-1.5-pro'
},
{
id: 'deepseek-r1-250120',
provider: 'doubao',
name: 'DeepSeek-R1',
group: 'DeepSeek'
},
{
id: 'deepseek-r1-distill-qwen-32b-250120',
provider: 'doubao',
name: 'DeepSeek-R1-Distill-Qwen-32B',
group: 'DeepSeek'
},
{
id: 'deepseek-r1-distill-qwen-7b-250120',
provider: 'doubao',
name: 'DeepSeek-R1-Distill-Qwen-7B',
group: 'DeepSeek'
},
{
id: 'deepseek-v3-250324',
provider: 'doubao',
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'deepseek-v3-250324',
provider: 'doubao',
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'doubao-pro-32k-241215',
provider: 'doubao',
name: 'Doubao-pro-32k',
group: 'Doubao-pro'
},
{
id: 'doubao-pro-32k-functioncall-241028',
provider: 'doubao',
name: 'Doubao-pro-32k-functioncall-241028',
group: 'Doubao-pro'
},
{
id: 'doubao-pro-32k-character-241215',
provider: 'doubao',
name: 'Doubao-pro-32k-character-241215',
group: 'Doubao-pro'
},
{
id: 'doubao-pro-256k-241115',
provider: 'doubao',
name: 'Doubao-pro-256k',
group: 'Doubao-pro'
},
{
id: 'doubao-lite-4k-character-240828',
provider: 'doubao',
name: 'Doubao-lite-4k-character-240828',
group: 'Doubao-lite'
},
{
id: 'doubao-lite-32k-240828',
provider: 'doubao',
name: 'Doubao-lite-32k',
group: 'Doubao-lite'
},
{
id: 'doubao-lite-32k-character-241015',
provider: 'doubao',
name: 'Doubao-lite-32k-character-241015',
group: 'Doubao-lite'
},
{
id: 'doubao-lite-128k-240828',
provider: 'doubao',
name: 'Doubao-lite-128k',
group: 'Doubao-lite'
},
{
id: 'doubao-1-5-lite-32k-250115',
provider: 'doubao',
name: 'Doubao-1.5-lite-32k',
group: 'Doubao-lite'
},
{
id: 'doubao-embedding-large-text-240915',
provider: 'doubao',
name: 'Doubao-embedding-large',
group: 'Doubao-embedding'
},
{
id: 'doubao-embedding-text-240715',
provider: 'doubao',
name: 'Doubao-embedding',
group: 'Doubao-embedding'
},
{
id: 'doubao-embedding-vision-241215',
provider: 'doubao',
name: 'Doubao-embedding-vision',
group: 'Doubao-embedding'
},
{
id: 'doubao-vision-lite-32k-241015',
provider: 'doubao',
name: 'Doubao-vision-lite-32k',
group: 'Doubao-vision-lite-32k'
}
],
doubao: [],
minimax: [
{
id: 'abab6.5s-chat',
@@ -1693,15 +1532,15 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Llama3'
},
{
id: 'mistral-saba-24b',
id: 'mixtral-8x7b-32768',
provider: 'groq',
name: 'Mistral Saba 24B',
group: 'Mistral'
name: 'Mixtral 8x7B',
group: 'Mixtral'
},
{
id: 'gemma-9b-it',
id: 'gemma-7b-it',
provider: 'groq',
name: 'Gemma 9B',
name: 'Gemma 7B',
group: 'Gemma'
}
],
@@ -1956,63 +1795,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'DeepSeek'
}
],
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'
}
]
gpustack: []
}
export const TEXT_TO_IMAGES_MODELS = [
@@ -2089,19 +1872,6 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
'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 const GEMINI_SEARCH_MODELS = [
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
'gemini-2.0-flash-exp',
'gemini-2.0-flash-001',
'gemini-2.0-pro-exp-02-05',
'gemini-2.0-pro-exp',
'gemini-2.5-pro-exp',
'gemini-2.5-pro-exp-03-25'
]
export function isTextToImageModel(model: Model): boolean {
return TEXT_TO_IMAGE_REGEX.test(model.id)
}
@@ -2119,15 +1889,14 @@ export function isEmbeddingModel(model: Model): boolean {
return EMBEDDING_REGEX.test(model.name)
}
if (isRerankModel(model)) {
return false
}
return EMBEDDING_REGEX.test(model.id) || model.type?.includes('embedding') || false
}
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 {
@@ -2149,18 +1918,6 @@ export function isOpenAIoSeries(model: Model): boolean {
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 {
if (!model) {
return false
@@ -2174,10 +1931,6 @@ export function isReasoningModel(model?: Model): boolean {
return true
}
if (model.id.includes('gemini-2.5-pro-exp')) {
return true
}
return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false
}
@@ -2206,25 +1959,32 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
if (provider.id === 'aihubmix') {
const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
return models.includes(model?.id)
}
if (provider?.type === 'openai') {
if (GEMINI_SEARCH_MODELS.includes(model?.id)) {
if (model?.id?.includes('gemini-2.0-flash-exp')) {
return true
}
}
if (provider.id === 'gemini' || provider?.type === 'gemini') {
return GEMINI_SEARCH_MODELS.includes(model?.id)
const models = [
'gemini-2.0-flash',
'gemini-2.0-flash-exp',
'gemini-2.0-flash-001',
'gemini-2.0-pro-exp-02-05',
'gemini-2.0-pro-exp'
]
return models.includes(model?.id)
}
if (provider.id === 'hunyuan') {
return model?.id !== 'hunyuan-lite'
}
if (provider.id === 'aihubmix') {
const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
return models.includes(model?.id)
}
if (provider.id === 'zhipu') {
return model?.id?.startsWith('glm-4-')
}
@@ -2242,28 +2002,6 @@ export function isWebSearchModel(model: Model): boolean {
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> {
if (isWebSearchModel(model)) {
if (assistant.enableWebSearch) {

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.
`
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:

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 TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png'
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
@@ -87,16 +86,13 @@ const PROVIDER_LOGO_MAP = {
o3: O3ProviderLogo,
'tencent-cloud-ti': TencentCloudProviderLogo,
gpustack: GPUStackProviderLogo,
alayanew: AlayaNewProviderLogo,
voyageai: VoyageAIProviderLogo
alayanew: AlayaNewProviderLogo
} as const
export function getProviderLogo(providerId: string) {
return PROVIDER_LOGO_MAP[providerId as keyof typeof PROVIDER_LOGO_MAP]
}
export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina', 'voyageai']
export const PROVIDER_CONFIG = {
openai: {
api: {
@@ -562,16 +558,5 @@ export const PROVIDER_CONFIG = {
docs: 'https://docs.gpustack.ai/latest/',
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'
}
}
}

View File

@@ -1,12 +1,8 @@
import { useSettings } from '@renderer/hooks/useSettings'
import { LanguageVarious } from '@renderer/types'
import { ConfigProvider, theme } from 'antd'
import elGR from 'antd/locale/el_GR'
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 ptPT from 'antd/locale/pt_PT'
import ruRU from 'antd/locale/ru_RU'
import zhCN from 'antd/locale/zh_CN'
import zhTW from 'antd/locale/zh_TW'
@@ -57,14 +53,7 @@ function getAntdLocale(language: LanguageVarious) {
return ruRU
case 'ja-JP':
return jaJP
case 'el-GR':
return elGR
case 'es-ES':
return esES
case 'fr-FR':
return frFR
case 'pt-PT':
return ptPT
default:
return zhCN
}

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