Compare commits
245 Commits
feat/node-
...
hlink/OMO1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb6bff68d | ||
|
|
dcdd8b4031 | ||
|
|
a0be911dc9 | ||
|
|
f7f7d2bde8 | ||
|
|
10efa444bf | ||
|
|
24e28b86cf | ||
|
|
fa66d048d7 | ||
|
|
fe7a392116 | ||
|
|
c883fd85d8 | ||
|
|
aa73025568 | ||
|
|
9689f00214 | ||
|
|
3674cc4afe | ||
|
|
1f21b99820 | ||
|
|
a3e10dd116 | ||
|
|
ab1a5f18c9 | ||
|
|
2a0d6eb08a | ||
|
|
f78663f815 | ||
|
|
8bcb31071e | ||
|
|
3aaa1848f0 | ||
|
|
037027f1f4 | ||
|
|
97c1d67cbf | ||
|
|
d38c4c7368 | ||
|
|
b1bd5d0531 | ||
|
|
1fcee6c829 | ||
|
|
cbcebdc87a | ||
|
|
96df9f6979 | ||
|
|
4ef9d52694 | ||
|
|
41b9f8dbd5 | ||
|
|
8191791036 | ||
|
|
99b37f2782 | ||
|
|
da49c3ddd3 | ||
|
|
6891068ca1 | ||
|
|
b361001f39 | ||
|
|
3823912b3e | ||
|
|
b5ad77e70c | ||
|
|
3491eec86b | ||
|
|
581ad5fbda | ||
|
|
90424808ab | ||
|
|
c884b11f01 | ||
|
|
a530ce652e | ||
|
|
9c052dee5c | ||
|
|
1085c11240 | ||
|
|
ae6097a29e | ||
|
|
d74f05f27e | ||
|
|
8501ab82c6 | ||
|
|
f2ca56a088 | ||
|
|
e02c967f5b | ||
|
|
7284679907 | ||
|
|
56e9a7371a | ||
|
|
95639df35c | ||
|
|
641dfc60b0 | ||
|
|
46c7df6f5b | ||
|
|
b828d1f54f | ||
|
|
d9abfc5443 | ||
|
|
7364646caa | ||
|
|
5fa7465174 | ||
|
|
bc02727633 | ||
|
|
c76f274562 | ||
|
|
f9be0e0d26 | ||
|
|
ea059d5517 | ||
|
|
9c6de71fbb | ||
|
|
3290ac4b1b | ||
|
|
ef8250ab72 | ||
|
|
773c0da9ef | ||
|
|
a3d124b9fd | ||
|
|
c11adc01dc | ||
|
|
d8baf378ea | ||
|
|
3768c135d8 | ||
|
|
10848f7a45 | ||
|
|
4d5cfe06f5 | ||
|
|
fb5ddaf9d5 | ||
|
|
8cb11e6d55 | ||
|
|
d0cb333f3c | ||
|
|
23de48ecbd | ||
|
|
06c730aaf6 | ||
|
|
aed9c04c20 | ||
|
|
d067d21561 | ||
|
|
515721239f | ||
|
|
5cdf4eff77 | ||
|
|
b53dbcbb30 | ||
|
|
a42283e789 | ||
|
|
d2ed9972bd | ||
|
|
0fd9b6e56c | ||
|
|
91b9a48c48 | ||
|
|
e572b3801b | ||
|
|
4bf15aed25 | ||
|
|
6d568688ed | ||
|
|
f20cbf31a8 | ||
|
|
bfbfba13fe | ||
|
|
8b9929cc7b | ||
|
|
a90be7e83f | ||
|
|
efa68c8519 | ||
|
|
d7bd240a9a | ||
|
|
95df69ff82 | ||
|
|
e41df917b4 | ||
|
|
0a33649b3c | ||
|
|
d1cb7258d2 | ||
|
|
8fbedb2bd0 | ||
|
|
750247aef8 | ||
|
|
32e1f428e7 | ||
|
|
aee6219a75 | ||
|
|
5329fa7ede | ||
|
|
ba640d4070 | ||
|
|
8c5f61d407 | ||
|
|
b43ecb75f5 | ||
|
|
3dc4947e26 | ||
|
|
a5b0480418 | ||
|
|
8a7db19e73 | ||
|
|
2da8a73124 | ||
|
|
5223a3c5a6 | ||
|
|
72c5de3b81 | ||
|
|
9f11e7c22b | ||
|
|
1ce86c11ca | ||
|
|
57c1b59a51 | ||
|
|
a2f9067908 | ||
|
|
2a4c512e49 | ||
|
|
94eb7f3a34 | ||
|
|
b363cb06a4 | ||
|
|
9e977f4b35 | ||
|
|
00de616958 | ||
|
|
22b0bd54b4 | ||
|
|
be39c5f40c | ||
|
|
8b00ff4b93 | ||
|
|
f5b675b356 | ||
|
|
de8dbb2646 | ||
|
|
7e67005e70 | ||
|
|
d6e66f3a4d | ||
|
|
e5aaec2129 | ||
|
|
464634d051 | ||
|
|
3698238e9e | ||
|
|
ae2a661201 | ||
|
|
d6dbe357fb | ||
|
|
e9dd795f9a | ||
|
|
03a18c1f3b | ||
|
|
e3ba44fc2c | ||
|
|
9976ad9ed0 | ||
|
|
3bb294e698 | ||
|
|
990b1651a9 | ||
|
|
11c070a1d7 | ||
|
|
57ba91072d | ||
|
|
433d562599 | ||
|
|
194ba1baa0 | ||
|
|
53ae427f2f | ||
|
|
3f40cc28ac | ||
|
|
d3584d2d39 | ||
|
|
da0db73916 | ||
|
|
21f1b8b373 | ||
|
|
f1a03916e7 | ||
|
|
45f0bfa0f9 | ||
|
|
f2102daf00 | ||
|
|
8f5c4483fc | ||
|
|
43adac3f74 | ||
|
|
7b8c5f185c | ||
|
|
eeb537048b | ||
|
|
5712a58a5e | ||
|
|
c4162bd9e3 | ||
|
|
eddbae6f5e | ||
|
|
29f7da1a4c | ||
|
|
403ed8cbf4 | ||
|
|
7263a682b7 | ||
|
|
29b5ba787b | ||
|
|
bb6fdd2db7 | ||
|
|
710171278a | ||
|
|
41191f6132 | ||
|
|
bbc7b20183 | ||
|
|
8bb8081f31 | ||
|
|
7ddd2cb9d5 | ||
|
|
06ff44f97c | ||
|
|
1a85b8bd5d | ||
|
|
fb9c23c500 | ||
|
|
7fb85dc311 | ||
|
|
2af15e4172 | ||
|
|
415f991143 | ||
|
|
c162242433 | ||
|
|
487d7a502e | ||
|
|
d64d6969ae | ||
|
|
cc32c36222 | ||
|
|
0d320120a4 | ||
|
|
3cbe45fc8d | ||
|
|
917943386e | ||
|
|
aee0f9ea3f | ||
|
|
2055615aca | ||
|
|
40cac47136 | ||
|
|
40d9629681 | ||
|
|
8acefaa907 | ||
|
|
e2d8b89ffd | ||
|
|
8d48824981 | ||
|
|
fd66881022 | ||
|
|
b321169ca2 | ||
|
|
123362b493 | ||
|
|
a1568808d4 | ||
|
|
6dff8b2725 | ||
|
|
c8b2e8dd79 | ||
|
|
8ac18934e9 | ||
|
|
6699b0902f | ||
|
|
9b98312775 | ||
|
|
1e14dd6ea2 | ||
|
|
0d612cb827 | ||
|
|
ccfac25a04 | ||
|
|
7447dfe771 | ||
|
|
0fe45a203c | ||
|
|
94942141b9 | ||
|
|
f08856ae42 | ||
|
|
a606f4b6c5 | ||
|
|
a5318ebefa | ||
|
|
ae8869e1b6 | ||
|
|
32b8fa7e63 | ||
|
|
c299d615fc | ||
|
|
8628dc188b | ||
|
|
eba746a3bc | ||
|
|
640ca19cba | ||
|
|
f9941a6858 | ||
|
|
17bd66259d | ||
|
|
9112ecc79b | ||
|
|
e75cfac8d8 | ||
|
|
a9b2b32c5a | ||
|
|
16ac419b9b | ||
|
|
13747a585a | ||
|
|
d56774fd59 | ||
|
|
6c6af2a12b | ||
|
|
ae7b94b01e | ||
|
|
36824c20f8 | ||
|
|
07b6d5ce1d | ||
|
|
43a6428653 | ||
|
|
404ec095d4 | ||
|
|
ed731db56a | ||
|
|
1e4d6f196f | ||
|
|
e0f1768c4f | ||
|
|
c9d640770a | ||
|
|
56207d5617 | ||
|
|
2e2ed664d0 | ||
|
|
183f1310e5 | ||
|
|
b7ee0ea7b3 | ||
|
|
117cf548fe | ||
|
|
bddec81402 | ||
|
|
a0ccc4e661 | ||
|
|
25c166cb8e | ||
|
|
bc1d6157f6 | ||
|
|
852274b4b1 | ||
|
|
998c4bc459 | ||
|
|
55bb4530c0 | ||
|
|
27a384b0c8 | ||
|
|
4927f98e59 | ||
|
|
36966cfc14 | ||
|
|
9ebc20882b |
4
.github/ISSUE_TEMPLATE/0_bug_report.yml
vendored
@@ -6,8 +6,8 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill out this bug report!
|
||||
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
|
||||
Thank you for taking the time to fill out this bug report!
|
||||
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
|
||||
5
.github/workflows/nightly-build.yml
vendored
@@ -76,7 +76,10 @@ jobs:
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: yarn build:win
|
||||
run: |
|
||||
yarn build:npm windows
|
||||
yarn build:win:x64
|
||||
yarn build:win:arm64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
@@ -88,7 +88,9 @@ jobs:
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: yarn build:win
|
||||
run: |
|
||||
yarn build:npm windows
|
||||
yarn build:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
|
||||
1
.gitignore
vendored
@@ -35,7 +35,6 @@ Thumbs.db
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
build/icons
|
||||
stats.html
|
||||
|
||||
# ENV
|
||||
|
||||
@@ -6,3 +6,4 @@ tsconfig.json
|
||||
tsconfig.*.json
|
||||
CHANGELOG*.md
|
||||
agents.json
|
||||
src/renderer/src/integration/nutstore/sso/lib
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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,
|
||||
@@ -1,158 +0,0 @@
|
||||
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': {
|
||||
@@ -1,26 +0,0 @@
|
||||
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,
|
||||
17
.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 4e8423491ab51a9eb9fee22182e4ea0fcc3d3d3b..2846c5d4354c130d478dc99565b3ecd6d85b7d2e 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -19,7 +19,11 @@ function requireNative() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
- return require(`@libsql/${target}`);
|
||||
+ if (target === "win32-arm64-msvc") {
|
||||
+ return require(`@strongtz/win32-arm64-msvc`);
|
||||
+ } else {
|
||||
+ return require(`@libsql/${target}`);
|
||||
+ }
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/core.js b/core.js
|
||||
index e75a18281ce8f051990c5a50bc1076afdddf91a3..e62f796791a155f23d054e74a429516c14d6e11b 100644
|
||||
index ebb071d31cd5a14792b62814df072c5971e83300..31e1062d4a7f2422ffec79cf96a35dbb69fe89cb 100644
|
||||
--- a/core.js
|
||||
+++ b/core.js
|
||||
@@ -156,7 +156,7 @@ class APIClient {
|
||||
@@ -157,7 +157,7 @@ class APIClient {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': this.getUserAgent(),
|
||||
@@ -12,10 +12,10 @@ index e75a18281ce8f051990c5a50bc1076afdddf91a3..e62f796791a155f23d054e74a429516c
|
||||
};
|
||||
}
|
||||
diff --git a/core.mjs b/core.mjs
|
||||
index fcef58eb502664c41a77483a00db8adaf29b2817..18c5d6ed4be86b3640931277bdc27700006764d7 100644
|
||||
index 9c1a0264dcd73a85de1cf81df4efab9ce9ee2ab7..33f9f1f237f2eb2667a05dae1a7e3dc916f6bfff 100644
|
||||
--- a/core.mjs
|
||||
+++ b/core.mjs
|
||||
@@ -149,7 +149,7 @@ export class APIClient {
|
||||
@@ -150,7 +150,7 @@ export class APIClient {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': this.getUserAgent(),
|
||||
18
.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
2
.yarn/releases/yarn-4.6.0.cjs
vendored
23
README.md
@@ -6,6 +6,7 @@
|
||||
<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-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
@@ -16,6 +17,10 @@ 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
|
||||
|
||||

|
||||
@@ -77,6 +82,14 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
- [ ] 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)
|
||||
@@ -119,11 +132,7 @@ Thank you for your support and contributions!
|
||||
|
||||
# 🌐 Community
|
||||
|
||||
[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-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# ☕ Sponsor
|
||||
|
||||
@@ -133,6 +142,10 @@ Thank you for your support and contributions!
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
# ✉️ Contact
|
||||
|
||||
yinsenho@cherry-ai.com
|
||||
|
||||
# ⭐️ Star History
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
BIN
build/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 621 B |
BIN
build/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</p>
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
# 🍒 Cherry Studio
|
||||
|
||||
@@ -17,6 +18,10 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
|
||||
|
||||
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
|
||||
|
||||
# 📖 ガイド
|
||||
|
||||
https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 スクリーンショット
|
||||
|
||||

|
||||
@@ -78,6 +83,13 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
|
||||
- [ ] 音声入出力(AI コール)
|
||||
- [ ] データバックアップはカスタムバックアップコンテンツをサポート
|
||||
|
||||
# 🌈 テーマ
|
||||
|
||||
テーマギャラリー: https://cherrycss.com
|
||||
Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
|
||||
|
||||
より多くのテーマのPRを歓迎します
|
||||
|
||||
# 🖥️ 開発
|
||||
|
||||
参考[開発ドキュメント](dev.md)
|
||||
@@ -117,11 +129,7 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
|
||||
|
||||
# コミュニティ
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# 📣 プロダクトハント
|
||||
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# スポンサー
|
||||
|
||||
@@ -131,6 +139,10 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
|
||||
|
||||
[LICENSE](../LICENSE)
|
||||
|
||||
# ✉️ お問い合わせ
|
||||
|
||||
yinsenho@cherry-ai.com
|
||||
|
||||
# ⭐️ スター履歴
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br></p>
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
|
||||
Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客户端,兼容 Windows、Mac 和 Linux 系统。
|
||||
@@ -16,6 +18,10 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
|
||||
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
|
||||
|
||||
# 📖 使用教程
|
||||
|
||||
https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 界面
|
||||
|
||||

|
||||
@@ -77,6 +83,13 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
- [ ] 语音输入输出(AI 通话)
|
||||
- [ ] 数据备份支持自定义备份内容
|
||||
|
||||
# 🌈 主题
|
||||
|
||||
主题库:https://cherrycss.com
|
||||
Aero 主题:https://github.com/hakadao/CherryStudio-Aero
|
||||
|
||||
欢迎 PR 更多主题
|
||||
|
||||
# 🖥️ 开发
|
||||
|
||||
参考[开发文档](dev.md)
|
||||
@@ -117,11 +130,7 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
|
||||
# 🌐 社区
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# 📣 产品猎人
|
||||
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# ☕ 赞助
|
||||
|
||||
@@ -131,6 +140,10 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
|
||||
[LICENSE](../LICENSE)
|
||||
|
||||
# ✉️ 联系我们
|
||||
|
||||
yinsenho@cherry-ai.com
|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
@@ -32,18 +32,20 @@ asarUnpack:
|
||||
- '**/*.{node,dll,metal,exp,lib}'
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
artifactName: ${productName}-${version}-portable.${ext}
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
target:
|
||||
- target: nsis
|
||||
- target: portable
|
||||
nsis:
|
||||
artifactName: ${productName}-${version}-setup.${ext}
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
allowToChangeInstallationDirectory: true
|
||||
oneClick: false
|
||||
include: build/nsis-installer.nsh
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-${arch}-portable.${ext}
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
@@ -83,8 +85,7 @@ afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
知识库设置增加重排模型,提升知识库的准确性
|
||||
自定义服务商增加兼容模式
|
||||
增加 Github Copilot 服务商
|
||||
PlantUML 预览支持放大和缩小
|
||||
联网模式支持增强模式
|
||||
知识库和服务商界面更新
|
||||
增加 Dangbei 小程序
|
||||
可以强制使用搜索引擎覆盖模型自带搜索能力
|
||||
修复部分公式无法正常渲染问题
|
||||
|
||||
@@ -12,17 +12,18 @@ export default defineConfig({
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: [
|
||||
'@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'
|
||||
'@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'
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin('main')
|
||||
@@ -41,7 +42,12 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve('packages/shared')
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
plugins: [
|
||||
@@ -69,7 +75,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['chunk-PZ64DZKH.js', 'chunk-JMKENWIY.js', 'chunk-UXYB6GHG.js', 'chunk-ALDIEZMG.js', 'chunk-4X6ZJEXY.js']
|
||||
exclude: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,6 +53,16 @@ export default defineConfig([
|
||||
}
|
||||
],
|
||||
{
|
||||
ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js']
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'build/**',
|
||||
'dist/**',
|
||||
'out/**',
|
||||
'local/**',
|
||||
'.yarn/**',
|
||||
'.gitignore',
|
||||
'scripts/cloudflare-worker.js',
|
||||
'src/main/integration/nutstore/sso/lib/**'
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
90
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.1.8",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -25,6 +25,7 @@
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac",
|
||||
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
|
||||
@@ -50,73 +51,87 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@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",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@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",
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@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",
|
||||
"color": "^5.0.0",
|
||||
"diff": "^7.0.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",
|
||||
"got-scraping": "^4.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"p-queue": "^8.1.0",
|
||||
"socks-proxy-agent": "^8.0.3",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"tar": "^7.4.3",
|
||||
"tokenx": "^0.4.1",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.4.0",
|
||||
"webdav": "4.11.4",
|
||||
"webdav": "^5.8.0",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@analytics/google-analytics": "^1.1.0",
|
||||
"@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",
|
||||
"@llm-tools/embedjs-loader-image": "^0.1.28",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@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/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^18.19.9",
|
||||
"@types/pako": "^1.0.2",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"analytics": "^0.8.16",
|
||||
"antd": "^5.22.5",
|
||||
"applescript": "^1.0.0",
|
||||
"axios": "^1.7.3",
|
||||
@@ -142,11 +157,15 @@
|
||||
"i18next": "^23.11.5",
|
||||
"lint-staged": "^15.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"mime": "^4.0.4",
|
||||
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"rc-virtual-list": "^3.18.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
@@ -160,27 +179,28 @@
|
||||
"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",
|
||||
"sass": "^1.77.2",
|
||||
"shiki": "^1.22.2",
|
||||
"shiki": "^3.2.1",
|
||||
"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"
|
||||
"node-gyp": "^9.1.0",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"lint-staged": {
|
||||
|
||||
150
packages/shared/IpcChannel.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export enum IpcChannel {
|
||||
App_ClearCache = 'app:clear-cache',
|
||||
App_SetLaunchOnBoot = 'app:set-launch-on-boot',
|
||||
App_SetLanguage = 'app:set-language',
|
||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
||||
App_CheckForUpdate = 'app:check-for-update',
|
||||
App_Reload = 'app:reload',
|
||||
App_Info = 'app:info',
|
||||
App_Proxy = 'app:proxy',
|
||||
App_SetLaunchToTray = 'app:set-launch-to-tray',
|
||||
App_SetTray = 'app:set-tray',
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
App_RestartTray = 'app:restart-tray',
|
||||
App_SetTheme = 'app:set-theme',
|
||||
|
||||
App_IsBinaryExist = 'app:is-binary-exist',
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
|
||||
// Open
|
||||
Open_Path = 'open:path',
|
||||
Open_Website = 'open:website',
|
||||
|
||||
Minapp = 'minapp',
|
||||
|
||||
Config_Set = 'config:set',
|
||||
Config_Get = 'config:get',
|
||||
|
||||
MiniWindow_Show = 'miniwindow:show',
|
||||
MiniWindow_Hide = 'miniwindow:hide',
|
||||
MiniWindow_Close = 'miniwindow:close',
|
||||
MiniWindow_Toggle = 'miniwindow:toggle',
|
||||
MiniWindow_SetPin = 'miniwindow:set-pin',
|
||||
|
||||
// Mcp
|
||||
Mcp_RemoveServer = 'mcp:remove-server',
|
||||
Mcp_RestartServer = 'mcp:restart-server',
|
||||
Mcp_StopServer = 'mcp:stop-server',
|
||||
Mcp_ListTools = 'mcp:list-tools',
|
||||
Mcp_CallTool = 'mcp:call-tool',
|
||||
Mcp_GetInstallInfo = 'mcp:get-install-info',
|
||||
Mcp_ServersChanged = 'mcp:servers-changed',
|
||||
Mcp_ServersUpdated = 'mcp:servers-updated',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||
Copilot_SaveCopilotToken = 'copilot:save-copilot-token',
|
||||
Copilot_GetToken = 'copilot:get-token',
|
||||
Copilot_Logout = 'copilot:logout',
|
||||
Copilot_GetUser = 'copilot:get-user',
|
||||
|
||||
// obsidian
|
||||
Obsidian_GetVaults = 'obsidian:get-vaults',
|
||||
Obsidian_GetFiles = 'obsidian:get-files',
|
||||
|
||||
// nutstore
|
||||
Nutstore_GetSsoUrl = 'nutstore:get-sso-url',
|
||||
Nutstore_DecryptToken = 'nutstore:decrypt-token',
|
||||
Nutstore_GetDirectoryContents = 'nutstore:get-directory-contents',
|
||||
|
||||
//aes
|
||||
Aes_Encrypt = 'aes:encrypt',
|
||||
Aes_Decrypt = 'aes:decrypt',
|
||||
|
||||
Gemini_UploadFile = 'gemini:upload-file',
|
||||
Gemini_Base64File = 'gemini:base64-file',
|
||||
Gemini_RetrieveFile = 'gemini:retrieve-file',
|
||||
Gemini_ListFiles = 'gemini:list-files',
|
||||
Gemini_DeleteFile = 'gemini:delete-file',
|
||||
|
||||
Windows_ResetMinimumSize = 'window:reset-minimum-size',
|
||||
Windows_SetMinimumSize = 'window:set-minimum-size',
|
||||
|
||||
SelectionMenu_Action = 'selection-menu:action',
|
||||
|
||||
KnowledgeBase_Create = 'knowledge-base:create',
|
||||
KnowledgeBase_Reset = 'knowledge-base:reset',
|
||||
KnowledgeBase_Delete = 'knowledge-base:delete',
|
||||
KnowledgeBase_Add = 'knowledge-base:add',
|
||||
KnowledgeBase_Remove = 'knowledge-base:remove',
|
||||
KnowledgeBase_Search = 'knowledge-base:search',
|
||||
KnowledgeBase_Rerank = 'knowledge-base:rerank',
|
||||
|
||||
//file
|
||||
File_Open = 'file:open',
|
||||
File_OpenPath = 'file:openPath',
|
||||
File_Save = 'file:save',
|
||||
File_Select = 'file:select',
|
||||
File_Upload = 'file:upload',
|
||||
File_Clear = 'file:clear',
|
||||
File_Read = 'file:read',
|
||||
File_Delete = 'file:delete',
|
||||
File_Get = 'file:get',
|
||||
File_SelectFolder = 'file:selectFolder',
|
||||
File_Create = 'file:create',
|
||||
File_Write = 'file:write',
|
||||
File_SaveImage = 'file:saveImage',
|
||||
File_Base64Image = 'file:base64Image',
|
||||
File_Download = 'file:download',
|
||||
File_Copy = 'file:copy',
|
||||
File_BinaryFile = 'file:binaryFile',
|
||||
|
||||
Fs_Read = 'fs:read',
|
||||
|
||||
Export_Word = 'export:word',
|
||||
|
||||
Shortcuts_Update = 'shortcuts:update',
|
||||
|
||||
// backup
|
||||
Backup_Backup = 'backup:backup',
|
||||
Backup_Restore = 'backup:restore',
|
||||
Backup_BackupToWebdav = 'backup:backupToWebdav',
|
||||
Backup_RestoreFromWebdav = 'backup:restoreFromWebdav',
|
||||
Backup_ListWebdavFiles = 'backup:listWebdavFiles',
|
||||
Backup_CheckConnection = 'backup:checkConnection',
|
||||
Backup_CreateDirectory = 'backup:createDirectory',
|
||||
|
||||
// zip
|
||||
Zip_Compress = 'zip:compress',
|
||||
Zip_Decompress = 'zip:decompress',
|
||||
|
||||
// system
|
||||
System_GetDeviceType = 'system:getDeviceType',
|
||||
|
||||
// events
|
||||
SelectionAction = 'selection-action',
|
||||
BackupProgress = 'backup-progress',
|
||||
ThemeChange = 'theme:change',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
UpdateError = 'update-error',
|
||||
UpdateAvailable = 'update-available',
|
||||
UpdateNotAvailable = 'update-not-available',
|
||||
DownloadProgress = 'download-progress',
|
||||
UpdateDownloaded = 'update-downloaded',
|
||||
DownloadUpdate = 'download-update',
|
||||
|
||||
DirectoryProcessingPercent = 'directory-processing-percent',
|
||||
|
||||
FullscreenStatusChanged = 'fullscreen-status-changed',
|
||||
|
||||
HideMiniWindow = 'hide-mini-window',
|
||||
ShowMiniWindow = 'show-mini-window',
|
||||
MiniWindowReload = 'miniwindow-reload',
|
||||
|
||||
ReduxStateChange = 'redux-state-change',
|
||||
ReduxStoreReady = 'redux-store-ready'
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export const textExts = [
|
||||
'.ctp', // CakePHP 视图文件
|
||||
'.cfm', // ColdFusion 标记语言文件
|
||||
'.cfc', // ColdFusion 组件文件
|
||||
'.m', // Objective-C 源文件
|
||||
'.m', // Objective-C 或 MATLAB 源文件
|
||||
'.mm', // Objective-C++ 源文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.groovy', // Gradle 构建文件
|
||||
@@ -106,7 +106,32 @@ export const textExts = [
|
||||
'.ixx', // C++20 模块实现文件
|
||||
'.f90', // Fortran 90 源文件
|
||||
'.f', // Fortran 固定格式源代码文件
|
||||
'.f03' // Fortran 2003+ 源代码文件
|
||||
'.f03', // Fortran 2003+ 源代码文件
|
||||
'.ahk', // AutoHotKey 语言文件
|
||||
'.tcl', // Tcl 脚本
|
||||
'.do', // Questa 或 Modelsim Tcl 脚本
|
||||
'.v', // Verilog 源文件
|
||||
'.sv', // SystemVerilog 源文件
|
||||
'.svh', // SystemVerilog 头文件
|
||||
'.vhd', // VHDL 源文件
|
||||
'.vhdl', // VHDL 源文件
|
||||
'.lef', // Library Exchange Format
|
||||
'.def', // Design Exchange Format
|
||||
'.edif', // Electronic Design Interchange Format
|
||||
'.sdf', // Standard Delay Format
|
||||
'.sdc', // Synopsys Design Constraints
|
||||
'.xdc', // Xilinx Design Constraints
|
||||
'.rpt', // 报告文件
|
||||
'.lisp', // Lisp 脚本
|
||||
'.il', // Cadence SKILL 脚本
|
||||
'.ils', // Cadence SKILL++ 脚本
|
||||
'.sp', // SPICE netlist 文件
|
||||
'.spi', // SPICE netlist 文件
|
||||
'.cir', // SPICE netlist 文件
|
||||
'.net', // SPICE netlist 文件
|
||||
'.scs', // Spectre netlist 文件
|
||||
'.asc', // LTspice netlist schematic 文件
|
||||
'.tf' // Technology File
|
||||
]
|
||||
|
||||
export const ZOOM_SHORTCUTS = [
|
||||
@@ -132,3 +157,8 @@ export const ZOOM_SHORTCUTS = [
|
||||
system: true
|
||||
}
|
||||
]
|
||||
|
||||
export const KB = 1024
|
||||
export const MB = 1024 * KB
|
||||
export const GB = 1024 * MB
|
||||
export const defaultLanguage = 'en-US'
|
||||
|
||||
1
packages/shared/config/nutstore.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const NUTSTORE_HOST = 'https://dav.jianguoyun.com/dav'
|
||||
@@ -1,118 +1,111 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CherryStudio 许可协议-ZH/EN</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CherryStudio 许可协议-ZH/EN</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-100 p-8">
|
||||
<div class="container mx-auto bg-white p-6 rounded shadow-lg">
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio 许可协议</h1>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">许可协议</h2>
|
||||
<p class="mb-4">
|
||||
本软件采用 <strong>Apache License 2.0</strong> 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry
|
||||
Studio 时还应遵守以下附加条款:
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold mb-2">一. 商用许可</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li><strong>免费商用</strong>:用户在不修改代码的情况下,可以免费用于商业目的。</li>
|
||||
<li>
|
||||
<strong>商业授权</strong>:如果您满足以下任意条件之一,需取得商业授权:
|
||||
<ol class="list-decimal list-inside ml-4">
|
||||
<li>对本软件进行二次修改、开发(包括但不限于修改应用名称、logo、代码以及功能)。</li>
|
||||
<li>为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。</li>
|
||||
<li>预装或集成到硬件设备或产品中进行捆绑销售。</li>
|
||||
<li>政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">二. 贡献者协议</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。</li>
|
||||
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">三. 其他条款</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>本协议条款的解释权归 Cherry Studio 开发者所有。</li>
|
||||
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
|
||||
</ol>
|
||||
<p class="mb-4">如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。</p>
|
||||
<p>
|
||||
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0"
|
||||
class="text-blue-500 underline">http://www.apache.org/licenses/LICENSE-2.0</a>
|
||||
</p>
|
||||
<body class="bg-gray-100 p-8">
|
||||
<div class="container mx-auto bg-white p-6 rounded shadow-lg">
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio 许可协议</h1>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">许可协议</h2>
|
||||
<p class="mb-4">
|
||||
本软件采用 <strong>Apache License 2.0</strong> 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry
|
||||
Studio 时还应遵守以下附加条款:
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold mb-2">一. 商用许可</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li><strong>免费商用</strong>:用户在不修改代码的情况下,可以免费用于商业目的。</li>
|
||||
<li>
|
||||
<strong>商业授权</strong>:如果您满足以下任意条件之一,需取得商业授权:
|
||||
<ol class="list-decimal list-inside ml-4">
|
||||
<li>对本软件进行二次修改、开发(包括但不限于修改应用名称、logo、代码以及功能)。</li>
|
||||
<li>为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。</li>
|
||||
<li>预装或集成到硬件设备或产品中进行捆绑销售。</li>
|
||||
<li>政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">二. 贡献者协议</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。</li>
|
||||
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">三. 其他条款</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>本协议条款的解释权归 Cherry Studio 开发者所有。</li>
|
||||
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
|
||||
</ol>
|
||||
<p class="mb-4">如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。</p>
|
||||
<p>
|
||||
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0" class="text-blue-500 underline"
|
||||
>http://www.apache.org/licenses/LICENSE-2.0</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio License</h1>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">License Agreement</h2>
|
||||
<p class="mb-4">
|
||||
This software is licensed under the <strong>Apache License 2.0</strong>. In addition to the terms of the
|
||||
Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold mb-2">I. Commercial Use License</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>
|
||||
<strong>Free Commercial Use</strong>: Users can use the software for commercial purposes without modifying
|
||||
the code.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Commercial License Required</strong>: A commercial license is required if any of the following
|
||||
conditions are met:
|
||||
<ol class="list-decimal list-inside ml-4">
|
||||
<li>
|
||||
You modify, develop, or alter the software, including but not limited to changes to the application
|
||||
name, logo, code, or functionality.
|
||||
</li>
|
||||
<li>You provide multi-tenant services to enterprise customers with 10 or more users.</li>
|
||||
<li>
|
||||
You pre-install or integrate the software into hardware devices or products and bundle it for sale.
|
||||
</li>
|
||||
<li>
|
||||
You are engaging in large-scale procurement for government or educational institutions, especially
|
||||
involving security, data privacy, or other sensitive requirements.
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">II. Contributor Agreement</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>
|
||||
<strong>License Adjustment</strong>: The producer reserves the right to adjust the open-source license as
|
||||
needed, making it stricter or more lenient.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes, including but
|
||||
not limited to cloud business operations.
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">III. Other Terms</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>The interpretation of these terms is subject to the discretion of Cherry Studio developers.</li>
|
||||
<li>These terms may be updated, and users will be notified through the software when changes occur.</li>
|
||||
</ol>
|
||||
<p class="mb-4">
|
||||
For any questions or to request a commercial license, please contact the Cherry Studio development team.
|
||||
</p>
|
||||
<p>
|
||||
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache
|
||||
License 2.0. Detailed information about the Apache License 2.0 can be found at
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0" class="text-blue-500 underline"
|
||||
>http://www.apache.org/licenses/LICENSE-2.0</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio License</h1>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">License Agreement</h2>
|
||||
<p class="mb-4">
|
||||
This software is licensed under the <strong>Apache License 2.0</strong>. In addition to the terms of the
|
||||
Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold mb-2">I. Commercial Use License</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>
|
||||
<strong>Free Commercial Use</strong>: Users can use the software for commercial purposes without
|
||||
modifying
|
||||
the code.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Commercial License Required</strong>: A commercial license is required if any of the
|
||||
following
|
||||
conditions are met:
|
||||
<ol class="list-decimal list-inside ml-4">
|
||||
<li>
|
||||
You modify, develop, or alter the software, including but not limited to changes to the
|
||||
application
|
||||
name, logo, code, or functionality.
|
||||
</li>
|
||||
<li>You provide multi-tenant services to enterprise customers with 10 or more users.</li>
|
||||
<li>
|
||||
You pre-install or integrate the software into hardware devices or products and bundle it
|
||||
for sale.
|
||||
</li>
|
||||
<li>
|
||||
You are engaging in large-scale procurement for government or educational institutions,
|
||||
especially
|
||||
involving security, data privacy, or other sensitive requirements.
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">II. Contributor Agreement</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>
|
||||
<strong>License Adjustment</strong>: The producer reserves the right to adjust the open-source
|
||||
license as
|
||||
needed, making it stricter or more lenient.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes,
|
||||
including but
|
||||
not limited to cloud business operations.
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">III. Other Terms</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>The interpretation of these terms is subject to the discretion of Cherry Studio developers.</li>
|
||||
<li>These terms may be updated, and users will be notified through the software when changes occur.</li>
|
||||
</ol>
|
||||
<p class="mb-4">
|
||||
For any questions or to request a commercial license, please contact the Cherry Studio development team.
|
||||
</p>
|
||||
<p>
|
||||
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache
|
||||
License 2.0. Detailed information about the Apache License 2.0 can be found at
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0"
|
||||
class="text-blue-500 underline">http://www.apache.org/licenses/LICENSE-2.0</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Github Releases Timeline</title>
|
||||
@@ -9,194 +8,201 @@
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.5.10/dist/typography.min.css"></script>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body id="app">
|
||||
<body id="app">
|
||||
<div :class="isDark ? 'dark-bg' : 'bg'" class="min-h-screen">
|
||||
<div class="max-w-3xl mx-auto py-12 px-4">
|
||||
<h1 class="text-3xl font-bold mb-8" :class="isDark ? 'text-white' : 'text-gray-900'">Release Timeline</h1>
|
||||
<div class="max-w-3xl mx-auto py-12 px-4">
|
||||
<h1 class="text-3xl font-bold mb-8" :class="isDark ? 'text-white' : 'text-gray-900'">Release Timeline</h1>
|
||||
|
||||
<!-- Loading状态 -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4"
|
||||
:class="isDark ? 'border-gray-700 border-t-blue-500' : 'border-gray-300 border-t-blue-500'"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error 状态 -->
|
||||
<div v-else-if="error" class="text-red-500 text-center py-8">{{ error }}</div>
|
||||
|
||||
<!-- Release 列表 -->
|
||||
<div v-else class="space-y-8">
|
||||
<div v-for="release in releases" :key="release.id" class="relative pl-8"
|
||||
:class="isDark ? 'border-l-2 border-gray-700' : 'border-l-2 border-gray-200'">
|
||||
<div class="absolute -left-2 top-0 w-4 h-4 rounded-full bg-green-500"></div>
|
||||
<div class="rounded-lg shadow-sm p-6 transition-shadow"
|
||||
:class="isDark ? 'bg-black hover:shadow-md hover:shadow-black' : 'bg-white hover:shadow-md'">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold" :class="isDark ? 'text-white' : 'text-gray-900'">
|
||||
{{ release.name || release.tag_name }}
|
||||
</h2>
|
||||
<p class="text-sm mt-1" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
|
||||
{{ formatDate(release.published_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||
:class="isDark ? 'bg-green-900 text-green-200' : 'bg-green-100 text-green-800'">
|
||||
{{ release.tag_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="prose" :class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
|
||||
v-html="renderMarkdown(release.body)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading状态 -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div
|
||||
class="inline-block animate-spin rounded-full h-8 w-8 border-4"
|
||||
:class="isDark ? 'border-gray-700 border-t-blue-500' : 'border-gray-300 border-t-blue-500'"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error 状态 -->
|
||||
<div v-else-if="error" class="text-red-500 text-center py-8">{{ error }}</div>
|
||||
|
||||
<!-- Release 列表 -->
|
||||
<div v-else class="space-y-8">
|
||||
<div
|
||||
v-for="release in releases"
|
||||
:key="release.id"
|
||||
class="relative pl-8"
|
||||
:class="isDark ? 'border-l-2 border-gray-700' : 'border-l-2 border-gray-200'">
|
||||
<div class="absolute -left-2 top-0 w-4 h-4 rounded-full bg-green-500"></div>
|
||||
<div
|
||||
class="rounded-lg shadow-sm p-6 transition-shadow"
|
||||
:class="isDark ? 'bg-black hover:shadow-md hover:shadow-black' : 'bg-white hover:shadow-md'">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold" :class="isDark ? 'text-white' : 'text-gray-900'">
|
||||
{{ release.name || release.tag_name }}
|
||||
</h2>
|
||||
<p class="text-sm mt-1" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
|
||||
{{ formatDate(release.published_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||
:class="isDark ? 'bg-green-900 text-green-200' : 'bg-green-100 text-green-800'">
|
||||
{{ release.tag_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="prose"
|
||||
:class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
|
||||
v-html="renderMarkdown(release.body)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const md = window.markdownit({
|
||||
breaks: true,
|
||||
linkify: true
|
||||
})
|
||||
const md = window.markdownit({
|
||||
breaks: true,
|
||||
linkify: true
|
||||
})
|
||||
|
||||
const { createApp } = Vue
|
||||
const { createApp } = Vue
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
releases: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
isDark: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchReleases() {
|
||||
try {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
const response = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch releases')
|
||||
}
|
||||
this.releases = await response.json()
|
||||
} catch (err) {
|
||||
this.error = 'Error loading releases: ' + err.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
},
|
||||
renderMarkdown(content) {
|
||||
if (!content) return ''
|
||||
return md.render(content)
|
||||
},
|
||||
initTheme() {
|
||||
// 从 URL 参数获取主题设置
|
||||
const url = new URL(window.location.href)
|
||||
const theme = url.searchParams.get('theme')
|
||||
this.isDark = theme === 'dark'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initTheme()
|
||||
this.fetchReleases()
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
releases: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
isDark: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchReleases() {
|
||||
try {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
const response = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch releases')
|
||||
}
|
||||
this.releases = await response.json()
|
||||
} catch (err) {
|
||||
this.error = 'Error loading releases: ' + err.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}).mount('#app')
|
||||
},
|
||||
formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
},
|
||||
renderMarkdown(content) {
|
||||
if (!content) return ''
|
||||
return md.render(content)
|
||||
},
|
||||
initTheme() {
|
||||
// 从 URL 参数获取主题设置
|
||||
const url = new URL(window.location.href)
|
||||
const theme = url.searchParams.get('theme')
|
||||
this.isDark = theme === 'dark'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initTheme()
|
||||
this.fetchReleases()
|
||||
}
|
||||
}).mount('#app')
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 基础的 Markdown 样式 */
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* 基础的 Markdown 样式 */
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.prose h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.3em;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
.prose h2 {
|
||||
font-size: 1.3em;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1.1em;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
.prose h3 {
|
||||
font-size: 1.1em;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
list-style-type: disc;
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose ul {
|
||||
list-style-type: disc;
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
list-style-type: decimal;
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose ol {
|
||||
list-style-type: decimal;
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.2em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.prose code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.2em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.dark .prose code {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
.dark .prose code {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.prose code {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
display: block;
|
||||
padding: 1em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.prose pre code {
|
||||
display: block;
|
||||
padding: 1em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dark .prose a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
.dark .prose a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 1em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.prose blockquote {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 1em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.dark .prose blockquote {
|
||||
border-left-color: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dark .prose blockquote {
|
||||
border-left-color: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .prose {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.dark .prose {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark-bg {
|
||||
background-color: #151515;
|
||||
}
|
||||
.dark-bg {
|
||||
background-color: #151515;
|
||||
}
|
||||
|
||||
.bg {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
.bg {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<script src="https://unpkg.com/3d-force-graph"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="3d-graph"></div>
|
||||
<script src="./js/bridge.js"></script>
|
||||
<script type="module">
|
||||
import { getQueryParam } from './js/utils.js'
|
||||
|
||||
const apiUrl = getQueryParam('apiUrl')
|
||||
const modelId = getQueryParam('modelId')
|
||||
const jsonUrl = `${apiUrl}/v1/global_graph/${modelId}`
|
||||
|
||||
const infoCard = document.createElement('div')
|
||||
infoCard.style.position = 'fixed'
|
||||
infoCard.style.backgroundColor = 'rgba(255, 255, 255, 0.9)'
|
||||
infoCard.style.padding = '8px'
|
||||
infoCard.style.borderRadius = '4px'
|
||||
infoCard.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'
|
||||
infoCard.style.fontSize = '12px'
|
||||
infoCard.style.maxWidth = '200px'
|
||||
infoCard.style.display = 'none'
|
||||
infoCard.style.zIndex = '1000'
|
||||
document.body.appendChild(infoCard)
|
||||
|
||||
document.addEventListener('mousemove', (event) => {
|
||||
infoCard.style.left = `${event.clientX + 10}px`
|
||||
infoCard.style.top = `${event.clientY + 10}px`
|
||||
})
|
||||
|
||||
const elem = document.getElementById('3d-graph')
|
||||
const Graph = ForceGraph3D()(elem)
|
||||
.jsonUrl(jsonUrl)
|
||||
.nodeAutoColorBy((node) => node.properties.type || 'default')
|
||||
.nodeVal((node) => node.properties.degree)
|
||||
.linkWidth((link) => link.properties.weight)
|
||||
.onNodeHover((node) => {
|
||||
if (node) {
|
||||
infoCard.innerHTML = `
|
||||
<div style="font-weight: bold; margin-bottom: 4px; color: #333;">
|
||||
${node.properties.title}
|
||||
</div>
|
||||
<div style="color: #666;">
|
||||
${node.properties.description}
|
||||
</div>`
|
||||
infoCard.style.display = 'block'
|
||||
} else {
|
||||
infoCard.style.display = 'none'
|
||||
}
|
||||
})
|
||||
.onNodeClick((node) => {
|
||||
const url = `${apiUrl}/v1/references/${modelId}/entities/${node.properties.human_readable_id}`
|
||||
window.api.minApp({
|
||||
url,
|
||||
windowOptions: {
|
||||
title: node.properties.title,
|
||||
width: 500,
|
||||
height: 800
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
@@ -1,8 +1,5 @@
|
||||
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
|
||||
@@ -11,42 +8,28 @@ const { pipeline } = require('stream/promises')
|
||||
* @returns {Promise<void>} Promise that resolves when download is complete
|
||||
*/
|
||||
async function downloadWithRedirects(url, destinationPath) {
|
||||
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}`)
|
||||
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 file = fs.createWriteStream(destinationPath)
|
||||
await pipeline(response.body, file)
|
||||
}
|
||||
request(url)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { downloadWithRedirects }
|
||||
|
||||
@@ -29,7 +29,14 @@ exports.default = async function (context) {
|
||||
|
||||
if (platform === 'windows') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
if (arch === Arch.arm64) {
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
||||
}
|
||||
if (arch === Arch.x64) {
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ async function downloadNpm(platform) {
|
||||
'@libsql/win32-x64-msvc',
|
||||
'https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz'
|
||||
)
|
||||
downloadNpmPackage(
|
||||
'@strongtz/win32-arm64-msvc',
|
||||
'https://registry.npmjs.org/@strongtz/win32-arm64-msvc/-/win32-arm64-msvc-0.4.7.tgz'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
130
scripts/update-i18n.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
}
|
||||
})()
|
||||
@@ -22,7 +22,8 @@ function downloadNpmPackage(packageName, url) {
|
||||
console.log(`Extracting ${filename}...`)
|
||||
execSync(`tar -xvf ${filename}`)
|
||||
execSync(`rm -rf ${filename}`)
|
||||
execSync(`mv package ${targetDir}`)
|
||||
execSync(`mkdir -p ${targetDir}`)
|
||||
execSync(`mv package/* ${targetDir}/`)
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${packageName}: ${error.message}`)
|
||||
if (fs.existsSync(filename)) {
|
||||
|
||||
24
src/main/embeddings/Embeddings.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import EmbeddingsFactory from './EmbeddingsFactory'
|
||||
|
||||
export default class Embeddings {
|
||||
private sdk: BaseEmbeddings
|
||||
constructor({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) {
|
||||
this.sdk = EmbeddingsFactory.create({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
|
||||
}
|
||||
public async init(): Promise<void> {
|
||||
return this.sdk.init()
|
||||
}
|
||||
public async getDimensions(): Promise<number> {
|
||||
return this.sdk.getDimensions()
|
||||
}
|
||||
public async embedDocuments(texts: string[]): Promise<number[][]> {
|
||||
return this.sdk.embedDocuments(texts)
|
||||
}
|
||||
|
||||
public async embedQuery(text: string): Promise<number[]> {
|
||||
return this.sdk.embedQuery(text)
|
||||
}
|
||||
}
|
||||
38
src/main/embeddings/EmbeddingsFactory.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
|
||||
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import VoyageEmbeddings from './VoyageEmbeddings'
|
||||
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
|
||||
const batchSize = 10
|
||||
if (model.includes('voyage')) {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: dimensions,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
if (apiVersion !== undefined) {
|
||||
return new AzureOpenAiEmbeddings({
|
||||
azureOpenAIApiKey: apiKey,
|
||||
azureOpenAIApiVersion: apiVersion,
|
||||
azureOpenAIApiDeploymentName: model,
|
||||
azureOpenAIApiInstanceName: getInstanceName(baseURL),
|
||||
dimensions,
|
||||
batchSize
|
||||
})
|
||||
}
|
||||
return new OpenAiEmbeddings({
|
||||
model,
|
||||
apiKey,
|
||||
dimensions,
|
||||
batchSize,
|
||||
configuration: { baseURL }
|
||||
})
|
||||
}
|
||||
}
|
||||
30
src/main/embeddings/VoyageEmbeddings.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
|
||||
|
||||
export default class VoyageEmbeddings extends BaseEmbeddings {
|
||||
private model: _VoyageEmbeddings
|
||||
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
|
||||
super()
|
||||
if (!this.configuration) this.configuration = {}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
|
||||
if (!this.configuration.outputDimension) {
|
||||
throw new Error('You need to pass in the optional dimensions parameter for this model')
|
||||
}
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
}
|
||||
override async getDimensions(): Promise<number> {
|
||||
if (!this.configuration?.outputDimension) {
|
||||
throw new Error('You need to pass in the optional dimensions parameter for this model')
|
||||
}
|
||||
return this.configuration?.outputDimension
|
||||
}
|
||||
|
||||
override async embedDocuments(texts: string[]): Promise<number[][]> {
|
||||
return this.model.embedDocuments(texts)
|
||||
}
|
||||
|
||||
override async embedQuery(text: string): Promise<number[]> {
|
||||
return this.model.embedQuery(text)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, 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'
|
||||
@@ -21,6 +24,12 @@ 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()
|
||||
|
||||
@@ -40,18 +49,39 @@ if (!app.requestSingleInstanceLock()) {
|
||||
replaceDevtoolsFont(mainWindow)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
installExtension(REDUX_DEVTOOLS)
|
||||
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
ipcMain.handle('system:getDeviceType', () => {
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
|
||||
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
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) => {
|
||||
|
||||
8
src/main/integration/nutstore/sso/lib/index.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
declare function decrypt(app: string, s: string): string
|
||||
|
||||
interface Secret {
|
||||
app: string
|
||||
}
|
||||
declare function createOAuthUrl(secret: Secret): string
|
||||
|
||||
export { type Secret, createOAuthUrl, decrypt }
|
||||
134463
src/main/integration/nutstore/sso/lib/index.js
Normal file
239
src/main/ipc.ts
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
import { MCPServer, Shortcut, ThemeMode } from '@types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
|
||||
@@ -15,35 +17,38 @@ 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 mcpService from './services/MCPService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getFilesDir } from './utils/file'
|
||||
import { getConfigDir, getFilesDir } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
const mcpService = new MCPService()
|
||||
const obsidianVaultService = new ObsidianVaultService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
|
||||
ipcMain.handle('app:info', () => ({
|
||||
ipcMain.handle(IpcChannel.App_Info, () => ({
|
||||
version: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
appPath: app.getAppPath(),
|
||||
filesPath: getFilesDir(),
|
||||
configPath: getConfigDir(),
|
||||
appDataPath: app.getPath('userData'),
|
||||
resourcesPath: getResourcePath(),
|
||||
logsPath: log.transports.file.getFile().path
|
||||
}))
|
||||
|
||||
ipcMain.handle('app:proxy', async (_, proxy: string) => {
|
||||
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
|
||||
let proxyConfig: ProxyConfig
|
||||
|
||||
if (proxy === 'system') {
|
||||
@@ -57,34 +62,53 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
await proxyManager.configureProxy(proxyConfig)
|
||||
})
|
||||
|
||||
ipcMain.handle('app:reload', () => mainWindow.reload())
|
||||
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
|
||||
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
|
||||
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
||||
|
||||
// Update
|
||||
ipcMain.handle('app:show-update-dialog', () => appUpdater.showUpdateDialog(mainWindow))
|
||||
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
|
||||
|
||||
// language
|
||||
ipcMain.handle('app:set-language', (_, language) => {
|
||||
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
||||
configManager.setLanguage(language)
|
||||
})
|
||||
|
||||
// launch on boot
|
||||
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, 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(IpcChannel.App_SetLaunchToTray, (_, isActive: boolean) => {
|
||||
configManager.setLaunchToTray(isActive)
|
||||
})
|
||||
|
||||
// tray
|
||||
ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
|
||||
ipcMain.handle(IpcChannel.App_SetTray, (_, isActive: boolean) => {
|
||||
configManager.setTray(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
|
||||
// to tray on close
|
||||
ipcMain.handle(IpcChannel.App_SetTrayOnClose, (_, isActive: boolean) => {
|
||||
configManager.setTrayOnClose(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle('config:set', (_, key: string, value: any) => {
|
||||
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
|
||||
configManager.set(key, value)
|
||||
})
|
||||
|
||||
ipcMain.handle('config:get', (_, key: string) => {
|
||||
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
|
||||
return configManager.get(key)
|
||||
})
|
||||
|
||||
// theme
|
||||
ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => {
|
||||
ipcMain.handle(IpcChannel.App_SetTheme, (event, theme: ThemeMode) => {
|
||||
if (theme === configManager.getTheme()) return
|
||||
|
||||
configManager.setTheme(theme)
|
||||
@@ -95,7 +119,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
// 向其他窗口广播主题变化
|
||||
windows.forEach((win) => {
|
||||
if (win.webContents.id !== senderWindowId) {
|
||||
win.webContents.send('theme:change', theme)
|
||||
win.webContents.send(IpcChannel.ThemeChange, theme)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -104,7 +128,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// clear cache
|
||||
ipcMain.handle('app:clear-cache', async () => {
|
||||
ipcMain.handle(IpcChannel.App_ClearCache, async () => {
|
||||
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
|
||||
|
||||
try {
|
||||
@@ -126,7 +150,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// check for update
|
||||
ipcMain.handle('app:check-for-update', async () => {
|
||||
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
|
||||
const update = await appUpdater.autoUpdater.checkForUpdates()
|
||||
return {
|
||||
currentVersion: appUpdater.autoUpdater.currentVersion,
|
||||
@@ -135,60 +159,50 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// zip
|
||||
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
|
||||
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
|
||||
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
|
||||
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
|
||||
|
||||
// backup
|
||||
ipcMain.handle('backup:backup', backupManager.backup)
|
||||
ipcMain.handle('backup:restore', backupManager.restore)
|
||||
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
|
||||
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
|
||||
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
|
||||
ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup)
|
||||
ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore)
|
||||
ipcMain.handle(IpcChannel.Backup_BackupToWebdav, backupManager.backupToWebdav)
|
||||
ipcMain.handle(IpcChannel.Backup_RestoreFromWebdav, backupManager.restoreFromWebdav)
|
||||
ipcMain.handle(IpcChannel.Backup_ListWebdavFiles, backupManager.listWebdavFiles)
|
||||
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
|
||||
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
|
||||
|
||||
// file
|
||||
ipcMain.handle('file:open', fileManager.open)
|
||||
ipcMain.handle('file:openPath', fileManager.openPath)
|
||||
ipcMain.handle('file:save', fileManager.save)
|
||||
ipcMain.handle('file:select', fileManager.selectFile)
|
||||
ipcMain.handle('file:upload', fileManager.uploadFile)
|
||||
ipcMain.handle('file:clear', fileManager.clear)
|
||||
ipcMain.handle('file:read', fileManager.readFile)
|
||||
ipcMain.handle('file:delete', fileManager.deleteFile)
|
||||
ipcMain.handle('file:get', fileManager.getFile)
|
||||
ipcMain.handle('file:selectFolder', fileManager.selectFolder)
|
||||
ipcMain.handle('file:create', fileManager.createTempFile)
|
||||
ipcMain.handle('file:write', fileManager.writeFile)
|
||||
ipcMain.handle('file:saveImage', fileManager.saveImage)
|
||||
ipcMain.handle('file:base64Image', fileManager.base64Image)
|
||||
ipcMain.handle('file:download', fileManager.downloadFile)
|
||||
ipcMain.handle('file:copy', fileManager.copyFile)
|
||||
ipcMain.handle('file:binaryFile', fileManager.binaryFile)
|
||||
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
|
||||
ipcMain.handle(IpcChannel.File_OpenPath, fileManager.openPath)
|
||||
ipcMain.handle(IpcChannel.File_Save, fileManager.save)
|
||||
ipcMain.handle(IpcChannel.File_Select, fileManager.selectFile)
|
||||
ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile)
|
||||
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
|
||||
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
|
||||
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
|
||||
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
|
||||
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
|
||||
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
|
||||
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
|
||||
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
|
||||
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
|
||||
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
ipcMain.handle(IpcChannel.File_BinaryFile, fileManager.binaryFile)
|
||||
|
||||
// fs
|
||||
ipcMain.handle('fs:read', FileService.readFile)
|
||||
|
||||
// minapp
|
||||
ipcMain.handle('minapp', (_, args) => {
|
||||
windowService.createMinappWindow({
|
||||
url: args.url,
|
||||
parent: mainWindow,
|
||||
windowOptions: {
|
||||
...mainWindow.getBounds(),
|
||||
...args.windowOptions
|
||||
}
|
||||
})
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
|
||||
|
||||
// export
|
||||
ipcMain.handle('export:word', exportService.exportToWord)
|
||||
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord)
|
||||
|
||||
// open path
|
||||
ipcMain.handle('open:path', async (_, path: string) => {
|
||||
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
|
||||
await shell.openPath(path)
|
||||
})
|
||||
|
||||
// shortcuts
|
||||
ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => {
|
||||
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
|
||||
configManager.setShortcuts(shortcuts)
|
||||
// Refresh shortcuts registration
|
||||
if (mainWindow) {
|
||||
@@ -198,20 +212,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// knowledge base
|
||||
ipcMain.handle('knowledge-base:create', KnowledgeService.create)
|
||||
ipcMain.handle('knowledge-base:reset', KnowledgeService.reset)
|
||||
ipcMain.handle('knowledge-base:delete', KnowledgeService.delete)
|
||||
ipcMain.handle('knowledge-base:add', KnowledgeService.add)
|
||||
ipcMain.handle('knowledge-base:remove', KnowledgeService.remove)
|
||||
ipcMain.handle('knowledge-base:search', KnowledgeService.search)
|
||||
ipcMain.handle('knowledge-base:rerank', KnowledgeService.rerank)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Add, KnowledgeService.add)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
|
||||
|
||||
// window
|
||||
ipcMain.handle('window:set-minimum-size', (_, width: number, height: number) => {
|
||||
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
|
||||
mainWindow?.setMinimumSize(width, height)
|
||||
})
|
||||
|
||||
ipcMain.handle('window:reset-minimum-size', () => {
|
||||
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
|
||||
mainWindow?.setMinimumSize(1080, 600)
|
||||
const [width, height] = mainWindow?.getSize() ?? [1080, 600]
|
||||
if (width < 1080) {
|
||||
@@ -220,60 +234,61 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// gemini
|
||||
ipcMain.handle('gemini:upload-file', GeminiService.uploadFile)
|
||||
ipcMain.handle('gemini:base64-file', GeminiService.base64File)
|
||||
ipcMain.handle('gemini:retrieve-file', GeminiService.retrieveFile)
|
||||
ipcMain.handle('gemini:list-files', GeminiService.listFiles)
|
||||
ipcMain.handle('gemini:delete-file', GeminiService.deleteFile)
|
||||
ipcMain.handle(IpcChannel.Gemini_UploadFile, GeminiService.uploadFile)
|
||||
ipcMain.handle(IpcChannel.Gemini_Base64File, GeminiService.base64File)
|
||||
ipcMain.handle(IpcChannel.Gemini_RetrieveFile, GeminiService.retrieveFile)
|
||||
ipcMain.handle(IpcChannel.Gemini_ListFiles, GeminiService.listFiles)
|
||||
ipcMain.handle(IpcChannel.Gemini_DeleteFile, GeminiService.deleteFile)
|
||||
|
||||
// mini window
|
||||
ipcMain.handle('miniwindow:show', () => windowService.showMiniWindow())
|
||||
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
|
||||
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
|
||||
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Close, () => windowService.closeMiniWindow())
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Toggle, () => windowService.toggleMiniWindow())
|
||||
ipcMain.handle(IpcChannel.MiniWindow_SetPin, (_, isPinned) => windowService.setPinMiniWindow(isPinned))
|
||||
|
||||
// aes
|
||||
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
|
||||
ipcMain.handle('aes:decrypt', (_, encryptedData: string, iv: string, secretKey: string) =>
|
||||
ipcMain.handle(IpcChannel.Aes_Encrypt, (_, text: string, secretKey: string, iv: string) =>
|
||||
encrypt(text, secretKey, iv)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.Aes_Decrypt, (_, encryptedData: string, iv: string, secretKey: string) =>
|
||||
decrypt(encryptedData, iv, secretKey)
|
||||
)
|
||||
|
||||
// Register MCP handlers
|
||||
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 })
|
||||
)
|
||||
ipcMain.handle(IpcChannel.Mcp_RemoveServer, mcpService.removeServer)
|
||||
ipcMain.handle(IpcChannel.Mcp_RestartServer, mcpService.restartServer)
|
||||
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
|
||||
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
|
||||
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
|
||||
// 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())
|
||||
|
||||
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())
|
||||
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
|
||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
||||
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
|
||||
|
||||
//copilot
|
||||
ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage)
|
||||
ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken)
|
||||
ipcMain.handle('copilot:save-copilot-token', CopilotService.saveCopilotToken)
|
||||
ipcMain.handle('copilot:get-token', CopilotService.getToken)
|
||||
ipcMain.handle('copilot:logout', CopilotService.logout)
|
||||
ipcMain.handle('copilot:get-user', CopilotService.getUser)
|
||||
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage)
|
||||
ipcMain.handle(IpcChannel.Copilot_GetCopilotToken, CopilotService.getCopilotToken)
|
||||
ipcMain.handle(IpcChannel.Copilot_SaveCopilotToken, CopilotService.saveCopilotToken)
|
||||
ipcMain.handle(IpcChannel.Copilot_GetToken, CopilotService.getToken)
|
||||
ipcMain.handle(IpcChannel.Copilot_Logout, CopilotService.logout)
|
||||
ipcMain.handle(IpcChannel.Copilot_GetUser, CopilotService.getUser)
|
||||
|
||||
// Obsidian service
|
||||
ipcMain.handle(IpcChannel.Obsidian_GetVaults, () => {
|
||||
return obsidianVaultService.getVaults()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Obsidian_GetFiles, (_event, vaultName) => {
|
||||
return obsidianVaultService.getFilesByVaultName(vaultName)
|
||||
})
|
||||
|
||||
// nutstore
|
||||
ipcMain.handle(IpcChannel.Nutstore_GetSsoUrl, NutstoreService.getNutstoreSSOUrl)
|
||||
ipcMain.handle(IpcChannel.Nutstore_DecryptToken, (_, token: string) => NutstoreService.decryptToken(token))
|
||||
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
|
||||
NutstoreService.getDirectoryContents(token, path)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { JsonLoader } from '@llm-tools/embedjs'
|
||||
import { JsonLoader } from '@cherrystudio/embedjs'
|
||||
|
||||
/**
|
||||
* Drafts 应用导出的笔记文件加载器
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
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 { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs'
|
||||
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { FileType, KnowledgeBaseParams } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
374
src/main/mcpServers/brave-search.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
// Brave Search MCP Server
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/brave-search/index.ts
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
const WEB_SEARCH_TOOL: Tool = {
|
||||
name: 'brave_web_search',
|
||||
description:
|
||||
'Performs a web search using the Brave Search API, ideal for general queries, news, articles, and online content. ' +
|
||||
'Use this for broad information gathering, recent events, or when you need diverse web sources. ' +
|
||||
'Supports pagination, content filtering, and freshness controls. ' +
|
||||
'Maximum 20 results per request, with offset for pagination. ',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query (max 400 chars, 50 words)'
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Number of results (1-20, default 10)',
|
||||
default: 10
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Pagination offset (max 9, default 0)',
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_SEARCH_TOOL: Tool = {
|
||||
name: 'brave_local_search',
|
||||
description:
|
||||
"Searches for local businesses and places using Brave's Local Search API. " +
|
||||
'Best for queries related to physical locations, businesses, restaurants, services, etc. ' +
|
||||
'Returns detailed information including:\n' +
|
||||
'- Business names and addresses\n' +
|
||||
'- Ratings and review counts\n' +
|
||||
'- Phone numbers and opening hours\n' +
|
||||
"Use this when the query implies 'near me' or mentions specific locations. " +
|
||||
'Automatically falls back to web search if no local results are found.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: "Local search query (e.g. 'pizza near Central Park')"
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Number of results (1-20, default 5)',
|
||||
default: 5
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
|
||||
const RATE_LIMIT = {
|
||||
perSecond: 1,
|
||||
perMonth: 15000
|
||||
}
|
||||
|
||||
const requestCount = {
|
||||
second: 0,
|
||||
month: 0,
|
||||
lastReset: Date.now()
|
||||
}
|
||||
|
||||
function checkRateLimit() {
|
||||
const now = Date.now()
|
||||
if (now - requestCount.lastReset > 1000) {
|
||||
requestCount.second = 0
|
||||
requestCount.lastReset = now
|
||||
}
|
||||
if (requestCount.second >= RATE_LIMIT.perSecond || requestCount.month >= RATE_LIMIT.perMonth) {
|
||||
throw new Error('Rate limit exceeded')
|
||||
}
|
||||
requestCount.second++
|
||||
requestCount.month++
|
||||
}
|
||||
|
||||
interface BraveWeb {
|
||||
web?: {
|
||||
results?: Array<{
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
language?: string
|
||||
published?: string
|
||||
rank?: number
|
||||
}>
|
||||
}
|
||||
locations?: {
|
||||
results?: Array<{
|
||||
id: string // Required by API
|
||||
title?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
interface BraveLocation {
|
||||
id: string
|
||||
name: string
|
||||
address: {
|
||||
streetAddress?: string
|
||||
addressLocality?: string
|
||||
addressRegion?: string
|
||||
postalCode?: string
|
||||
}
|
||||
coordinates?: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
phone?: string
|
||||
rating?: {
|
||||
ratingValue?: number
|
||||
ratingCount?: number
|
||||
}
|
||||
openingHours?: string[]
|
||||
priceRange?: string
|
||||
}
|
||||
|
||||
interface BravePoiResponse {
|
||||
results: BraveLocation[]
|
||||
}
|
||||
|
||||
interface BraveDescription {
|
||||
descriptions: { [id: string]: string }
|
||||
}
|
||||
|
||||
function isBraveWebSearchArgs(args: unknown): args is { query: string; count?: number } {
|
||||
return (
|
||||
typeof args === 'object' &&
|
||||
args !== null &&
|
||||
'query' in args &&
|
||||
typeof (args as { query: string }).query === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isBraveLocalSearchArgs(args: unknown): args is { query: string; count?: number } {
|
||||
return (
|
||||
typeof args === 'object' &&
|
||||
args !== null &&
|
||||
'query' in args &&
|
||||
typeof (args as { query: string }).query === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
async function performWebSearch(apiKey: string, query: string, count: number = 10, offset: number = 0) {
|
||||
checkRateLimit()
|
||||
const url = new URL('https://api.search.brave.com/res/v1/web/search')
|
||||
url.searchParams.set('q', query)
|
||||
url.searchParams.set('count', Math.min(count, 20).toString()) // API limit
|
||||
url.searchParams.set('offset', offset.toString())
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BraveWeb
|
||||
|
||||
// Extract just web results
|
||||
const results = (data.web?.results || []).map((result) => ({
|
||||
title: result.title || '',
|
||||
description: result.description || '',
|
||||
url: result.url || ''
|
||||
}))
|
||||
|
||||
return results.map((r) => `Title: ${r.title}\nDescription: ${r.description}\nURL: ${r.url}`).join('\n\n')
|
||||
}
|
||||
|
||||
async function performLocalSearch(apiKey: string, query: string, count: number = 5) {
|
||||
checkRateLimit()
|
||||
// Initial search to get location IDs
|
||||
const webUrl = new URL('https://api.search.brave.com/res/v1/web/search')
|
||||
webUrl.searchParams.set('q', query)
|
||||
webUrl.searchParams.set('search_lang', 'en')
|
||||
webUrl.searchParams.set('result_filter', 'locations')
|
||||
webUrl.searchParams.set('count', Math.min(count, 20).toString())
|
||||
|
||||
const webResponse = await fetch(webUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!webResponse.ok) {
|
||||
throw new Error(`Brave API error: ${webResponse.status} ${webResponse.statusText}\n${await webResponse.text()}`)
|
||||
}
|
||||
|
||||
const webData = (await webResponse.json()) as BraveWeb
|
||||
const locationIds =
|
||||
webData.locations?.results?.filter((r): r is { id: string; title?: string } => r.id != null).map((r) => r.id) || []
|
||||
|
||||
if (locationIds.length === 0) {
|
||||
return performWebSearch(apiKey, query, count) // Fallback to web search
|
||||
}
|
||||
|
||||
// Get POI details and descriptions in parallel
|
||||
const [poisData, descriptionsData] = await Promise.all([
|
||||
getPoisData(apiKey, locationIds),
|
||||
getDescriptionsData(apiKey, locationIds)
|
||||
])
|
||||
|
||||
return formatLocalResults(poisData, descriptionsData)
|
||||
}
|
||||
|
||||
async function getPoisData(apiKey: string, ids: string[]): Promise<BravePoiResponse> {
|
||||
checkRateLimit()
|
||||
const url = new URL('https://api.search.brave.com/res/v1/local/pois')
|
||||
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
|
||||
}
|
||||
|
||||
const poisResponse = (await response.json()) as BravePoiResponse
|
||||
return poisResponse
|
||||
}
|
||||
|
||||
async function getDescriptionsData(apiKey: string, ids: string[]): Promise<BraveDescription> {
|
||||
checkRateLimit()
|
||||
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions')
|
||||
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
|
||||
}
|
||||
|
||||
const descriptionsData = (await response.json()) as BraveDescription
|
||||
return descriptionsData
|
||||
}
|
||||
|
||||
function formatLocalResults(poisData: BravePoiResponse, descData: BraveDescription): string {
|
||||
return (
|
||||
(poisData.results || [])
|
||||
.map((poi) => {
|
||||
const address =
|
||||
[
|
||||
poi.address?.streetAddress ?? '',
|
||||
poi.address?.addressLocality ?? '',
|
||||
poi.address?.addressRegion ?? '',
|
||||
poi.address?.postalCode ?? ''
|
||||
]
|
||||
.filter((part) => part !== '')
|
||||
.join(', ') || 'N/A'
|
||||
|
||||
return `Name: ${poi.name}
|
||||
Address: ${address}
|
||||
Phone: ${poi.phone || 'N/A'}
|
||||
Rating: ${poi.rating?.ratingValue ?? 'N/A'} (${poi.rating?.ratingCount ?? 0} reviews)
|
||||
Price Range: ${poi.priceRange || 'N/A'}
|
||||
Hours: ${(poi.openingHours || []).join(', ') || 'N/A'}
|
||||
Description: ${descData.descriptions[poi.id] || 'No description available'}
|
||||
`
|
||||
})
|
||||
.join('\n---\n') || 'No local results found'
|
||||
)
|
||||
}
|
||||
|
||||
class BraveSearchServer {
|
||||
public server: Server
|
||||
private apiKey: string
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error('BRAVE_API_KEY is required for Brave Search MCP server')
|
||||
}
|
||||
this.apiKey = apiKey
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'brave-search-server',
|
||||
version: '0.1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Tool handlers
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL]
|
||||
}))
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
try {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
if (!args) {
|
||||
throw new Error('No arguments provided')
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'brave_web_search': {
|
||||
if (!isBraveWebSearchArgs(args)) {
|
||||
throw new Error('Invalid arguments for brave_web_search')
|
||||
}
|
||||
const { query, count = 10 } = args
|
||||
const results = await performWebSearch(this.apiKey, query, count)
|
||||
return {
|
||||
content: [{ type: 'text', text: results }],
|
||||
isError: false
|
||||
}
|
||||
}
|
||||
|
||||
case 'brave_local_search': {
|
||||
if (!isBraveLocalSearchArgs(args)) {
|
||||
throw new Error('Invalid arguments for brave_local_search')
|
||||
}
|
||||
const { query, count = 5 } = args
|
||||
const results = await performLocalSearch(this.apiKey, query, count)
|
||||
return {
|
||||
content: [{ type: 'text', text: results }],
|
||||
isError: false
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default BraveSearchServer
|
||||
32
src/main/mcpServers/factory.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import BraveSearchServer from './brave-search'
|
||||
import FetchServer from './fetch'
|
||||
import FileSystemServer from './filesystem'
|
||||
import MemoryServer from './memory'
|
||||
import ThinkingServer from './sequentialthinking'
|
||||
|
||||
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
|
||||
Logger.info(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`)
|
||||
switch (name) {
|
||||
case '@cherry/memory': {
|
||||
const envPath = envs.MEMORY_FILE_PATH
|
||||
return new MemoryServer(envPath).server
|
||||
}
|
||||
case '@cherry/sequentialthinking': {
|
||||
return new ThinkingServer().server
|
||||
}
|
||||
case '@cherry/brave-search': {
|
||||
return new BraveSearchServer(envs.BRAVE_API_KEY).server
|
||||
}
|
||||
case '@cherry/fetch': {
|
||||
return new FetchServer().server
|
||||
}
|
||||
case '@cherry/filesystem': {
|
||||
return new FileSystemServer(args).server
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||
}
|
||||
}
|
||||
236
src/main/mcpServers/fetch.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// port https://github.com/zcaceres/fetch-mcp/blob/main/src/index.ts
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import TurndownService from 'turndown'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const RequestPayloadSchema = z.object({
|
||||
url: z.string().url(),
|
||||
headers: z.record(z.string()).optional()
|
||||
})
|
||||
|
||||
export type RequestPayload = z.infer<typeof RequestPayloadSchema>
|
||||
|
||||
export class Fetcher {
|
||||
private static async _fetch({ url, headers }: RequestPayload): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
...headers
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error: ${response.status}`)
|
||||
}
|
||||
return response
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
throw new Error(`Failed to fetch ${url}: ${e.message}`)
|
||||
} else {
|
||||
throw new Error(`Failed to fetch ${url}: Unknown error`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async html(requestPayload: RequestPayload) {
|
||||
try {
|
||||
const response = await this._fetch(requestPayload)
|
||||
const html = await response.text()
|
||||
return { content: [{ type: 'text', text: html }], isError: false }
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: (error as Error).message }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async json(requestPayload: RequestPayload) {
|
||||
try {
|
||||
const response = await this._fetch(requestPayload)
|
||||
const json = await response.json()
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(json) }],
|
||||
isError: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: (error as Error).message }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async txt(requestPayload: RequestPayload) {
|
||||
try {
|
||||
const response = await this._fetch(requestPayload)
|
||||
const html = await response.text()
|
||||
|
||||
const dom = new JSDOM(html)
|
||||
const document = dom.window.document
|
||||
|
||||
const scripts = document.getElementsByTagName('script')
|
||||
const styles = document.getElementsByTagName('style')
|
||||
Array.from(scripts).forEach((script: any) => script.remove())
|
||||
Array.from(styles).forEach((style: any) => style.remove())
|
||||
|
||||
const text = document.body.textContent || ''
|
||||
|
||||
const normalizedText = text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: normalizedText }],
|
||||
isError: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: (error as Error).message }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async markdown(requestPayload: RequestPayload) {
|
||||
try {
|
||||
const response = await this._fetch(requestPayload)
|
||||
const html = await response.text()
|
||||
const turndownService = new TurndownService()
|
||||
const markdown = turndownService.turndown(html)
|
||||
return { content: [{ type: 'text', text: markdown }], isError: false }
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: (error as Error).message }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'zcaceres/fetch',
|
||||
version: '0.1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'fetch_html',
|
||||
description: 'Fetch a website and return the content as HTML',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the website to fetch'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional headers to include in the request'
|
||||
}
|
||||
},
|
||||
required: ['url']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fetch_markdown',
|
||||
description: 'Fetch a website and return the content as Markdown',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the website to fetch'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional headers to include in the request'
|
||||
}
|
||||
},
|
||||
required: ['url']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fetch_txt',
|
||||
description: 'Fetch a website, return the content as plain text (no HTML)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the website to fetch'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional headers to include in the request'
|
||||
}
|
||||
},
|
||||
required: ['url']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fetch_json',
|
||||
description: 'Fetch a JSON file from a URL',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the JSON to fetch'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional headers to include in the request'
|
||||
}
|
||||
},
|
||||
required: ['url']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { arguments: args } = request.params
|
||||
|
||||
const validatedArgs = RequestPayloadSchema.parse(args)
|
||||
|
||||
if (request.params.name === 'fetch_html') {
|
||||
const fetchResult = await Fetcher.html(validatedArgs)
|
||||
return fetchResult
|
||||
}
|
||||
if (request.params.name === 'fetch_json') {
|
||||
const fetchResult = await Fetcher.json(validatedArgs)
|
||||
return fetchResult
|
||||
}
|
||||
if (request.params.name === 'fetch_txt') {
|
||||
const fetchResult = await Fetcher.txt(validatedArgs)
|
||||
return fetchResult
|
||||
}
|
||||
if (request.params.name === 'fetch_markdown') {
|
||||
const fetchResult = await Fetcher.markdown(validatedArgs)
|
||||
return fetchResult
|
||||
}
|
||||
throw new Error('Tool not found')
|
||||
})
|
||||
|
||||
class FetchServer {
|
||||
public server: Server
|
||||
constructor() {
|
||||
this.server = server
|
||||
}
|
||||
}
|
||||
export default FetchServer
|
||||
655
src/main/mcpServers/filesystem.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem/index.ts
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import fs from 'fs/promises'
|
||||
import { minimatch } from 'minimatch'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { z } from 'zod'
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema'
|
||||
|
||||
// Normalize all paths consistently
|
||||
function normalizePath(p: string): string {
|
||||
return path.normalize(p)
|
||||
}
|
||||
|
||||
function expandHome(filepath: string): string {
|
||||
if (filepath.startsWith('~/') || filepath === '~') {
|
||||
return path.join(os.homedir(), filepath.slice(1))
|
||||
}
|
||||
return filepath
|
||||
}
|
||||
|
||||
// Security utilities
|
||||
async function validatePath(allowedDirectories: string[], requestedPath: string): Promise<string> {
|
||||
const expandedPath = expandHome(requestedPath)
|
||||
const absolute = path.isAbsolute(expandedPath)
|
||||
? path.resolve(expandedPath)
|
||||
: path.resolve(process.cwd(), expandedPath)
|
||||
|
||||
const normalizedRequested = normalizePath(absolute)
|
||||
|
||||
// Check if path is within allowed directories
|
||||
const isAllowed = allowedDirectories.some((dir) => normalizedRequested.startsWith(dir))
|
||||
if (!isAllowed) {
|
||||
throw new Error(
|
||||
`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Handle symlinks by checking their real path
|
||||
try {
|
||||
const realPath = await fs.realpath(absolute)
|
||||
const normalizedReal = normalizePath(realPath)
|
||||
const isRealPathAllowed = allowedDirectories.some((dir) => normalizedReal.startsWith(dir))
|
||||
if (!isRealPathAllowed) {
|
||||
throw new Error('Access denied - symlink target outside allowed directories')
|
||||
}
|
||||
return realPath
|
||||
} catch (error) {
|
||||
// For new files that don't exist yet, verify parent directory
|
||||
const parentDir = path.dirname(absolute)
|
||||
try {
|
||||
const realParentPath = await fs.realpath(parentDir)
|
||||
const normalizedParent = normalizePath(realParentPath)
|
||||
const isParentAllowed = allowedDirectories.some((dir) => normalizedParent.startsWith(dir))
|
||||
if (!isParentAllowed) {
|
||||
throw new Error('Access denied - parent directory outside allowed directories')
|
||||
}
|
||||
return absolute
|
||||
} catch {
|
||||
throw new Error(`Parent directory does not exist: ${parentDir}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schema definitions
|
||||
const ReadFileArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
const ReadMultipleFilesArgsSchema = z.object({
|
||||
paths: z.array(z.string())
|
||||
})
|
||||
|
||||
const WriteFileArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
content: z.string()
|
||||
})
|
||||
|
||||
const EditOperation = z.object({
|
||||
oldText: z.string().describe('Text to search for - must match exactly'),
|
||||
newText: z.string().describe('Text to replace with')
|
||||
})
|
||||
|
||||
const EditFileArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
edits: z.array(EditOperation),
|
||||
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
|
||||
})
|
||||
|
||||
const CreateDirectoryArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
const ListDirectoryArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
const DirectoryTreeArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
const MoveFileArgsSchema = z.object({
|
||||
source: z.string(),
|
||||
destination: z.string()
|
||||
})
|
||||
|
||||
const SearchFilesArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
pattern: z.string(),
|
||||
excludePatterns: z.array(z.string()).optional().default([])
|
||||
})
|
||||
|
||||
const GetFileInfoArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const ToolInputSchema = ToolSchema.shape.inputSchema
|
||||
type ToolInput = z.infer<typeof ToolInputSchema>
|
||||
|
||||
interface FileInfo {
|
||||
size: number
|
||||
created: Date
|
||||
modified: Date
|
||||
accessed: Date
|
||||
isDirectory: boolean
|
||||
isFile: boolean
|
||||
permissions: string
|
||||
}
|
||||
|
||||
// Tool implementations
|
||||
async function getFileStats(filePath: string): Promise<FileInfo> {
|
||||
const stats = await fs.stat(filePath)
|
||||
return {
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime,
|
||||
accessed: stats.atime,
|
||||
isDirectory: stats.isDirectory(),
|
||||
isFile: stats.isFile(),
|
||||
permissions: stats.mode.toString(8).slice(-3)
|
||||
}
|
||||
}
|
||||
|
||||
async function searchFiles(
|
||||
allowedDirectories: string[],
|
||||
rootPath: string,
|
||||
pattern: string,
|
||||
excludePatterns: string[] = []
|
||||
): Promise<string[]> {
|
||||
const results: string[] = []
|
||||
|
||||
async function search(currentPath: string) {
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name)
|
||||
|
||||
try {
|
||||
// Validate each path before processing
|
||||
await validatePath(allowedDirectories, fullPath)
|
||||
|
||||
// Check if path matches any exclude pattern
|
||||
const relativePath = path.relative(rootPath, fullPath)
|
||||
const shouldExclude = excludePatterns.some((pattern) => {
|
||||
const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`
|
||||
return minimatch(relativePath, globPattern, { dot: true })
|
||||
})
|
||||
|
||||
if (shouldExclude) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
results.push(fullPath)
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await search(fullPath)
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip invalid paths during search
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await search(rootPath)
|
||||
return results
|
||||
}
|
||||
|
||||
// file editing and diffing utilities
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
|
||||
// Ensure consistent line endings for diff
|
||||
const normalizedOriginal = normalizeLineEndings(originalContent)
|
||||
const normalizedNew = normalizeLineEndings(newContent)
|
||||
|
||||
return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified')
|
||||
}
|
||||
|
||||
async function applyFileEdits(
|
||||
filePath: string,
|
||||
edits: Array<{ oldText: string; newText: string }>,
|
||||
dryRun = false
|
||||
): Promise<string> {
|
||||
// Read file content and normalize line endings
|
||||
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'))
|
||||
|
||||
// Apply edits sequentially
|
||||
let modifiedContent = content
|
||||
for (const edit of edits) {
|
||||
const normalizedOld = normalizeLineEndings(edit.oldText)
|
||||
const normalizedNew = normalizeLineEndings(edit.newText)
|
||||
|
||||
// If exact match exists, use it
|
||||
if (modifiedContent.includes(normalizedOld)) {
|
||||
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew)
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, try line-by-line matching with flexibility for whitespace
|
||||
const oldLines = normalizedOld.split('\n')
|
||||
const contentLines = modifiedContent.split('\n')
|
||||
let matchFound = false
|
||||
|
||||
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
|
||||
const potentialMatch = contentLines.slice(i, i + oldLines.length)
|
||||
|
||||
// Compare lines with normalized whitespace
|
||||
const isMatch = oldLines.every((oldLine, j) => {
|
||||
const contentLine = potentialMatch[j]
|
||||
return oldLine.trim() === contentLine.trim()
|
||||
})
|
||||
|
||||
if (isMatch) {
|
||||
// Preserve original indentation of first line
|
||||
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ''
|
||||
const newLines = normalizedNew.split('\n').map((line, j) => {
|
||||
if (j === 0) return originalIndent + line.trimStart()
|
||||
// For subsequent lines, try to preserve relative indentation
|
||||
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ''
|
||||
const newIndent = line.match(/^\s*/)?.[0] || ''
|
||||
if (oldIndent && newIndent) {
|
||||
const relativeIndent = newIndent.length - oldIndent.length
|
||||
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart()
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
contentLines.splice(i, oldLines.length, ...newLines)
|
||||
modifiedContent = contentLines.join('\n')
|
||||
matchFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create unified diff
|
||||
const diff = createUnifiedDiff(content, modifiedContent, filePath)
|
||||
|
||||
// Format diff with appropriate number of backticks
|
||||
let numBackticks = 3
|
||||
while (diff.includes('`'.repeat(numBackticks))) {
|
||||
numBackticks++
|
||||
}
|
||||
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`
|
||||
|
||||
if (!dryRun) {
|
||||
await fs.writeFile(filePath, modifiedContent, 'utf-8')
|
||||
}
|
||||
|
||||
return formattedDiff
|
||||
}
|
||||
|
||||
class FileSystemServer {
|
||||
public server: Server
|
||||
private allowedDirectories: string[]
|
||||
constructor(allowedDirs: string[]) {
|
||||
if (!Array.isArray(allowedDirs) || allowedDirs.length === 0) {
|
||||
throw new Error('No allowed directories provided, please specify at least one directory in args')
|
||||
}
|
||||
|
||||
this.allowedDirectories = allowedDirs.map((dir) => normalizePath(path.resolve(expandHome(dir))))
|
||||
|
||||
// Validate that all directories exist and are accessible
|
||||
this.validateDirs().catch((error) => {
|
||||
console.error('Error validating allowed directories:', error)
|
||||
throw new Error(`Error validating allowed directories: ${error}`)
|
||||
})
|
||||
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'secure-filesystem-server',
|
||||
version: '0.2.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
async validateDirs() {
|
||||
// Validate that all directories exist and are accessible
|
||||
await Promise.all(
|
||||
this.allowedDirectories.map(async (dir) => {
|
||||
try {
|
||||
const stats = await fs.stat(expandHome(dir))
|
||||
if (!stats.isDirectory()) {
|
||||
console.error(`Error: ${dir} is not a directory`)
|
||||
throw new Error(`Error: ${dir} is not a directory`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Error accessing directory ${dir}:`, error)
|
||||
throw new Error(`Error accessing directory ${dir}:`, error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Tool handlers
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'read_file',
|
||||
description:
|
||||
'Read the complete contents of a file from the file system. ' +
|
||||
'Handles various text encodings and provides detailed error messages ' +
|
||||
'if the file cannot be read. Use this tool when you need to examine ' +
|
||||
'the contents of a single file. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'read_multiple_files',
|
||||
description:
|
||||
'Read the contents of multiple files simultaneously. This is more ' +
|
||||
'efficient than reading files one by one when you need to analyze ' +
|
||||
"or compare multiple files. Each file's content is returned with its " +
|
||||
"path as a reference. Failed reads for individual files won't stop " +
|
||||
'the entire operation. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'write_file',
|
||||
description:
|
||||
'Create a new file or completely overwrite an existing file with new content. ' +
|
||||
'Use with caution as it will overwrite existing files without warning. ' +
|
||||
'Handles text content with proper encoding. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'edit_file',
|
||||
description:
|
||||
'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
|
||||
'with new content. Returns a git-style diff showing the changes made. ' +
|
||||
'Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'create_directory',
|
||||
description:
|
||||
'Create a new directory or ensure a directory exists. Can create multiple ' +
|
||||
'nested directories in one operation. If the directory already exists, ' +
|
||||
'this operation will succeed silently. Perfect for setting up directory ' +
|
||||
'structures for projects or ensuring required paths exist. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'list_directory',
|
||||
description:
|
||||
'Get a detailed listing of all files and directories in a specified path. ' +
|
||||
'Results clearly distinguish between files and directories with [FILE] and [DIR] ' +
|
||||
'prefixes. This tool is essential for understanding directory structure and ' +
|
||||
'finding specific files within a directory. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'directory_tree',
|
||||
description:
|
||||
'Get a recursive tree view of files and directories as a JSON structure. ' +
|
||||
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
|
||||
'Files have no children array, while directories always have a children array (which may be empty). ' +
|
||||
'The output is formatted with 2-space indentation for readability. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'move_file',
|
||||
description:
|
||||
'Move or rename files and directories. Can move files between directories ' +
|
||||
'and rename them in a single operation. If the destination exists, the ' +
|
||||
'operation will fail. Works across different directories and can be used ' +
|
||||
'for simple renaming within the same directory. Both source and destination must be within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(MoveFileArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'search_files',
|
||||
description:
|
||||
'Recursively search for files and directories matching a pattern. ' +
|
||||
'Searches through all subdirectories from the starting path. The search ' +
|
||||
'is case-insensitive and matches partial names. Returns full paths to all ' +
|
||||
"matching items. Great for finding files when you don't know their exact location. " +
|
||||
'Only searches within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'get_file_info',
|
||||
description:
|
||||
'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
|
||||
'information including size, creation time, last modified time, permissions, ' +
|
||||
'and type. This tool is perfect for understanding file characteristics ' +
|
||||
'without reading the actual content. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'list_allowed_directories',
|
||||
description:
|
||||
'Returns the list of directories that this server is allowed to access. ' +
|
||||
'Use this to understand which directories are available before trying to access files.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
try {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
switch (name) {
|
||||
case 'read_file': {
|
||||
const parsed = ReadFileArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for read_file: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const content = await fs.readFile(validPath, 'utf-8')
|
||||
return {
|
||||
content: [{ type: 'text', text: content }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'read_multiple_files': {
|
||||
const parsed = ReadMultipleFilesArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`)
|
||||
}
|
||||
const results = await Promise.all(
|
||||
parsed.data.paths.map(async (filePath: string) => {
|
||||
try {
|
||||
const validPath = await validatePath(this.allowedDirectories, filePath)
|
||||
const content = await fs.readFile(validPath, 'utf-8')
|
||||
return `${filePath}:\n${content}\n`
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return `${filePath}: Error - ${errorMessage}`
|
||||
}
|
||||
})
|
||||
)
|
||||
return {
|
||||
content: [{ type: 'text', text: results.join('\n---\n') }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'write_file': {
|
||||
const parsed = WriteFileArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for write_file: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
await fs.writeFile(validPath, parsed.data.content, 'utf-8')
|
||||
return {
|
||||
content: [{ type: 'text', text: `Successfully wrote to ${parsed.data.path}` }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'edit_file': {
|
||||
const parsed = EditFileArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun)
|
||||
return {
|
||||
content: [{ type: 'text', text: result }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'create_directory': {
|
||||
const parsed = CreateDirectoryArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
await fs.mkdir(validPath, { recursive: true })
|
||||
return {
|
||||
content: [{ type: 'text', text: `Successfully created directory ${parsed.data.path}` }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'list_directory': {
|
||||
const parsed = ListDirectoryArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const entries = await fs.readdir(validPath, { withFileTypes: true })
|
||||
const formatted = entries
|
||||
.map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
|
||||
.join('\n')
|
||||
return {
|
||||
content: [{ type: 'text', text: formatted }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'directory_tree': {
|
||||
const parsed = DirectoryTreeArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`)
|
||||
}
|
||||
|
||||
interface TreeEntry {
|
||||
name: string
|
||||
type: 'file' | 'directory'
|
||||
children?: TreeEntry[]
|
||||
}
|
||||
|
||||
async function buildTree(allowedDirectories: string[], currentPath: string): Promise<TreeEntry[]> {
|
||||
const validPath = await validatePath(allowedDirectories, currentPath)
|
||||
const entries = await fs.readdir(validPath, { withFileTypes: true })
|
||||
const result: TreeEntry[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryData: TreeEntry = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subPath = path.join(currentPath, entry.name)
|
||||
entryData.children = await buildTree(allowedDirectories, subPath)
|
||||
}
|
||||
|
||||
result.push(entryData)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const treeData = await buildTree(this.allowedDirectories, parsed.data.path)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(treeData, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
case 'move_file': {
|
||||
const parsed = MoveFileArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for move_file: ${parsed.error}`)
|
||||
}
|
||||
const validSourcePath = await validatePath(this.allowedDirectories, parsed.data.source)
|
||||
const validDestPath = await validatePath(this.allowedDirectories, parsed.data.destination)
|
||||
await fs.rename(validSourcePath, validDestPath)
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
case 'search_files': {
|
||||
const parsed = SearchFilesArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for search_files: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const results = await searchFiles(
|
||||
this.allowedDirectories,
|
||||
validPath,
|
||||
parsed.data.pattern,
|
||||
parsed.data.excludePatterns
|
||||
)
|
||||
return {
|
||||
content: [{ type: 'text', text: results.length > 0 ? results.join('\n') : 'No matches found' }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'get_file_info': {
|
||||
const parsed = GetFileInfoArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const info = await getFileStats(validPath)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: Object.entries(info)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
case 'list_allowed_directories': {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Allowed directories:\n${this.allowedDirectories.join('\n')}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default FileSystemServer
|
||||
509
src/main/mcpServers/memory.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/memory/index.ts
|
||||
import { getConfigDir } from '@main/utils/file'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// Define memory file path using environment variable with fallback
|
||||
const defaultMemoryPath = path.join(getConfigDir(), 'memory.json')
|
||||
|
||||
// We are storing our memory using entities, relations, and observations in a graph structure
|
||||
interface Entity {
|
||||
name: string
|
||||
entityType: string
|
||||
observations: string[]
|
||||
}
|
||||
|
||||
interface Relation {
|
||||
from: string
|
||||
to: string
|
||||
relationType: string
|
||||
}
|
||||
|
||||
interface KnowledgeGraph {
|
||||
entities: Entity[]
|
||||
relations: Relation[]
|
||||
}
|
||||
|
||||
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
|
||||
class KnowledgeGraphManager {
|
||||
private memoryPath: string
|
||||
|
||||
constructor(memoryPath: string) {
|
||||
this.memoryPath = memoryPath
|
||||
this.ensureMemoryPathExists()
|
||||
}
|
||||
|
||||
private async ensureMemoryPathExists(): Promise<void> {
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
const directory = path.dirname(this.memoryPath)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
|
||||
// Check if the file exists, if not create an empty one
|
||||
try {
|
||||
await fs.access(this.memoryPath)
|
||||
} catch (error) {
|
||||
// File doesn't exist, create an empty file
|
||||
await fs.writeFile(this.memoryPath, '')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create memory path:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private async loadGraph(): Promise<KnowledgeGraph> {
|
||||
try {
|
||||
const data = await fs.readFile(this.memoryPath, 'utf-8')
|
||||
const lines = data.split('\n').filter((line) => line.trim() !== '')
|
||||
return lines.reduce(
|
||||
(graph: KnowledgeGraph, line) => {
|
||||
const item = JSON.parse(line)
|
||||
if (item.type === 'entity') graph.entities.push(item as Entity)
|
||||
if (item.type === 'relation') graph.relations.push(item as Relation)
|
||||
return graph
|
||||
},
|
||||
{ entities: [], relations: [] }
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
|
||||
return { entities: [], relations: [] }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async saveGraph(graph: KnowledgeGraph): Promise<void> {
|
||||
const lines = [
|
||||
...graph.entities.map((e) => JSON.stringify({ type: 'entity', ...e })),
|
||||
...graph.relations.map((r) => JSON.stringify({ type: 'relation', ...r }))
|
||||
]
|
||||
await fs.writeFile(this.memoryPath, lines.join('\n'))
|
||||
}
|
||||
|
||||
async createEntities(entities: Entity[]): Promise<Entity[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const newEntities = entities.filter((e) => !graph.entities.some((existingEntity) => existingEntity.name === e.name))
|
||||
graph.entities.push(...newEntities)
|
||||
await this.saveGraph(graph)
|
||||
return newEntities
|
||||
}
|
||||
|
||||
async createRelations(relations: Relation[]): Promise<Relation[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const newRelations = relations.filter(
|
||||
(r) =>
|
||||
!graph.relations.some(
|
||||
(existingRelation) =>
|
||||
existingRelation.from === r.from &&
|
||||
existingRelation.to === r.to &&
|
||||
existingRelation.relationType === r.relationType
|
||||
)
|
||||
)
|
||||
graph.relations.push(...newRelations)
|
||||
await this.saveGraph(graph)
|
||||
return newRelations
|
||||
}
|
||||
|
||||
async addObservations(
|
||||
observations: { entityName: string; contents: string[] }[]
|
||||
): Promise<{ entityName: string; addedObservations: string[] }[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const results = observations.map((o) => {
|
||||
const entity = graph.entities.find((e) => e.name === o.entityName)
|
||||
if (!entity) {
|
||||
throw new Error(`Entity with name ${o.entityName} not found`)
|
||||
}
|
||||
const newObservations = o.contents.filter((content) => !entity.observations.includes(content))
|
||||
entity.observations.push(...newObservations)
|
||||
return { entityName: o.entityName, addedObservations: newObservations }
|
||||
})
|
||||
await this.saveGraph(graph)
|
||||
return results
|
||||
}
|
||||
|
||||
async deleteEntities(entityNames: string[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
graph.entities = graph.entities.filter((e) => !entityNames.includes(e.name))
|
||||
graph.relations = graph.relations.filter((r) => !entityNames.includes(r.from) && !entityNames.includes(r.to))
|
||||
await this.saveGraph(graph)
|
||||
}
|
||||
|
||||
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
deletions.forEach((d) => {
|
||||
const entity = graph.entities.find((e) => e.name === d.entityName)
|
||||
if (entity) {
|
||||
entity.observations = entity.observations.filter((o) => !d.observations.includes(o))
|
||||
}
|
||||
})
|
||||
await this.saveGraph(graph)
|
||||
}
|
||||
|
||||
async deleteRelations(relations: Relation[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
graph.relations = graph.relations.filter(
|
||||
(r) =>
|
||||
!relations.some(
|
||||
(delRelation) =>
|
||||
r.from === delRelation.from && r.to === delRelation.to && r.relationType === delRelation.relationType
|
||||
)
|
||||
)
|
||||
await this.saveGraph(graph)
|
||||
}
|
||||
|
||||
async readGraph(): Promise<KnowledgeGraph> {
|
||||
return this.loadGraph()
|
||||
}
|
||||
|
||||
// Very basic search function
|
||||
async searchNodes(query: string): Promise<KnowledgeGraph> {
|
||||
const graph = await this.loadGraph()
|
||||
|
||||
// Filter entities
|
||||
const filteredEntities = graph.entities.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
e.entityType.toLowerCase().includes(query.toLowerCase()) ||
|
||||
e.observations.some((o) => o.toLowerCase().includes(query.toLowerCase()))
|
||||
)
|
||||
|
||||
// Create a Set of filtered entity names for quick lookup
|
||||
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
|
||||
|
||||
// Filter relations to only include those between filtered entities
|
||||
const filteredRelations = graph.relations.filter(
|
||||
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
|
||||
)
|
||||
|
||||
const filteredGraph: KnowledgeGraph = {
|
||||
entities: filteredEntities,
|
||||
relations: filteredRelations
|
||||
}
|
||||
|
||||
return filteredGraph
|
||||
}
|
||||
|
||||
async openNodes(names: string[]): Promise<KnowledgeGraph> {
|
||||
const graph = await this.loadGraph()
|
||||
|
||||
// Filter entities
|
||||
const filteredEntities = graph.entities.filter((e) => names.includes(e.name))
|
||||
|
||||
// Create a Set of filtered entity names for quick lookup
|
||||
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
|
||||
|
||||
// Filter relations to only include those between filtered entities
|
||||
const filteredRelations = graph.relations.filter(
|
||||
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
|
||||
)
|
||||
|
||||
const filteredGraph: KnowledgeGraph = {
|
||||
entities: filteredEntities,
|
||||
relations: filteredRelations
|
||||
}
|
||||
|
||||
return filteredGraph
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryServer {
|
||||
public server: Server
|
||||
private knowledgeGraphManager: KnowledgeGraphManager
|
||||
|
||||
constructor(envPath: string = '') {
|
||||
const memoryPath = envPath
|
||||
? path.isAbsolute(envPath)
|
||||
? envPath
|
||||
: path.join(path.dirname(fileURLToPath(import.meta.url)), envPath)
|
||||
: defaultMemoryPath
|
||||
this.knowledgeGraphManager = new KnowledgeGraphManager(memoryPath)
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'memory-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// The server instance and tools exposed to Claude
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'create_entities',
|
||||
description: 'Create multiple new entities in the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entities: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'The name of the entity' },
|
||||
entityType: { type: 'string', description: 'The type of the entity' },
|
||||
observations: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of observation contents associated with the entity'
|
||||
}
|
||||
},
|
||||
required: ['name', 'entityType', 'observations']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['entities']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'create_relations',
|
||||
description:
|
||||
'Create multiple new relations between entities in the knowledge graph. Relations should be in active voice',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
relations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'The name of the entity where the relation starts' },
|
||||
to: { type: 'string', description: 'The name of the entity where the relation ends' },
|
||||
relationType: { type: 'string', description: 'The type of the relation' }
|
||||
},
|
||||
required: ['from', 'to', 'relationType']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['relations']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'add_observations',
|
||||
description: 'Add new observations to existing entities in the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
observations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entityName: { type: 'string', description: 'The name of the entity to add the observations to' },
|
||||
contents: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of observation contents to add'
|
||||
}
|
||||
},
|
||||
required: ['entityName', 'contents']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['observations']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_entities',
|
||||
description: 'Delete multiple entities and their associated relations from the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entityNames: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of entity names to delete'
|
||||
}
|
||||
},
|
||||
required: ['entityNames']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_observations',
|
||||
description: 'Delete specific observations from entities in the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deletions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entityName: { type: 'string', description: 'The name of the entity containing the observations' },
|
||||
observations: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of observations to delete'
|
||||
}
|
||||
},
|
||||
required: ['entityName', 'observations']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['deletions']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_relations',
|
||||
description: 'Delete multiple relations from the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
relations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'The name of the entity where the relation starts' },
|
||||
to: { type: 'string', description: 'The name of the entity where the relation ends' },
|
||||
relationType: { type: 'string', description: 'The type of the relation' }
|
||||
},
|
||||
required: ['from', 'to', 'relationType']
|
||||
},
|
||||
description: 'An array of relations to delete'
|
||||
}
|
||||
},
|
||||
required: ['relations']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'read_graph',
|
||||
description: 'Read the entire knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: 'Search for nodes in the knowledge graph based on a query',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The search query to match against entity names, types, and observation content'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open_nodes',
|
||||
description: 'Open specific nodes in the knowledge graph by their names',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
names: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of entity names to retrieve'
|
||||
}
|
||||
},
|
||||
required: ['names']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
if (!args) {
|
||||
throw new Error(`No arguments provided for tool: ${name}`)
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'create_entities':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.createEntities(args.entities as Entity[]),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'create_relations':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.createRelations(args.relations as Relation[]),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'add_observations':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.addObservations(
|
||||
args.observations as { entityName: string; contents: string[] }[]
|
||||
),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'delete_entities':
|
||||
await this.knowledgeGraphManager.deleteEntities(args.entityNames as string[])
|
||||
return { content: [{ type: 'text', text: 'Entities deleted successfully' }] }
|
||||
case 'delete_observations':
|
||||
await this.knowledgeGraphManager.deleteObservations(
|
||||
args.deletions as { entityName: string; observations: string[] }[]
|
||||
)
|
||||
return { content: [{ type: 'text', text: 'Observations deleted successfully' }] }
|
||||
case 'delete_relations':
|
||||
await this.knowledgeGraphManager.deleteRelations(args.relations as Relation[])
|
||||
return { content: [{ type: 'text', text: 'Relations deleted successfully' }] }
|
||||
case 'read_graph':
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(await this.knowledgeGraphManager.readGraph(), null, 2) }]
|
||||
}
|
||||
case 'search_nodes':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(await this.knowledgeGraphManager.searchNodes(args.query as string), null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'open_nodes':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(await this.knowledgeGraphManager.openNodes(args.names as string[]), null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default MemoryServer
|
||||
289
src/main/mcpServers/sequentialthinking.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// Sequential Thinking MCP Server
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/sequentialthinking/index.ts
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
// Fixed chalk import for ESM
|
||||
import chalk from 'chalk'
|
||||
|
||||
interface ThoughtData {
|
||||
thought: string
|
||||
thoughtNumber: number
|
||||
totalThoughts: number
|
||||
isRevision?: boolean
|
||||
revisesThought?: number
|
||||
branchFromThought?: number
|
||||
branchId?: string
|
||||
needsMoreThoughts?: boolean
|
||||
nextThoughtNeeded: boolean
|
||||
}
|
||||
|
||||
class SequentialThinkingServer {
|
||||
private thoughtHistory: ThoughtData[] = []
|
||||
private branches: Record<string, ThoughtData[]> = {}
|
||||
|
||||
private validateThoughtData(input: unknown): ThoughtData {
|
||||
const data = input as Record<string, unknown>
|
||||
|
||||
if (!data.thought || typeof data.thought !== 'string') {
|
||||
throw new Error('Invalid thought: must be a string')
|
||||
}
|
||||
if (!data.thoughtNumber || typeof data.thoughtNumber !== 'number') {
|
||||
throw new Error('Invalid thoughtNumber: must be a number')
|
||||
}
|
||||
if (!data.totalThoughts || typeof data.totalThoughts !== 'number') {
|
||||
throw new Error('Invalid totalThoughts: must be a number')
|
||||
}
|
||||
if (typeof data.nextThoughtNeeded !== 'boolean') {
|
||||
throw new Error('Invalid nextThoughtNeeded: must be a boolean')
|
||||
}
|
||||
|
||||
return {
|
||||
thought: data.thought,
|
||||
thoughtNumber: data.thoughtNumber,
|
||||
totalThoughts: data.totalThoughts,
|
||||
nextThoughtNeeded: data.nextThoughtNeeded,
|
||||
isRevision: data.isRevision as boolean | undefined,
|
||||
revisesThought: data.revisesThought as number | undefined,
|
||||
branchFromThought: data.branchFromThought as number | undefined,
|
||||
branchId: data.branchId as string | undefined,
|
||||
needsMoreThoughts: data.needsMoreThoughts as boolean | undefined
|
||||
}
|
||||
}
|
||||
|
||||
private formatThought(thoughtData: ThoughtData): string {
|
||||
const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId } =
|
||||
thoughtData
|
||||
|
||||
let prefix = ''
|
||||
let context = ''
|
||||
|
||||
if (isRevision) {
|
||||
prefix = chalk.yellow('🔄 Revision')
|
||||
context = ` (revising thought ${revisesThought})`
|
||||
} else if (branchFromThought) {
|
||||
prefix = chalk.green('🌿 Branch')
|
||||
context = ` (from thought ${branchFromThought}, ID: ${branchId})`
|
||||
} else {
|
||||
prefix = chalk.blue('💭 Thought')
|
||||
context = ''
|
||||
}
|
||||
|
||||
const header = `${prefix} ${thoughtNumber}/${totalThoughts}${context}`
|
||||
const border = '─'.repeat(Math.max(header.length, thought.length) + 4)
|
||||
|
||||
return `
|
||||
┌${border}┐
|
||||
│ ${header} │
|
||||
├${border}┤
|
||||
│ ${thought.padEnd(border.length - 2)} │
|
||||
└${border}┘`
|
||||
}
|
||||
|
||||
public processThought(input: unknown): { content: Array<{ type: string; text: string }>; isError?: boolean } {
|
||||
try {
|
||||
const validatedInput = this.validateThoughtData(input)
|
||||
|
||||
if (validatedInput.thoughtNumber > validatedInput.totalThoughts) {
|
||||
validatedInput.totalThoughts = validatedInput.thoughtNumber
|
||||
}
|
||||
|
||||
this.thoughtHistory.push(validatedInput)
|
||||
|
||||
if (validatedInput.branchFromThought && validatedInput.branchId) {
|
||||
if (!this.branches[validatedInput.branchId]) {
|
||||
this.branches[validatedInput.branchId] = []
|
||||
}
|
||||
this.branches[validatedInput.branchId].push(validatedInput)
|
||||
}
|
||||
|
||||
const formattedThought = this.formatThought(validatedInput)
|
||||
console.error(formattedThought)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
thoughtNumber: validatedInput.thoughtNumber,
|
||||
totalThoughts: validatedInput.totalThoughts,
|
||||
nextThoughtNeeded: validatedInput.nextThoughtNeeded,
|
||||
branches: Object.keys(this.branches),
|
||||
thoughtHistoryLength: this.thoughtHistory.length
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
status: 'failed'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SEQUENTIAL_THINKING_TOOL: Tool = {
|
||||
name: 'sequentialthinking',
|
||||
description: `A detailed tool for dynamic and reflective problem-solving through thoughts.
|
||||
This tool helps analyze problems through a flexible thinking process that can adapt and evolve.
|
||||
Each thought can build on, question, or revise previous insights as understanding deepens.
|
||||
|
||||
When to use this tool:
|
||||
- Breaking down complex problems into steps
|
||||
- Planning and design with room for revision
|
||||
- Analysis that might need course correction
|
||||
- Problems where the full scope might not be clear initially
|
||||
- Problems that require a multi-step solution
|
||||
- Tasks that need to maintain context over multiple steps
|
||||
- Situations where irrelevant information needs to be filtered out
|
||||
|
||||
Key features:
|
||||
- You can adjust total_thoughts up or down as you progress
|
||||
- You can question or revise previous thoughts
|
||||
- You can add more thoughts even after reaching what seemed like the end
|
||||
- You can express uncertainty and explore alternative approaches
|
||||
- Not every thought needs to build linearly - you can branch or backtrack
|
||||
- Generates a solution hypothesis
|
||||
- Verifies the hypothesis based on the Chain of Thought steps
|
||||
- Repeats the process until satisfied
|
||||
- Provides a correct answer
|
||||
|
||||
Parameters explained:
|
||||
- thought: Your current thinking step, which can include:
|
||||
* Regular analytical steps
|
||||
* Revisions of previous thoughts
|
||||
* Questions about previous decisions
|
||||
* Realizations about needing more analysis
|
||||
* Changes in approach
|
||||
* Hypothesis generation
|
||||
* Hypothesis verification
|
||||
- next_thought_needed: True if you need more thinking, even if at what seemed like the end
|
||||
- thought_number: Current number in sequence (can go beyond initial total if needed)
|
||||
- total_thoughts: Current estimate of thoughts needed (can be adjusted up/down)
|
||||
- is_revision: A boolean indicating if this thought revises previous thinking
|
||||
- revises_thought: If is_revision is true, which thought number is being reconsidered
|
||||
- branch_from_thought: If branching, which thought number is the branching point
|
||||
- branch_id: Identifier for the current branch (if any)
|
||||
- needs_more_thoughts: If reaching end but realizing more thoughts needed
|
||||
|
||||
You should:
|
||||
1. Start with an initial estimate of needed thoughts, but be ready to adjust
|
||||
2. Feel free to question or revise previous thoughts
|
||||
3. Don't hesitate to add more thoughts if needed, even at the "end"
|
||||
4. Express uncertainty when present
|
||||
5. Mark thoughts that revise previous thinking or branch into new paths
|
||||
6. Ignore information that is irrelevant to the current step
|
||||
7. Generate a solution hypothesis when appropriate
|
||||
8. Verify the hypothesis based on the Chain of Thought steps
|
||||
9. Repeat the process until satisfied with the solution
|
||||
10. Provide a single, ideally correct answer as the final output
|
||||
11. Only set next_thought_needed to false when truly done and a satisfactory answer is reached`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
thought: {
|
||||
type: 'string',
|
||||
description: 'Your current thinking step'
|
||||
},
|
||||
nextThoughtNeeded: {
|
||||
type: 'boolean',
|
||||
description: 'Whether another thought step is needed'
|
||||
},
|
||||
thoughtNumber: {
|
||||
type: 'integer',
|
||||
description: 'Current thought number',
|
||||
minimum: 1
|
||||
},
|
||||
totalThoughts: {
|
||||
type: 'integer',
|
||||
description: 'Estimated total thoughts needed',
|
||||
minimum: 1
|
||||
},
|
||||
isRevision: {
|
||||
type: 'boolean',
|
||||
description: 'Whether this revises previous thinking'
|
||||
},
|
||||
revisesThought: {
|
||||
type: 'integer',
|
||||
description: 'Which thought is being reconsidered',
|
||||
minimum: 1
|
||||
},
|
||||
branchFromThought: {
|
||||
type: 'integer',
|
||||
description: 'Branching point thought number',
|
||||
minimum: 1
|
||||
},
|
||||
branchId: {
|
||||
type: 'string',
|
||||
description: 'Branch identifier'
|
||||
},
|
||||
needsMoreThoughts: {
|
||||
type: 'boolean',
|
||||
description: 'If more thoughts are needed'
|
||||
}
|
||||
},
|
||||
required: ['thought', 'nextThoughtNeeded', 'thoughtNumber', 'totalThoughts']
|
||||
}
|
||||
}
|
||||
|
||||
class ThinkingServer {
|
||||
public server: Server
|
||||
private thinkingServer: SequentialThinkingServer
|
||||
|
||||
constructor() {
|
||||
this.thinkingServer = new SequentialThinkingServer()
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'sequential-thinking-server',
|
||||
version: '0.2.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [SEQUENTIAL_THINKING_TOOL]
|
||||
}))
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === 'sequentialthinking') {
|
||||
return this.thinkingServer.processThought(request.params.arguments)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Unknown tool: ${request.params.name}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ThinkingServer
|
||||
@@ -1,20 +1,77 @@
|
||||
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
export default abstract class BaseReranker {
|
||||
protected base: KnowledgeBaseParams
|
||||
|
||||
constructor(base: KnowledgeBaseParams) {
|
||||
if (!base.rerankModel) {
|
||||
throw new Error('Rerank model is required')
|
||||
}
|
||||
this.base = base
|
||||
}
|
||||
|
||||
abstract rerank(query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]>
|
||||
|
||||
/**
|
||||
* Get Rerank Request Url
|
||||
*/
|
||||
protected getRerankUrl() {
|
||||
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
|
||||
? this.base.rerankBaseURL.slice(0, -1)
|
||||
: this.base.rerankBaseURL
|
||||
// 必须携带/v1,否则会404
|
||||
if (baseURL && !baseURL.endsWith('/v1')) {
|
||||
baseURL = `${baseURL}/v1`
|
||||
}
|
||||
|
||||
return `${baseURL}/rerank`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Rerank Result
|
||||
* @param searchResults
|
||||
* @param rerankResults
|
||||
* @protected
|
||||
*/
|
||||
protected getRerankResult(
|
||||
searchResults: ExtractChunkData[],
|
||||
rerankResults: Array<{
|
||||
index: number
|
||||
relevance_score: number
|
||||
}>
|
||||
) {
|
||||
const resultMap = new Map(rerankResults.map((result) => [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)
|
||||
}
|
||||
|
||||
public defaultHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.base.rerankApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
protected formatErrorMessage(url: string, error: any, requestBody: any) {
|
||||
const errorDetails = {
|
||||
url: url,
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
requestBody: requestBody
|
||||
}
|
||||
return JSON.stringify(errorDetails, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
@@ -7,6 +7,7 @@ export default class DefaultReranker extends BaseReranker {
|
||||
constructor(base: KnowledgeBaseParams) {
|
||||
super(base)
|
||||
}
|
||||
|
||||
async rerank(): Promise<ExtractChunkData[]> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -10,10 +10,7 @@ export default class JinaReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
|
||||
const baseURL = this.base?.rerankBaseURL?.endsWith('/')
|
||||
? this.base.rerankBaseURL.slice(0, -1)
|
||||
: this.base.rerankBaseURL
|
||||
const url = `${baseURL}/rerank`
|
||||
const url = this.getRerankUrl()
|
||||
|
||||
const requestBody = {
|
||||
model: this.base.rerankModel,
|
||||
@@ -26,23 +23,11 @@ export default class JinaReranker extends BaseReranker {
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
|
||||
const rerankResults = data.results
|
||||
console.log(rerankResults)
|
||||
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) {
|
||||
console.error('Jina Reranker API 错误:', error)
|
||||
throw error
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
} catch (error: any) {
|
||||
const errorDetails = this.formatErrorMessage(url, error, requestBody)
|
||||
console.error('Jina Reranker API Error:', errorDetails)
|
||||
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -11,6 +12,8 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -10,10 +10,7 @@ export default class SiliconFlowReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
|
||||
const baseURL = this.base?.rerankBaseURL?.endsWith('/')
|
||||
? this.base.rerankBaseURL.slice(0, -1)
|
||||
: this.base.rerankBaseURL
|
||||
const url = `${baseURL}/rerank`
|
||||
const url = this.getRerankUrl()
|
||||
|
||||
const requestBody = {
|
||||
model: this.base.rerankModel,
|
||||
@@ -28,23 +25,12 @@ export default class SiliconFlowReranker extends BaseReranker {
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
|
||||
const rerankResults = data.results
|
||||
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
} catch (error: any) {
|
||||
const errorDetails = this.formatErrorMessage(url, error, requestBody)
|
||||
|
||||
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) {
|
||||
console.error('SiliconFlow Reranker API 错误:', error)
|
||||
throw error
|
||||
console.error('SiliconFlow Reranker API 错误:', errorDetails)
|
||||
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
src/main/reranker/VoyageReranker.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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[]> => {
|
||||
const url = this.getRerankUrl()
|
||||
|
||||
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
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
} catch (error: any) {
|
||||
const errorDetails = this.formatErrorMessage(url, error, requestBody)
|
||||
|
||||
console.error('Voyage Reranker API Error:', errorDetails)
|
||||
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 353 KiB |
@@ -1,3 +1,4 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
@@ -24,27 +25,27 @@ export default class AppUpdater {
|
||||
stack: error.stack,
|
||||
time: new Date().toISOString()
|
||||
})
|
||||
mainWindow.webContents.send('update-error', error)
|
||||
mainWindow.webContents.send(IpcChannel.UpdateError, error)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||
logger.info('检测到新版本', releaseInfo)
|
||||
mainWindow.webContents.send('update-available', releaseInfo)
|
||||
mainWindow.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
||||
})
|
||||
|
||||
// 检测到不需要更新时
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
mainWindow.webContents.send('update-not-available')
|
||||
mainWindow.webContents.send(IpcChannel.UpdateNotAvailable)
|
||||
})
|
||||
|
||||
// 更新下载进度
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
mainWindow.webContents.send('download-progress', progress)
|
||||
mainWindow.webContents.send(IpcChannel.DownloadProgress, progress)
|
||||
})
|
||||
|
||||
// 当需要更新的内容下载完成后
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
mainWindow.webContents.send('update-downloaded', releaseInfo)
|
||||
mainWindow.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
||||
this.releaseInfo = releaseInfo
|
||||
logger.info('下载完成', releaseInfo)
|
||||
})
|
||||
@@ -73,7 +74,7 @@ export default class AppUpdater {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
} else {
|
||||
mainWindow.webContents.send('update-downloaded-cancelled')
|
||||
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { WebDavConfig } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { exec } from 'child_process'
|
||||
@@ -5,7 +6,7 @@ import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
import { createClient, FileStat } from 'webdav'
|
||||
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
@@ -15,6 +16,7 @@ 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)
|
||||
@@ -78,7 +80,7 @@ class BackupManager {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send('backup-progress', processData)
|
||||
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
}
|
||||
|
||||
@@ -86,9 +88,16 @@ class BackupManager {
|
||||
await fs.ensureDir(this.tempDir)
|
||||
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
||||
|
||||
// 将 data 写入临时文件
|
||||
// 使用流的方式写入 data.json
|
||||
const tempDataPath = path.join(this.tempDir, 'data.json')
|
||||
await fs.writeFile(tempDataPath, data)
|
||||
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))
|
||||
})
|
||||
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
||||
|
||||
// 复制 Data 目录到临时目录
|
||||
@@ -131,7 +140,7 @@ class BackupManager {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send('restore-progress', processData)
|
||||
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
}
|
||||
|
||||
@@ -207,8 +216,15 @@ class BackupManager {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
}
|
||||
|
||||
// sync为同步写,无须await
|
||||
fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
|
||||
// 使用流的方式写入文件
|
||||
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))
|
||||
})
|
||||
|
||||
return await this.restore(_, backupedFilePath)
|
||||
} catch (error: any) {
|
||||
@@ -278,6 +294,21 @@ 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
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
|
||||
import { locales } from '../utils/locales'
|
||||
|
||||
enum ConfigKeys {
|
||||
Language = 'language',
|
||||
Theme = 'theme',
|
||||
LaunchToTray = 'launchToTray',
|
||||
Tray = 'tray',
|
||||
TrayOnClose = 'trayOnClose',
|
||||
ZoomFactor = 'ZoomFactor',
|
||||
Shortcuts = 'shortcuts',
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
private store: Store
|
||||
private subscribers: Map<string, Array<(newValue: any) => void>> = new Map()
|
||||
@@ -14,38 +26,54 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
getLanguage(): LanguageVarious {
|
||||
const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : 'en-US'
|
||||
return this.store.get('language', locale) as LanguageVarious
|
||||
const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : defaultLanguage
|
||||
return this.get(ConfigKeys.Language, locale) as LanguageVarious
|
||||
}
|
||||
|
||||
setLanguage(theme: LanguageVarious) {
|
||||
this.store.set('language', theme)
|
||||
this.set(ConfigKeys.Language, theme)
|
||||
}
|
||||
|
||||
getTheme(): ThemeMode {
|
||||
return this.store.get('theme', ThemeMode.light) as ThemeMode
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.light)
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
this.store.set('theme', theme)
|
||||
this.set(ConfigKeys.Theme, theme)
|
||||
}
|
||||
|
||||
getLaunchToTray(): boolean {
|
||||
return !!this.get(ConfigKeys.LaunchToTray, false)
|
||||
}
|
||||
|
||||
setLaunchToTray(value: boolean) {
|
||||
this.set(ConfigKeys.LaunchToTray, value)
|
||||
}
|
||||
|
||||
getTray(): boolean {
|
||||
return !!this.store.get('tray', true)
|
||||
return !!this.get(ConfigKeys.Tray, true)
|
||||
}
|
||||
|
||||
setTray(value: boolean) {
|
||||
this.store.set('tray', value)
|
||||
this.notifySubscribers('tray', value)
|
||||
this.set(ConfigKeys.Tray, value)
|
||||
this.notifySubscribers(ConfigKeys.Tray, value)
|
||||
}
|
||||
|
||||
getTrayOnClose(): boolean {
|
||||
return !!this.get(ConfigKeys.TrayOnClose, true)
|
||||
}
|
||||
|
||||
setTrayOnClose(value: boolean) {
|
||||
this.set(ConfigKeys.TrayOnClose, value)
|
||||
}
|
||||
|
||||
getZoomFactor(): number {
|
||||
return this.store.get('zoomFactor', 1) as number
|
||||
return this.get<number>(ConfigKeys.ZoomFactor, 1)
|
||||
}
|
||||
|
||||
setZoomFactor(factor: number) {
|
||||
this.store.set('zoomFactor', factor)
|
||||
this.notifySubscribers('zoomFactor', factor)
|
||||
this.set(ConfigKeys.ZoomFactor, factor)
|
||||
this.notifySubscribers(ConfigKeys.ZoomFactor, factor)
|
||||
}
|
||||
|
||||
subscribe<T>(key: string, callback: (newValue: T) => void) {
|
||||
@@ -73,39 +101,39 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
getShortcuts() {
|
||||
return this.store.get('shortcuts', ZOOM_SHORTCUTS) as Shortcut[] | []
|
||||
return this.get(ConfigKeys.Shortcuts, ZOOM_SHORTCUTS) as Shortcut[] | []
|
||||
}
|
||||
|
||||
setShortcuts(shortcuts: Shortcut[]) {
|
||||
this.store.set(
|
||||
'shortcuts',
|
||||
this.set(
|
||||
ConfigKeys.Shortcuts,
|
||||
shortcuts.filter((shortcut) => shortcut.system)
|
||||
)
|
||||
this.notifySubscribers('shortcuts', shortcuts)
|
||||
this.notifySubscribers(ConfigKeys.Shortcuts, shortcuts)
|
||||
}
|
||||
|
||||
getClickTrayToShowQuickAssistant(): boolean {
|
||||
return this.store.get('clickTrayToShowQuickAssistant', false) as boolean
|
||||
return this.get<boolean>(ConfigKeys.ClickTrayToShowQuickAssistant, false)
|
||||
}
|
||||
|
||||
setClickTrayToShowQuickAssistant(value: boolean) {
|
||||
this.store.set('clickTrayToShowQuickAssistant', value)
|
||||
this.set(ConfigKeys.ClickTrayToShowQuickAssistant, value)
|
||||
}
|
||||
|
||||
getEnableQuickAssistant(): boolean {
|
||||
return this.store.get('enableQuickAssistant', false) as boolean
|
||||
return this.get(ConfigKeys.EnableQuickAssistant, false)
|
||||
}
|
||||
|
||||
setEnableQuickAssistant(value: boolean) {
|
||||
this.store.set('enableQuickAssistant', value)
|
||||
this.set(ConfigKeys.EnableQuickAssistant, value)
|
||||
}
|
||||
|
||||
set(key: string, value: any) {
|
||||
set(key: string, value: unknown) {
|
||||
this.store.set(key, value)
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
return this.store.get(key)
|
||||
get<T>(key: string, defaultValue?: T) {
|
||||
return this.store.get(key, defaultValue) as T
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
|
||||
import { documentExts, imageExts } from '@shared/config/constant'
|
||||
import { documentExts, imageExts, MB } from '@shared/config/constant'
|
||||
import { FileType } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import {
|
||||
@@ -122,7 +122,7 @@ class FileStorage {
|
||||
private async compressImage(sourcePath: string, destPath: string): Promise<void> {
|
||||
try {
|
||||
const stats = fs.statSync(sourcePath)
|
||||
const fileSizeInMB = stats.size / (1024 * 1024)
|
||||
const fileSizeInMB = stats.size / MB
|
||||
|
||||
// 如果图片大于1MB才进行压缩
|
||||
if (fileSizeInMB > 1) {
|
||||
|
||||
@@ -3,14 +3,12 @@ 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',
|
||||
@@ -31,7 +29,6 @@ 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)
|
||||
@@ -55,13 +52,11 @@ 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)
|
||||
}
|
||||
|
||||
@@ -16,19 +16,19 @@
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
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 { 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 { 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 { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
@@ -93,7 +93,7 @@ class KnowledgeService {
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||
private static MAXIMUM_WORKLOAD = 1024 * 1024 * 80
|
||||
private static MAXIMUM_WORKLOAD = 80 * MB
|
||||
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
||||
private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
|
||||
|
||||
@@ -115,30 +115,20 @@ class KnowledgeService {
|
||||
baseURL,
|
||||
dimensions
|
||||
}: KnowledgeBaseParams): Promise<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()
|
||||
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
|
||||
}
|
||||
|
||||
public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
|
||||
@@ -206,7 +196,7 @@ class KnowledgeService {
|
||||
|
||||
const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send('directory-processing-percent', {
|
||||
mainWindow?.webContents.send(IpcChannel.DirectoryProcessingPercent, {
|
||||
itemId: item.id,
|
||||
percent: (processedFiles / totalFiles) * 100
|
||||
})
|
||||
@@ -282,7 +272,7 @@ class KnowledgeService {
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: 1024 * 1024 * 2 }
|
||||
evaluateTaskWorkload: { workload: 2 * MB }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
@@ -321,7 +311,7 @@ class KnowledgeService {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: 1024 * 1024 * 20 }
|
||||
evaluateTaskWorkload: { workload: 20 * MB }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
@@ -426,7 +416,6 @@ 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 }
|
||||
@@ -488,6 +477,9 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,559 +1,258 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
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 { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
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 { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { MCPServer, MCPTool } from '@types'
|
||||
import log from 'electron-log'
|
||||
import { EventEmitter } from 'events'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import { windowService } from './WindowService'
|
||||
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
|
||||
|
||||
/**
|
||||
* 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
|
||||
class McpService {
|
||||
private clients: Map<string, Client> = new Map()
|
||||
|
||||
// Simplified server loading state management
|
||||
private readyState = {
|
||||
serversLoaded: false,
|
||||
promise: null as Promise<void> | null,
|
||||
resolve: null as ((value: void) => void) | null
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.createServerLoadingPromise()
|
||||
this.init().catch((err) => this.logError('Failed to initialize MCP service', err))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a promise that resolves when servers are loaded
|
||||
*/
|
||||
private createServerLoadingPromise(): void {
|
||||
this.readyState.promise = new Promise<void>((resolve) => {
|
||||
this.readyState.resolve = resolve
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// Initialize if not already initialized
|
||||
if (!this.initialized) {
|
||||
this.init().catch((err) => this.logError('Failed to initialize MCP service', err))
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the MCP service if not already initialized
|
||||
*/
|
||||
public async init(): Promise<void> {
|
||||
// If already initialized, return immediately
|
||||
if (this.initialized) return
|
||||
async initClient(server: MCPServer): Promise<Client> {
|
||||
const serverKey = this.getServerKey(server)
|
||||
|
||||
// If initialization is in progress, return that promise
|
||||
if (this.initPromise) return this.initPromise
|
||||
|
||||
this.initPromise = (async () => {
|
||||
// Check if we already have a client for this server configuration
|
||||
const existingClient = this.clients.get(serverKey)
|
||||
if (existingClient) {
|
||||
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
|
||||
// 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
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Error pinging server ${server.name}:`, error)
|
||||
this.clients.delete(serverKey)
|
||||
}
|
||||
})()
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
// Create new client instance for each connection
|
||||
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
|
||||
|
||||
/**
|
||||
* 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
|
||||
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
|
||||
try {
|
||||
// Create appropriate transport based on configuration
|
||||
if (baseUrl) {
|
||||
transport = new this.sseTransport!(new URL(baseUrl))
|
||||
} else if (command) {
|
||||
let cmd: string = command
|
||||
if (command === 'npx') {
|
||||
if (server.type === 'inMemory') {
|
||||
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
// start the in-memory server with the given name and environment variables
|
||||
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error}`)
|
||||
}
|
||||
// set the client transport to the client
|
||||
transport = clientTransport
|
||||
} else if (server.baseUrl) {
|
||||
if (server.type === 'streamableHttp') {
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(server.baseUrl!),
|
||||
{} as StreamableHTTPClientTransportOptions
|
||||
)
|
||||
} else if (server.type === 'sse') {
|
||||
transport = new SSEClientTransport(new URL(server.baseUrl!))
|
||||
} else {
|
||||
throw new Error('Invalid server type')
|
||||
}
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
|
||||
if (cmd === 'bun') {
|
||||
cmd = 'npx'
|
||||
}
|
||||
|
||||
log.info(`[MCP] Using command: ${cmd}`)
|
||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||
|
||||
// add -x to args if args exist
|
||||
if (args && args.length > 0) {
|
||||
if (!args.includes('-y')) {
|
||||
args.unshift('-y')
|
||||
!args.includes('-y') && args.unshift('-y')
|
||||
}
|
||||
if (cmd.includes('bun') && !args.includes('x')) {
|
||||
if (!args.includes('x')) {
|
||||
args.unshift('x')
|
||||
}
|
||||
}
|
||||
} else if (command === 'uvx') {
|
||||
cmd = await getBinaryPath('uvx')
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
NPM_CONFIG_REGISTRY: server.registryUrl
|
||||
}
|
||||
|
||||
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
|
||||
if (server.name.includes('mcp-auto-install')) {
|
||||
const binPath = await getBinaryPath()
|
||||
makeSureDirExists(binPath)
|
||||
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
|
||||
}
|
||||
}
|
||||
} else if (server.command === 'uvx' || server.command === 'uv') {
|
||||
cmd = await getBinaryPath(server.command)
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
UV_DEFAULT_INDEX: server.registryUrl,
|
||||
PIP_INDEX_URL: server.registryUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
|
||||
transport = new this.stdioTransport!({
|
||||
transport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: this.getEnhancedPath(process.env.PATH || ''),
|
||||
...env
|
||||
...server.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 client and server info
|
||||
this.clients[name] = client
|
||||
this.activeServers.set(name, { client, server })
|
||||
// Store the new client in the cache
|
||||
this.clients.set(serverKey, client)
|
||||
|
||||
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 })
|
||||
Logger.info(`[MCP] Activated server: ${server.name}`)
|
||||
return client
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||
const serverKey = this.getServerKey(server)
|
||||
Logger.info(`[MCP] Stopping server: ${server.name}`)
|
||||
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 removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||
const serverKey = this.getServerKey(server)
|
||||
const existingClient = this.clients.get(serverKey)
|
||||
if (existingClient) {
|
||||
await this.closeClient(serverKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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)
|
||||
}
|
||||
|
||||
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)) {
|
||||
log.info(`[MCP] Tools from ${serverName} loaded from cache`)
|
||||
// Check if cache is still valid
|
||||
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
|
||||
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
|
||||
if (cachedTools && cachedTools.length > 0) {
|
||||
return cachedTools
|
||||
}
|
||||
CacheService.remove(cacheKey)
|
||||
}
|
||||
|
||||
const { tools } = await this.clients[serverName].listTools()
|
||||
|
||||
const transformedTools = tools.map((tool: any) => ({
|
||||
...tool,
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool on an MCP server
|
||||
*/
|
||||
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)
|
||||
|
||||
public async callTool(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ server, name, args }: { server: MCPServer; name: string; args: any }
|
||||
): Promise<any> {
|
||||
try {
|
||||
return await this.clients[client].callTool({
|
||||
name,
|
||||
arguments: args
|
||||
})
|
||||
Logger.info('[MCP] Calling:', server.name, name, args)
|
||||
const client = await this.initClient(server)
|
||||
const result = await client.callTool({ name, arguments: args })
|
||||
return result
|
||||
} catch (error) {
|
||||
log.error(`[MCP] Error calling tool ${name} on ${client}:`, error)
|
||||
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`)
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,6 +280,7 @@ export default class MCPService extends EventEmitter {
|
||||
`${homeDir}/.npm-global/bin`,
|
||||
`${homeDir}/.yarn/bin`,
|
||||
`${homeDir}/.cargo/bin`,
|
||||
`${homeDir}/.cherrystudio/bin`,
|
||||
'/opt/local/bin'
|
||||
)
|
||||
}
|
||||
@@ -594,12 +294,18 @@ export default class MCPService extends EventEmitter {
|
||||
`${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`)
|
||||
newPaths.push(
|
||||
`${process.env.APPDATA}\\npm`,
|
||||
`${homeDir}\\AppData\\Local\\Yarn\\bin`,
|
||||
`${homeDir}\\.cargo\\bin`,
|
||||
`${homeDir}\\.cherrystudio\\bin`
|
||||
)
|
||||
}
|
||||
|
||||
// 只添加不存在的路径
|
||||
@@ -613,3 +319,5 @@ export default class MCPService extends EventEmitter {
|
||||
return Array.from(existingPaths).join(pathSeparator)
|
||||
}
|
||||
}
|
||||
|
||||
export default new McpService()
|
||||
|
||||
365
src/main/services/MCPStreamableHttpClient.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
export class StreamableHTTPError extends Error {
|
||||
constructor(
|
||||
public readonly code: number | undefined,
|
||||
message: string | undefined,
|
||||
public readonly event: ErrorEvent
|
||||
) {
|
||||
super(`Streamable HTTP error: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the `StreamableHTTPClientTransport`.
|
||||
*/
|
||||
export type StreamableHTTPClientTransportOptions = {
|
||||
/**
|
||||
* An OAuth client provider to use for authentication.
|
||||
*
|
||||
* When an `authProvider` is specified and the connection is started:
|
||||
* 1. The connection is attempted with any existing access token from the `authProvider`.
|
||||
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
|
||||
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
|
||||
*
|
||||
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection.
|
||||
*
|
||||
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
|
||||
*
|
||||
* `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected.
|
||||
*/
|
||||
authProvider?: OAuthClientProvider
|
||||
|
||||
/**
|
||||
* Customizes HTTP requests to the server.
|
||||
*/
|
||||
requestInit?: RequestInit
|
||||
}
|
||||
|
||||
/**
|
||||
* Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
|
||||
* It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events
|
||||
* for receiving messages.
|
||||
*/
|
||||
export class StreamableHTTPClientTransport implements Transport {
|
||||
private _activeStreams: Map<string, ReadableStreamDefaultReader<Uint8Array>> = new Map()
|
||||
private _abortController?: AbortController
|
||||
private _url: URL
|
||||
private _requestInit?: RequestInit
|
||||
private _authProvider?: OAuthClientProvider
|
||||
private _sessionId?: string
|
||||
private _lastEventId?: string
|
||||
|
||||
onclose?: () => void
|
||||
onerror?: (error: Error) => void
|
||||
onmessage?: (message: JSONRPCMessage) => void
|
||||
|
||||
constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) {
|
||||
this._url = url
|
||||
this._requestInit = opts?.requestInit
|
||||
this._authProvider = opts?.authProvider
|
||||
}
|
||||
|
||||
private async _authThenStart(): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider')
|
||||
}
|
||||
|
||||
let result: AuthResult
|
||||
try {
|
||||
result = await auth(this._authProvider, { serverUrl: this._url })
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
|
||||
return await this._startOrAuth()
|
||||
}
|
||||
|
||||
private async _commonHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {}
|
||||
if (this._authProvider) {
|
||||
const tokens = await this._authProvider.tokens()
|
||||
if (tokens) {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
||||
}
|
||||
}
|
||||
|
||||
if (this._sessionId) {
|
||||
headers['mcp-session-id'] = this._sessionId
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
private async _startOrAuth(): Promise<void> {
|
||||
try {
|
||||
// Try to open an initial SSE stream with GET to listen for server messages
|
||||
// This is optional according to the spec - server may not support it
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const headers = new Headers(commonHeaders)
|
||||
headers.set('Accept', 'text/event-stream')
|
||||
|
||||
// Include Last-Event-ID header for resumable streams
|
||||
if (this._lastEventId) {
|
||||
headers.set('last-event-id', this._lastEventId)
|
||||
}
|
||||
|
||||
const response = await fetch(this._url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: this._abortController?.signal
|
||||
})
|
||||
|
||||
if (response.status === 405) {
|
||||
// Server doesn't support GET for SSE, which is allowed by the spec
|
||||
// We'll rely on SSE responses to POST requests for communication
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
// Need to authenticate
|
||||
return await this._authThenStart()
|
||||
}
|
||||
|
||||
const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`)
|
||||
this.onerror?.(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Successful connection, handle the SSE stream as a standalone listener
|
||||
const streamId = `initial-${Date.now()}`
|
||||
this._handleSseStream(response.body, streamId)
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this._activeStreams.size > 0) {
|
||||
throw new Error(
|
||||
'StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.'
|
||||
)
|
||||
}
|
||||
|
||||
this._abortController = new AbortController()
|
||||
return await this._startOrAuth()
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
|
||||
*/
|
||||
async finishAuth(authorizationCode: string): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider')
|
||||
}
|
||||
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode })
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError('Failed to authorize')
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Close all active streams
|
||||
for (const reader of this._activeStreams.values()) {
|
||||
try {
|
||||
reader.cancel()
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
this._activeStreams.clear()
|
||||
|
||||
// Abort any pending requests
|
||||
this._abortController?.abort()
|
||||
|
||||
// If we have a session ID, send a DELETE request to explicitly terminate the session
|
||||
if (this._sessionId) {
|
||||
try {
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const response = await fetch(this._url, {
|
||||
method: 'DELETE',
|
||||
headers: commonHeaders,
|
||||
signal: this._abortController?.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Server might respond with 405 if it doesn't support explicit session termination
|
||||
// We don't throw an error in that case
|
||||
if (response.status !== 405) {
|
||||
const text = await response.text().catch(() => null)
|
||||
throw new Error(`Error terminating session (HTTP ${response.status}): ${text}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// We still want to invoke onclose even if the session termination fails
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise<void> {
|
||||
try {
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers })
|
||||
headers.set('content-type', 'application/json')
|
||||
headers.set('accept', 'application/json, text/event-stream')
|
||||
|
||||
const init = {
|
||||
...this._requestInit,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
signal: this._abortController?.signal
|
||||
}
|
||||
|
||||
const response = await fetch(this._url, init)
|
||||
|
||||
// Handle session ID received during initialization
|
||||
const sessionId = response.headers.get('mcp-session-id')
|
||||
if (sessionId) {
|
||||
this._sessionId = sessionId
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url })
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this.send(message)
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => null)
|
||||
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
// If the response is 202 Accepted, there's no body to process
|
||||
if (response.status === 202) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get original message(s) for detecting request IDs
|
||||
const messages = Array.isArray(message) ? message : [message]
|
||||
|
||||
// Extract IDs from request messages for tracking responses
|
||||
const requestIds = messages
|
||||
.filter((msg) => 'method' in msg && 'id' in msg)
|
||||
.map((msg) => ('id' in msg ? msg.id : undefined))
|
||||
.filter((id) => id !== undefined)
|
||||
|
||||
// If we have request IDs and an SSE response, create a unique stream ID
|
||||
const hasRequests = requestIds.length > 0
|
||||
|
||||
// Check the response type
|
||||
const contentType = response.headers.get('content-type')
|
||||
|
||||
if (hasRequests) {
|
||||
if (contentType?.includes('text/event-stream')) {
|
||||
// For streaming responses, create a unique stream ID based on request IDs
|
||||
const streamId = `req-${requestIds.join('-')}-${Date.now()}`
|
||||
this._handleSseStream(response.body, streamId)
|
||||
} else if (contentType?.includes('application/json')) {
|
||||
// For non-streaming servers, we might get direct JSON responses
|
||||
const data = await response.json()
|
||||
const responseMessages = Array.isArray(data)
|
||||
? data.map((msg) => JSONRPCMessageSchema.parse(msg))
|
||||
: [JSONRPCMessageSchema.parse(data)]
|
||||
|
||||
for (const msg of responseMessages) {
|
||||
this.onmessage?.(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSseStream(stream: ReadableStream<Uint8Array> | null, streamId: string): void {
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set up stream handling for server-sent events
|
||||
const reader = stream.getReader()
|
||||
this._activeStreams.set(streamId, reader)
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
// Stream closed by server
|
||||
this._activeStreams.delete(streamId)
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// Process SSE messages in the buffer
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() || ''
|
||||
|
||||
for (const event of events) {
|
||||
const lines = event.split('\n')
|
||||
let id: string | undefined
|
||||
let eventType: string | undefined
|
||||
let data: string | undefined
|
||||
|
||||
// Parse SSE message according to the format
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('id:')) {
|
||||
id = line.slice(3).trim()
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventType = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
data = line.slice(5).trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Update last event ID if provided by server
|
||||
// As per spec: the ID MUST be globally unique across all streams within that session
|
||||
if (id) {
|
||||
this._lastEventId = id
|
||||
}
|
||||
|
||||
// Handle message event
|
||||
if (data) {
|
||||
// Default event type is 'message' per SSE spec if not specified
|
||||
if (!eventType || eventType === 'message') {
|
||||
try {
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(data))
|
||||
this.onmessage?.(message)
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this._activeStreams.delete(streamId)
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
processStream()
|
||||
}
|
||||
}
|
||||
134
src/main/services/NutstoreService.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
167
src/main/services/ObsidianVaultService.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
interface VaultInfo {
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
path: string
|
||||
type: 'folder' | 'markdown'
|
||||
name: string
|
||||
}
|
||||
|
||||
class ObsidianVaultService {
|
||||
private obsidianConfigPath: string
|
||||
|
||||
constructor() {
|
||||
// 根据操作系统获取Obsidian配置文件路径
|
||||
if (process.platform === 'win32') {
|
||||
this.obsidianConfigPath = path.join(app.getPath('appData'), 'obsidian', 'obsidian.json')
|
||||
} else if (process.platform === 'darwin') {
|
||||
this.obsidianConfigPath = path.join(
|
||||
app.getPath('home'),
|
||||
'Library',
|
||||
'Application Support',
|
||||
'obsidian',
|
||||
'obsidian.json'
|
||||
)
|
||||
} else {
|
||||
// Linux
|
||||
this.obsidianConfigPath = path.join(app.getPath('home'), '.config', 'obsidian', 'obsidian.json')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有的Obsidian Vault
|
||||
*/
|
||||
getVaults(): VaultInfo[] {
|
||||
try {
|
||||
if (!fs.existsSync(this.obsidianConfigPath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const configContent = fs.readFileSync(this.obsidianConfigPath, 'utf8')
|
||||
const config = JSON.parse(configContent)
|
||||
|
||||
if (!config.vaults) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(config.vaults).map(([, vault]: [string, any]) => ({
|
||||
path: vault.path,
|
||||
name: vault.name || path.basename(vault.path)
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('获取Obsidian Vault失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Vault中的文件夹和Markdown文件结构
|
||||
*/
|
||||
getVaultStructure(vaultPath: string): FileInfo[] {
|
||||
const results: FileInfo[] = []
|
||||
|
||||
try {
|
||||
// 检查vault路径是否存在
|
||||
if (!fs.existsSync(vaultPath)) {
|
||||
console.error('Vault路径不存在:', vaultPath)
|
||||
return []
|
||||
}
|
||||
|
||||
// 检查是否是目录
|
||||
const stats = fs.statSync(vaultPath)
|
||||
if (!stats.isDirectory()) {
|
||||
console.error('Vault路径不是一个目录:', vaultPath)
|
||||
return []
|
||||
}
|
||||
|
||||
this.traverseDirectory(vaultPath, '', results)
|
||||
} catch (error) {
|
||||
console.error('读取Vault文件夹结构失败:', error)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归遍历目录获取所有文件夹和Markdown文件
|
||||
*/
|
||||
private traverseDirectory(dirPath: string, relativePath: string, results: FileInfo[]) {
|
||||
try {
|
||||
// 首先添加当前文件夹
|
||||
if (relativePath) {
|
||||
results.push({
|
||||
path: relativePath,
|
||||
type: 'folder',
|
||||
name: path.basename(relativePath)
|
||||
})
|
||||
}
|
||||
|
||||
// 确保目录存在且可访问
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
console.error('目录不存在:', dirPath)
|
||||
return
|
||||
}
|
||||
|
||||
let items
|
||||
try {
|
||||
items = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
console.error(`无法读取目录 ${dirPath}:`, err)
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
// 忽略以.开头的隐藏文件夹和文件
|
||||
if (item.name.startsWith('.')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const newRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
|
||||
if (item.isDirectory()) {
|
||||
this.traverseDirectory(fullPath, newRelativePath, results)
|
||||
} else if (item.isFile() && item.name.endsWith('.md')) {
|
||||
// 收集.md文件
|
||||
results.push({
|
||||
path: newRelativePath,
|
||||
type: 'markdown',
|
||||
name: item.name
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`遍历目录出错 ${dirPath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定Vault的文件夹和Markdown文件结构
|
||||
* @param vaultName vault名称
|
||||
*/
|
||||
getFilesByVaultName(vaultName: string): FileInfo[] {
|
||||
try {
|
||||
const vaults = this.getVaults()
|
||||
const vault = vaults.find((v) => v.name === vaultName)
|
||||
|
||||
if (!vault) {
|
||||
console.error('未找到指定名称的Vault:', vaultName)
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('获取Vault文件结构:', vault.name, vault.path)
|
||||
return this.getVaultStructure(vault.path)
|
||||
} catch (error) {
|
||||
console.error('获取Vault文件结构时发生错误:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ObsidianVaultService
|
||||
34
src/main/services/ProtocolClient.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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¶m2=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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,23 @@
|
||||
import { ProxyConfig as _ProxyConfig, session } from 'electron'
|
||||
import { socksDispatcher } from 'fetch-socks'
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
|
||||
import { ProxyAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
type ProxyMode = 'system' | 'custom' | 'none'
|
||||
|
||||
export interface ProxyConfig {
|
||||
mode: ProxyMode
|
||||
url?: string | null
|
||||
url?: string
|
||||
}
|
||||
|
||||
export class ProxyManager {
|
||||
private config: ProxyConfig
|
||||
private proxyAgent: HttpsProxyAgent | null = null
|
||||
private proxyUrl: string | null = null
|
||||
private proxyAgent: GeneralProxyAgent | null = null
|
||||
private systemProxyInterval: NodeJS.Timeout | null = null
|
||||
|
||||
constructor() {
|
||||
this.config = {
|
||||
mode: 'none',
|
||||
url: ''
|
||||
mode: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +49,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()
|
||||
@@ -73,11 +71,13 @@ export class ProxyManager {
|
||||
private async setSystemProxy(): Promise<void> {
|
||||
try {
|
||||
await this.setSessionsProxy({ mode: 'system' })
|
||||
const url = await this.resolveSystemProxy()
|
||||
if (url && url !== this.proxyUrl) {
|
||||
this.proxyUrl = url.toLowerCase()
|
||||
this.proxyAgent = new HttpsProxyAgent(this.proxyUrl)
|
||||
this.setEnvironment(this.proxyUrl)
|
||||
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()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set system proxy:', error)
|
||||
@@ -88,10 +88,9 @@ export class ProxyManager {
|
||||
private async setCustomProxy(): Promise<void> {
|
||||
try {
|
||||
if (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 })
|
||||
this.setEnvironment(this.config.url)
|
||||
this.proxyAgent = new GeneralProxyAgent()
|
||||
await this.setSessionsProxy({ proxyRules: this.config.url })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set custom proxy:', error)
|
||||
@@ -99,45 +98,31 @@ export class ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async clearProxy(): Promise<void> {
|
||||
private clearEnvironment(): void {
|
||||
delete process.env.HTTP_PROXY
|
||||
delete process.env.HTTPS_PROXY
|
||||
await this.setSessionsProxy({})
|
||||
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' })
|
||||
this.config = { mode: 'none' }
|
||||
this.proxyAgent = null
|
||||
this.proxyUrl = 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 {
|
||||
getProxyAgent(): GeneralProxyAgent | null {
|
||||
return this.proxyAgent
|
||||
}
|
||||
|
||||
getProxyUrl(): string | null {
|
||||
return this.proxyUrl
|
||||
getProxyUrl(): string {
|
||||
return this.config.url || ''
|
||||
}
|
||||
|
||||
setGlobalProxy() {
|
||||
const proxyUrl = this.proxyUrl
|
||||
const proxyUrl = this.config.url
|
||||
if (proxyUrl) {
|
||||
const [protocol, address] = proxyUrl.split('://')
|
||||
const [host, port] = address.split(':')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ipcMain } from 'electron'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
@@ -10,6 +11,8 @@ export class ReduxService extends EventEmitter {
|
||||
private stateCache: any = {}
|
||||
private isReady = false
|
||||
|
||||
private readonly STATUS_CHANGE_EVENT = 'statusChange'
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.setupIpcHandlers()
|
||||
@@ -17,15 +20,15 @@ export class ReduxService extends EventEmitter {
|
||||
|
||||
private setupIpcHandlers() {
|
||||
// 监听 store 就绪事件
|
||||
ipcMain.handle('redux-store-ready', () => {
|
||||
ipcMain.handle(IpcChannel.ReduxStoreReady, () => {
|
||||
this.isReady = true
|
||||
this.emit('ready')
|
||||
})
|
||||
|
||||
// 监听 store 状态变化
|
||||
ipcMain.on('redux-state-change', (_, newState) => {
|
||||
ipcMain.on(IpcChannel.ReduxStateChange, (_, newState) => {
|
||||
this.stateCache = newState
|
||||
this.emit('stateChange', newState)
|
||||
this.emit(this.STATUS_CHANGE_EVENT, newState)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,19 +125,23 @@ export class ReduxService extends EventEmitter {
|
||||
await this.waitForStoreReady(mainWindow.webContents)
|
||||
|
||||
// 在渲染进程中设置监听
|
||||
await mainWindow.webContents.executeJavaScript(`
|
||||
await mainWindow.webContents.executeJavaScript(
|
||||
`
|
||||
if (!window._storeSubscriptions) {
|
||||
window._storeSubscriptions = new Set();
|
||||
|
||||
// 设置全局状态变化监听
|
||||
const unsubscribe = window.store.subscribe(() => {
|
||||
const state = window.store.getState();
|
||||
window.electron.ipcRenderer.send('redux-state-change', state);
|
||||
window.electron.ipcRenderer.send('` +
|
||||
IpcChannel.ReduxStateChange +
|
||||
`', state);
|
||||
});
|
||||
|
||||
window._storeSubscriptions.add(unsubscribe);
|
||||
}
|
||||
`)
|
||||
`
|
||||
)
|
||||
|
||||
// 在主进程中处理回调
|
||||
const handler = async () => {
|
||||
@@ -146,9 +153,9 @@ export class ReduxService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
this.on('stateChange', handler)
|
||||
this.on(this.STATUS_CHANGE_EVENT, handler)
|
||||
return () => {
|
||||
this.off('stateChange', handler)
|
||||
this.off(this.STATUS_CHANGE_EVENT, handler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,41 +187,41 @@ export class ReduxService extends EventEmitter {
|
||||
export const reduxService = new ReduxService()
|
||||
|
||||
/** example
|
||||
async function example() {
|
||||
try {
|
||||
// 读取状态
|
||||
const settings = await reduxService.select('state.settings')
|
||||
console.log('settings', settings)
|
||||
async function example() {
|
||||
try {
|
||||
// 读取状态
|
||||
const settings = await reduxService.select('state.settings')
|
||||
console.log('settings', settings)
|
||||
|
||||
// 派发 action
|
||||
await reduxService.dispatch({
|
||||
type: 'settings/updateApiKey',
|
||||
payload: 'new-api-key'
|
||||
})
|
||||
// 派发 action
|
||||
await reduxService.dispatch({
|
||||
type: 'settings/updateApiKey',
|
||||
payload: 'new-api-key'
|
||||
})
|
||||
|
||||
// 订阅状态变化
|
||||
const unsubscribe = await reduxService.subscribe('state.settings.apiKey', (newValue) => {
|
||||
console.log('API key changed:', newValue)
|
||||
})
|
||||
// 订阅状态变化
|
||||
const unsubscribe = await reduxService.subscribe('state.settings.apiKey', (newValue) => {
|
||||
console.log('API key changed:', newValue)
|
||||
})
|
||||
|
||||
// 批量执行 actions
|
||||
await reduxService.batch([
|
||||
{ type: 'action1', payload: 'data1' },
|
||||
{ type: 'action2', payload: 'data2' }
|
||||
])
|
||||
// 批量执行 actions
|
||||
await reduxService.batch([
|
||||
{ type: 'action1', payload: 'data1' },
|
||||
{ type: 'action2', payload: 'data2' }
|
||||
])
|
||||
|
||||
// 同步方法虽然可能不是最新的数据,但响应更快
|
||||
const apiKey = reduxService.selectSync('state.settings.apiKey')
|
||||
console.log('apiKey', apiKey)
|
||||
// 同步方法虽然可能不是最新的数据,但响应更快
|
||||
const apiKey = reduxService.selectSync('state.settings.apiKey')
|
||||
console.log('apiKey', apiKey)
|
||||
|
||||
// 处理保证是最新的数据
|
||||
const apiKey1 = await reduxService.select('state.settings.apiKey')
|
||||
console.log('apiKey1', apiKey1)
|
||||
// 处理保证是最新的数据
|
||||
const apiKey1 = await reduxService.select('state.settings.apiKey')
|
||||
console.log('apiKey1', apiKey1)
|
||||
|
||||
// 取消订阅
|
||||
unsubscribe()
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
}
|
||||
}
|
||||
*/
|
||||
// 取消订阅
|
||||
unsubscribe()
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -23,17 +23,8 @@ function getShortcutHandler(shortcut: Shortcut) {
|
||||
configManager.setZoomFactor(1)
|
||||
}
|
||||
case 'show_app':
|
||||
return (window: BrowserWindow) => {
|
||||
if (window.isVisible()) {
|
||||
if (window.isFocused()) {
|
||||
window.hide()
|
||||
} else {
|
||||
window.focus()
|
||||
}
|
||||
} else {
|
||||
window.show()
|
||||
window.focus()
|
||||
}
|
||||
return () => {
|
||||
windowService.toggleMainWindow()
|
||||
}
|
||||
case 'mini_window':
|
||||
return () => {
|
||||
@@ -115,7 +106,20 @@ const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutForm
|
||||
}
|
||||
|
||||
export function registerShortcuts(window: BrowserWindow) {
|
||||
const register = () => {
|
||||
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) => {
|
||||
if (window.isDestroyed()) return
|
||||
|
||||
const shortcuts = configManager.getShortcuts()
|
||||
@@ -132,6 +136,11 @@ 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
|
||||
@@ -203,9 +212,13 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
|
||||
// only register the event handlers once
|
||||
if (undefined === windowOnHandlers.get(window)) {
|
||||
window.on('focus', register)
|
||||
// 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('blur', unregister)
|
||||
windowOnHandlers.set(window, { onFocusHandler: register, onBlurHandler: unregister })
|
||||
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
|
||||
}
|
||||
|
||||
if (!window.isDestroyed() && window.isFocused()) {
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
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, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
|
||||
import {
|
||||
BufferLike,
|
||||
createClient,
|
||||
CreateDirectoryOptions,
|
||||
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,
|
||||
httpAgent: url ? new HttpProxyAgent(url) : undefined,
|
||||
httpsAgent: proxyManager.getProxyAgent()
|
||||
maxContentLength: Infinity
|
||||
})
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
this.getFileContents = this.getFileContents.bind(this)
|
||||
this.createDirectory = this.createDirectory.bind(this)
|
||||
}
|
||||
|
||||
public putFileContents = async (
|
||||
@@ -69,4 +72,30 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isDev, isLinux, isWin } from '@main/constant'
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { getFilesDir } from '@main/utils/file'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
@@ -15,7 +16,11 @@ 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
|
||||
@@ -30,6 +35,7 @@ export class WindowService {
|
||||
public createMainWindow(): BrowserWindow {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
return this.mainWindow
|
||||
}
|
||||
|
||||
@@ -39,8 +45,6 @@ export class WindowService {
|
||||
})
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isLinux = process.platform === 'linux'
|
||||
|
||||
this.mainWindow = new BrowserWindow({
|
||||
x: mainWindowState.x,
|
||||
@@ -58,7 +62,7 @@ export class WindowService {
|
||||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
@@ -70,39 +74,15 @@ 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
|
||||
}
|
||||
|
||||
public createMinappWindow({
|
||||
url,
|
||||
parent,
|
||||
windowOptions
|
||||
}: {
|
||||
url: string
|
||||
parent?: BrowserWindow
|
||||
windowOptions?: Electron.BrowserWindowConstructorOptions
|
||||
}): BrowserWindow {
|
||||
const width = windowOptions?.width || 1000
|
||||
const height = windowOptions?.height || 680
|
||||
|
||||
const minappWindow = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
autoHideMenuBar: true,
|
||||
title: 'Cherry Studio',
|
||||
...windowOptions,
|
||||
parent,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/minapp.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: false
|
||||
}
|
||||
})
|
||||
|
||||
minappWindow.loadURL(url)
|
||||
return minappWindow
|
||||
}
|
||||
|
||||
private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) {
|
||||
mainWindowState.manage(mainWindow)
|
||||
|
||||
@@ -146,20 +126,46 @@ export class WindowService {
|
||||
private setupWindowEvents(mainWindow: BrowserWindow) {
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
mainWindow.show()
|
||||
|
||||
// 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.on('enter-full-screen', () => {
|
||||
this.wasFullScreen = true
|
||||
mainWindow.webContents.send('fullscreen-status-changed', true)
|
||||
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
|
||||
})
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
this.wasFullScreen = false
|
||||
mainWindow.webContents.send('fullscreen-status-changed', false)
|
||||
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, 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键且窗口处于全屏状态时退出全屏
|
||||
@@ -255,12 +261,20 @@ export class WindowService {
|
||||
return app.quit()
|
||||
}
|
||||
|
||||
// 没有开启托盘,且是Windows或Linux系统,直接退出
|
||||
const notInTray = !configManager.getTray()
|
||||
if ((isWin || isLinux) && notInTray) {
|
||||
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,且处于全屏状态,则退出应用
|
||||
if (this.wasFullScreen) {
|
||||
if (isWin || isLinux) {
|
||||
@@ -271,8 +285,14 @@ export class WindowService {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
mainWindow.hide()
|
||||
|
||||
//for mac users, should hide dock icon if close to tray
|
||||
if (isMac && isTrayOnClose) {
|
||||
app.dock?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
@@ -293,46 +313,60 @@ export class WindowService {
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
if (this.mainWindow.isMinimized()) {
|
||||
return this.mainWindow.restore()
|
||||
this.mainWindow.restore()
|
||||
return
|
||||
}
|
||||
//[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
|
||||
|
||||
// [Linux] Known Issue
|
||||
// setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland)会导致窗口进入"假弹出"状态
|
||||
// 因此在 Linux 环境下不执行这两行代码
|
||||
if (!isLinux) {
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(true)
|
||||
}
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
if (!isLinux) {
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(false)
|
||||
}
|
||||
} else {
|
||||
this.mainWindow = this.createMainWindow()
|
||||
this.mainWindow.focus()
|
||||
}
|
||||
}
|
||||
|
||||
public showMiniWindow() {
|
||||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
||||
|
||||
if (!enableQuickAssistant) {
|
||||
public toggleMainWindow() {
|
||||
// should not toggle main window when in full screen
|
||||
if (this.wasFullScreen) {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
}
|
||||
this.miniWindow.show()
|
||||
this.miniWindow.center()
|
||||
this.miniWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
this.showMainWindow()
|
||||
}
|
||||
|
||||
public createMiniWindow(isPreload: boolean = false): BrowserWindow {
|
||||
this.miniWindow = new BrowserWindow({
|
||||
width: 500,
|
||||
height: 520,
|
||||
show: true,
|
||||
width: 550,
|
||||
height: 400,
|
||||
minWidth: 350,
|
||||
minHeight: 380,
|
||||
maxWidth: 1024,
|
||||
maxHeight: 768,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'under-window',
|
||||
@@ -340,8 +374,13 @@ export class WindowService {
|
||||
center: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
resizable: true,
|
||||
useContentSize: true,
|
||||
...(isMac ? { type: 'panel' } : {}),
|
||||
skipTaskbar: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
@@ -350,8 +389,25 @@ 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', () => {
|
||||
this.miniWindow?.hide()
|
||||
if (!this.isPinnedMiniWindow) {
|
||||
this.hideMiniWindow()
|
||||
}
|
||||
})
|
||||
|
||||
this.miniWindow.on('closed', () => {
|
||||
@@ -359,14 +415,14 @@ export class WindowService {
|
||||
})
|
||||
|
||||
this.miniWindow.on('hide', () => {
|
||||
this.miniWindow?.webContents.send('hide-mini-window')
|
||||
this.miniWindow?.webContents.send(IpcChannel.HideMiniWindow)
|
||||
})
|
||||
|
||||
this.miniWindow.on('show', () => {
|
||||
this.miniWindow?.webContents.send('show-mini-window')
|
||||
this.miniWindow?.webContents.send(IpcChannel.ShowMiniWindow)
|
||||
})
|
||||
|
||||
ipcMain.on('miniwindow-reload', () => {
|
||||
ipcMain.on(IpcChannel.MiniWindowReload, () => {
|
||||
this.miniWindow?.reload()
|
||||
})
|
||||
|
||||
@@ -377,9 +433,48 @@ 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()
|
||||
}
|
||||
|
||||
@@ -388,11 +483,16 @@ export class WindowService {
|
||||
}
|
||||
|
||||
public toggleMiniWindow() {
|
||||
if (this.miniWindow) {
|
||||
this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
|
||||
} else {
|
||||
this.showMiniWindow()
|
||||
if (this.miniWindow && !this.miniWindow.isDestroyed() && this.miniWindow.isVisible()) {
|
||||
this.hideMiniWindow()
|
||||
return
|
||||
}
|
||||
|
||||
this.showMiniWindow()
|
||||
}
|
||||
|
||||
public setPinMiniWindow(isPinned) {
|
||||
this.isPinnedMiniWindow = isPinned
|
||||
}
|
||||
|
||||
public showSelectionMenu(bounds: { x: number; y: number }) {
|
||||
@@ -403,7 +503,6 @@ export class WindowService {
|
||||
}
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
const isMac = process.platform === 'darwin'
|
||||
|
||||
this.selectionMenuWindow = new BrowserWindow({
|
||||
width: 280,
|
||||
@@ -429,7 +528,7 @@ export class WindowService {
|
||||
// 点击其他地方时隐藏窗口
|
||||
this.selectionMenuWindow.on('blur', () => {
|
||||
this.selectionMenuWindow?.hide()
|
||||
this.miniWindow?.webContents.send('selection-action', {
|
||||
this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
|
||||
action: 'home',
|
||||
selectedText: this.lastSelectedText
|
||||
})
|
||||
@@ -447,12 +546,12 @@ export class WindowService {
|
||||
private setupSelectionMenuEvents() {
|
||||
if (!this.selectionMenuWindow) return
|
||||
|
||||
ipcMain.removeHandler('selection-menu:action')
|
||||
ipcMain.handle('selection-menu:action', (_, action) => {
|
||||
ipcMain.removeHandler(IpcChannel.SelectionMenu_Action)
|
||||
ipcMain.handle(IpcChannel.SelectionMenu_Action, (_, action) => {
|
||||
this.selectionMenuWindow?.hide()
|
||||
this.showMiniWindow()
|
||||
setTimeout(() => {
|
||||
this.miniWindow?.webContents.send('selection-action', {
|
||||
this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
|
||||
action,
|
||||
selectedText: this.lastSelectedText
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
@@ -74,3 +75,7 @@ export function getTempDir() {
|
||||
export function getFilesDir() {
|
||||
return path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
}
|
||||
|
||||
export function getConfigDir() {
|
||||
return path.join(os.homedir(), '.cherrystudio', 'config')
|
||||
}
|
||||
|
||||
@@ -42,3 +42,13 @@ 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +35,22 @@ export function runInstallScript(scriptPath: string): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBinaryPath(name: string): Promise<string> {
|
||||
let cmd = process.platform === 'win32' ? `${name}.exe` : name
|
||||
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)
|
||||
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const binariesDirExists = await fs.existsSync(binariesDir)
|
||||
cmd = binariesDirExists ? path.join(binariesDir, cmd) : name
|
||||
return cmd
|
||||
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
|
||||
}
|
||||
|
||||
export async function isBinaryExists(name: string): Promise<boolean> {
|
||||
|
||||
34
src/preload/index.d.ts
vendored
@@ -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,12 @@ 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
|
||||
reload: () => void
|
||||
clearCache: () => Promise<{ success: boolean; error?: string }>
|
||||
system: {
|
||||
@@ -42,6 +44,8 @@ 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>
|
||||
@@ -132,6 +136,7 @@ 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 }>
|
||||
@@ -141,17 +146,12 @@ declare global {
|
||||
openExternal: (url: string, options?: OpenExternalOptions) => Promise<void>
|
||||
}
|
||||
mcp: {
|
||||
// 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>
|
||||
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 }>
|
||||
}
|
||||
copilot: {
|
||||
getAuthMessage: (
|
||||
@@ -167,6 +167,14 @@ declare global {
|
||||
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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,82 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
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 = {
|
||||
getAppInfo: () => ipcRenderer.invoke('app:info'),
|
||||
reload: () => ipcRenderer.invoke('app:reload'),
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy),
|
||||
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
|
||||
showUpdateDialog: () => ipcRenderer.invoke('app:show-update-dialog'),
|
||||
setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', 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),
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||
clearCache: () => ipcRenderer.invoke('app:clear-cache'),
|
||||
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
|
||||
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke(IpcChannel.App_Proxy, proxy),
|
||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
||||
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive),
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
system: {
|
||||
getDeviceType: () => ipcRenderer.invoke('system:getDeviceType')
|
||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType)
|
||||
},
|
||||
zip: {
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
|
||||
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
|
||||
},
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string) =>
|
||||
ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
|
||||
restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath),
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath),
|
||||
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
|
||||
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)
|
||||
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_RestoreFromWebdav, webdavConfig),
|
||||
listWebdavFiles: (webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_ListWebdavFiles, webdavConfig),
|
||||
checkConnection: (webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
|
||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
|
||||
upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
|
||||
delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
|
||||
read: (fileId: string) => ipcRenderer.invoke('file:read', fileId),
|
||||
clear: () => ipcRenderer.invoke('file:clear'),
|
||||
get: (filePath: string) => ipcRenderer.invoke('file:get', filePath),
|
||||
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
|
||||
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data),
|
||||
open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options),
|
||||
openPath: (path: string) => ipcRenderer.invoke('file:openPath', path),
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
upload: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Upload, filePath),
|
||||
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
|
||||
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
||||
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
|
||||
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
create: (fileName: string) => ipcRenderer.invoke(IpcChannel.File_Create, fileName),
|
||||
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
|
||||
open: (options?: { decompress: boolean }) => ipcRenderer.invoke(IpcChannel.File_Open, options),
|
||||
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
|
||||
save: (path: string, content: string, options?: { compress: boolean }) =>
|
||||
ipcRenderer.invoke('file:save', path, content, options),
|
||||
selectFolder: () => ipcRenderer.invoke('file:selectFolder'),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
|
||||
download: (url: string) => ipcRenderer.invoke('file:download', url),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath),
|
||||
binaryFile: (fileId: string) => ipcRenderer.invoke('file:binaryFile', fileId)
|
||||
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
|
||||
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||
download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
binaryFile: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryFile, fileId)
|
||||
},
|
||||
fs: {
|
||||
read: (path: string) => ipcRenderer.invoke('fs:read', path)
|
||||
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
|
||||
},
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
|
||||
},
|
||||
openPath: (path: string) => ipcRenderer.invoke('open:path', path),
|
||||
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path),
|
||||
shortcuts: {
|
||||
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
|
||||
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
|
||||
},
|
||||
knowledgeBase: {
|
||||
create: (base: KnowledgeBaseParams) => ipcRenderer.invoke('knowledge-base:create', base),
|
||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke('knowledge-base:reset', base),
|
||||
delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id),
|
||||
create: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Create, base),
|
||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
||||
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
|
||||
add: ({
|
||||
base,
|
||||
item,
|
||||
@@ -75,72 +85,91 @@ const api = {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
}) => ipcRenderer.invoke('knowledge-base:add', { base, item, forceReload }),
|
||||
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload }),
|
||||
remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) =>
|
||||
ipcRenderer.invoke('knowledge-base:remove', { uniqueId, uniqueIds, base }),
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
|
||||
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
|
||||
ipcRenderer.invoke('knowledge-base:search', { search, base }),
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }),
|
||||
rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) =>
|
||||
ipcRenderer.invoke('knowledge-base:rerank', { search, base, results })
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results })
|
||||
},
|
||||
window: {
|
||||
setMinimumSize: (width: number, height: number) => ipcRenderer.invoke('window:set-minimum-size', width, height),
|
||||
resetMinimumSize: () => ipcRenderer.invoke('window:reset-minimum-size')
|
||||
setMinimumSize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
|
||||
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
|
||||
},
|
||||
gemini: {
|
||||
uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke('gemini:upload-file', file, apiKey),
|
||||
base64File: (file: FileType) => ipcRenderer.invoke('gemini:base64-file', file),
|
||||
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke('gemini:retrieve-file', file, apiKey),
|
||||
listFiles: (apiKey: string) => ipcRenderer.invoke('gemini:list-files', apiKey),
|
||||
deleteFile: (apiKey: string, fileId: string) => ipcRenderer.invoke('gemini:delete-file', apiKey, fileId)
|
||||
uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, apiKey),
|
||||
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
|
||||
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
|
||||
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
|
||||
deleteFile: (apiKey: string, fileId: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, apiKey, fileId)
|
||||
},
|
||||
selectionMenu: {
|
||||
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
|
||||
action: (action: string) => ipcRenderer.invoke(IpcChannel.SelectionMenu_Action, action)
|
||||
},
|
||||
config: {
|
||||
set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value),
|
||||
get: (key: string) => ipcRenderer.invoke('config:get', key)
|
||||
set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
|
||||
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
|
||||
},
|
||||
miniWindow: {
|
||||
show: () => ipcRenderer.invoke('miniwindow:show'),
|
||||
hide: () => ipcRenderer.invoke('miniwindow:hide'),
|
||||
close: () => ipcRenderer.invoke('miniwindow:close'),
|
||||
toggle: () => ipcRenderer.invoke('miniwindow:toggle')
|
||||
show: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Show),
|
||||
hide: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Hide),
|
||||
close: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Close),
|
||||
toggle: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Toggle),
|
||||
setPin: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.MiniWindow_SetPin, isPinned)
|
||||
},
|
||||
aes: {
|
||||
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),
|
||||
encrypt: (text: string, secretKey: string, iv: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Aes_Encrypt, text, secretKey, iv),
|
||||
decrypt: (encryptedData: string, iv: string, secretKey: string) =>
|
||||
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
|
||||
ipcRenderer.invoke(IpcChannel.Aes_Decrypt, encryptedData, iv, secretKey)
|
||||
},
|
||||
mcp: {
|
||||
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')
|
||||
removeServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RemoveServer, server),
|
||||
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
|
||||
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
|
||||
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server),
|
||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
|
||||
},
|
||||
shell: {
|
||||
openExternal: shell.openExternal
|
||||
},
|
||||
copilot: {
|
||||
getAuthMessage: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-auth-message', headers),
|
||||
getAuthMessage: (headers?: Record<string, string>) =>
|
||||
ipcRenderer.invoke(IpcChannel.Copilot_GetAuthMessage, headers),
|
||||
getCopilotToken: (device_code: string, headers?: Record<string, string>) =>
|
||||
ipcRenderer.invoke('copilot:get-copilot-token', device_code, headers),
|
||||
saveCopilotToken: (access_token: string) => ipcRenderer.invoke('copilot:save-copilot-token', access_token),
|
||||
getToken: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-token', headers),
|
||||
logout: () => ipcRenderer.invoke('copilot:logout'),
|
||||
getUser: (token: string) => ipcRenderer.invoke('copilot:get-user', token)
|
||||
ipcRenderer.invoke(IpcChannel.Copilot_GetCopilotToken, device_code, headers),
|
||||
saveCopilotToken: (access_token: string) => ipcRenderer.invoke(IpcChannel.Copilot_SaveCopilotToken, access_token),
|
||||
getToken: (headers?: Record<string, string>) => ipcRenderer.invoke(IpcChannel.Copilot_GetToken, headers),
|
||||
logout: () => ipcRenderer.invoke(IpcChannel.Copilot_Logout),
|
||||
getUser: (token: string) => ipcRenderer.invoke(IpcChannel.Copilot_GetUser, token)
|
||||
},
|
||||
|
||||
// Binary related APIs
|
||||
isBinaryExist: (name: string) => ipcRenderer.invoke('app:is-binary-exist', name),
|
||||
getBinaryPath: (name: string) => ipcRenderer.invoke('app:get-binary-path', name),
|
||||
installUVBinary: () => ipcRenderer.invoke('app:install-uv-binary'),
|
||||
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary')
|
||||
isBinaryExist: (name: string) => ipcRenderer.invoke(IpcChannel.App_IsBinaryExist, name),
|
||||
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
|
||||
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
|
||||
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
|
||||
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(IpcChannel.Nutstore_GetSsoUrl),
|
||||
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
|
||||
getDirectoryContents: (token: string, path: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
@@ -150,6 +179,11 @@ if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
contextBridge.exposeInMainWorld('obsidian', {
|
||||
getVaults: () => ipcRenderer.invoke(IpcChannel.Obsidian_GetVaults),
|
||||
getFolders: (vaultName: string) => ipcRenderer.invoke(IpcChannel.Obsidian_GetFiles, vaultName),
|
||||
getFiles: (vaultName: string) => ipcRenderer.invoke(IpcChannel.Obsidian_GetFiles, vaultName)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,7 +21,7 @@ import PaintingsPage from './pages/paintings/PaintingsPage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
|
||||
function App(): JSX.Element {
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<StyleSheetManager>
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/dangbei.jpg
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src/renderer/src/assets/images/models/voyageai.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/renderer/src/assets/images/providers/qiniu.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/renderer/src/assets/images/providers/voyageai.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
18
src/renderer/src/assets/styles/animation.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@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;
|
||||
}
|
||||
@@ -192,3 +192,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu .ant-dropdown-menu-sub {
|
||||
max-height: 350px;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@use './ant.scss';
|
||||
@use './scrollbar.scss';
|
||||
@use './container.scss';
|
||||
@use './animation.scss';
|
||||
@import '../fonts/icon-fonts/iconfont.css';
|
||||
@import '../fonts/ubuntu/ubuntu.css';
|
||||
|
||||
@@ -18,7 +19,7 @@
|
||||
--color-gray-2: #414853;
|
||||
--color-gray-3: #32363f;
|
||||
|
||||
--color-text-1: rgba(255, 255, 245, 0.86);
|
||||
--color-text-1: rgba(255, 255, 245, 0.9);
|
||||
--color-text-2: rgba(235, 235, 245, 0.6);
|
||||
--color-text-3: rgba(235, 235, 245, 0.38);
|
||||
|
||||
@@ -35,7 +36,7 @@
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff15;
|
||||
--color-border: #ffffff19;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
@@ -79,7 +80,7 @@ body {
|
||||
|
||||
body[theme-mode='light'] {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: #f2f2f2;
|
||||
--color-white-soft: rgba(0, 0, 0, 0.04);
|
||||
--color-white-mute: #eee;
|
||||
|
||||
--color-black: #1b1b1f;
|
||||
@@ -107,7 +108,7 @@ body[theme-mode='light'] {
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #00000099;
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000015;
|
||||
--color-border: #00000019;
|
||||
--color-border-soft: #00000010;
|
||||
--color-border-mute: #00000005;
|
||||
--color-error: #f44336;
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
h6 {
|
||||
margin: 1em 0 1em 0;
|
||||
font-weight: 800;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -170,8 +171,9 @@
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -294,3 +296,11 @@
|
||||
emoji-picker {
|
||||
--border-size: 0;
|
||||
}
|
||||
|
||||
.katex-display {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ interface Props {
|
||||
model: Model
|
||||
size: number
|
||||
props?: AvatarProps
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ModelAvatar: FC<Props> = ({ model, size, props }) => {
|
||||
const ModelAvatar: FC<Props> = ({ model, size, props, className }) => {
|
||||
return (
|
||||
<Avatar
|
||||
src={getModelLogo(model?.id || '')}
|
||||
@@ -23,7 +24,8 @@ const ModelAvatar: FC<Props> = ({ model, size, props }) => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
{...props}>
|
||||
{...props}
|
||||
className={className}>
|
||||
{first(model?.name)}
|
||||
</Avatar>
|
||||
)
|
||||
|
||||
62
src/renderer/src/components/CustomCollapse.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Collapse } from 'antd'
|
||||
import { FC, memo } from 'react'
|
||||
|
||||
interface CustomCollapseProps {
|
||||
label: React.ReactNode
|
||||
extra: React.ReactNode
|
||||
children: React.ReactNode
|
||||
destroyInactivePanel?: boolean
|
||||
defaultActiveKey?: string[]
|
||||
activeKey?: string[]
|
||||
collapsible?: 'header' | 'icon' | 'disabled'
|
||||
}
|
||||
|
||||
const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
label,
|
||||
extra,
|
||||
children,
|
||||
destroyInactivePanel = false,
|
||||
defaultActiveKey = ['1'],
|
||||
activeKey,
|
||||
collapsible = undefined
|
||||
}) => {
|
||||
const CollapseStyle = {
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: '0.5px solid var(--color-border)'
|
||||
}
|
||||
const CollapseItemStyles = {
|
||||
header: {
|
||||
padding: '8px 16px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--color-background-soft)',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
},
|
||||
body: {
|
||||
borderTop: '0.5px solid var(--color-border)'
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Collapse
|
||||
bordered={false}
|
||||
style={CollapseStyle}
|
||||
defaultActiveKey={defaultActiveKey}
|
||||
activeKey={activeKey}
|
||||
destroyInactivePanel={destroyInactivePanel}
|
||||
collapsible={collapsible}
|
||||
items={[
|
||||
{
|
||||
styles: CollapseItemStyles,
|
||||
key: '1',
|
||||
label,
|
||||
extra,
|
||||
children
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomCollapse)
|
||||
67
src/renderer/src/components/CustomTag.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { CloseOutlined } from '@ant-design/icons'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CustomTagProps {
|
||||
icon?: React.ReactNode
|
||||
children?: React.ReactNode | string
|
||||
color: string
|
||||
size?: number
|
||||
tooltip?: string
|
||||
closable?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const CustomTag: FC<CustomTagProps> = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => {
|
||||
return (
|
||||
<Tooltip title={tooltip} placement="top">
|
||||
<Tag $color={color} $size={size} $closable={closable}>
|
||||
{icon && icon} {children}
|
||||
{closable && <CloseIcon $size={size} $color={color} onClick={onClose} />}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomTag
|
||||
|
||||
const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: ${({ $size }) => $size / 3}px ${({ $size }) => $size * 0.8}px;
|
||||
padding-right: ${({ $closable, $size }) => ($closable ? $size * 1.8 : $size * 0.8)}px;
|
||||
border-radius: 99px;
|
||||
color: ${({ $color }) => $color};
|
||||
background-color: ${({ $color }) => $color + '20'};
|
||||
font-size: ${({ $size }) => $size}px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
.iconfont {
|
||||
font-size: ${({ $size }) => $size}px;
|
||||
color: ${({ $color }) => $color};
|
||||
}
|
||||
`
|
||||
|
||||
const CloseIcon = styled(CloseOutlined)<{ $size: number; $color: string }>`
|
||||
cursor: pointer;
|
||||
font-size: ${({ $size }) => $size * 0.8}px;
|
||||
color: ${({ $color }) => $color};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: ${({ $size }) => $size * 0.2}px;
|
||||
top: ${({ $size }) => $size * 0.2}px;
|
||||
bottom: ${({ $size }) => $size * 0.2}px;
|
||||
border-radius: 99px;
|
||||
transition: all 0.2s ease;
|
||||
aspect-ratio: 1;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
background-color: #da8a8a;
|
||||
color: #ffffff;
|
||||
}
|
||||
`
|
||||
36
src/renderer/src/components/DividerWithText.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { CSSProperties } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface DividerWithTextProps {
|
||||
text: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
const DividerWithText: React.FC<DividerWithTextProps> = ({ text, style }) => {
|
||||
return (
|
||||
<DividerContainer style={style}>
|
||||
<DividerText>{text}</DividerText>
|
||||
<DividerLine />
|
||||
</DividerContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const DividerContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0px 0;
|
||||
`
|
||||
|
||||
const DividerText = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const DividerLine = styled.div`
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
`
|
||||
|
||||
export default DividerWithText
|
||||