Compare commits
96 Commits
feat/varia
...
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d46308915 | ||
|
|
55e5a5bf58 | ||
|
|
24d6d146c0 | ||
|
|
978c3ea3cf | ||
|
|
a9eb235c43 | ||
|
|
e0a47de8f7 | ||
|
|
78a4696327 | ||
|
|
57fa0aad38 | ||
|
|
2e0251aed7 | ||
|
|
afd1381d7f | ||
|
|
c3b5cbee8f | ||
|
|
e1f255048e | ||
|
|
8a579be4c1 | ||
|
|
efcffbaa30 | ||
|
|
f9c6bddae5 | ||
|
|
5e086a1686 | ||
|
|
0db4c8b475 | ||
|
|
d5fcef39d3 | ||
|
|
5c44f71684 | ||
|
|
3462be2a2a | ||
|
|
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 |
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
|
||||
|
||||
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(),
|
||||
2
.yarn/releases/yarn-4.6.0.cjs
vendored
@@ -84,7 +84,7 @@ https://docs.cherry-ai.com
|
||||
|
||||
# 🌈 Theme
|
||||
|
||||
- Theme Gallery: https://cherrycss.com
|
||||
- Theme Gallery: https://cherrycss.com
|
||||
- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
|
||||
|
||||
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 |
@@ -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,7 +85,9 @@ afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
小程序支持多开
|
||||
支持 GPT-4o 图像生成
|
||||
修复 MCP 服务器无法使用问题
|
||||
修复升级导致旧版本数据丢失问题
|
||||
增加对 grok-3 和 Grok-3-mini 的支持
|
||||
助手支持使用拼音排序
|
||||
网络搜索增加 Baidu, Google, Bing 支持(免费使用)
|
||||
网络搜索增加 uBlacklist 订阅
|
||||
快速面板 (QuickPanel) 进行性能优化
|
||||
解决 mcp 依赖工具下载速度问题
|
||||
|
||||
@@ -42,7 +42,12 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve('packages/shared')
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
plugins: [
|
||||
@@ -70,7 +75,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['chunk-PZ64DZKH.js', 'chunk-JMKENWIY.js', 'chunk-UXYB6GHG.js', 'chunk-ALDIEZMG.js', 'chunk-4X6ZJEXY.js']
|
||||
exclude: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.1.17",
|
||||
"version": "1.2.2",
|
||||
"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",
|
||||
@@ -65,11 +66,15 @@
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@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",
|
||||
"color": "^5.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"electron-log": "^5.1.5",
|
||||
"electron-store": "^8.2.0",
|
||||
@@ -79,10 +84,15 @@
|
||||
"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",
|
||||
"officeparser": "^4.1.1",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.4.0",
|
||||
"webdav": "^5.8.0",
|
||||
"zipread": "^1.3.3"
|
||||
@@ -91,6 +101,7 @@
|
||||
"@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",
|
||||
@@ -103,12 +114,13 @@
|
||||
"@google/genai": "^0.4.0",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||
"@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",
|
||||
@@ -120,6 +132,7 @@
|
||||
"@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",
|
||||
@@ -145,9 +158,10 @@
|
||||
"i18next": "^23.11.5",
|
||||
"lint-staged": "^15.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"mime": "^4.0.4",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
|
||||
"openai": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rc-virtual-list": "^3.18.5",
|
||||
@@ -171,7 +185,7 @@
|
||||
"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",
|
||||
@@ -184,7 +198,9 @@
|
||||
"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",
|
||||
|
||||
155
packages/shared/IpcChannel.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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',
|
||||
|
||||
// Search Window
|
||||
SearchWindow_Open = 'search-window:open',
|
||||
SearchWindow_Close = 'search-window:close',
|
||||
SearchWindow_OpenUrl = 'search-window:open-url'
|
||||
}
|
||||
@@ -157,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,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>
|
||||
@@ -6,8 +6,8 @@ const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading bun binaries
|
||||
const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download'
|
||||
const DEFAULT_BUN_VERSION = '1.2.5' // Default fallback version
|
||||
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
|
||||
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const BUN_PACKAGES = {
|
||||
|
||||
@@ -7,8 +7,8 @@ const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading uv binaries
|
||||
const UV_RELEASE_BASE_URL = 'https://github.com/astral-sh/uv/releases/download'
|
||||
const DEFAULT_UV_VERSION = '0.6.6'
|
||||
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
|
||||
const DEFAULT_UV_VERSION = '0.6.14'
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const UV_PACKAGES = {
|
||||
|
||||
@@ -18,28 +18,48 @@ exports.default = async function (context) {
|
||||
'node_modules'
|
||||
)
|
||||
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
}
|
||||
|
||||
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) {
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
||||
}
|
||||
if (arch === Arch.x64) {
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeDifferentArchNodeFiles(nodeModulesPath, packageName, arch) {
|
||||
/**
|
||||
* 使用指定架构的 node_modules 文件
|
||||
* @param {*} nodeModulesPath
|
||||
* @param {*} packageName
|
||||
* @param {*} arch
|
||||
* @returns
|
||||
*/
|
||||
function keepPackageNodeFiles(nodeModulesPath, packageName, arch) {
|
||||
const modulePath = path.join(nodeModulesPath, packageName)
|
||||
|
||||
if (!fs.existsSync(modulePath)) {
|
||||
console.log(`[After Pack] Directory does not exist: ${modulePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const dirs = fs.readdirSync(modulePath)
|
||||
dirs
|
||||
.filter((dir) => !arch.includes(dir))
|
||||
.forEach((dir) => {
|
||||
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
|
||||
console.log(`Removed dir: ${dir}`, arch)
|
||||
console.log(`[After Pack] Removed dir: ${dir}`, arch)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
@@ -52,7 +55,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
.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'
|
||||
})
|
||||
})
|
||||
@@ -91,6 +94,15 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.isQuitting = true
|
||||
})
|
||||
|
||||
app.on('will-quit', async () => {
|
||||
// event.preventDefault()
|
||||
try {
|
||||
await mcpService.cleanup()
|
||||
} catch (error) {
|
||||
Logger.error('Error cleaning up MCP service:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app"s specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
declare function decrypt(app: string, s: string): string;
|
||||
declare function decrypt(app: string, s: string): string
|
||||
|
||||
interface Secret {
|
||||
app: string;
|
||||
app: string
|
||||
}
|
||||
declare function createOAuthUrl(secret: Secret): string;
|
||||
declare function createOAuthUrl(secret: Secret): string
|
||||
|
||||
export { type Secret, createOAuthUrl, decrypt };
|
||||
export { type Secret, createOAuthUrl, decrypt }
|
||||
|
||||
208
src/main/ipc.ts
@@ -2,6 +2,7 @@ import fs from 'node:fs'
|
||||
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
@@ -20,12 +21,13 @@ import mcpService from './services/MCPService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
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()
|
||||
@@ -36,17 +38,18 @@ 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') {
|
||||
@@ -60,19 +63,19 @@ 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('app:set-launch-on-boot', (_, openAtLogin: boolean) => {
|
||||
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) {
|
||||
@@ -81,32 +84,32 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// launch to tray
|
||||
ipcMain.handle('app:set-launch-to-tray', (_, isActive: boolean) => {
|
||||
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)
|
||||
})
|
||||
|
||||
// to tray on close
|
||||
ipcMain.handle('app:set-tray-on-close', (_, isActive: boolean) => {
|
||||
ipcMain.handle(IpcChannel.App_SetTrayOnClose, (_, isActive: boolean) => {
|
||||
configManager.setTrayOnClose(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
|
||||
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
|
||||
|
||||
ipcMain.handle('config:set', (_, key: string, value: any) => {
|
||||
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)
|
||||
@@ -117,7 +120,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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -126,7 +129,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 {
|
||||
@@ -148,7 +151,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,
|
||||
@@ -157,62 +160,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('backup:checkConnection', backupManager.checkConnection)
|
||||
ipcMain.handle('backup:createDirectory', backupManager.createDirectory)
|
||||
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) {
|
||||
@@ -222,20 +213,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) {
|
||||
@@ -244,59 +235,72 @@ 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('miniwindow:set-pin', (_, isPinned) => windowService.setPinMiniWindow(isPinned))
|
||||
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.handle('mcp:remove-server', mcpService.removeServer)
|
||||
ipcMain.handle('mcp:restart-server', mcpService.restartServer)
|
||||
ipcMain.handle('mcp:stop-server', mcpService.stopServer)
|
||||
ipcMain.handle('mcp:list-tools', mcpService.listTools)
|
||||
ipcMain.handle('mcp:call-tool', mcpService.callTool)
|
||||
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
|
||||
ipcMain.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)
|
||||
|
||||
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'))
|
||||
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('obsidian:get-vaults', () => {
|
||||
ipcMain.handle(IpcChannel.Obsidian_GetVaults, () => {
|
||||
return obsidianVaultService.getVaults()
|
||||
})
|
||||
|
||||
ipcMain.handle('obsidian:get-files', (_event, vaultName) => {
|
||||
ipcMain.handle(IpcChannel.Obsidian_GetFiles, (_event, vaultName) => {
|
||||
return obsidianVaultService.getFilesByVaultName(vaultName)
|
||||
})
|
||||
|
||||
// nutstore
|
||||
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl)
|
||||
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token))
|
||||
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) =>
|
||||
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)
|
||||
)
|
||||
|
||||
// search window
|
||||
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => {
|
||||
await searchService.openSearchWindow(uid)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => {
|
||||
await searchService.closeSearchWindow(uid)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, async (_, uid: string, url: string) => {
|
||||
return await searchService.openUrlInSearchWindow(uid, url)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -3,14 +3,60 @@ 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}`,
|
||||
@@ -18,7 +64,7 @@ export default abstract class BaseReranker {
|
||||
}
|
||||
}
|
||||
|
||||
public formatErrorMessage(url: string, error: any, requestBody: any) {
|
||||
protected formatErrorMessage(url: string, error: any, requestBody: any) {
|
||||
const errorDetails = {
|
||||
url: url,
|
||||
message: error.message,
|
||||
|
||||
@@ -10,16 +10,7 @@ export default class JinaReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
|
||||
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`
|
||||
}
|
||||
|
||||
const url = `${baseURL}/rerank`
|
||||
const url = this.getRerankUrl()
|
||||
|
||||
const requestBody = {
|
||||
model: this.base.rerankModel,
|
||||
@@ -32,23 +23,9 @@ 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)
|
||||
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}`)
|
||||
}
|
||||
|
||||
@@ -10,16 +10,7 @@ export default class SiliconFlowReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
|
||||
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`
|
||||
}
|
||||
|
||||
const url = `${baseURL}/rerank`
|
||||
const url = this.getRerankUrl()
|
||||
|
||||
const requestBody = {
|
||||
model: this.base.rerankModel,
|
||||
@@ -34,20 +25,7 @@ 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 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)
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
} catch (error: any) {
|
||||
const errorDetails = this.formatErrorMessage(url, error, requestBody)
|
||||
|
||||
|
||||
@@ -10,15 +10,7 @@ export default class VoyageReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
|
||||
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
|
||||
? this.base.rerankBaseURL.slice(0, -1)
|
||||
: this.base.rerankBaseURL
|
||||
|
||||
if (baseURL && !baseURL.endsWith('/v1')) {
|
||||
baseURL = `${baseURL}/v1`
|
||||
}
|
||||
|
||||
const url = `${baseURL}/rerank`
|
||||
const url = this.getRerankUrl()
|
||||
|
||||
const requestBody = {
|
||||
model: this.base.rerankModel,
|
||||
@@ -37,21 +29,7 @@ export default class VoyageReranker extends BaseReranker {
|
||||
})
|
||||
|
||||
const rerankResults = data.data
|
||||
|
||||
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
|
||||
|
||||
return searchResults
|
||||
.map((doc: ExtractChunkData, index: number) => {
|
||||
const score = resultMap.get(index)
|
||||
if (score === undefined) return undefined
|
||||
|
||||
return {
|
||||
...doc,
|
||||
score
|
||||
}
|
||||
})
|
||||
.filter((doc): doc is ExtractChunkData => doc !== undefined)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
} catch (error: any) {
|
||||
const errorDetails = this.formatErrorMessage(url, error, requestBody)
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -79,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)
|
||||
}
|
||||
|
||||
@@ -139,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,54 +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.store.get('launchToTray', false)
|
||||
return !!this.get(ConfigKeys.LaunchToTray, false)
|
||||
}
|
||||
|
||||
setLaunchToTray(value: boolean) {
|
||||
this.store.set('launchToTray', value)
|
||||
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.store.get('trayOnClose', true)
|
||||
return !!this.get(ConfigKeys.TrayOnClose, true)
|
||||
}
|
||||
|
||||
setTrayOnClose(value: boolean) {
|
||||
this.store.set('trayOnClose', value)
|
||||
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) {
|
||||
@@ -89,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) {
|
||||
|
||||
@@ -26,7 +26,9 @@ import { addFileLoader } from '@main/loader'
|
||||
import Reranker from '@main/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
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'
|
||||
@@ -91,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: '' }
|
||||
|
||||
@@ -194,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
|
||||
})
|
||||
@@ -270,7 +272,7 @@ class KnowledgeService {
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: 1024 * 1024 * 2 }
|
||||
evaluateTaskWorkload: { workload: 2 * MB }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
@@ -309,7 +311,7 @@ class KnowledgeService {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: 1024 * 1024 * 20 }
|
||||
evaluateTaskWorkload: { workload: 20 * MB }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
|
||||
@@ -2,17 +2,20 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
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 { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
|
||||
|
||||
class McpService {
|
||||
private clients: Map<string, Client> = new Map()
|
||||
@@ -36,6 +39,7 @@ class McpService {
|
||||
this.removeServer = this.removeServer.bind(this)
|
||||
this.restartServer = this.restartServer.bind(this)
|
||||
this.stopServer = this.stopServer.bind(this)
|
||||
this.cleanup = this.cleanup.bind(this)
|
||||
}
|
||||
|
||||
async initClient(server: MCPServer): Promise<Client> {
|
||||
@@ -44,29 +48,56 @@ class McpService {
|
||||
// Check if we already have a client for this server configuration
|
||||
const existingClient = this.clients.get(serverKey)
|
||||
if (existingClient) {
|
||||
// Check if the existing client is still connected
|
||||
const pingResult = await existingClient.ping()
|
||||
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
|
||||
// If the ping fails, remove the client from the cache
|
||||
// and create a new one
|
||||
if (!pingResult) {
|
||||
try {
|
||||
// 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)
|
||||
} else {
|
||||
return existingClient
|
||||
}
|
||||
}
|
||||
|
||||
// Create new client instance for each connection
|
||||
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
|
||||
|
||||
const args = [...(server.args || [])]
|
||||
|
||||
let transport: StdioClientTransport | SSEClientTransport
|
||||
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
|
||||
try {
|
||||
// Create appropriate transport based on configuration
|
||||
if (server.baseUrl) {
|
||||
transport = new SSEClientTransport(new URL(server.baseUrl))
|
||||
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
|
||||
|
||||
@@ -90,10 +121,10 @@ class McpService {
|
||||
}
|
||||
|
||||
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
|
||||
if (server.name === 'mcp-auto-install') {
|
||||
if (server.name.includes('mcp-auto-install')) {
|
||||
const binPath = await getBinaryPath()
|
||||
makeSureDirExists(binPath)
|
||||
server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json')
|
||||
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
|
||||
}
|
||||
}
|
||||
} else if (server.command === 'uvx' || server.command === 'uv') {
|
||||
@@ -117,8 +148,12 @@ class McpService {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: this.getEnhancedPath(process.env.PATH || ''),
|
||||
...server.env
|
||||
}
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
transport.stderr?.on('data', (data) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
} else {
|
||||
throw new Error('Either baseUrl or command must be provided')
|
||||
}
|
||||
@@ -171,6 +206,16 @@ class McpService {
|
||||
await this.initClient(server)
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
for (const [key] of this.clients) {
|
||||
try {
|
||||
await this.closeClient(key)
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to close client: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||
const client = await this.initClient(server)
|
||||
const serverKey = this.getServerKey(server)
|
||||
@@ -290,4 +335,5 @@ class McpService {
|
||||
}
|
||||
}
|
||||
|
||||
export default new McpService()
|
||||
const mcpService = new McpService()
|
||||
export default 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
82
src/main/services/SearchService.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
export class SearchService {
|
||||
private static instance: SearchService | null = null
|
||||
private searchWindows: Record<string, BrowserWindow> = {}
|
||||
public static getInstance(): SearchService {
|
||||
if (!SearchService.instance) {
|
||||
SearchService.instance = new SearchService()
|
||||
}
|
||||
return SearchService.instance
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize the service
|
||||
}
|
||||
|
||||
private async createNewSearchWindow(uid: string): Promise<BrowserWindow> {
|
||||
const newWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
devTools: is.dev
|
||||
}
|
||||
})
|
||||
newWindow.webContents.session.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
const headers = {
|
||||
...details.requestHeaders,
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
this.searchWindows[uid] = newWindow
|
||||
newWindow.on('closed', () => {
|
||||
delete this.searchWindows[uid]
|
||||
})
|
||||
return newWindow
|
||||
}
|
||||
|
||||
public async openSearchWindow(uid: string): Promise<void> {
|
||||
await this.createNewSearchWindow(uid)
|
||||
}
|
||||
|
||||
public async closeSearchWindow(uid: string): Promise<void> {
|
||||
const window = this.searchWindows[uid]
|
||||
if (window) {
|
||||
window.close()
|
||||
delete this.searchWindows[uid]
|
||||
}
|
||||
}
|
||||
|
||||
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
|
||||
let window = this.searchWindows[uid]
|
||||
if (window) {
|
||||
await window.loadURL(url)
|
||||
} else {
|
||||
window = await this.createNewSearchWindow(uid)
|
||||
await window.loadURL(url)
|
||||
}
|
||||
|
||||
// Get the page content after loading the URL
|
||||
// Wait for the page to fully load before getting the content
|
||||
await new Promise<void>((resolve) => {
|
||||
const loadTimeout = setTimeout(() => resolve(), 10000) // 10 second timeout
|
||||
window.webContents.once('did-finish-load', () => {
|
||||
clearTimeout(loadTimeout)
|
||||
// Small delay to ensure JavaScript has executed
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
})
|
||||
|
||||
// Get the page content after ensuring it's fully loaded
|
||||
const content = await window.webContents.executeJavaScript('document.documentElement.outerHTML')
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
export const searchService = SearchService.getInstance()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
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'
|
||||
@@ -16,7 +17,6 @@ export class WindowService {
|
||||
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
|
||||
@@ -40,7 +40,8 @@ export class WindowService {
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1080,
|
||||
defaultHeight: 670
|
||||
defaultHeight: 670,
|
||||
fullScreen: false
|
||||
})
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
@@ -52,7 +53,7 @@ export class WindowService {
|
||||
height: mainWindowState.height,
|
||||
minWidth: 1080,
|
||||
minHeight: 600,
|
||||
show: false, // 初始不显示
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'sidebar',
|
||||
@@ -82,36 +83,6 @@ export class WindowService {
|
||||
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)
|
||||
|
||||
@@ -167,13 +138,11 @@ export class WindowService {
|
||||
|
||||
// 处理全屏相关事件
|
||||
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
|
||||
@@ -304,22 +273,14 @@ export class WindowService {
|
||||
}
|
||||
|
||||
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
|
||||
// 如果是Windows或Linux,且处于全屏状态,则退出应用
|
||||
if (this.wasFullScreen) {
|
||||
if (isWin || isLinux) {
|
||||
return app.quit()
|
||||
} else {
|
||||
event.preventDefault()
|
||||
mainWindow.setFullScreen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
mainWindow.hide()
|
||||
|
||||
//for mac users, should hide dock icon if close to tray
|
||||
app.dock?.hide()
|
||||
if (isMac && isTrayOnClose) {
|
||||
app.dock?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
@@ -343,13 +304,34 @@ export class WindowService {
|
||||
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
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(true)
|
||||
|
||||
/**
|
||||
* About setVisibleOnAllWorkspaces
|
||||
*
|
||||
* [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)
|
||||
}
|
||||
|
||||
//[macOS] After being closed in fullscreen, the fullscreen behavior will become strange when window shows again
|
||||
// So we need to set it to FALSE explicitly.
|
||||
// althougle other platforms don't have the issue, but it's a good practice to do so
|
||||
if (this.mainWindow.isFullScreen()) {
|
||||
this.mainWindow.setFullScreen(false)
|
||||
}
|
||||
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(false)
|
||||
if (!isLinux) {
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(false)
|
||||
}
|
||||
} else {
|
||||
this.mainWindow = this.createMainWindow()
|
||||
}
|
||||
@@ -357,7 +339,9 @@ export class WindowService {
|
||||
|
||||
public toggleMainWindow() {
|
||||
// should not toggle main window when in full screen
|
||||
if (this.wasFullScreen) {
|
||||
// but if the main window is close to tray when it's in full screen, we can show it again
|
||||
// (it's a bug in macos, because we can close the window when it's in full screen, and the state will be remained)
|
||||
if (this.mainWindow?.isFullScreen() && this.mainWindow?.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -411,7 +395,8 @@ export class WindowService {
|
||||
//miniWindow should show in current desktop
|
||||
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||
//make miniWindow always on top of fullscreen apps with level set
|
||||
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
|
||||
//[mac] level higher than 'floating' will cover the pinyin input method
|
||||
this.miniWindow.setAlwaysOnTop(true, 'floating')
|
||||
|
||||
this.miniWindow.on('ready-to-show', () => {
|
||||
if (isPreload) {
|
||||
@@ -434,14 +419,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()
|
||||
})
|
||||
|
||||
@@ -547,7 +532,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
|
||||
})
|
||||
@@ -565,12 +550,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')
|
||||
}
|
||||
|
||||
6
src/preload/index.d.ts
vendored
@@ -29,7 +29,6 @@ declare global {
|
||||
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: {
|
||||
@@ -176,6 +175,11 @@ declare global {
|
||||
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
|
||||
getDirectoryContents: (token: string, path: string) => Promise<any>
|
||||
}
|
||||
searchService: {
|
||||
openSearchWindow: (uid: string) => Promise<string>
|
||||
closeSearchWindow: (uid: string) => Promise<string>
|
||||
openUrlInSearchWindow: (uid: string, url: string) => Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,82 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
|
||||
import { 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),
|
||||
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-on-boot', isActive),
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-to-tray', isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke('app:set-tray-on-close', isActive),
|
||||
restartTray: () => ipcRenderer.invoke('app:restart-tray'),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
|
||||
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),
|
||||
checkConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:checkConnection', 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('backup:createDirectory', webdavConfig, path, options)
|
||||
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,
|
||||
@@ -82,71 +85,74 @@ 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'),
|
||||
setPin: (isPinned: boolean) => ipcRenderer.invoke('miniwindow:set-pin', isPinned)
|
||||
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: {
|
||||
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server),
|
||||
restartServer: (server: MCPServer) => ipcRenderer.invoke('mcp:restart-server', server),
|
||||
stopServer: (server: MCPServer) => ipcRenderer.invoke('mcp:stop-server', server),
|
||||
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server),
|
||||
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('mcp:call-tool', { server, name, args }),
|
||||
getInstallInfo: () => ipcRenderer.invoke('mcp:get-install-info')
|
||||
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 }) => {
|
||||
@@ -159,10 +165,15 @@ const api = {
|
||||
}
|
||||
},
|
||||
nutstore: {
|
||||
getSSOUrl: () => ipcRenderer.invoke('nutstore:get-sso-url'),
|
||||
decryptToken: (token: string) => ipcRenderer.invoke('nutstore:decrypt-token', token),
|
||||
getSSOUrl: () => ipcRenderer.invoke(IpcChannel.Nutstore_GetSsoUrl),
|
||||
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
|
||||
getDirectoryContents: (token: string, path: string) =>
|
||||
ipcRenderer.invoke('nutstore:get-directory-contents', token, path)
|
||||
ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path)
|
||||
},
|
||||
searchService: {
|
||||
openSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid),
|
||||
closeSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Close, uid),
|
||||
openUrlInSearchWindow: (uid: string, url: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_OpenUrl, uid, url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +185,9 @@ if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
contextBridge.exposeInMainWorld('obsidian', {
|
||||
getVaults: () => ipcRenderer.invoke('obsidian:get-vaults'),
|
||||
getFolders: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName),
|
||||
getFiles: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName)
|
||||
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,42 +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>
|
||||
<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>
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/dangbei.jpg
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src/renderer/src/assets/images/providers/qiniu.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
@@ -36,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;
|
||||
@@ -80,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;
|
||||
@@ -108,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 {
|
||||
@@ -295,10 +297,10 @@ emoji-picker {
|
||||
--border-size: 0;
|
||||
}
|
||||
|
||||
.katex-display{
|
||||
.katex-display {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
mjx-container{
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,23 @@ 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 }) => {
|
||||
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)'
|
||||
}
|
||||
@@ -16,7 +29,10 @@ const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) =>
|
||||
header: {
|
||||
padding: '8px 16px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--color-background-soft)',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
},
|
||||
body: {
|
||||
borderTop: '0.5px solid var(--color-border)'
|
||||
@@ -26,7 +42,10 @@ const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) =>
|
||||
<Collapse
|
||||
bordered={false}
|
||||
style={CollapseStyle}
|
||||
defaultActiveKey={['1']}
|
||||
defaultActiveKey={defaultActiveKey}
|
||||
activeKey={activeKey}
|
||||
destroyInactivePanel={destroyInactivePanel}
|
||||
collapsible={collapsible}
|
||||
items={[
|
||||
{
|
||||
styles: CollapseItemStyles,
|
||||
|
||||
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
|
||||
@@ -1,6 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
// 记录失败的URL的缓存键前缀
|
||||
const FAILED_FAVICON_CACHE_PREFIX = 'failed_favicon_'
|
||||
// 失败URL的缓存时间 (24小时)
|
||||
const FAILED_FAVICON_CACHE_DURATION = 24 * 60 * 60 * 1000
|
||||
|
||||
// 检查URL是否在失败缓存中
|
||||
const isUrlFailedRecently = (url: string): boolean => {
|
||||
const cacheKey = `${FAILED_FAVICON_CACHE_PREFIX}${url}`
|
||||
const cachedTimestamp = localStorage.getItem(cacheKey)
|
||||
|
||||
if (!cachedTimestamp) return false
|
||||
|
||||
const timestamp = parseInt(cachedTimestamp, 10)
|
||||
const now = Date.now()
|
||||
|
||||
// 如果时间戳在缓存期内,则认为URL仍处于失败状态
|
||||
if (now - timestamp < FAILED_FAVICON_CACHE_DURATION) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 清除过期的缓存
|
||||
localStorage.removeItem(cacheKey)
|
||||
return false
|
||||
}
|
||||
|
||||
// 记录失败的URL到缓存
|
||||
const markUrlAsFailed = (url: string): void => {
|
||||
const cacheKey = `${FAILED_FAVICON_CACHE_PREFIX}${url}`
|
||||
localStorage.setItem(cacheKey, Date.now().toString())
|
||||
}
|
||||
|
||||
// FallbackFavicon component that tries multiple favicon sources
|
||||
interface FallbackFaviconProps {
|
||||
hostname: string
|
||||
@@ -22,20 +53,27 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
|
||||
|
||||
// Generate all possible favicon URLs
|
||||
const faviconUrls = [
|
||||
`https://favicon.splitbee.io/?url=${hostname}`,
|
||||
`https://${hostname}/favicon.ico`,
|
||||
`https://icon.horse/icon/${hostname}`,
|
||||
`https://favicon.cccyun.cc/${hostname}`,
|
||||
`https://favicon.splitbee.io/?url=${hostname}`,
|
||||
`https://favicon.im/${hostname}`,
|
||||
`https://www.google.com/s2/favicons?domain=${hostname}`
|
||||
`https://${hostname}/favicon.ico`
|
||||
]
|
||||
|
||||
// 过滤掉最近已失败的URL
|
||||
const validFaviconUrls = faviconUrls.filter((url) => !isUrlFailedRecently(url))
|
||||
|
||||
// 如果所有URL都被缓存为失败,使用第一个URL
|
||||
if (validFaviconUrls.length === 0) {
|
||||
setFaviconState({ status: 'loaded', src: faviconUrls[0] })
|
||||
return
|
||||
}
|
||||
|
||||
// Main controller to abort all requests when needed
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
|
||||
// Create a promise for each favicon URL
|
||||
const faviconPromises = faviconUrls.map((url) =>
|
||||
const faviconPromises = validFaviconUrls.map((url) =>
|
||||
fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal,
|
||||
@@ -45,6 +83,10 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
|
||||
if (response.ok) {
|
||||
return url
|
||||
}
|
||||
// 记录4xx或5xx失败
|
||||
if (response.status >= 400) {
|
||||
markUrlAsFailed(url)
|
||||
}
|
||||
throw new Error(`Failed to fetch ${url}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -89,6 +131,10 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
|
||||
}, [hostname]) // Only depend on hostname
|
||||
|
||||
const handleError = () => {
|
||||
if (faviconState.status === 'loaded') {
|
||||
// 记录图片加载失败的URL
|
||||
markUrlAsFailed(faviconState.src)
|
||||
}
|
||||
setFaviconState({ status: 'failed' })
|
||||
}
|
||||
|
||||
|
||||
13
src/renderer/src/components/Icons/SVGIcon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
export const StreamlineGoodHealthAndWellBeing = (props: SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 14 14" {...props}>
|
||||
{/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */}
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m10.097 12.468l-2.773-2.52c-1.53-1.522.717-4.423 2.773-2.045c2.104-2.33 4.303.57 2.773 2.045z"></path>
|
||||
<path d="M.621 6.088h1.367l1.823 3.19l4.101-7.747l1.823 3.646"></path>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ const TextContainer = styled.div`
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const TitleText = styled.div`
|
||||
const TitleText = styled.div<{ $active?: boolean }>`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -38,13 +38,6 @@ const WebviewContainer = memo(
|
||||
useEffect(() => {
|
||||
if (!webviewRef.current) return
|
||||
|
||||
const handleNewWindow = (event: any) => {
|
||||
event.preventDefault()
|
||||
if (webviewRef.current?.loadURL) {
|
||||
webviewRef.current.loadURL(event.url)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoaded = () => {
|
||||
onLoadedCallback(appid)
|
||||
}
|
||||
@@ -53,7 +46,6 @@ const WebviewContainer = memo(
|
||||
onNavigateCallback(appid, event.url)
|
||||
}
|
||||
|
||||
webviewRef.current.addEventListener('new-window', handleNewWindow)
|
||||
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
|
||||
|
||||
@@ -61,7 +53,6 @@ const WebviewContainer = memo(
|
||||
webviewRef.current.src = url
|
||||
|
||||
return () => {
|
||||
webviewRef.current?.removeEventListener('new-window', handleNewWindow)
|
||||
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
|
||||
}
|
||||
@@ -76,7 +67,6 @@ const WebviewContainer = memo(
|
||||
style={WebviewStyle}
|
||||
allowpopups={'true' as any}
|
||||
partition="persist:webview"
|
||||
nodeintegration={'true' as any}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
131
src/renderer/src/components/ModelTagsWithLabel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { EyeOutlined, GlobalOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
isEmbeddingModel,
|
||||
isFunctionCallingModel,
|
||||
isReasoningModel,
|
||||
isRerankModel,
|
||||
isVisionModel,
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { Model } from '@renderer/types'
|
||||
import { isFreeModel } from '@renderer/utils'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CustomTag from './CustomTag'
|
||||
|
||||
interface ModelTagsProps {
|
||||
model: Model
|
||||
showFree?: boolean
|
||||
showReasoning?: boolean
|
||||
showToolsCalling?: boolean
|
||||
size?: number
|
||||
showLabel?: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
model,
|
||||
showFree = true,
|
||||
showReasoning = true,
|
||||
showToolsCalling = true,
|
||||
size = 12,
|
||||
showLabel = true,
|
||||
style
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [_showLabel, _setShowLabel] = useState(showLabel)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const resizeObserver = useRef<ResizeObserver>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLabel) return
|
||||
|
||||
if (containerRef.current) {
|
||||
const currentElement = containerRef.current
|
||||
resizeObserver.current = new ResizeObserver((entries) => {
|
||||
const maxWidth = i18n.language.startsWith('zh') ? 300 : 350
|
||||
|
||||
for (const entry of entries) {
|
||||
const { width } = entry.contentRect
|
||||
_setShowLabel(width >= maxWidth)
|
||||
}
|
||||
})
|
||||
resizeObserver.current.observe(currentElement)
|
||||
|
||||
return () => {
|
||||
if (resizeObserver.current) {
|
||||
resizeObserver.current.unobserve(currentElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [showLabel])
|
||||
|
||||
return (
|
||||
<Container ref={containerRef} style={style}>
|
||||
{isVisionModel(model) && (
|
||||
<CustomTag
|
||||
size={size}
|
||||
color="#00b96b"
|
||||
icon={<EyeOutlined style={{ fontSize: size }} />}
|
||||
tooltip={t('models.type.vision')}>
|
||||
{_showLabel ? t('models.type.vision') : ''}
|
||||
</CustomTag>
|
||||
)}
|
||||
{isWebSearchModel(model) && (
|
||||
<CustomTag
|
||||
size={size}
|
||||
color="#1677ff"
|
||||
icon={<GlobalOutlined style={{ fontSize: size }} />}
|
||||
tooltip={t('models.type.websearch')}>
|
||||
{_showLabel ? t('models.type.websearch') : ''}
|
||||
</CustomTag>
|
||||
)}
|
||||
{showReasoning && isReasoningModel(model) && (
|
||||
<CustomTag
|
||||
size={size}
|
||||
color="#6372bd"
|
||||
icon={<i className="iconfont icon-thinking" />}
|
||||
tooltip={t('models.type.reasoning')}>
|
||||
{_showLabel ? t('models.type.reasoning') : ''}
|
||||
</CustomTag>
|
||||
)}
|
||||
{showToolsCalling && isFunctionCallingModel(model) && (
|
||||
<CustomTag
|
||||
size={size}
|
||||
color="#f18737"
|
||||
icon={<ToolOutlined style={{ fontSize: size }} />}
|
||||
tooltip={t('models.type.function_calling')}>
|
||||
{_showLabel ? t('models.type.function_calling') : ''}
|
||||
</CustomTag>
|
||||
)}
|
||||
{isEmbeddingModel(model) && (
|
||||
<CustomTag size={size} color="#FFA500" icon={t('models.type.embedding')} tooltip={t('models.type.embedding')} />
|
||||
)}
|
||||
{showFree && isFreeModel(model) && (
|
||||
<CustomTag size={size} color="#7cb305" icon={t('models.type.free')} tooltip={t('models.type.free')} />
|
||||
)}
|
||||
{isRerankModel(model) && (
|
||||
<CustomTag size={size} color="#6495ED" icon={t('models.type.rerank')} tooltip={t('models.type.rerank')} />
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: scroll;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default ModelTagsWithLabel
|
||||
@@ -1,4 +1,5 @@
|
||||
import { backup } from '@renderer/services/BackupService'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Modal, Progress } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -21,7 +22,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electron.ipcRenderer.on('backup-progress', (_, data: ProgressData) => {
|
||||
const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => {
|
||||
setProgressData(data)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { restore } from '@renderer/services/BackupService'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Modal, Progress } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -21,7 +22,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electron.ipcRenderer.on('restore-progress', (_, data: ProgressData) => {
|
||||
const removeListener = window.electron.ipcRenderer.on(IpcChannel.RestoreProgress, (_, data: ProgressData) => {
|
||||
setProgressData(data)
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HStack } from '../Layout'
|
||||
import ModelTags from '../ModelTags'
|
||||
import ModelTagsWithLabel from '../ModelTagsWithLabel'
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number]
|
||||
@@ -130,7 +130,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>{m?.name}</span> <ModelTags model={m} />
|
||||
<span>{m?.name}</span> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
@@ -184,7 +184,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
<span>
|
||||
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
|
||||
</span>{' '}
|
||||
<ModelTags model={m.model} />
|
||||
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
@@ -366,6 +366,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
width={600}
|
||||
transitionName="ant-move-down"
|
||||
styles={{
|
||||
content: {
|
||||
@@ -480,6 +481,10 @@ const StyledMenu = styled(Menu)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anticon {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime'
|
||||
import { setUserName } from '@renderer/store/settings'
|
||||
import { compressImage, isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
|
||||
11
src/renderer/src/components/QuickPanel/hook.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { use } from 'react'
|
||||
|
||||
import { QuickPanelContext } from './provider'
|
||||
|
||||
export const useQuickPanel = () => {
|
||||
const context = use(QuickPanelContext)
|
||||
if (!context) {
|
||||
throw new Error('useQuickPanel must be used within a QuickPanelProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
4
src/renderer/src/components/QuickPanel/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './hook'
|
||||
export * from './provider'
|
||||
export * from './types'
|
||||
export * from './view'
|
||||
83
src/renderer/src/components/QuickPanel/provider.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
QuickPanelCallBackOptions,
|
||||
QuickPanelCloseAction,
|
||||
QuickPanelContextType,
|
||||
QuickPanelListItem,
|
||||
QuickPanelOpenOptions
|
||||
} from './types'
|
||||
|
||||
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
|
||||
|
||||
export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [symbol, setSymbol] = useState<string>('')
|
||||
|
||||
const [list, setList] = useState<QuickPanelListItem[]>([])
|
||||
const [title, setTitle] = useState<string | undefined>()
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
||||
const [pageSize, setPageSize] = useState<number>(7)
|
||||
const [multiple, setMultiple] = useState<boolean>(false)
|
||||
const [onClose, setOnClose] = useState<
|
||||
((Options: Pick<QuickPanelCallBackOptions, 'symbol' | 'action'>) => void) | undefined
|
||||
>()
|
||||
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
|
||||
const open = useCallback((options: QuickPanelOpenOptions) => {
|
||||
setTitle(options.title)
|
||||
setList(options.list)
|
||||
setDefaultIndex(options.defaultIndex ?? 0)
|
||||
setPageSize(options.pageSize ?? 7)
|
||||
setMultiple(options.multiple ?? false)
|
||||
setSymbol(options.symbol)
|
||||
|
||||
setOnClose(() => options.onClose)
|
||||
setBeforeAction(() => options.beforeAction)
|
||||
setAfterAction(() => options.afterAction)
|
||||
|
||||
setIsVisible(true)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(
|
||||
(action?: QuickPanelCloseAction) => {
|
||||
setIsVisible(false)
|
||||
onClose?.({ symbol, action })
|
||||
|
||||
setTimeout(() => {
|
||||
setList([])
|
||||
setOnClose(undefined)
|
||||
setBeforeAction(undefined)
|
||||
setAfterAction(undefined)
|
||||
setTitle(undefined)
|
||||
setSymbol('')
|
||||
}, 200)
|
||||
},
|
||||
[onClose, symbol]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
|
||||
isVisible,
|
||||
symbol,
|
||||
|
||||
list,
|
||||
title,
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
}),
|
||||
[open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction]
|
||||
)
|
||||
|
||||
return <QuickPanelContext value={value}>{children}</QuickPanelContext>
|
||||
}
|
||||
|
||||
export { QuickPanelContext }
|
||||
66
src/renderer/src/components/QuickPanel/types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
export type QuickPanelCallBackOptions = {
|
||||
symbol: string
|
||||
action: QuickPanelCloseAction
|
||||
item: QuickPanelListItem
|
||||
searchText?: string
|
||||
/** 是否处于多选状态 */
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
export type QuickPanelOpenOptions = {
|
||||
/** 显示在底部左边,类似于Placeholder */
|
||||
title?: string
|
||||
/** default: [] */
|
||||
list: QuickPanelListItem[]
|
||||
/** default: 0 */
|
||||
defaultIndex?: number
|
||||
/** default: 7 */
|
||||
pageSize?: number
|
||||
/** 是否支持按住cmd/ctrl键多选,default: false */
|
||||
multiple?: boolean
|
||||
/**
|
||||
* 用于标识是哪个快捷面板,不是用于触发显示
|
||||
* 可以是/@#符号,也可以是其他字符串
|
||||
*/
|
||||
symbol: string
|
||||
beforeAction?: (options: QuickPanelCallBackOptions) => void
|
||||
afterAction?: (options: QuickPanelCallBackOptions) => void
|
||||
onClose?: (options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
|
||||
export type QuickPanelListItem = {
|
||||
label: React.ReactNode | string
|
||||
description?: React.ReactNode | string
|
||||
/**
|
||||
* 由于title跟description可能是ReactNode,
|
||||
* 所以需要单独提供一个用于搜索过滤的文本,
|
||||
* 这个filterText可以是title跟description的字符串组合
|
||||
*/
|
||||
filterText?: string
|
||||
icon: React.ReactNode | string
|
||||
suffix?: React.ReactNode | string
|
||||
isSelected?: boolean
|
||||
isMenu?: boolean
|
||||
disabled?: boolean
|
||||
action?: (options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
|
||||
// 定义上下文类型
|
||||
export interface QuickPanelContextType {
|
||||
readonly open: (options: QuickPanelOpenOptions) => void
|
||||
readonly close: (action?: QuickPanelCloseAction) => void
|
||||
readonly isVisible: boolean
|
||||
readonly symbol: string
|
||||
readonly list: QuickPanelListItem[]
|
||||
readonly title?: string
|
||||
readonly defaultIndex: number
|
||||
readonly pageSize: number
|
||||
readonly multiple: boolean
|
||||
|
||||
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
668
src/renderer/src/components/QuickPanel/view.tsx
Normal file
@@ -0,0 +1,668 @@
|
||||
import { CheckOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { theme } from 'antd'
|
||||
import Color from 'color'
|
||||
import { t } from 'i18next'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import { QuickPanelContext } from './provider'
|
||||
import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types'
|
||||
|
||||
interface Props {
|
||||
setInputText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 快捷面板内容视图;
|
||||
* 请不要往这里添加入参,避免耦合;
|
||||
* 这里只读取来自上下文QuickPanelContext的数据
|
||||
*
|
||||
* 无奈之举,为了清除输入框搜索文本,所以传了个setInputText进来
|
||||
*/
|
||||
export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const ctx = use(QuickPanelContext)
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('QuickPanel must be used within a QuickPanelProvider')
|
||||
}
|
||||
|
||||
const { token } = theme.useToken()
|
||||
const colorPrimary = Color(token.colorPrimary || '#008000')
|
||||
const selectedColor = colorPrimary.alpha(0.15).toString()
|
||||
const selectedColorHover = colorPrimary.alpha(0.2).toString()
|
||||
|
||||
const ASSISTIVE_KEY = isMac ? '⌘' : 'Ctrl'
|
||||
const [isAssistiveKeyPressed, setIsAssistiveKeyPressed] = useState(false)
|
||||
|
||||
// 避免上下翻页时,鼠标干扰
|
||||
const [isMouseOver, setIsMouseOver] = useState(false)
|
||||
|
||||
const [_index, setIndex] = useState(ctx.defaultIndex)
|
||||
const index = useDeferredValue(_index)
|
||||
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const footerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollBlock = useRef<ScrollLogicalPosition>('nearest')
|
||||
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 解决长按上下键时滚动太慢问题
|
||||
const keyPressCount = useRef<number>(0)
|
||||
const scrollBehavior = useRef<'auto' | 'smooth'>('smooth')
|
||||
|
||||
// 处理搜索,过滤列表
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
const newList = ctx.list?.filter((item) => {
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
if (!_searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = _searchText.toLowerCase()
|
||||
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true)
|
||||
if (pinyinText.toLowerCase().includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
|
||||
|
||||
return newList
|
||||
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
|
||||
|
||||
const canForwardAndBackward = useMemo(() => {
|
||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||
}, [list, historyPanel])
|
||||
|
||||
const clearSearchText = useCallback(
|
||||
(includeSymbol = false) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
const cursorPosition = textArea.selectionStart ?? 0
|
||||
const prevChar = textArea.value[cursorPosition - 1]
|
||||
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
|
||||
searchTextRef.current = prevChar
|
||||
}
|
||||
|
||||
const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '')
|
||||
if (!_searchText) return
|
||||
|
||||
const inputText = textArea.value
|
||||
let newText = inputText
|
||||
const searchPattern = new RegExp(`${_searchText}$`)
|
||||
|
||||
const match = inputText.slice(0, cursorPosition).match(searchPattern)
|
||||
if (match) {
|
||||
const start = match.index || 0
|
||||
const end = start + match[0].length
|
||||
newText = inputText.slice(0, start) + inputText.slice(end)
|
||||
setInputText(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(start, start)
|
||||
}, 0)
|
||||
}
|
||||
setSearchText('')
|
||||
},
|
||||
[setInputText]
|
||||
)
|
||||
|
||||
const handleClose = useCallback(
|
||||
(action?: QuickPanelCloseAction) => {
|
||||
ctx.close(action)
|
||||
setHistoryPanel([])
|
||||
|
||||
if (action === 'delete-symbol') {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
if (textArea) {
|
||||
setInputText(textArea.value)
|
||||
}
|
||||
} else if (action && !['outsideclick', 'esc', 'enter_empty'].includes(action)) {
|
||||
clearSearchText(true)
|
||||
}
|
||||
},
|
||||
[ctx, clearSearchText, setInputText]
|
||||
)
|
||||
|
||||
const handleItemAction = useCallback(
|
||||
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
|
||||
if (item.disabled) return
|
||||
|
||||
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
|
||||
symbol: ctx.symbol,
|
||||
action,
|
||||
item,
|
||||
searchText: searchText,
|
||||
multiple: isAssistiveKeyPressed
|
||||
}
|
||||
|
||||
ctx.beforeAction?.(quickPanelCallBackOptions)
|
||||
item?.action?.(quickPanelCallBackOptions)
|
||||
ctx.afterAction?.(quickPanelCallBackOptions)
|
||||
|
||||
if (item.isMenu) {
|
||||
// 保存上一个打开的选项,用于回退
|
||||
setHistoryPanel((prev) => [
|
||||
...(prev || []),
|
||||
{
|
||||
title: ctx.title,
|
||||
list: ctx.list,
|
||||
symbol: ctx.symbol,
|
||||
multiple: ctx.multiple,
|
||||
defaultIndex: index,
|
||||
pageSize: ctx.pageSize,
|
||||
onClose: ctx.onClose,
|
||||
beforeAction: ctx.beforeAction,
|
||||
afterAction: ctx.afterAction
|
||||
}
|
||||
])
|
||||
clearSearchText(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.multiple && isAssistiveKeyPressed) return
|
||||
|
||||
handleClose(action)
|
||||
},
|
||||
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
searchTextRef.current = searchText
|
||||
}, [searchText])
|
||||
|
||||
// 获取当前输入的搜索词
|
||||
const isComposing = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const cursorPosition = target.selectionStart
|
||||
const textBeforeCursor = target.value.slice(0, cursorPosition)
|
||||
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
const lastSymbolIndex = Math.max(lastSlashIndex, lastAtIndex)
|
||||
|
||||
if (lastSymbolIndex !== -1) {
|
||||
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
||||
setSearchText(newSearchText)
|
||||
} else {
|
||||
handleClose('delete-symbol')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompositionUpdate = () => {
|
||||
isComposing.current = true
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
isComposing.current = false
|
||||
}
|
||||
|
||||
textArea.addEventListener('input', handleInput)
|
||||
textArea.addEventListener('compositionupdate', handleCompositionUpdate)
|
||||
textArea.addEventListener('compositionend', handleCompositionEnd)
|
||||
|
||||
return () => {
|
||||
textArea.removeEventListener('input', handleInput)
|
||||
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
|
||||
textArea.removeEventListener('compositionend', handleCompositionEnd)
|
||||
setTimeout(() => {
|
||||
setSearchText('')
|
||||
}, 200) // 等待面板关闭动画结束后,再清空搜索词
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible])
|
||||
|
||||
// 处理上下翻时滚动到选中的元素
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const selectedElement = contentRef.current.children[index] as HTMLElement
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: scrollBlock.current,
|
||||
behavior: scrollBehavior.current
|
||||
})
|
||||
scrollBlock.current = 'nearest'
|
||||
}
|
||||
}, [index])
|
||||
|
||||
// 处理键盘事件
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isMac ? e.metaKey : e.ctrlKey) {
|
||||
setIsAssistiveKeyPressed(true)
|
||||
}
|
||||
|
||||
// 处理上下翻页时,滚动太慢问题
|
||||
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
keyPressCount.current++
|
||||
if (keyPressCount.current > 5) {
|
||||
scrollBehavior.current = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Escape'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
}
|
||||
if (['ArrowLeft', 'ArrowRight'].includes(e.key) && isAssistiveKeyPressed) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
if (isAssistiveKeyPressed) {
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev - ctx.pageSize
|
||||
if (prev === 0) return list.length - 1
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
} else {
|
||||
scrollBlock.current = 'nearest'
|
||||
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
if (isAssistiveKeyPressed) {
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev + ctx.pageSize
|
||||
if (prev + 1 === list.length) return 0
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
} else {
|
||||
scrollBlock.current = 'nearest'
|
||||
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
|
||||
}
|
||||
break
|
||||
|
||||
case 'PageUp':
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev - ctx.pageSize
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
break
|
||||
|
||||
case 'PageDown':
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev + ctx.pageSize
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
break
|
||||
|
||||
case 'ArrowLeft':
|
||||
if (!isAssistiveKeyPressed) return
|
||||
if (!historyPanel.length) return
|
||||
clearSearchText(false)
|
||||
if (historyPanel.length > 0) {
|
||||
const lastPanel = historyPanel.pop()
|
||||
if (lastPanel) {
|
||||
ctx.open(lastPanel)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowRight':
|
||||
if (!isAssistiveKeyPressed) return
|
||||
if (!list?.[index]?.isMenu) return
|
||||
clearSearchText(false)
|
||||
handleItemAction(list[index], 'enter')
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
if (isComposing.current) return
|
||||
|
||||
if (list?.[index]) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
|
||||
handleItemAction(list[index], 'enter')
|
||||
} else {
|
||||
handleClose('enter_empty')
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
handleClose('esc')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (isMac ? !e.metaKey : !e.ctrlKey) {
|
||||
setIsAssistiveKeyPressed(false)
|
||||
}
|
||||
|
||||
keyPressCount.current = 0
|
||||
scrollBehavior.current = 'smooth'
|
||||
}
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('#inputbar')) return
|
||||
if (bodyRef.current && !bodyRef.current.contains(target)) {
|
||||
handleClose('outsideclick')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText])
|
||||
|
||||
const [footerWidth, setFooterWidth] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!footerRef.current || !ctx.isVisible) return
|
||||
const footerWidth = footerRef.current.clientWidth
|
||||
setFooterWidth(footerWidth)
|
||||
|
||||
const handleResize = () => {
|
||||
const footerWidth = footerRef.current!.clientWidth
|
||||
setFooterWidth(footerWidth)
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [ctx.isVisible])
|
||||
|
||||
return (
|
||||
<QuickPanelContainer
|
||||
$pageSize={ctx.pageSize}
|
||||
$selectedColor={selectedColor}
|
||||
$selectedColorHover={selectedColorHover}
|
||||
className={ctx.isVisible ? 'visible' : ''}>
|
||||
<QuickPanelBody ref={bodyRef} onMouseMove={() => setIsMouseOver(true)}>
|
||||
<QuickPanelContent ref={contentRef} $pageSize={ctx.pageSize} $isMouseOver={isMouseOver}>
|
||||
{list.map((item, i) => (
|
||||
<QuickPanelItem
|
||||
className={classNames({
|
||||
focused: i === index,
|
||||
selected: item.isSelected,
|
||||
disabled: item.disabled
|
||||
})}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleItemAction(item, 'click')
|
||||
}}
|
||||
onMouseEnter={() => setIndex(i)}>
|
||||
<QuickPanelItemLeft>
|
||||
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
|
||||
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
|
||||
</QuickPanelItemLeft>
|
||||
|
||||
<QuickPanelItemRight>
|
||||
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
|
||||
<QuickPanelItemSuffixIcon>
|
||||
{item.suffix ? (
|
||||
item.suffix
|
||||
) : item.isSelected ? (
|
||||
<CheckOutlined />
|
||||
) : (
|
||||
item.isMenu && !item.disabled && <RightOutlined />
|
||||
)}
|
||||
</QuickPanelItemSuffixIcon>
|
||||
</QuickPanelItemRight>
|
||||
</QuickPanelItem>
|
||||
))}
|
||||
</QuickPanelContent>
|
||||
<QuickPanelFooter ref={footerRef}>
|
||||
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
|
||||
<QuickPanelFooterTips $footerWidth={footerWidth}>
|
||||
<span>ESC {t('settings.quickPanel.close')}</span>
|
||||
|
||||
<Flex align="center" gap={4}>
|
||||
▲▼ {t('settings.quickPanel.select')}
|
||||
</Flex>
|
||||
|
||||
{footerWidth >= 500 && (
|
||||
<>
|
||||
<Flex align="center" gap={4}>
|
||||
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
|
||||
{ASSISTIVE_KEY}
|
||||
</span>
|
||||
+ ▲▼ {t('settings.quickPanel.page')}
|
||||
</Flex>
|
||||
|
||||
{canForwardAndBackward && (
|
||||
<Flex align="center" gap={4}>
|
||||
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
|
||||
{ASSISTIVE_KEY}
|
||||
</span>
|
||||
+ ◀︎▶︎ {t('settings.quickPanel.back')}/{t('settings.quickPanel.forward')}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Flex align="center" gap={4}>
|
||||
↩︎ {t('settings.quickPanel.confirm')}
|
||||
</Flex>
|
||||
|
||||
{ctx.multiple && (
|
||||
<Flex align="center" gap={4}>
|
||||
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
|
||||
{ASSISTIVE_KEY}
|
||||
</span>
|
||||
+ ↩︎ {t('settings.quickPanel.multiple')}
|
||||
</Flex>
|
||||
)}
|
||||
</QuickPanelFooterTips>
|
||||
</QuickPanelFooter>
|
||||
</QuickPanelBody>
|
||||
</QuickPanelContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const QuickPanelContainer = styled.div<{
|
||||
$pageSize: number
|
||||
$selectedColor: string
|
||||
$selectedColorHover: string
|
||||
}>`
|
||||
--focused-color: rgba(0, 0, 0, 0.06);
|
||||
--selected-color: ${(props) => props.$selectedColor};
|
||||
--selected-color-dark: ${(props) => props.$selectedColorHover};
|
||||
max-height: 0;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding: 0 30px 0 30px;
|
||||
transform: translateY(-100%);
|
||||
transform-origin: bottom;
|
||||
transition: max-height 0.2s ease;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
&.visible {
|
||||
pointer-events: auto;
|
||||
max-height: ${(props) => props.$pageSize * 31 + 100}px;
|
||||
}
|
||||
body[theme-mode='dark'] & {
|
||||
--focused-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelBody = styled.div`
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 5px 0;
|
||||
border-width: 0.5px 0.5px 0 0.5px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-border);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: rgba(240, 240, 240, 0.5);
|
||||
backdrop-filter: blur(35px) saturate(150%);
|
||||
z-index: -1;
|
||||
|
||||
body[theme-mode='dark'] & {
|
||||
background-color: rgba(40, 40, 40, 0.4);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelFooter = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 12px 5px;
|
||||
`
|
||||
|
||||
const QuickPanelFooterTips = styled.div<{ $footerWidth: number }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
gap: 16px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const QuickPanelFooterTitle = styled.div`
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const QuickPanelContent = styled.div<{ $pageSize: number; $isMouseOver: boolean }>`
|
||||
width: 100%;
|
||||
max-height: ${(props) => props.$pageSize * 31}px;
|
||||
padding: 0 5px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
pointer-events: ${(props) => (props.$isMouseOver ? 'auto' : 'none')};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelItem = styled.div`
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
margin-bottom: 1px;
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
&.focused {
|
||||
background-color: var(--selected-color-dark);
|
||||
}
|
||||
}
|
||||
&.focused {
|
||||
background-color: var(--focused-color);
|
||||
}
|
||||
&.disabled {
|
||||
--selected-color: rgba(0, 0, 0, 0.02);
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelItemLeft = styled.div`
|
||||
max-width: 60%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const QuickPanelItemIcon = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const QuickPanelItemLabel = styled.span`
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const QuickPanelItemRight = styled.div`
|
||||
min-width: 20%;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const QuickPanelItemDescription = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const QuickPanelItemSuffixIcon = styled.span`
|
||||
min-width: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
`
|
||||
@@ -1,103 +0,0 @@
|
||||
import { DeleteOutlined, ImportOutlined } from '@ant-design/icons'
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import { Variable } from '@renderer/types'
|
||||
import { Button, Input, Tooltip } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface VariableListProps {
|
||||
variables: Variable[]
|
||||
setVariables: (variables: Variable[]) => void
|
||||
onUpdate?: (variables: Variable[]) => void
|
||||
onInsertVariable?: (name: string) => void
|
||||
}
|
||||
|
||||
const VariableList: React.FC<VariableListProps> = ({ variables, setVariables, onUpdate, onInsertVariable }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const deleteVariable = (id: string) => {
|
||||
const updatedVariables = variables.filter((v) => v.id !== id)
|
||||
setVariables(updatedVariables)
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(updatedVariables)
|
||||
}
|
||||
}
|
||||
|
||||
const updateVariable = (id: string, field: 'name' | 'value', value: string) => {
|
||||
// Only update the local state when typing, don't call the parent's onUpdate
|
||||
const updatedVariables = variables.map((v) => (v.id === id ? { ...v, [field]: value } : v))
|
||||
setVariables(updatedVariables)
|
||||
}
|
||||
|
||||
// This function will be called when input loses focus
|
||||
const handleInputBlur = () => {
|
||||
if (onUpdate) {
|
||||
onUpdate(variables)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<VariablesContainer>
|
||||
{variables.length === 0 ? (
|
||||
<EmptyText>{t('common.no_variables_added')}</EmptyText>
|
||||
) : (
|
||||
<VStack gap={8} width="100%">
|
||||
{variables.map((variable) => (
|
||||
<VariableItem key={variable.id}>
|
||||
<Input
|
||||
placeholder={t('common.variable_name')}
|
||||
value={variable.name}
|
||||
onChange={(e) => updateVariable(variable.id, 'name', e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
style={{ width: '30%' }}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('common.value')}
|
||||
value={variable.value}
|
||||
onChange={(e) => updateVariable(variable.id, 'value', e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{onInsertVariable && (
|
||||
<Tooltip title={t('common.insert_variable_into_prompt')}>
|
||||
<Button type="text" onClick={() => onInsertVariable(variable.name)}>
|
||||
<ImportOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => deleteVariable(variable.id)} />
|
||||
</VariableItem>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VariablesContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const VariablesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
`
|
||||
|
||||
const VariableItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const EmptyText = styled.div`
|
||||
color: var(--color-text-2);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
`
|
||||
|
||||
export default VariableList
|
||||
@@ -288,7 +288,7 @@ const PinnedApps: FC = () => {
|
||||
<Icon
|
||||
theme={theme}
|
||||
onClick={() => openMinappKeepAlive(app)}
|
||||
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-animation' : ''}`}>
|
||||
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-minapp' : ''}`}>
|
||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
||||
</Icon>
|
||||
</Dropdown>
|
||||
@@ -403,11 +403,10 @@ const Icon = styled.div<{ theme: string }>`
|
||||
}
|
||||
}
|
||||
|
||||
&.opened-animation {
|
||||
&.opened-minapp {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.opened-animation::after {
|
||||
&.opened-minapp::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -415,13 +414,8 @@ const Icon = styled.div<{ theme: string }>`
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
opacity: 0.3;
|
||||
border: 0.5px solid var(--color-primary);
|
||||
/* NOTICE: although we have optimized for the performance,
|
||||
* the infinite animation will still consume a little GPU resources,
|
||||
* it's a trade-off balance between performance and animation smoothness*/
|
||||
animation: borderBreath 4s ease-in-out infinite;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url'
|
||||
import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url'
|
||||
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
|
||||
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
|
||||
import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url'
|
||||
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
|
||||
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg?url'
|
||||
import CiciAppLogo from '@renderer/assets/images/apps/cici.webp?url'
|
||||
import CozeAppLogo from '@renderer/assets/images/apps/coze.webp?url'
|
||||
import DangbeiLogo from '@renderer/assets/images/apps/dangbei.jpg?url'
|
||||
import DevvAppLogo from '@renderer/assets/images/apps/devv.png?url'
|
||||
import DifyAppLogo from '@renderer/assets/images/apps/dify.svg?url'
|
||||
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png?url'
|
||||
@@ -308,12 +308,6 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
url: 'https://3min.top',
|
||||
bodered: false
|
||||
},
|
||||
{
|
||||
id: 'aistudio',
|
||||
name: 'AI Studio',
|
||||
logo: AIStudioLogo,
|
||||
url: 'https://aistudio.google.com/'
|
||||
},
|
||||
{
|
||||
id: 'xiaoyi',
|
||||
name: '小艺',
|
||||
@@ -391,5 +385,12 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
logo: ZhihuAppLogo,
|
||||
url: 'https://zhida.zhihu.com/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'dangbei',
|
||||
name: '当贝AI',
|
||||
logo: DangbeiLogo,
|
||||
url: 'https://ai.dangbei.com/',
|
||||
bodered: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -130,9 +130,11 @@ import XirangModelLogoDark from '@renderer/assets/images/models/xirang_dark.png'
|
||||
import YiModelLogo from '@renderer/assets/images/models/yi.png'
|
||||
import YiModelLogoDark from '@renderer/assets/images/models/yi_dark.png'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { Assistant, Model } from '@renderer/types'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts'
|
||||
import { getWebSearchTools } from './tools'
|
||||
|
||||
// Vision models
|
||||
@@ -184,7 +186,7 @@ export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|
|
||||
|
||||
// Reasoning models
|
||||
export const REASONING_REGEX =
|
||||
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*)$/i
|
||||
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-3-mini(?:-[\w-]+)?\b.*)$/i
|
||||
|
||||
// Embedding models
|
||||
export const EMBEDDING_REGEX =
|
||||
@@ -208,7 +210,8 @@ export const FUNCTION_CALLING_MODELS = [
|
||||
'deepseek',
|
||||
'glm-4(?:-[\\w-]+)?',
|
||||
'learnlm(?:-[\\w-]+)?',
|
||||
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
|
||||
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
|
||||
'grok-3(?:-[\\w-]+)?'
|
||||
]
|
||||
|
||||
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||
@@ -232,6 +235,10 @@ export function isFunctionCallingModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.provider === 'qiniu') {
|
||||
return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id)
|
||||
}
|
||||
|
||||
if (['deepseek', 'anthropic'].includes(model.provider)) {
|
||||
return true
|
||||
}
|
||||
@@ -498,12 +505,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'text-embedding-3-small',
|
||||
group: '嵌入模型'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-3-small',
|
||||
provider: 'o3',
|
||||
name: 'text-embedding-3-small',
|
||||
group: '嵌入模型'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-ada-002',
|
||||
provider: 'o3',
|
||||
@@ -2012,6 +2013,56 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'rerank-2-lite',
|
||||
group: 'Voyage Rerank V2'
|
||||
}
|
||||
],
|
||||
qiniu: [
|
||||
{
|
||||
id: 'deepseek-r1',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-r1-search',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek R1 Search',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-r1-32b',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek R1 32B',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3-search',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek V3 Search',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3-tool',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek V3 Tool',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'qwq-32b',
|
||||
provider: 'qiniu',
|
||||
name: 'QWQ 32B',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwen2.5-72b-instruct',
|
||||
provider: 'qiniu',
|
||||
name: 'Qwen2.5 72B Instruct',
|
||||
group: 'Qwen'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2148,13 +2199,33 @@ export function isVisionModel(model: Model): boolean {
|
||||
export function isOpenAIoSeries(model: Model): boolean {
|
||||
return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3')
|
||||
}
|
||||
export function isOpenAIWebSearch(model: Model): boolean {
|
||||
return model.id.includes('gpt-4o-search-preview') || model.id.includes('gpt-4o-mini-search-preview')
|
||||
}
|
||||
|
||||
export function isSupportedResoningEffortModel(model?: Model): boolean {
|
||||
export function isSupportedReasoningEffortModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet') || isOpenAIoSeries(model)) {
|
||||
if (
|
||||
model.id.includes('claude-3-7-sonnet') ||
|
||||
model.id.includes('claude-3.7-sonnet') ||
|
||||
isOpenAIoSeries(model) ||
|
||||
isGrokReasoningModel(model)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isGrokReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.id.includes('grok-3-mini')) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2212,7 +2283,7 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
if (provider?.type === 'openai') {
|
||||
if (GEMINI_SEARCH_MODELS.includes(model?.id)) {
|
||||
if (GEMINI_SEARCH_MODELS.includes(model?.id) || isOpenAIWebSearch(model)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -2239,7 +2310,7 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return model.type?.includes('web_search') || false
|
||||
}
|
||||
|
||||
export function isGenerateImageModel(model: Model): boolean {
|
||||
@@ -2265,12 +2336,15 @@ export function isGenerateImageModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||
if (WebSearchService.isWebSearchEnabled() && WebSearchService.isOverwriteEnabled()) {
|
||||
return {}
|
||||
}
|
||||
if (isWebSearchModel(model)) {
|
||||
if (assistant.enableWebSearch) {
|
||||
const webSearchTools = getWebSearchTools(model)
|
||||
|
||||
if (model.provider === 'hunyuan') {
|
||||
return { enable_enhancement: true }
|
||||
return { enable_enhancement: true, citation: true, search_info: true }
|
||||
}
|
||||
|
||||
if (model.provider === 'dashscope') {
|
||||
@@ -2284,10 +2358,14 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
|
||||
|
||||
if (model.provider === 'openrouter') {
|
||||
return {
|
||||
plugins: [{ id: 'web' }]
|
||||
plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }]
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpenAIWebSearch(model)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
tools: webSearchTools
|
||||
}
|
||||
@@ -2308,3 +2386,23 @@ export function isGemmaModel(model?: Model): boolean {
|
||||
|
||||
return model.id.includes('gemma-') || model.group === 'Gemma'
|
||||
}
|
||||
|
||||
export function isZhipuModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
return model.provider === 'zhipu'
|
||||
}
|
||||
|
||||
export function isHunyuanSearchModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.provider === 'hunyuan') {
|
||||
return model.id !== 'hunyuan-lite'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const AGENT_PROMPT = `
|
||||
You are a Prompt Generator. You will integrate user input information into a structured Prompt using Markdown syntax. Please do not use code blocks for output, display directly!
|
||||
|
||||
@@ -109,3 +111,20 @@ export const FOOTNOTE_PROMPT = `Please answer the question based on the referenc
|
||||
|
||||
{references}
|
||||
`
|
||||
|
||||
export const WEB_SEARCH_PROMPT_FOR_ZHIPU = `
|
||||
# 以下是来自互联网的信息:
|
||||
{search_result}
|
||||
|
||||
# 当前日期: ${dayjs().format('YYYY-MM-DD')}
|
||||
# 要求:
|
||||
根据最新发布的信息回答用户问题,当回答引用了参考信息时,必须在句末使用对应的[ref_序号](url)的markdown链接形式来标明参考信息来源。
|
||||
`
|
||||
export const WEB_SEARCH_PROMPT_FOR_OPENROUTER = `
|
||||
A web search was conducted on \`${dayjs().format('YYYY-MM-DD')}\`. Incorporate the following web search results into your response.
|
||||
|
||||
IMPORTANT: Cite them using markdown links named using the domain of the source.
|
||||
Example: [nytimes.com](https://nytimes.com/some-page).
|
||||
If have multiple citations, please directly list them like this:
|
||||
[www.nytimes.com](https://nytimes.com/some-page)[www.bbc.com](https://bbc.com/some-page)
|
||||
`
|
||||
|
||||
@@ -14,7 +14,6 @@ import GiteeAIProviderLogo from '@renderer/assets/images/providers/gitee-ai.png'
|
||||
import GithubProviderLogo from '@renderer/assets/images/providers/github.png'
|
||||
import GoogleProviderLogo from '@renderer/assets/images/providers/google.png'
|
||||
import GPUStackProviderLogo from '@renderer/assets/images/providers/gpustack.svg'
|
||||
import GraphRagProviderLogo from '@renderer/assets/images/providers/graph-rag.png'
|
||||
import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
|
||||
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
|
||||
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
|
||||
@@ -33,6 +32,7 @@ import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
|
||||
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
|
||||
import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png'
|
||||
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
||||
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
||||
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
||||
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
|
||||
@@ -64,7 +64,6 @@ const PROVIDER_LOGO_MAP = {
|
||||
gemini: GoogleProviderLogo,
|
||||
stepfun: StepProviderLogo,
|
||||
doubao: BytedanceProviderLogo,
|
||||
'graphrag-kylin-mountain': GraphRagProviderLogo,
|
||||
minimax: MinimaxProviderLogo,
|
||||
github: GithubProviderLogo,
|
||||
copilot: GithubProviderLogo,
|
||||
@@ -88,7 +87,8 @@ const PROVIDER_LOGO_MAP = {
|
||||
'tencent-cloud-ti': TencentCloudProviderLogo,
|
||||
gpustack: GPUStackProviderLogo,
|
||||
alayanew: AlayaNewProviderLogo,
|
||||
voyageai: VoyageAIProviderLogo
|
||||
voyageai: VoyageAIProviderLogo,
|
||||
qiniu: QiniuProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -125,10 +125,9 @@ export const PROVIDER_CONFIG = {
|
||||
url: 'https://api.ppinfra.com/v3/openai'
|
||||
},
|
||||
websites: {
|
||||
official:
|
||||
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link',
|
||||
apiKey: 'https://ppinfra.com/settings/key-management',
|
||||
docs: 'https://ppinfra.com/docs/model-api/reference/llm/llm.html',
|
||||
official: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
apiKey: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
docs: 'https://docs.cherry-ai.com/pre-basic/providers/ppio?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
models:
|
||||
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link'
|
||||
}
|
||||
@@ -150,7 +149,7 @@ export const PROVIDER_CONFIG = {
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.siliconflow.cn/',
|
||||
apiKey: 'https://cloud.siliconflow.cn/account/ak?referrer=clxty1xuy0014lvqwh5z50i88',
|
||||
apiKey: 'https://cloud.siliconflow.cn/i/d1nTBKXU',
|
||||
docs: 'https://docs.siliconflow.cn/',
|
||||
models: 'https://docs.siliconflow.cn/docs/model-names'
|
||||
}
|
||||
@@ -573,5 +572,16 @@ export const PROVIDER_CONFIG = {
|
||||
docs: 'https://docs.voyageai.com/docs',
|
||||
models: 'https://docs.voyageai.com/docs'
|
||||
}
|
||||
},
|
||||
qiniu: {
|
||||
api: {
|
||||
url: 'https://api.qnaigc.com'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://qiniu.com',
|
||||
apiKey: 'https://marketing.qiniu.com/activity/2025_newspring?cps_key=1h4vzfbkxobiq#deepseek-title',
|
||||
docs: 'https://developer.qiniu.com/aitokenapi',
|
||||
models: 'https://developer.qiniu.com/aitokenapi/12883/model-list'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import { ChatCompletionTool } from 'openai/resources'
|
||||
|
||||
import { WEB_SEARCH_PROMPT_FOR_ZHIPU } from './prompts'
|
||||
|
||||
export function getWebSearchTools(model: Model): ChatCompletionTool[] {
|
||||
if (model?.provider === 'zhipu') {
|
||||
if (model.id === 'glm-4-alltools') {
|
||||
return [
|
||||
{
|
||||
type: 'web_browser'
|
||||
type: 'web_browser',
|
||||
web_browser: {
|
||||
browser: 'auto'
|
||||
}
|
||||
} as unknown as ChatCompletionTool
|
||||
]
|
||||
}
|
||||
@@ -15,18 +20,22 @@ export function getWebSearchTools(model: Model): ChatCompletionTool[] {
|
||||
type: 'web_search',
|
||||
web_search: {
|
||||
enable: true,
|
||||
search_result: true
|
||||
search_result: true,
|
||||
search_prompt: WEB_SEARCH_PROMPT_FOR_ZHIPU
|
||||
}
|
||||
} as unknown as ChatCompletionTool
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'googleSearch'
|
||||
if (model?.id.includes('gemini')) {
|
||||
return [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'googleSearch'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -31,5 +31,20 @@ export const WEB_SEARCH_PROVIDER_CONFIG = {
|
||||
official: 'https://exa.ai',
|
||||
apiKey: 'https://dashboard.exa.ai/api-keys'
|
||||
}
|
||||
},
|
||||
'local-google': {
|
||||
websites: {
|
||||
official: 'https://www.google.com'
|
||||
}
|
||||
},
|
||||
'local-bing': {
|
||||
websites: {
|
||||
official: 'https://www.bing.com'
|
||||
}
|
||||
},
|
||||
'local-baidu': {
|
||||
websites: {
|
||||
official: 'https://www.baidu.com'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { CodeCacheService } from '@renderer/services/CodeCacheService'
|
||||
import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
|
||||
import type React from 'react'
|
||||
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki'
|
||||
import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki'
|
||||
import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
|
||||
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null
|
||||
|
||||
async function getHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
langs: ['javascript', 'typescript', 'python', 'java', 'markdown'],
|
||||
themes: ['one-light', 'material-theme-darker']
|
||||
})
|
||||
}
|
||||
|
||||
return await highlighterPromise
|
||||
}
|
||||
|
||||
interface SyntaxHighlighterContextType {
|
||||
codeToHtml: (code: string, language: string) => Promise<string>
|
||||
codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
|
||||
}
|
||||
|
||||
const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined)
|
||||
|
||||
export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const { theme } = useTheme()
|
||||
const [highlighter, setHighlighter] = useState<HighlighterGeneric<BundledLanguage, BundledTheme> | null>(null)
|
||||
const { codeStyle } = useSettings()
|
||||
useMermaid()
|
||||
|
||||
@@ -27,29 +39,14 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
|
||||
return codeStyle
|
||||
}, [theme, codeStyle])
|
||||
|
||||
useEffect(() => {
|
||||
const initHighlighter = async () => {
|
||||
const commonLanguages = ['javascript', 'typescript', 'python', 'java', 'markdown']
|
||||
|
||||
const hl = await createHighlighter({
|
||||
themes: [highlighterTheme],
|
||||
langs: commonLanguages
|
||||
})
|
||||
|
||||
setHighlighter(hl)
|
||||
|
||||
// Load all themes and languages
|
||||
// hl.loadTheme(...(Object.keys(bundledThemes) as BundledTheme[]))
|
||||
// hl.loadLanguage(...(Object.keys(bundledLanguages) as BundledLanguage[]))
|
||||
}
|
||||
|
||||
initHighlighter()
|
||||
}, [highlighterTheme])
|
||||
|
||||
const codeToHtml = useCallback(
|
||||
async (_code: string, language: string) => {
|
||||
async (_code: string, language: string, enableCache: boolean) => {
|
||||
{
|
||||
if (!highlighter) return ''
|
||||
if (!_code) return ''
|
||||
|
||||
const key = CodeCacheService.generateCacheKey(_code, language, highlighterTheme)
|
||||
const cached = enableCache ? CodeCacheService.getCachedResult(key) : null
|
||||
if (cached) return cached
|
||||
|
||||
const languageMap: Record<string, string> = {
|
||||
vab: 'vb'
|
||||
@@ -61,25 +58,41 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
|
||||
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '<', '>': '>' })[char]!)
|
||||
|
||||
try {
|
||||
if (!highlighter.getLoadedLanguages().includes(mappedLanguage as BundledLanguage)) {
|
||||
if (mappedLanguage in bundledLanguages || mappedLanguage === 'text') {
|
||||
await highlighter.loadLanguage(mappedLanguage as BundledLanguage)
|
||||
} else {
|
||||
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
|
||||
const highlighter = await getHighlighter()
|
||||
|
||||
if (!highlighter.getLoadedThemes().includes(highlighterTheme)) {
|
||||
const themeImportFn = bundledThemes[highlighterTheme]
|
||||
if (themeImportFn) {
|
||||
await highlighter.loadTheme(await themeImportFn())
|
||||
}
|
||||
}
|
||||
|
||||
return highlighter.codeToHtml(code, {
|
||||
if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) {
|
||||
const languageImportFn = bundledLanguages[mappedLanguage]
|
||||
if (languageImportFn) {
|
||||
await highlighter.loadLanguage(await languageImportFn())
|
||||
}
|
||||
}
|
||||
|
||||
// 生成高亮HTML
|
||||
const html = highlighter.codeToHtml(code, {
|
||||
lang: mappedLanguage,
|
||||
theme: highlighterTheme
|
||||
})
|
||||
|
||||
// 设置缓存
|
||||
if (enableCache) {
|
||||
CodeCacheService.setCachedResult(key, html, _code.length)
|
||||
}
|
||||
|
||||
return html
|
||||
} catch (error) {
|
||||
console.warn(`Error highlighting code for language '${mappedLanguage}':`, error)
|
||||
console.debug(`Error highlighting code for language '${mappedLanguage}':`, error)
|
||||
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
|
||||
}
|
||||
}
|
||||
},
|
||||
[highlighter, highlighterTheme]
|
||||
[highlighterTheme]
|
||||
)
|
||||
|
||||
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react'
|
||||
|
||||
interface ThemeContextType {
|
||||
@@ -49,7 +50,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
|
||||
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
|
||||
|
||||
// listen theme change from main process from other windows
|
||||
const themeChangeListenerRemover = window.electron.ipcRenderer.on('theme:change', (_, newTheme) => {
|
||||
const themeChangeListenerRemover = window.electron.ipcRenderer.on(IpcChannel.ThemeChange, (_, newTheme) => {
|
||||
setTheme(newTheme)
|
||||
})
|
||||
return () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FileType, KnowledgeItem, Topic, TranslateHistory } from '@renderer/types'
|
||||
import { FileType, KnowledgeItem, QuickPhrase, Topic, TranslateHistory } from '@renderer/types'
|
||||
import { Dexie, type EntityTable } from 'dexie'
|
||||
|
||||
import { upgradeToV5 } from './upgrades'
|
||||
|
||||
// Database declaration (move this to its own module also)
|
||||
export const db = new Dexie('CherryStudio') as Dexie & {
|
||||
files: EntityTable<FileType, 'id'>
|
||||
@@ -9,6 +10,7 @@ export const db = new Dexie('CherryStudio') as Dexie & {
|
||||
settings: EntityTable<{ id: string; value: any }, 'id'>
|
||||
knowledge_notes: EntityTable<KnowledgeItem, 'id'>
|
||||
translate_history: EntityTable<TranslateHistory, 'id'>
|
||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
@@ -46,4 +48,13 @@ db.version(5)
|
||||
})
|
||||
.upgrade((tx) => upgradeToV5(tx))
|
||||
|
||||
db.version(6).stores({
|
||||
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
|
||||
topics: '&id, messages',
|
||||
settings: '&id, value',
|
||||
knowledge_notes: '&id, baseId, type, content, created_at, updated_at',
|
||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||
quick_phrases: 'id'
|
||||
})
|
||||
|
||||
export default db
|
||||
|
||||
@@ -6,6 +6,8 @@ import i18n from '@renderer/i18n'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||
import { disableAnalytics, initAnalytics } from '@renderer/utils/analytics'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
@@ -17,7 +19,7 @@ import useUpdateHandler from './useUpdateHandler'
|
||||
|
||||
export function useAppInit() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss } = useSettings()
|
||||
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
@@ -53,7 +55,7 @@ export function useAppInit() {
|
||||
}, [proxyUrl, proxyMode])
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || 'en-US')
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,4 +104,8 @@ export function useAppInit() {
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
}, [customCss])
|
||||
|
||||
useEffect(() => {
|
||||
enableDataCollection ? initAnalytics() : disableAnalytics()
|
||||
}, [enableDataCollection])
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ export function useAssistant(id: string) {
|
||||
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
|
||||
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),
|
||||
setModel: useCallback(
|
||||
(model: Model) => dispatch(setModel({ assistantId: assistant.id, model })),
|
||||
[dispatch, assistant.id]
|
||||
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
||||
[assistant, dispatch]
|
||||
),
|
||||
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
|
||||
updateAssistantSettings: (settings: Partial<AssistantSettings>) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isWindows } from '@renderer/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -6,7 +7,7 @@ export function useFullScreenNotice() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electron.ipcRenderer.on('fullscreen-status-changed', (_, isFullscreen) => {
|
||||
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, isFullscreen) => {
|
||||
if (isWindows && isFullscreen) {
|
||||
window.message.info({
|
||||
content: t('common.fullscreen'),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@renderer/store/knowledge'
|
||||
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -207,7 +208,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
}
|
||||
|
||||
const cleanup = window.electron.ipcRenderer.on(
|
||||
'directory-processing-percent',
|
||||
IpcChannel.DirectoryProcessingPercent,
|
||||
(_, { itemId: id, percent }: { itemId: string; percent: number }) => {
|
||||
if (itemId === id) {
|
||||
setPercent(percent)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
|
||||
const ipcRenderer = window.electron.ipcRenderer
|
||||
|
||||
// Listen for server changes from main process
|
||||
ipcRenderer.on('mcp:servers-changed', (_event, servers) => {
|
||||
ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
|
||||
store.dispatch(setMCPServers(servers))
|
||||
})
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ export const useMermaid = () => {
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const mermaidElement = (e.target as HTMLElement).closest('.mermaid')
|
||||
if (!mermaidElement) return
|
||||
|
||||
@@ -61,7 +60,7 @@ export const useMermaid = () => {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('wheel', handleWheel, { passive: false })
|
||||
document.addEventListener('wheel', handleWheel, { passive: true })
|
||||
return () => document.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setUpdateState } from '@renderer/store/runtime'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import type { ProgressInfo, UpdateInfo } from 'builder-util-runtime'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -14,13 +15,13 @@ export default function useUpdateHandler() {
|
||||
const ipcRenderer = window.electron.ipcRenderer
|
||||
|
||||
const removers = [
|
||||
ipcRenderer.on('update-not-available', () => {
|
||||
ipcRenderer.on(IpcChannel.UpdateNotAvailable, () => {
|
||||
dispatch(setUpdateState({ checking: false }))
|
||||
if (window.location.hash.includes('settings/about')) {
|
||||
window.message.success(t('settings.about.updateNotAvailable'))
|
||||
}
|
||||
}),
|
||||
ipcRenderer.on('update-available', (_, releaseInfo: UpdateInfo) => {
|
||||
ipcRenderer.on(IpcChannel.UpdateAvailable, (_, releaseInfo: UpdateInfo) => {
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
checking: false,
|
||||
@@ -30,7 +31,7 @@ export default function useUpdateHandler() {
|
||||
})
|
||||
)
|
||||
}),
|
||||
ipcRenderer.on('download-update', () => {
|
||||
ipcRenderer.on(IpcChannel.DownloadUpdate, () => {
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
checking: false,
|
||||
@@ -38,7 +39,7 @@ export default function useUpdateHandler() {
|
||||
})
|
||||
)
|
||||
}),
|
||||
ipcRenderer.on('download-progress', (_, progress: ProgressInfo) => {
|
||||
ipcRenderer.on(IpcChannel.DownloadProgress, (_, progress: ProgressInfo) => {
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
downloading: progress.percent < 100,
|
||||
@@ -46,7 +47,7 @@ export default function useUpdateHandler() {
|
||||
})
|
||||
)
|
||||
}),
|
||||
ipcRenderer.on('update-downloaded', (_, releaseInfo: UpdateInfo) => {
|
||||
ipcRenderer.on(IpcChannel.UpdateDownloaded, (_, releaseInfo: UpdateInfo) => {
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
downloading: false,
|
||||
@@ -55,7 +56,7 @@ export default function useUpdateHandler() {
|
||||
})
|
||||
)
|
||||
}),
|
||||
ipcRenderer.on('update-error', (_, error) => {
|
||||
ipcRenderer.on(IpcChannel.UpdateError, (_, error) => {
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
checking: false,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addSubscribeSource as _addSubscribeSource,
|
||||
removeSubscribeSource as _removeSubscribeSource,
|
||||
setDefaultProvider as _setDefaultProvider,
|
||||
setSubscribeSources as _setSubscribeSources,
|
||||
updateSubscribeBlacklist as _updateSubscribeBlacklist,
|
||||
updateWebSearchProvider,
|
||||
updateWebSearchProviders
|
||||
} from '@renderer/store/websearch'
|
||||
@@ -25,11 +29,20 @@ export const useDefaultWebSearchProvider = () => {
|
||||
|
||||
export const useWebSearchProviders = () => {
|
||||
const providers = useAppSelector((state) => state.websearch.providers)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
providers,
|
||||
updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers))
|
||||
updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers)),
|
||||
addWebSearchProvider: (provider: WebSearchProvider) => {
|
||||
// Check if provider exists
|
||||
const exists = providers.some((p) => p.id === provider.id)
|
||||
if (!exists) {
|
||||
// Use the existing update action to add the new provider
|
||||
dispatch(updateWebSearchProviders([...providers, provider]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +50,7 @@ export const useWebSearchProvider = (id: string) => {
|
||||
const providers = useAppSelector((state) => state.websearch.providers)
|
||||
const provider = providers.find((provider) => provider.id === id)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
if (!provider) {
|
||||
throw new Error(`Web search provider with id ${id} not found`)
|
||||
}
|
||||
@@ -47,3 +61,32 @@ export const useWebSearchProvider = (id: string) => {
|
||||
|
||||
return { provider, updateProvider }
|
||||
}
|
||||
|
||||
export const useBlacklist = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const websearch = useAppSelector((state) => state.websearch)
|
||||
|
||||
const addSubscribeSource = ({ url, name, blacklist }) => {
|
||||
dispatch(_addSubscribeSource({ url, name, blacklist }))
|
||||
}
|
||||
|
||||
const removeSubscribeSource = (key: number) => {
|
||||
dispatch(_removeSubscribeSource(key))
|
||||
}
|
||||
|
||||
const updateSubscribeBlacklist = (key: number, blacklist: string[]) => {
|
||||
dispatch(_updateSubscribeBlacklist({ key, blacklist }))
|
||||
}
|
||||
|
||||
const setSubscribeSources = (sources: { key: number; url: string; name: string; blacklist?: string[] }[]) => {
|
||||
dispatch(_setSubscribeSources(sources))
|
||||
}
|
||||
|
||||
return {
|
||||
websearch,
|
||||
addSubscribeSource,
|
||||
removeSubscribeSource,
|
||||
updateSubscribeBlacklist,
|
||||
setSubscribeSources
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
@@ -26,7 +27,7 @@ const resources = {
|
||||
}
|
||||
|
||||
export const getLanguage = () => {
|
||||
return localStorage.getItem('language') || navigator.language || 'en-US'
|
||||
return localStorage.getItem('language') || navigator.language || defaultLanguage
|
||||
}
|
||||
|
||||
export const getLanguageCode = () => {
|
||||
@@ -36,7 +37,7 @@ export const getLanguageCode = () => {
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: getLanguage(),
|
||||
fallbackLng: 'en-US',
|
||||
fallbackLng: defaultLanguage,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"settings.reasoning_effort.low": "low",
|
||||
"settings.reasoning_effort.medium": "medium",
|
||||
"settings.reasoning_effort.off": "off",
|
||||
"settings.reasoning_effort.tip": "Only supports OpenAI o-series and Anthropic reasoning models",
|
||||
"settings.reasoning_effort.tip": "Only supported by OpenAI o-series, Anthropic, and Grok reasoning models",
|
||||
"settings.more": "Assistant Settings"
|
||||
},
|
||||
"auth": {
|
||||
@@ -159,6 +159,14 @@
|
||||
"save": "Save",
|
||||
"settings.code_collapsible": "Code block collapsible",
|
||||
"settings.code_wrappable": "Code block wrappable",
|
||||
"settings.code_cacheable": "Code block cache",
|
||||
"settings.code_cacheable.tip": "Caching code blocks can reduce the rendering time of long code blocks, but it will increase memory usage",
|
||||
"settings.code_cache_max_size": "Max cache size",
|
||||
"settings.code_cache_max_size.tip": "The maximum number of characters allowed to be cached (thousand characters), calculated according to the highlighted code. The length of the highlighted code is much longer than the pure text.",
|
||||
"settings.code_cache_ttl": "Cache TTL",
|
||||
"settings.code_cache_ttl.tip": "Cache expiration time (minutes)",
|
||||
"settings.code_cache_threshold": "Cache threshold",
|
||||
"settings.code_cache_threshold.tip": "The minimum number of characters allowed to be cached (thousand characters), calculated according to the actual code. Only code blocks exceeding the threshold will be cached.",
|
||||
"settings.context_count": "Context",
|
||||
"settings.context_count.tip": "The number of previous messages to keep in the context.",
|
||||
"settings.max": "Max",
|
||||
@@ -188,6 +196,7 @@
|
||||
"topics.export.image": "Export as image",
|
||||
"topics.export.joplin": "Export to Joplin",
|
||||
"topics.export.md": "Export as markdown",
|
||||
"topics.export.md.reason": "Export as Markdown (with reasoning)",
|
||||
"topics.export.notion": "Export to Notion",
|
||||
"topics.export.obsidian": "Export to Obsidian",
|
||||
"topics.export.obsidian_vault": "Vault",
|
||||
@@ -235,7 +244,9 @@
|
||||
"topics.export.siyuan": "Export to Siyuan Note",
|
||||
"topics.export.wait_for_title_naming": "Generating title...",
|
||||
"topics.export.title_naming_success": "Title generated successfully",
|
||||
"topics.export.title_naming_failed": "Failed to generate title, using default title"
|
||||
"topics.export.title_naming_failed": "Failed to generate title, using default title",
|
||||
"input.translating": "Translating...",
|
||||
"input.upload.upload_from_local": "Upload local file..."
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Collapse",
|
||||
@@ -266,6 +277,7 @@
|
||||
"duplicate": "Duplicate",
|
||||
"edit": "Edit",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"footnote": "Reference content",
|
||||
"footnotes": "References",
|
||||
"fullscreen": "Entered fullscreen mode. Press F11 to exit",
|
||||
@@ -287,12 +299,12 @@
|
||||
"topics": "Topics",
|
||||
"warning": "Warning",
|
||||
"you": "You",
|
||||
"variable_name": "Variable Name",
|
||||
"value": "Value",
|
||||
"no_variables_added": "No variables added",
|
||||
"insert_variable_into_prompt": "Insert variable into prompt",
|
||||
"variables": "Variables",
|
||||
"variables_help": "Add variables that need to be replaced in the text, triggered by {{variable_name}} in the replacement document"
|
||||
"reasoning_content": "Deep reasoning",
|
||||
"sort": {
|
||||
"pinyin": "Sort by Pinyin",
|
||||
"pinyin.asc": "Sort by Pinyin (A-Z)",
|
||||
"pinyin.desc": "Sort by Pinyin (Z-A)"
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"title": "Docs"
|
||||
@@ -334,7 +346,7 @@
|
||||
"files": {
|
||||
"actions": "Actions",
|
||||
"all": "All Files",
|
||||
"count": "Count",
|
||||
"count": "files",
|
||||
"created_at": "Created At",
|
||||
"delete": "Delete",
|
||||
"delete.content": "Deleting a file will delete its reference from all messages. Are you sure you want to delete this file?",
|
||||
@@ -703,7 +715,6 @@
|
||||
"gitee-ai": "Gitee AI",
|
||||
"github": "GitHub Models",
|
||||
"gpustack": "GPUStack",
|
||||
"graphrag-kylin-mountain": "GraphRAG",
|
||||
"grok": "Grok",
|
||||
"groq": "Groq",
|
||||
"hunyuan": "Tencent Hunyuan",
|
||||
@@ -732,7 +743,8 @@
|
||||
"yi": "Yi",
|
||||
"zhinao": "360AI",
|
||||
"zhipu": "ZHIPU AI",
|
||||
"voyageai": "Voyage AI"
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Are you sure you want to restore data?",
|
||||
@@ -792,8 +804,24 @@
|
||||
"title": "Clear Cache"
|
||||
},
|
||||
"data.title": "Data Directory",
|
||||
"divider.basic": "Basic Data Settings",
|
||||
"divider.cloud_storage": "Cloud Backup Settings",
|
||||
"divider.export_settings": "Export Settings",
|
||||
"divider.third_party": "Third-party Connections",
|
||||
"hour_interval_one": "{{count}} hour",
|
||||
"hour_interval_other": "{{count}} hours",
|
||||
"export_menu": {
|
||||
"title": "Export Menu Settings",
|
||||
"image": "Export as Image",
|
||||
"markdown": "Export as Markdown",
|
||||
"markdown_reason": "Export as Markdown (with reasoning)",
|
||||
"notion": "Export to Notion",
|
||||
"yuque": "Export to Yuque",
|
||||
"obsidian": "Export to Obsidian",
|
||||
"siyuan": "Export to SiYuan Note",
|
||||
"joplin": "Export to Joplin",
|
||||
"docx": "Export as Word"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
"button": "Check",
|
||||
@@ -1005,6 +1033,10 @@
|
||||
"argsTooltip": "Each argument on a new line",
|
||||
"baseUrlTooltip": "Remote server base URL",
|
||||
"command": "Command",
|
||||
"sse": "Server-Sent Events (sse)",
|
||||
"streamableHttp": "Streamable HTTP (streamableHttp)",
|
||||
"stdio": "Standard Input/Output (stdio)",
|
||||
"inMemory": "Memory",
|
||||
"config_description": "Configure Model Context Protocol servers",
|
||||
"deleteError": "Failed to delete server",
|
||||
"deleteSuccess": "Server deleted successfully",
|
||||
@@ -1066,7 +1098,10 @@
|
||||
"deleteServerConfirm": "Are you sure you want to delete this server?",
|
||||
"registry": "Package Registry",
|
||||
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
|
||||
"registryDefault": "Default"
|
||||
"registryDefault": "Default",
|
||||
"not_support": "Model not supported",
|
||||
"user": "User",
|
||||
"system": "System"
|
||||
},
|
||||
"messages.divider": "Show divider between messages",
|
||||
"messages.grid_columns": "Message grid display columns",
|
||||
@@ -1078,6 +1113,7 @@
|
||||
"messages.input.send_shortcuts": "Send shortcuts",
|
||||
"messages.input.show_estimated_tokens": "Show estimated tokens",
|
||||
"messages.input.title": "Input Settings",
|
||||
"messages.input.enable_quick_triggers": "Enable '/' and '@' triggers",
|
||||
"messages.markdown_rendering_input_message": "Markdown render input message",
|
||||
"messages.math_engine": "Math engine",
|
||||
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
|
||||
@@ -1263,7 +1299,44 @@
|
||||
"description": "Tavily is a search engine tailored for AI agents, delivering real-time, accurate results, intelligent query suggestions, and in-depth research capabilities.",
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "Web Search"
|
||||
"title": "Web Search",
|
||||
"subscribe": "Blacklist Subscription",
|
||||
"subscribe_update": "Update now",
|
||||
"subscribe_add": "Add Subscription",
|
||||
"subscribe_url": "Subscription feed address",
|
||||
"subscribe_name": "Alternative name",
|
||||
"subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.",
|
||||
"subscribe_add_success": "Subscription feed added successfully!",
|
||||
"subscribe_delete": "Delete subscription source",
|
||||
"overwrite": "Override search service",
|
||||
"overwrite_tooltip": "Force use search service instead of LLM",
|
||||
"apikey": "API key",
|
||||
"free": "Free"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "Quick Phrases",
|
||||
"add": "Add Phrase",
|
||||
"edit": "Edit Phrase",
|
||||
"titleLabel": "Title",
|
||||
"contentLabel": "Content",
|
||||
"titlePlaceholder": "Please enter phrase title",
|
||||
"contentPlaceholder": "Please enter phrase content, support using variables, and press Tab to quickly locate the variable to modify. For example: \nHelp me plan a route from ${from} to ${to}, and send it to ${email}.",
|
||||
"delete": "Delete Phrase",
|
||||
"deleteConfirm": "The phrase cannot be recovered after deletion, continue?"
|
||||
},
|
||||
"quickPanel": {
|
||||
"title": "Quick Menu",
|
||||
"close": "Close",
|
||||
"select": "Select",
|
||||
"page": "Page",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"forward": "Forward",
|
||||
"multiple": "Multiple Select"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy Settings",
|
||||
"enable_privacy_mode": "Anonymous reporting of errors and statistics"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
@@ -1289,7 +1362,10 @@
|
||||
"scroll_sync.disable": "Disable synced scroll",
|
||||
"scroll_sync.enable": "Enable synced scroll",
|
||||
"title": "Translation",
|
||||
"tooltip.newline": "Newline"
|
||||
"tooltip.newline": "Newline",
|
||||
"menu": {
|
||||
"description": "Translate the content of the current input box"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"quit": "Quit",
|
||||
|
||||