Compare commits

..

5 Commits

Author SHA1 Message Date
Neal_Tan
370cfd6e9f Merge pull request #4331 from CherryHQ/main
Merge main code
2025-04-02 22:06:47 +01:00
Neal_Tan
d213bc1024 Merge branch 'main' into feat/variable_replace_prompt 2025-04-01 18:57:46 +01:00
Neal_Tan
1187a47698 Merge pull request #4129 from TeacherTan/main
feat(Assistant): Variables replace prompts
2025-03-30 02:15:18 +01:00
Neal_Tan
83d0eb07aa fix(i18n): update locales json file
关联提交 8f6bf113
2025-03-30 02:10:50 +01:00
Neal_Tan
8f6bf11320 feat(Assistant): 增加提示词变量输入
- 在编辑助手处添加了变量
- 保存智能体时可以保存变量
Fixed #4049
2025-03-30 00:51:48 +00:00
209 changed files with 4034 additions and 146431 deletions

View File

@@ -76,10 +76,7 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:npm windows
yarn build:win:x64
yarn build:win:arm64
run: yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}

View File

@@ -88,9 +88,7 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:npm windows
yarn build:win
run: yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}

1
.gitignore vendored
View File

@@ -35,6 +35,7 @@ Thumbs.db
node_modules
dist
out
build/icons
stats.html
# ENV

View File

@@ -1,17 +0,0 @@
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 {

View File

@@ -1,8 +1,8 @@
diff --git a/core.js b/core.js
index ebb071d31cd5a14792b62814df072c5971e83300..31e1062d4a7f2422ffec79cf96a35dbb69fe89cb 100644
index e75a18281ce8f051990c5a50bc1076afdddf91a3..e62f796791a155f23d054e74a429516c14d6e11b 100644
--- a/core.js
+++ b/core.js
@@ -157,7 +157,7 @@ class APIClient {
@@ -156,7 +156,7 @@ class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
@@ -12,10 +12,10 @@ index ebb071d31cd5a14792b62814df072c5971e83300..31e1062d4a7f2422ffec79cf96a35dbb
};
}
diff --git a/core.mjs b/core.mjs
index 9c1a0264dcd73a85de1cf81df4efab9ce9ee2ab7..33f9f1f237f2eb2667a05dae1a7e3dc916f6bfff 100644
index fcef58eb502664c41a77483a00db8adaf29b2817..18c5d6ed4be86b3640931277bdc27700006764d7 100644
--- a/core.mjs
+++ b/core.mjs
@@ -150,7 +150,7 @@ export class APIClient {
@@ -149,7 +149,7 @@ export class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -32,20 +32,18 @@ asarUnpack:
- '**/*.{node,dll,metal,exp,lib}'
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}
artifactName: ${productName}-${version}-portable.${ext}
target:
- target: nsis
- target: portable
nsis:
artifactName: ${productName}-${version}-${arch}-setup.${ext}
artifactName: ${productName}-${version}-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
@@ -85,9 +83,7 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
增加对 grok-3 和 Grok-3-mini 的支持
助手支持使用拼音排序
网络搜索增加 Baidu, Google, Bing 支持(免费使用)
网络搜索增加 uBlacklist 订阅
快速面板 (QuickPanel) 进行性能优化
解决 mcp 依赖工具下载速度问题
小程序支持多开
支持 GPT-4o 图像生成
修复 MCP 服务器无法使用问题
修复升级导致旧版本数据丢失问题

View File

@@ -42,12 +42,7 @@ export default defineConfig({
}
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('packages/shared')
}
}
plugins: [externalizeDepsPlugin()]
},
renderer: {
plugins: [
@@ -75,7 +70,7 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: []
exclude: ['chunk-PZ64DZKH.js', 'chunk-JMKENWIY.js', 'chunk-UXYB6GHG.js', 'chunk-ALDIEZMG.js', 'chunk-4X6ZJEXY.js']
}
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.2.2",
"version": "1.1.17",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -25,7 +25,6 @@
"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",
@@ -66,15 +65,11 @@
"@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",
@@ -84,15 +79,10 @@
"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"
@@ -101,7 +91,6 @@
"@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",
@@ -114,13 +103,12 @@
"@google/genai": "^0.4.0",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.9.0",
"@modelcontextprotocol/sdk": "^1.8.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",
@@ -132,7 +120,6 @@
"@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",
@@ -158,10 +145,9 @@
"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.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"p-queue": "^8.1.0",
"prettier": "^3.5.3",
"rc-virtual-list": "^3.18.5",
@@ -185,7 +171,7 @@
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.2",
"shiki": "^3.2.1",
"shiki": "^1.22.2",
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tinycolor2": "^1.6.0",
@@ -198,9 +184,7 @@
"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",
"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",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
},
"packageManager": "yarn@4.6.0",

View File

@@ -1,155 +0,0 @@
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'
}

View File

@@ -157,8 +157,3 @@ 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'

View File

@@ -1,13 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<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>
</head>
<body class="bg-gray-100 p-8">
<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">
@@ -42,9 +43,8 @@
<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
>
<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>
@@ -57,23 +57,28 @@
<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
<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
<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
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.
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
You are engaging in large-scale procurement for government or educational institutions,
especially
involving security, data privacy, or other sensitive requirements.
</li>
</ol>
@@ -82,11 +87,13 @@
<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
<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
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes,
including but
not limited to cloud business operations.
</li>
</ol>
@@ -101,11 +108,11 @@
<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
>
<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>
</body>
</html>

View File

@@ -1,6 +1,7 @@
<!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>
@@ -8,17 +9,16 @@
<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>
<!-- Loading状态 -->
<div v-if="loading" class="text-center py-8">
<div
class="inline-block animate-spin rounded-full h-8 w-8 border-4"
<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>
@@ -27,14 +27,10 @@
<!-- Release 列表 -->
<div v-else class="space-y-8">
<div
v-for="release in releases"
:key="release.id"
class="relative pl-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"
<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>
@@ -45,15 +41,12 @@
{{ formatDate(release.published_at) }}
</p>
</div>
<span
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
<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'"
<div class="prose" :class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
v-html="renderMarkdown(release.body)"></div>
</div>
</div>
@@ -204,5 +197,6 @@
background-color: #f2f2f2;
}
</style>
</body>
</body>
</html>

68
resources/graphrag.html Normal file
View File

@@ -0,0 +1,68 @@
<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>

View File

@@ -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://gitcode.com/CherryHQ/bun/releases/download'
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download'
const DEFAULT_BUN_VERSION = '1.2.5' // Default fallback version
// Mapping of platform+arch to binary package name
const BUN_PACKAGES = {

View File

@@ -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://gitcode.com/CherryHQ/uv/releases/download'
const DEFAULT_UV_VERSION = '0.6.14'
const UV_RELEASE_BASE_URL = 'https://github.com/astral-sh/uv/releases/download'
const DEFAULT_UV_VERSION = '0.6.6'
// Mapping of platform+arch to binary package name
const UV_PACKAGES = {

View File

@@ -18,48 +18,28 @@ exports.default = async function (context) {
'node_modules'
)
keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
removeDifferentArchNodeFiles(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']
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch)
}
if (platform === 'windows') {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
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'])
}
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
}
}
/**
* 使用指定架构的 node_modules 文件
* @param {*} nodeModulesPath
* @param {*} packageName
* @param {*} arch
* @returns
*/
function keepPackageNodeFiles(nodeModulesPath, packageName, arch) {
function removeDifferentArchNodeFiles(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(`[After Pack] Removed dir: ${dir}`, arch)
console.log(`Removed dir: ${dir}`, arch)
})
}

View File

@@ -33,10 +33,6 @@ 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'
)
}
}

View File

@@ -22,8 +22,7 @@ function downloadNpmPackage(packageName, url) {
console.log(`Extracting ${filename}...`)
execSync(`tar -xvf ${filename}`)
execSync(`rm -rf ${filename}`)
execSync(`mkdir -p ${targetDir}`)
execSync(`mv package/* ${targetDir}/`)
execSync(`mv package ${targetDir}`)
} catch (error) {
console.error(`Error processing ${packageName}: ${error.message}`)
if (fs.existsSync(filename)) {

View File

@@ -1,13 +1,10 @@
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'
@@ -55,7 +52,7 @@ if (!app.requestSingleInstanceLock()) {
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
ipcMain.handle('system:getDeviceType', () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
})
})
@@ -94,15 +91,6 @@ 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.
}

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,6 @@ 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'
@@ -21,13 +20,12 @@ 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 { getConfigDir, getFilesDir } from './utils/file'
import { getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
@@ -38,18 +36,17 @@ const obsidianVaultService = new ObsidianVaultService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({
ipcMain.handle('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(IpcChannel.App_Proxy, async (_, proxy: string) => {
ipcMain.handle('app:proxy', async (_, proxy: string) => {
let proxyConfig: ProxyConfig
if (proxy === 'system') {
@@ -63,19 +60,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await proxyManager.configureProxy(proxyConfig)
})
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
ipcMain.handle('app:reload', () => mainWindow.reload())
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
ipcMain.handle('app:show-update-dialog', () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
ipcMain.handle('app:set-language', (_, language) => {
configManager.setLanguage(language)
})
// launch on boot
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
ipcMain.handle('app:set-launch-on-boot', (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
@@ -84,32 +81,32 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// launch to tray
ipcMain.handle(IpcChannel.App_SetLaunchToTray, (_, isActive: boolean) => {
ipcMain.handle('app:set-launch-to-tray', (_, isActive: boolean) => {
configManager.setLaunchToTray(isActive)
})
// tray
ipcMain.handle(IpcChannel.App_SetTray, (_, isActive: boolean) => {
ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
configManager.setTray(isActive)
})
// to tray on close
ipcMain.handle(IpcChannel.App_SetTrayOnClose, (_, isActive: boolean) => {
ipcMain.handle('app:set-tray-on-close', (_, isActive: boolean) => {
configManager.setTrayOnClose(isActive)
})
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
ipcMain.handle('config:set', (_, key: string, value: any) => {
configManager.set(key, value)
})
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
ipcMain.handle('config:get', (_, key: string) => {
return configManager.get(key)
})
// theme
ipcMain.handle(IpcChannel.App_SetTheme, (event, theme: ThemeMode) => {
ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => {
if (theme === configManager.getTheme()) return
configManager.setTheme(theme)
@@ -120,7 +117,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// 向其他窗口广播主题变化
windows.forEach((win) => {
if (win.webContents.id !== senderWindowId) {
win.webContents.send(IpcChannel.ThemeChange, theme)
win.webContents.send('theme:change', theme)
}
})
@@ -129,7 +126,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// clear cache
ipcMain.handle(IpcChannel.App_ClearCache, async () => {
ipcMain.handle('app:clear-cache', async () => {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
try {
@@ -151,7 +148,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// check for update
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
ipcMain.handle('app:check-for-update', async () => {
const update = await appUpdater.autoUpdater.checkForUpdates()
return {
currentVersion: appUpdater.autoUpdater.currentVersion,
@@ -160,50 +157,62 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// zip
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
// backup
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)
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)
// file
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)
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)
// fs
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
ipcMain.handle('fs:read', FileService.readFile)
// minapp
ipcMain.handle('minapp', (_, args) => {
windowService.createMinappWindow({
url: args.url,
parent: mainWindow,
windowOptions: {
...mainWindow.getBounds(),
...args.windowOptions
}
})
})
// export
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord)
ipcMain.handle('export:word', exportService.exportToWord)
// open path
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
ipcMain.handle('open:path', async (_, path: string) => {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
@@ -213,20 +222,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// knowledge base
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)
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)
// window
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
ipcMain.handle('window:set-minimum-size', (_, width: number, height: number) => {
mainWindow?.setMinimumSize(width, height)
})
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
ipcMain.handle('window:reset-minimum-size', () => {
mainWindow?.setMinimumSize(1080, 600)
const [width, height] = mainWindow?.getSize() ?? [1080, 600]
if (width < 1080) {
@@ -235,72 +244,59 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// gemini
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)
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)
// mini window
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))
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))
// aes
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) =>
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
ipcMain.handle('aes:decrypt', (_, encryptedData: string, iv: string, secretKey: string) =>
decrypt(encryptedData, iv, secretKey)
)
// Register MCP handlers
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('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.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'))
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'))
//copilot
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)
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)
// Obsidian service
ipcMain.handle(IpcChannel.Obsidian_GetVaults, () => {
ipcMain.handle('obsidian:get-vaults', () => {
return obsidianVaultService.getVaults()
})
ipcMain.handle(IpcChannel.Obsidian_GetFiles, (_event, vaultName) => {
ipcMain.handle('obsidian:get-files', (_event, vaultName) => {
return obsidianVaultService.getFilesByVaultName(vaultName)
})
// nutstore
ipcMain.handle(IpcChannel.Nutstore_GetSsoUrl, NutstoreService.getNutstoreSSOUrl)
ipcMain.handle(IpcChannel.Nutstore_DecryptToken, (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl)
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
// 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)
})
}

View File

@@ -1,374 +0,0 @@
// 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

View File

@@ -1,32 +0,0 @@
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}`)
}
}

View File

@@ -1,236 +0,0 @@
// 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

View File

@@ -1,655 +0,0 @@
// 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

View File

@@ -1,509 +0,0 @@
// 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

View File

@@ -1,289 +0,0 @@
// 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

View File

@@ -3,60 +3,14 @@ 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}`,
@@ -64,7 +18,7 @@ export default abstract class BaseReranker {
}
}
protected formatErrorMessage(url: string, error: any, requestBody: any) {
public formatErrorMessage(url: string, error: any, requestBody: any) {
const errorDetails = {
url: url,
message: error.message,

View File

@@ -10,7 +10,16 @@ export default class JinaReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
const url = this.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`
}
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
@@ -23,9 +32,23 @@ export default class JinaReranker extends BaseReranker {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const rerankResults = data.results
return this.getRerankResult(searchResults, rerankResults)
console.log(rerankResults)
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('Jina Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}

View File

@@ -10,7 +10,16 @@ export default class SiliconFlowReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
const url = this.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`
}
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
@@ -25,7 +34,20 @@ export default class SiliconFlowReranker extends BaseReranker {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const rerankResults = data.results
return this.getRerankResult(searchResults, rerankResults)
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)

View File

@@ -10,7 +10,15 @@ export default class VoyageReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
const url = this.getRerankUrl()
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
@@ -29,7 +37,21 @@ export default class VoyageReranker extends BaseReranker {
})
const rerankResults = data.data
return this.getRerankResult(searchResults, rerankResults)
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)

View File

@@ -1,4 +1,3 @@
import { IpcChannel } from '@shared/IpcChannel'
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
@@ -25,27 +24,27 @@ export default class AppUpdater {
stack: error.stack,
time: new Date().toISOString()
})
mainWindow.webContents.send(IpcChannel.UpdateError, error)
mainWindow.webContents.send('update-error', error)
})
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
logger.info('检测到新版本', releaseInfo)
mainWindow.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
mainWindow.webContents.send('update-available', releaseInfo)
})
// 检测到不需要更新时
autoUpdater.on('update-not-available', () => {
mainWindow.webContents.send(IpcChannel.UpdateNotAvailable)
mainWindow.webContents.send('update-not-available')
})
// 更新下载进度
autoUpdater.on('download-progress', (progress) => {
mainWindow.webContents.send(IpcChannel.DownloadProgress, progress)
mainWindow.webContents.send('download-progress', progress)
})
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
mainWindow.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
mainWindow.webContents.send('update-downloaded', releaseInfo)
this.releaseInfo = releaseInfo
logger.info('下载完成', releaseInfo)
})
@@ -74,7 +73,7 @@ export default class AppUpdater {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
mainWindow.webContents.send('update-downloaded-cancelled')
}
})
}

View File

@@ -1,4 +1,3 @@
import { IpcChannel } from '@shared/IpcChannel'
import { WebDavConfig } from '@types'
import AdmZip from 'adm-zip'
import { exec } from 'child_process'
@@ -80,7 +79,7 @@ class BackupManager {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
mainWindow?.webContents.send('backup-progress', processData)
Logger.log('[BackupManager] backup progress', processData)
}
@@ -140,7 +139,7 @@ class BackupManager {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
mainWindow?.webContents.send('restore-progress', processData)
Logger.log('[BackupManager] restore progress', processData)
}

View File

@@ -1,22 +1,10 @@
import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant'
import { 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()
@@ -26,54 +14,54 @@ export class ConfigManager {
}
getLanguage(): LanguageVarious {
const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : defaultLanguage
return this.get(ConfigKeys.Language, locale) as LanguageVarious
const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : 'en-US'
return this.store.get('language', locale) as LanguageVarious
}
setLanguage(theme: LanguageVarious) {
this.set(ConfigKeys.Language, theme)
this.store.set('language', theme)
}
getTheme(): ThemeMode {
return this.get(ConfigKeys.Theme, ThemeMode.light)
return this.store.get('theme', ThemeMode.light) as ThemeMode
}
setTheme(theme: ThemeMode) {
this.set(ConfigKeys.Theme, theme)
this.store.set('theme', theme)
}
getLaunchToTray(): boolean {
return !!this.get(ConfigKeys.LaunchToTray, false)
return !!this.store.get('launchToTray', false)
}
setLaunchToTray(value: boolean) {
this.set(ConfigKeys.LaunchToTray, value)
this.store.set('launchToTray', value)
}
getTray(): boolean {
return !!this.get(ConfigKeys.Tray, true)
return !!this.store.get('tray', true)
}
setTray(value: boolean) {
this.set(ConfigKeys.Tray, value)
this.notifySubscribers(ConfigKeys.Tray, value)
this.store.set('tray', value)
this.notifySubscribers('tray', value)
}
getTrayOnClose(): boolean {
return !!this.get(ConfigKeys.TrayOnClose, true)
return !!this.store.get('trayOnClose', true)
}
setTrayOnClose(value: boolean) {
this.set(ConfigKeys.TrayOnClose, value)
this.store.set('trayOnClose', value)
}
getZoomFactor(): number {
return this.get<number>(ConfigKeys.ZoomFactor, 1)
return this.store.get('zoomFactor', 1) as number
}
setZoomFactor(factor: number) {
this.set(ConfigKeys.ZoomFactor, factor)
this.notifySubscribers(ConfigKeys.ZoomFactor, factor)
this.store.set('zoomFactor', factor)
this.notifySubscribers('zoomFactor', factor)
}
subscribe<T>(key: string, callback: (newValue: T) => void) {
@@ -101,39 +89,39 @@ export class ConfigManager {
}
getShortcuts() {
return this.get(ConfigKeys.Shortcuts, ZOOM_SHORTCUTS) as Shortcut[] | []
return this.store.get('shortcuts', ZOOM_SHORTCUTS) as Shortcut[] | []
}
setShortcuts(shortcuts: Shortcut[]) {
this.set(
ConfigKeys.Shortcuts,
this.store.set(
'shortcuts',
shortcuts.filter((shortcut) => shortcut.system)
)
this.notifySubscribers(ConfigKeys.Shortcuts, shortcuts)
this.notifySubscribers('shortcuts', shortcuts)
}
getClickTrayToShowQuickAssistant(): boolean {
return this.get<boolean>(ConfigKeys.ClickTrayToShowQuickAssistant, false)
return this.store.get('clickTrayToShowQuickAssistant', false) as boolean
}
setClickTrayToShowQuickAssistant(value: boolean) {
this.set(ConfigKeys.ClickTrayToShowQuickAssistant, value)
this.store.set('clickTrayToShowQuickAssistant', value)
}
getEnableQuickAssistant(): boolean {
return this.get(ConfigKeys.EnableQuickAssistant, false)
return this.store.get('enableQuickAssistant', false) as boolean
}
setEnableQuickAssistant(value: boolean) {
this.set(ConfigKeys.EnableQuickAssistant, value)
this.store.set('enableQuickAssistant', value)
}
set(key: string, value: unknown) {
set(key: string, value: any) {
this.store.set(key, value)
}
get<T>(key: string, defaultValue?: T) {
return this.store.get(key, defaultValue) as T
get(key: string) {
return this.store.get(key)
}
}

View File

@@ -1,5 +1,5 @@
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
import { documentExts, imageExts, MB } from '@shared/config/constant'
import { documentExts, imageExts } 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 / MB
const fileSizeInMB = stats.size / (1024 * 1024)
// 如果图片大于1MB才进行压缩
if (fileSizeInMB > 1) {

View File

@@ -26,9 +26,7 @@ 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'
@@ -93,7 +91,7 @@ class KnowledgeService {
private workload = 0
private processingItemCount = 0
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
private static MAXIMUM_WORKLOAD = 80 * MB
private static MAXIMUM_WORKLOAD = 1024 * 1024 * 80
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
@@ -196,7 +194,7 @@ class KnowledgeService {
const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => {
const mainWindow = windowService.getMainWindow()
mainWindow?.webContents.send(IpcChannel.DirectoryProcessingPercent, {
mainWindow?.webContents.send('directory-processing-percent', {
itemId: item.id,
percent: (processedFiles / totalFiles) * 100
})
@@ -272,7 +270,7 @@ class KnowledgeService {
return KnowledgeService.ERROR_LOADER_RETURN
})
},
evaluateTaskWorkload: { workload: 2 * MB }
evaluateTaskWorkload: { workload: 1024 * 1024 * 2 }
}
],
loaderDoneReturn: null
@@ -311,7 +309,7 @@ class KnowledgeService {
Logger.error(err)
return KnowledgeService.ERROR_LOADER_RETURN
}),
evaluateTaskWorkload: { workload: 20 * MB }
evaluateTaskWorkload: { workload: 1024 * 1024 * 20 }
}
],
loaderDoneReturn: null

View File

@@ -2,20 +2,17 @@ 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()
@@ -39,7 +36,6 @@ 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> {
@@ -48,7 +44,6 @@ class McpService {
// Check if we already have a client for this server configuration
const existingClient = this.clients.get(serverKey)
if (existingClient) {
try {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
@@ -59,45 +54,19 @@ class McpService {
} else {
return existingClient
}
} catch (error) {
Logger.error(`[MCP] Error pinging server ${server.name}:`, error)
this.clients.delete(serverKey)
}
}
// Create new client instance for each connection
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
const args = [...(server.args || [])]
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
let transport: StdioClientTransport | SSEClientTransport
try {
// Create appropriate transport based on configuration
if (server.type === 'inMemory') {
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error) {
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')
}
if (server.baseUrl) {
transport = new SSEClientTransport(new URL(server.baseUrl))
} else if (server.command) {
let cmd = server.command
@@ -121,10 +90,10 @@ class McpService {
}
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name.includes('mcp-auto-install')) {
if (server.name === 'mcp-auto-install') {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json')
}
}
} else if (server.command === 'uvx' || server.command === 'uv') {
@@ -148,12 +117,8 @@ 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')
}
@@ -206,16 +171,6 @@ 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)
@@ -335,5 +290,4 @@ class McpService {
}
}
const mcpService = new McpService()
export default mcpService
export default new McpService()

View File

@@ -1,365 +0,0 @@
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()
}
}

View File

@@ -1,4 +1,3 @@
import { IpcChannel } from '@shared/IpcChannel'
import { ipcMain } from 'electron'
import { EventEmitter } from 'events'
@@ -11,8 +10,6 @@ export class ReduxService extends EventEmitter {
private stateCache: any = {}
private isReady = false
private readonly STATUS_CHANGE_EVENT = 'statusChange'
constructor() {
super()
this.setupIpcHandlers()
@@ -20,15 +17,15 @@ export class ReduxService extends EventEmitter {
private setupIpcHandlers() {
// 监听 store 就绪事件
ipcMain.handle(IpcChannel.ReduxStoreReady, () => {
ipcMain.handle('redux-store-ready', () => {
this.isReady = true
this.emit('ready')
})
// 监听 store 状态变化
ipcMain.on(IpcChannel.ReduxStateChange, (_, newState) => {
ipcMain.on('redux-state-change', (_, newState) => {
this.stateCache = newState
this.emit(this.STATUS_CHANGE_EVENT, newState)
this.emit('stateChange', newState)
})
}
@@ -125,23 +122,19 @@ 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('` +
IpcChannel.ReduxStateChange +
`', state);
window.electron.ipcRenderer.send('redux-state-change', state);
});
window._storeSubscriptions.add(unsubscribe);
}
`
)
`)
// 在主进程中处理回调
const handler = async () => {
@@ -153,9 +146,9 @@ export class ReduxService extends EventEmitter {
}
}
this.on(this.STATUS_CHANGE_EVENT, handler)
this.on('stateChange', handler)
return () => {
this.off(this.STATUS_CHANGE_EVENT, handler)
this.off('stateChange', handler)
}
}
@@ -187,7 +180,7 @@ export class ReduxService extends EventEmitter {
export const reduxService = new ReduxService()
/** example
async function example() {
async function example() {
try {
// 读取状态
const settings = await reduxService.select('state.settings')
@@ -223,5 +216,5 @@ export const reduxService = new ReduxService()
} catch (error) {
console.error('Error:', error)
}
}
*/
}
*/

View File

@@ -1,82 +0,0 @@
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()

View File

@@ -1,7 +1,6 @@
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'
@@ -17,6 +16,7 @@ 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,8 +40,7 @@ export class WindowService {
const mainWindowState = windowStateKeeper({
defaultWidth: 1080,
defaultHeight: 670,
fullScreen: false
defaultHeight: 670
})
const theme = configManager.getTheme()
@@ -53,7 +52,7 @@ export class WindowService {
height: mainWindowState.height,
minWidth: 1080,
minHeight: 600,
show: false,
show: false, // 初始不显示
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'sidebar',
@@ -83,6 +82,36 @@ 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)
@@ -138,11 +167,13 @@ export class WindowService {
// 处理全屏相关事件
mainWindow.on('enter-full-screen', () => {
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
this.wasFullScreen = true
mainWindow.webContents.send('fullscreen-status-changed', true)
})
mainWindow.on('leave-full-screen', () => {
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
this.wasFullScreen = false
mainWindow.webContents.send('fullscreen-status-changed', false)
})
// set the zoom factor again when the window is going to resize
@@ -273,14 +304,22 @@ 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
if (isMac && isTrayOnClose) {
app.dock?.hide()
}
})
mainWindow.on('closed', () => {
@@ -304,34 +343,13 @@ export class WindowService {
this.mainWindow.restore()
return
}
/**
* 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) {
//[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)
}
//[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()
if (!isLinux) {
this.mainWindow.setVisibleOnAllWorkspaces(false)
}
} else {
this.mainWindow = this.createMainWindow()
}
@@ -339,9 +357,7 @@ export class WindowService {
public toggleMainWindow() {
// should not toggle main window when in full screen
// 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()) {
if (this.wasFullScreen) {
return
}
@@ -395,8 +411,7 @@ 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
//[mac] level higher than 'floating' will cover the pinyin input method
this.miniWindow.setAlwaysOnTop(true, 'floating')
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
this.miniWindow.on('ready-to-show', () => {
if (isPreload) {
@@ -419,14 +434,14 @@ export class WindowService {
})
this.miniWindow.on('hide', () => {
this.miniWindow?.webContents.send(IpcChannel.HideMiniWindow)
this.miniWindow?.webContents.send('hide-mini-window')
})
this.miniWindow.on('show', () => {
this.miniWindow?.webContents.send(IpcChannel.ShowMiniWindow)
this.miniWindow?.webContents.send('show-mini-window')
})
ipcMain.on(IpcChannel.MiniWindowReload, () => {
ipcMain.on('miniwindow-reload', () => {
this.miniWindow?.reload()
})
@@ -532,7 +547,7 @@ export class WindowService {
// 点击其他地方时隐藏窗口
this.selectionMenuWindow.on('blur', () => {
this.selectionMenuWindow?.hide()
this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
this.miniWindow?.webContents.send('selection-action', {
action: 'home',
selectedText: this.lastSelectedText
})
@@ -550,12 +565,12 @@ export class WindowService {
private setupSelectionMenuEvents() {
if (!this.selectionMenuWindow) return
ipcMain.removeHandler(IpcChannel.SelectionMenu_Action)
ipcMain.handle(IpcChannel.SelectionMenu_Action, (_, action) => {
ipcMain.removeHandler('selection-menu:action')
ipcMain.handle('selection-menu:action', (_, action) => {
this.selectionMenuWindow?.hide()
this.showMiniWindow()
setTimeout(() => {
this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
this.miniWindow?.webContents.send('selection-action', {
action,
selectedText: this.lastSelectedText
})

View File

@@ -1,5 +1,4 @@
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'
@@ -75,7 +74,3 @@ export function getTempDir() {
export function getFilesDir() {
return path.join(app.getPath('userData'), 'Data', 'Files')
}
export function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
}

View File

@@ -29,6 +29,7 @@ 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: {
@@ -175,11 +176,6 @@ 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>
}
}
}
}

View File

@@ -1,82 +1,79 @@
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(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),
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'),
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType)
getDeviceType: () => ipcRenderer.invoke('system:getDeviceType')
},
zip: {
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
},
backup: {
backup: (fileName: string, data: string, destinationPath?: string) =>
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath),
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath),
backupToWebdav: (data: string, webdavConfig: 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),
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig),
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig),
checkConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:checkConnection', webdavConfig),
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options)
ipcRenderer.invoke('backup:createDirectory', webdavConfig, path, options)
},
file: {
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),
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),
save: (path: string, content: string, options?: { compress: boolean }) =>
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)
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)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
read: (path: string) => ipcRenderer.invoke('fs:read', path)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
},
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path),
openPath: (path: string) => ipcRenderer.invoke('open:path', path),
shortcuts: {
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
},
knowledgeBase: {
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),
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),
add: ({
base,
item,
@@ -85,74 +82,71 @@ const api = {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload }),
}) => ipcRenderer.invoke('knowledge-base:add', { base, item, forceReload }),
remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
ipcRenderer.invoke('knowledge-base:remove', { uniqueId, uniqueIds, base }),
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }),
ipcRenderer.invoke('knowledge-base:search', { search, base }),
rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results })
ipcRenderer.invoke('knowledge-base:rerank', { search, base, results })
},
window: {
setMinimumSize: (width: number, height: number) =>
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
setMinimumSize: (width: number, height: number) => ipcRenderer.invoke('window:set-minimum-size', width, height),
resetMinimumSize: () => ipcRenderer.invoke('window:reset-minimum-size')
},
gemini: {
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)
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)
},
selectionMenu: {
action: (action: string) => ipcRenderer.invoke(IpcChannel.SelectionMenu_Action, action)
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
},
config: {
set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value),
get: (key: string) => ipcRenderer.invoke('config:get', key)
},
miniWindow: {
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)
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)
},
aes: {
encrypt: (text: string, secretKey: string, iv: string) =>
ipcRenderer.invoke(IpcChannel.Aes_Encrypt, text, secretKey, iv),
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),
decrypt: (encryptedData: string, iv: string, secretKey: string) =>
ipcRenderer.invoke(IpcChannel.Aes_Decrypt, encryptedData, iv, secretKey)
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
},
mcp: {
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),
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server),
restartServer: (server: MCPServer) => ipcRenderer.invoke('mcp:restart-server', server),
stopServer: (server: MCPServer) => ipcRenderer.invoke('mcp:stop-server', server),
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
ipcRenderer.invoke('mcp:call-tool', { server, name, args }),
getInstallInfo: () => ipcRenderer.invoke('mcp:get-install-info')
},
shell: {
openExternal: shell.openExternal
},
copilot: {
getAuthMessage: (headers?: Record<string, string>) =>
ipcRenderer.invoke(IpcChannel.Copilot_GetAuthMessage, headers),
getAuthMessage: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-auth-message', headers),
getCopilotToken: (device_code: string, headers?: Record<string, string>) =>
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)
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)
},
// Binary related APIs
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),
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'),
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
@@ -165,15 +159,10 @@ const api = {
}
},
nutstore: {
getSSOUrl: () => ipcRenderer.invoke(IpcChannel.Nutstore_GetSsoUrl),
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
getSSOUrl: () => ipcRenderer.invoke('nutstore:get-sso-url'),
decryptToken: (token: string) => ipcRenderer.invoke('nutstore:decrypt-token', token),
getDirectoryContents: (token: string, path: string) =>
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)
ipcRenderer.invoke('nutstore:get-directory-contents', token, path)
}
}
@@ -185,9 +174,9 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('obsidian', {
getVaults: () => ipcRenderer.invoke(IpcChannel.Obsidian_GetVaults),
getFolders: (vaultName: string) => ipcRenderer.invoke(IpcChannel.Obsidian_GetFiles, vaultName),
getFiles: (vaultName: string) => ipcRenderer.invoke(IpcChannel.Obsidian_GetFiles, vaultName)
getVaults: () => ipcRenderer.invoke('obsidian:get-vaults'),
getFolders: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName),
getFiles: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName)
})
} catch (error) {
console.error(error)

View File

@@ -1,10 +1,10 @@
<!doctype html>
<html lang="zh-CN">
<head>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
http-equiv="Content-Security-Policy"
<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>
@@ -29,14 +29,14 @@
border-radius: 50px;
}
</style>
</head>
</head>
<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>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -36,7 +36,7 @@
--color-text: var(--color-text-1);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff19;
--color-border: #ffffff15;
--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: rgba(0, 0, 0, 0.04);
--color-white-soft: #f2f2f2;
--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: #00000019;
--color-border: #00000015;
--color-border-soft: #00000010;
--color-border-mute: #00000005;
--color-error: #f44336;

View File

@@ -21,9 +21,8 @@
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 {
@@ -171,9 +170,8 @@
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 {
@@ -297,10 +295,10 @@ emoji-picker {
--border-size: 0;
}
.katex-display {
.katex-display{
overflow-x: auto;
overflow-y: hidden;
}
mjx-container {
mjx-container{
overflow-x: auto;
}

View File

@@ -5,23 +5,10 @@ interface CustomCollapseProps {
label: React.ReactNode
extra: React.ReactNode
children: React.ReactNode
destroyInactivePanel?: boolean
defaultActiveKey?: string[]
activeKey?: string[]
collapsible?: 'header' | 'icon' | 'disabled'
}
const CustomCollapse: FC<CustomCollapseProps> = ({
label,
extra,
children,
destroyInactivePanel = false,
defaultActiveKey = ['1'],
activeKey,
collapsible = undefined
}) => {
const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) => {
const CollapseStyle = {
width: '100%',
background: 'transparent',
border: '0.5px solid var(--color-border)'
}
@@ -29,10 +16,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
header: {
padding: '8px 16px',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--color-background-soft)',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px'
justifyContent: 'space-between'
},
body: {
borderTop: '0.5px solid var(--color-border)'
@@ -42,10 +26,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
<Collapse
bordered={false}
style={CollapseStyle}
defaultActiveKey={defaultActiveKey}
activeKey={activeKey}
destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible}
defaultActiveKey={['1']}
items={[
{
styles: CollapseItemStyles,

View File

@@ -1,67 +0,0 @@
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;
}
`

View File

@@ -1,36 +0,0 @@
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

View File

@@ -1,37 +1,6 @@
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
@@ -53,27 +22,20 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
// Generate all possible favicon URLs
const faviconUrls = [
`https://icon.horse/icon/${hostname}`,
`https://favicon.splitbee.io/?url=${hostname}`,
`https://${hostname}/favicon.ico`,
`https://icon.horse/icon/${hostname}`,
`https://favicon.cccyun.cc/${hostname}`,
`https://favicon.im/${hostname}`,
`https://${hostname}/favicon.ico`
`https://www.google.com/s2/favicons?domain=${hostname}`
]
// 过滤掉最近已失败的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 = validFaviconUrls.map((url) =>
const faviconPromises = faviconUrls.map((url) =>
fetch(url, {
method: 'HEAD',
signal,
@@ -83,10 +45,6 @@ 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) => {
@@ -131,10 +89,6 @@ 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' })
}

View File

@@ -1,13 +0,0 @@
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>
)
}

View File

@@ -70,7 +70,7 @@ const TextContainer = styled.div`
overflow: hidden;
`
const TitleText = styled.div<{ $active?: boolean }>`
const TitleText = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -38,6 +38,13 @@ 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)
}
@@ -46,6 +53,7 @@ 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)
@@ -53,6 +61,7 @@ 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)
}
@@ -67,6 +76,7 @@ const WebviewContainer = memo(
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
nodeintegration={'true' as any}
/>
)
}

View File

@@ -1,131 +0,0 @@
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

View File

@@ -1,5 +1,4 @@
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'
@@ -22,7 +21,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const { t } = useTranslation()
useEffect(() => {
const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => {
const removeListener = window.electron.ipcRenderer.on('backup-progress', (_, data: ProgressData) => {
setProgressData(data)
})

View File

@@ -1,5 +1,4 @@
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'
@@ -22,7 +21,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const { t } = useTranslation()
useEffect(() => {
const removeListener = window.electron.ipcRenderer.on(IpcChannel.RestoreProgress, (_, data: ProgressData) => {
const removeListener = window.electron.ipcRenderer.on('restore-progress', (_, data: ProgressData) => {
setProgressData(data)
})

View File

@@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { HStack } from '../Layout'
import ModelTagsWithLabel from '../ModelTagsWithLabel'
import ModelTags from '../ModelTags'
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> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
<span>{m?.name}</span> <ModelTags model={m} />
</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>{' '}
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
<ModelTags model={m.model} />
</ModelNameRow>
<PinIcon
onClick={(e) => {
@@ -366,7 +366,6 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
open={open}
onCancel={onCancel}
afterClose={onClose}
width={600}
transitionName="ant-move-down"
styles={{
content: {
@@ -481,10 +480,6 @@ const StyledMenu = styled(Menu)`
}
}
}
.anticon {
min-width: auto;
}
}
`

View File

@@ -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 React, { useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'

View File

@@ -1,11 +0,0 @@
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
}

View File

@@ -1,4 +0,0 @@
export * from './hook'
export * from './provider'
export * from './types'
export * from './view'

View File

@@ -1,83 +0,0 @@
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 }

View File

@@ -1,66 +0,0 @@
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
}

View File

@@ -1,668 +0,0 @@
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;
`

View File

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

View File

@@ -288,7 +288,7 @@ const PinnedApps: FC = () => {
<Icon
theme={theme}
onClick={() => openMinappKeepAlive(app)}
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-minapp' : ''}`}>
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-animation' : ''}`}>
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
</Icon>
</Dropdown>
@@ -403,10 +403,11 @@ const Icon = styled.div<{ theme: string }>`
}
}
&.opened-minapp {
&.opened-animation {
position: relative;
}
&.opened-minapp::after {
&.opened-animation::after {
content: '';
position: absolute;
width: 100%;
@@ -414,8 +415,13 @@ const Icon = styled.div<{ theme: string }>`
top: 0;
left: 0;
border-radius: inherit;
opacity: 0.3;
opacity: 0;
will-change: opacity;
border: 0.5px solid var(--color-primary);
/* NOTICE: although we have optimized for the performance,
* the infinite animation will still consume a little GPU resources,
* it's a trade-off balance between performance and animation smoothness*/
animation: borderBreath 4s ease-in-out infinite;
}
`

View File

@@ -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,6 +308,12 @@ 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: '小艺',
@@ -385,12 +391,5 @@ 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
}
]

View File

@@ -130,11 +130,9 @@ 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
@@ -186,7 +184,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.*|.*\bgrok-3-mini(?:-[\w-]+)?\b.*)$/i
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*)$/i
// Embedding models
export const EMBEDDING_REGEX =
@@ -210,8 +208,7 @@ export const FUNCTION_CALLING_MODELS = [
'deepseek',
'glm-4(?:-[\\w-]+)?',
'learnlm(?:-[\\w-]+)?',
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
'grok-3(?:-[\\w-]+)?'
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
]
const FUNCTION_CALLING_EXCLUDED_MODELS = [
@@ -235,10 +232,6 @@ 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
}
@@ -505,6 +498,12 @@ 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',
@@ -2013,56 +2012,6 @@ 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'
}
]
}
@@ -2199,33 +2148,13 @@ 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 isSupportedReasoningEffortModel(model?: Model): boolean {
export function isSupportedResoningEffortModel(model?: Model): boolean {
if (!model) {
return false
}
if (
model.id.includes('claude-3-7-sonnet') ||
model.id.includes('claude-3.7-sonnet') ||
isOpenAIoSeries(model) ||
isGrokReasoningModel(model)
) {
return true
}
return false
}
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
if (model.id.includes('grok-3-mini')) {
if (model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet') || isOpenAIoSeries(model)) {
return true
}
@@ -2283,7 +2212,7 @@ export function isWebSearchModel(model: Model): boolean {
}
if (provider?.type === 'openai') {
if (GEMINI_SEARCH_MODELS.includes(model?.id) || isOpenAIWebSearch(model)) {
if (GEMINI_SEARCH_MODELS.includes(model?.id)) {
return true
}
}
@@ -2310,7 +2239,7 @@ export function isWebSearchModel(model: Model): boolean {
return true
}
return model.type?.includes('web_search') || false
return false
}
export function isGenerateImageModel(model: Model): boolean {
@@ -2336,15 +2265,12 @@ 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, citation: true, search_info: true }
return { enable_enhancement: true }
}
if (model.provider === 'dashscope') {
@@ -2358,14 +2284,10 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
if (model.provider === 'openrouter') {
return {
plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }]
plugins: [{ id: 'web' }]
}
}
if (isOpenAIWebSearch(model)) {
return {}
}
return {
tools: webSearchTools
}
@@ -2386,23 +2308,3 @@ 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
}

View File

@@ -1,5 +1,3 @@
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!
@@ -111,20 +109,3 @@ 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)
`

View File

@@ -14,6 +14,7 @@ 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'
@@ -32,7 +33,6 @@ 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,6 +64,7 @@ const PROVIDER_LOGO_MAP = {
gemini: GoogleProviderLogo,
stepfun: StepProviderLogo,
doubao: BytedanceProviderLogo,
'graphrag-kylin-mountain': GraphRagProviderLogo,
minimax: MinimaxProviderLogo,
github: GithubProviderLogo,
copilot: GithubProviderLogo,
@@ -87,8 +88,7 @@ const PROVIDER_LOGO_MAP = {
'tencent-cloud-ti': TencentCloudProviderLogo,
gpustack: GPUStackProviderLogo,
alayanew: AlayaNewProviderLogo,
voyageai: VoyageAIProviderLogo,
qiniu: QiniuProviderLogo
voyageai: VoyageAIProviderLogo
} as const
export function getProviderLogo(providerId: string) {
@@ -125,9 +125,10 @@ export const PROVIDER_CONFIG = {
url: 'https://api.ppinfra.com/v3/openai'
},
websites: {
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',
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',
models:
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link'
}
@@ -149,7 +150,7 @@ export const PROVIDER_CONFIG = {
},
websites: {
official: 'https://www.siliconflow.cn/',
apiKey: 'https://cloud.siliconflow.cn/i/d1nTBKXU',
apiKey: 'https://cloud.siliconflow.cn/account/ak?referrer=clxty1xuy0014lvqwh5z50i88',
docs: 'https://docs.siliconflow.cn/',
models: 'https://docs.siliconflow.cn/docs/model-names'
}
@@ -572,16 +573,5 @@ 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'
}
}
}

View File

@@ -1,17 +1,12 @@
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',
web_browser: {
browser: 'auto'
}
type: 'web_browser'
} as unknown as ChatCompletionTool
]
}
@@ -20,14 +15,12 @@ export function getWebSearchTools(model: Model): ChatCompletionTool[] {
type: 'web_search',
web_search: {
enable: true,
search_result: true,
search_prompt: WEB_SEARCH_PROMPT_FOR_ZHIPU
search_result: true
}
} as unknown as ChatCompletionTool
]
}
if (model?.id.includes('gemini')) {
return [
{
type: 'function',
@@ -36,6 +29,4 @@ export function getWebSearchTools(model: Model): ChatCompletionTool[] {
}
}
]
}
return []
}

View File

@@ -31,20 +31,5 @@ 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'
}
}
}

View File

@@ -1,33 +1,21 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings'
import { CodeCacheService } from '@renderer/services/CodeCacheService'
import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
let highlighterPromise: Promise<Highlighter> | null = null
async function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
langs: ['javascript', 'typescript', 'python', 'java', 'markdown'],
themes: ['one-light', 'material-theme-darker']
})
}
return await highlighterPromise
}
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki'
import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki'
interface SyntaxHighlighterContextType {
codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
codeToHtml: (code: string, language: string) => 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()
@@ -39,14 +27,29 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
return codeStyle
}, [theme, codeStyle])
const codeToHtml = useCallback(
async (_code: string, language: string, enableCache: boolean) => {
{
if (!_code) return ''
useEffect(() => {
const initHighlighter = async () => {
const commonLanguages = ['javascript', 'typescript', 'python', 'java', 'markdown']
const key = CodeCacheService.generateCacheKey(_code, language, highlighterTheme)
const cached = enableCache ? CodeCacheService.getCachedResult(key) : null
if (cached) return cached
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) => {
{
if (!highlighter) return ''
const languageMap: Record<string, string> = {
vab: 'vb'
@@ -58,41 +61,25 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!)
try {
const highlighter = await getHighlighter()
if (!highlighter.getLoadedThemes().includes(highlighterTheme)) {
const themeImportFn = bundledThemes[highlighterTheme]
if (themeImportFn) {
await highlighter.loadTheme(await themeImportFn())
if (!highlighter.getLoadedLanguages().includes(mappedLanguage as BundledLanguage)) {
if (mappedLanguage in bundledLanguages || mappedLanguage === 'text') {
await highlighter.loadLanguage(mappedLanguage as BundledLanguage)
} else {
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) {
const languageImportFn = bundledLanguages[mappedLanguage]
if (languageImportFn) {
await highlighter.loadLanguage(await languageImportFn())
}
}
// 生成高亮HTML
const html = highlighter.codeToHtml(code, {
return highlighter.codeToHtml(code, {
lang: mappedLanguage,
theme: highlighterTheme
})
// 设置缓存
if (enableCache) {
CodeCacheService.setCachedResult(key, html, _code.length)
}
return html
} catch (error) {
console.debug(`Error highlighting code for language '${mappedLanguage}':`, error)
console.warn(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
},
[highlighterTheme]
[highlighter, highlighterTheme]
)
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>

View File

@@ -1,7 +1,6 @@
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 {
@@ -50,7 +49,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(IpcChannel.ThemeChange, (_, newTheme) => {
const themeChangeListenerRemover = window.electron.ipcRenderer.on('theme:change', (_, newTheme) => {
setTheme(newTheme)
})
return () => {

View File

@@ -1,8 +1,7 @@
import { FileType, KnowledgeItem, QuickPhrase, Topic, TranslateHistory } from '@renderer/types'
import { FileType, KnowledgeItem, 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'>
@@ -10,7 +9,6 @@ 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({
@@ -48,13 +46,4 @@ 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

View File

@@ -6,8 +6,6 @@ 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'
@@ -19,7 +17,7 @@ import useUpdateHandler from './useUpdateHandler'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss } = useSettings()
const { minappShow } = useRuntime()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
@@ -55,7 +53,7 @@ export function useAppInit() {
}, [proxyUrl, proxyMode])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || defaultLanguage)
i18n.changeLanguage(language || navigator.language || 'en-US')
}, [language])
useEffect(() => {
@@ -104,8 +102,4 @@ export function useAppInit() {
document.head.appendChild(style)
}
}, [customCss])
useEffect(() => {
enableDataCollection ? initAnalytics() : disableAnalytics()
}, [enableDataCollection])
}

View File

@@ -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) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
[assistant, dispatch]
(model: Model) => dispatch(setModel({ assistantId: assistant.id, model })),
[dispatch, assistant.id]
),
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
updateAssistantSettings: (settings: Partial<AssistantSettings>) => {

View File

@@ -1,5 +1,4 @@
import { isWindows } from '@renderer/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@@ -7,7 +6,7 @@ export function useFullScreenNotice() {
const { t } = useTranslation()
useEffect(() => {
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, isFullscreen) => {
const cleanup = window.electron.ipcRenderer.on('fullscreen-status-changed', (_, isFullscreen) => {
if (isWindows && isFullscreen) {
window.message.info({
content: t('common.fullscreen'),

View File

@@ -21,7 +21,6 @@ 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'
@@ -208,7 +207,7 @@ export const useKnowledge = (baseId: string) => {
}
const cleanup = window.electron.ipcRenderer.on(
IpcChannel.DirectoryProcessingPercent,
'directory-processing-percent',
(_, { itemId: id, percent }: { itemId: string; percent: number }) => {
if (itemId === id) {
setPercent(percent)

View File

@@ -1,12 +1,11 @@
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(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
ipcRenderer.on('mcp:servers-changed', (_event, servers) => {
store.dispatch(setMCPServers(servers))
})

View File

@@ -40,6 +40,7 @@ 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
@@ -60,7 +61,7 @@ export const useMermaid = () => {
}
}
document.addEventListener('wheel', handleWheel, { passive: true })
document.addEventListener('wheel', handleWheel, { passive: false })
return () => document.removeEventListener('wheel', handleWheel)
}, [])
}

View File

@@ -1,6 +1,5 @@
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'
@@ -15,13 +14,13 @@ export default function useUpdateHandler() {
const ipcRenderer = window.electron.ipcRenderer
const removers = [
ipcRenderer.on(IpcChannel.UpdateNotAvailable, () => {
ipcRenderer.on('update-not-available', () => {
dispatch(setUpdateState({ checking: false }))
if (window.location.hash.includes('settings/about')) {
window.message.success(t('settings.about.updateNotAvailable'))
}
}),
ipcRenderer.on(IpcChannel.UpdateAvailable, (_, releaseInfo: UpdateInfo) => {
ipcRenderer.on('update-available', (_, releaseInfo: UpdateInfo) => {
dispatch(
setUpdateState({
checking: false,
@@ -31,7 +30,7 @@ export default function useUpdateHandler() {
})
)
}),
ipcRenderer.on(IpcChannel.DownloadUpdate, () => {
ipcRenderer.on('download-update', () => {
dispatch(
setUpdateState({
checking: false,
@@ -39,7 +38,7 @@ export default function useUpdateHandler() {
})
)
}),
ipcRenderer.on(IpcChannel.DownloadProgress, (_, progress: ProgressInfo) => {
ipcRenderer.on('download-progress', (_, progress: ProgressInfo) => {
dispatch(
setUpdateState({
downloading: progress.percent < 100,
@@ -47,7 +46,7 @@ export default function useUpdateHandler() {
})
)
}),
ipcRenderer.on(IpcChannel.UpdateDownloaded, (_, releaseInfo: UpdateInfo) => {
ipcRenderer.on('update-downloaded', (_, releaseInfo: UpdateInfo) => {
dispatch(
setUpdateState({
downloading: false,
@@ -56,7 +55,7 @@ export default function useUpdateHandler() {
})
)
}),
ipcRenderer.on(IpcChannel.UpdateError, (_, error) => {
ipcRenderer.on('update-error', (_, error) => {
dispatch(
setUpdateState({
checking: false,

View File

@@ -1,10 +1,6 @@
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'
@@ -29,20 +25,11 @@ export const useDefaultWebSearchProvider = () => {
export const useWebSearchProviders = () => {
const providers = useAppSelector((state) => state.websearch.providers)
const dispatch = useAppDispatch()
return {
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]))
}
}
updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers))
}
}
@@ -50,7 +37,6 @@ 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`)
}
@@ -61,32 +47,3 @@ 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
}
}

View File

@@ -1,4 +1,3 @@
import { defaultLanguage } from '@shared/config/constant'
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
@@ -27,7 +26,7 @@ const resources = {
}
export const getLanguage = () => {
return localStorage.getItem('language') || navigator.language || defaultLanguage
return localStorage.getItem('language') || navigator.language || 'en-US'
}
export const getLanguageCode = () => {
@@ -37,7 +36,7 @@ export const getLanguageCode = () => {
i18n.use(initReactI18next).init({
resources,
lng: getLanguage(),
fallbackLng: defaultLanguage,
fallbackLng: 'en-US',
interpolation: {
escapeValue: false
}

View File

@@ -59,7 +59,7 @@
"settings.reasoning_effort.low": "low",
"settings.reasoning_effort.medium": "medium",
"settings.reasoning_effort.off": "off",
"settings.reasoning_effort.tip": "Only supported by OpenAI o-series, Anthropic, and Grok reasoning models",
"settings.reasoning_effort.tip": "Only supports OpenAI o-series and Anthropic reasoning models",
"settings.more": "Assistant Settings"
},
"auth": {
@@ -159,14 +159,6 @@
"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",
@@ -196,7 +188,6 @@
"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",
@@ -244,9 +235,7 @@
"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",
"input.translating": "Translating...",
"input.upload.upload_from_local": "Upload local file..."
"topics.export.title_naming_failed": "Failed to generate title, using default title"
},
"code_block": {
"collapse": "Collapse",
@@ -277,7 +266,6 @@
"duplicate": "Duplicate",
"edit": "Edit",
"expand": "Expand",
"collapse": "Collapse",
"footnote": "Reference content",
"footnotes": "References",
"fullscreen": "Entered fullscreen mode. Press F11 to exit",
@@ -299,12 +287,12 @@
"topics": "Topics",
"warning": "Warning",
"you": "You",
"reasoning_content": "Deep reasoning",
"sort": {
"pinyin": "Sort by Pinyin",
"pinyin.asc": "Sort by Pinyin (A-Z)",
"pinyin.desc": "Sort by Pinyin (Z-A)"
}
"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"
},
"docs": {
"title": "Docs"
@@ -346,7 +334,7 @@
"files": {
"actions": "Actions",
"all": "All Files",
"count": "files",
"count": "Count",
"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?",
@@ -715,6 +703,7 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent Hunyuan",
@@ -743,8 +732,7 @@
"yi": "Yi",
"zhinao": "360AI",
"zhipu": "ZHIPU AI",
"voyageai": "Voyage AI",
"qiniu": "Qiniu"
"voyageai": "Voyage AI"
},
"restore": {
"confirm": "Are you sure you want to restore data?",
@@ -804,24 +792,8 @@
"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",
@@ -1033,10 +1005,6 @@
"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",
@@ -1098,10 +1066,7 @@
"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",
"not_support": "Model not supported",
"user": "User",
"system": "System"
"registryDefault": "Default"
},
"messages.divider": "Show divider between messages",
"messages.grid_columns": "Message grid display columns",
@@ -1113,7 +1078,6 @@
"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",
@@ -1299,44 +1263,7 @@
"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",
"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"
"title": "Web Search"
}
},
"translate": {
@@ -1362,10 +1289,7 @@
"scroll_sync.disable": "Disable synced scroll",
"scroll_sync.enable": "Enable synced scroll",
"title": "Translation",
"tooltip.newline": "Newline",
"menu": {
"description": "Translate the content of the current input box"
}
"tooltip.newline": "Newline"
},
"tray": {
"quit": "Quit",

View File

@@ -59,7 +59,7 @@
"settings.reasoning_effort.low": "短い",
"settings.reasoning_effort.medium": "中程度",
"settings.reasoning_effort.off": "オフ",
"settings.reasoning_effort.tip": "OpenAI o-series、Anthropic、および Grok の推論モデルのみサポート",
"settings.reasoning_effort.tip": "OpenAIのoシリーズとAnthropicの推論モデルのみサポートしています",
"settings.more": "アシスタント設定"
},
"auth": {
@@ -159,14 +159,6 @@
"save": "保存",
"settings.code_collapsible": "コードブロック折り畳み",
"settings.code_wrappable": "コードブロック折り返し",
"settings.code_cacheable": "コードブロックキャッシュ",
"settings.code_cacheable.tip": "コードブロックのキャッシュは長いコードブロックのレンダリング時間を短縮できますが、メモリ使用量が増加します",
"settings.code_cache_max_size": "キャッシュ上限",
"settings.code_cache_max_size.tip": "キャッシュできる文字数の上限(千字符)。ハイライトされたコードの長さは純粋なテキストよりもはるかに長くなります。",
"settings.code_cache_ttl": "キャッシュ期限",
"settings.code_cache_ttl.tip": "キャッシュの有効期限(分単位)。",
"settings.code_cache_threshold": "キャッシュ閾値",
"settings.code_cache_threshold.tip": "キャッシュできる最小のコード長(千字符)。キャッシュできる最小のコード長を超えたコードブロックのみがキャッシュされます。",
"settings.context_count": "コンテキスト",
"settings.context_count.tip": "コンテキストに保持する以前のメッセージの数",
"settings.max": "最大",
@@ -196,7 +188,6 @@
"topics.export.image": "画像としてエクスポート",
"topics.export.joplin": "Joplin にエクスポート",
"topics.export.md": "Markdownとしてエクスポート",
"topics.export.md.reason": "Markdown としてエクスポート (思考内容を含む)",
"topics.export.notion": "Notion にエクスポート",
"topics.export.obsidian": "Obsidian にエクスポート",
"topics.export.obsidian_vault": "保管庫",
@@ -244,9 +235,7 @@
"topics.export.siyuan": "思源笔记にエクスポート",
"topics.export.wait_for_title_naming": "タイトルを生成中...",
"topics.export.title_naming_success": "タイトルの生成に成功しました",
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します",
"input.translating": "翻訳中...",
"input.upload.upload_from_local": "ローカルファイルをアップロード..."
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します"
},
"code_block": {
"collapse": "折りたたむ",
@@ -277,7 +266,6 @@
"duplicate": "複製",
"edit": "編集",
"expand": "展開",
"collapse": "折りたたむ",
"footnote": "引用内容",
"footnotes": "脚注",
"fullscreen": "全画面モードに入りました。F11キーで終了します",
@@ -299,12 +287,12 @@
"topics": "トピック",
"warning": "警告",
"you": "あなた",
"reasoning_content": "深く考察済み",
"sort": {
"pinyin": "ピンインでソート",
"pinyin.asc": "ピンインで昇順ソート",
"pinyin.desc": "ピンインで降順ソート"
}
"variable_name": "変数名",
"value": "値",
"no_variables_added": "変数がありません",
"insert_variable_into_prompt": "プロンプトに変数を挿入",
"variables": "変数",
"variables_help": "テキスト内で置換が必要な変数を追加し、置換ドキュメント内で{{variable_name}}の形式でトリガーします"
},
"docs": {
"title": "ドキュメント"
@@ -346,7 +334,7 @@
"files": {
"actions": "操作",
"all": "すべてのファイル",
"count": "ファイル",
"count": "",
"created_at": "作成日",
"delete": "削除",
"delete.content": "ファイルを削除すると、ファイルがすべてのメッセージで参照されることを削除します。このファイルを削除してもよろしいですか?",
@@ -715,6 +703,7 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "腾讯混元",
@@ -743,8 +732,7 @@
"yi": "零一万物",
"zhinao": "360智脳",
"zhipu": "智譜AI",
"voyageai": "Voyage AI",
"qiniu": "七牛云"
"voyageai": "Voyage AI"
},
"restore": {
"confirm": "データを復元しますか?",
@@ -804,24 +792,8 @@
"title": "キャッシュをクリア"
},
"data.title": "データディレクトリ",
"divider.basic": "基本データ設定",
"divider.cloud_storage": "クラウドバックアップ設定",
"divider.export_settings": "エクスポート設定",
"divider.third_party": "サードパーティー連携",
"hour_interval_one": "{{count}} 時間",
"hour_interval_other": "{{count}} 時間",
"export_menu": {
"title": "エクスポートメニュー設定",
"image": "画像としてエクスポート",
"markdown": "Markdownとしてエクスポート",
"markdown_reason": "Markdownとしてエクスポート思考内容を含む",
"notion": "Notionにエクスポート",
"yuque": "語雀にエクスポート",
"obsidian": "Obsidianにエクスポート",
"siyuan": "思源ノートにエクスポート",
"joplin": "Joplinにエクスポート",
"docx": "Wordとしてエクスポート"
},
"joplin": {
"check": {
"button": "確認",
@@ -1032,10 +1004,6 @@
"argsTooltip": "1行に1つの引数を入力してください",
"baseUrlTooltip": "リモートURLアドレス",
"command": "コマンド",
"sse": "サーバー送信イベント (sse)",
"streamableHttp": "ストリーミング可能なHTTP (streamable)",
"stdio": "標準入力/出力 (stdio)",
"inMemory": "メモリ",
"config_description": "モデルコンテキストプロトコルサーバーの設定",
"deleteError": "サーバーの削除に失敗しました",
"deleteSuccess": "サーバーが正常に削除されました",
@@ -1097,10 +1065,7 @@
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
"registry": "パッケージ管理レジストリ",
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
"registryDefault": "デフォルト",
"not_support": "モデルはサポートされていません",
"user": "ユーザー",
"system": "システム"
"registryDefault": "デフォルト"
},
"messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数",
@@ -1112,7 +1077,6 @@
"messages.input.send_shortcuts": "送信ショートカット",
"messages.input.show_estimated_tokens": "推定トークン数を表示",
"messages.input.title": "入力設定",
"messages.input.enable_quick_triggers": "'/' と '@' を有効にしてクイックメニューを表示します。",
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
"messages.math_engine": "数式エンジン",
"messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec",
@@ -1279,6 +1243,7 @@
"websearch": {
"blacklist": "ブラックリスト",
"blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません",
"blacklist_tooltip": "以下の形式を使用してください(改行区切り)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"check": "チェック",
"check_failed": "検証に失敗しました",
"check_success": "検証に成功しました",
@@ -1297,47 +1262,9 @@
"description": "Tavily は、AI エージェントのために特別に開発された検索エンジンで、最新の結果、インテリジェントな検索提案、そして深い研究能力を提供します",
"title": "Tavily"
},
"title": "ウェブ検索",
"blacklist_tooltip": "マッチパターン: *://*.example.com/*\n正規表現: /example\\.(net|org)/",
"subscribe": "ブラックリスト購読",
"subscribe_update": "今すぐ更新",
"subscribe_add": "サブスクリプションを追加",
"subscribe_url": "フィードのURL",
"subscribe_name": "代替名",
"subscribe_name.placeholder": "ダウンロードしたフィードに名前がない場合に使用される代替名",
"subscribe_add_success": "フィードの追加が成功しました!",
"subscribe_delete": "フィードの削除",
"overwrite": "サービス検索を上書き",
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
"apikey": "API キー",
"free": "無料"
"title": "ウェブ検索"
},
"general.auto_check_update.title": "自動更新チェックを有効にする",
"quickPhrase": {
"title": "クイックフレーズ",
"add": "フレーズを追加",
"edit": "フレーズを編集",
"titleLabel": "タイトル",
"contentLabel": "内容",
"titlePlaceholder": "フレーズのタイトルを入力してください",
"contentPlaceholder": "フレーズの内容を入力してください。変数を使用することもできます。変数を使用する場合は、Tabキーを押して変数を選択し、変数を変更してください。例\n私の名前は${name}です。",
"delete": "フレーズを削除",
"deleteConfirm": "削除後は復元できません。続行しますか?"
},
"quickPanel": {
"title": "クイックメニュー",
"close": "閉じる",
"select": "選択",
"page": "ページ",
"confirm": "確認",
"back": "戻る",
"forward": "進む",
"multiple": "複数選択"
},
"privacy": {
"title": "プライバシー設定",
"enable_privacy_mode": "匿名エラーレポートとデータ統計の送信"
}
"general.auto_check_update.title": "自動更新チェックを有効にする"
},
"translate": {
"any.language": "任意の言語",
@@ -1362,10 +1289,7 @@
"scroll_sync.disable": "關閉滾動同步",
"scroll_sync.enable": "開啟滾動同步",
"title": "翻訳",
"tooltip.newline": "改行",
"menu": {
"description": "對當前輸入框內容進行翻譯"
}
"tooltip.newline": "改行"
},
"tray": {
"quit": "終了",

View File

@@ -59,7 +59,7 @@
"settings.reasoning_effort.low": "Короткая",
"settings.reasoning_effort.medium": "Средняя",
"settings.reasoning_effort.off": "Выключено",
"settings.reasoning_effort.tip": "Поддерживается только моделями рассуждений OpenAI o-series, Anthropic и Grok",
"settings.reasoning_effort.tip": "Поддерживается только моделями с рассуждением OpenAI o-series и Anthropic",
"settings.more": "Настройки ассистента"
},
"auth": {
@@ -159,14 +159,6 @@
"save": "Сохранить",
"settings.code_collapsible": "Блок кода свернут",
"settings.code_wrappable": "Блок кода можно переносить",
"settings.code_cacheable": "Кэш блока кода",
"settings.code_cacheable.tip": "Кэширование блока кода может уменьшить время рендеринга длинных блоков кода, но увеличит использование памяти",
"settings.code_cache_max_size": "Максимальный размер кэша",
"settings.code_cache_max_size.tip": "Максимальное количество символов, которое может быть кэшировано (тысяч символов), рассчитывается по кэшированному коду. Длина кэшированного кода значительно превышает длину чистого текста.",
"settings.code_cache_ttl": "Время жизни кэша",
"settings.code_cache_ttl.tip": "Время жизни кэша (минуты)",
"settings.code_cache_threshold": "Пороговое значение кэша",
"settings.code_cache_threshold.tip": "Минимальное количество символов для кэширования (тысяч символов), рассчитывается по фактическому коду. Будут кэшированы только те блоки кода, которые превышают пороговое значение",
"settings.context_count": "Контекст",
"settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.",
"settings.max": "Максимум",
@@ -196,7 +188,6 @@
"topics.export.image": "Экспорт как изображение",
"topics.export.joplin": "Экспорт в Joplin",
"topics.export.md": "Экспорт как markdown",
"topics.export.md.reason": "Экспорт в Markdown (с рассуждениями)",
"topics.export.notion": "Экспорт в Notion",
"topics.export.obsidian": "Экспорт в Obsidian",
"topics.export.obsidian_vault": "Хранилище",
@@ -244,9 +235,7 @@
"topics.export.siyuan": "Экспорт в Siyuan Note",
"topics.export.wait_for_title_naming": "Создание заголовка...",
"topics.export.title_naming_success": "Заголовок успешно создан",
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию",
"input.translating": "Перевод...",
"input.upload.upload_from_local": "Загрузить локальный файл..."
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию"
},
"code_block": {
"collapse": "Свернуть",
@@ -277,7 +266,6 @@
"duplicate": "Дублировать",
"edit": "Редактировать",
"expand": "Развернуть",
"collapse": "Свернуть",
"footnote": "Цитируемый контент",
"footnotes": "Сноски",
"fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода",
@@ -299,12 +287,12 @@
"topics": "Топики",
"warning": "Предупреждение",
"you": "Вы",
"reasoning_content": "Глубокий анализ",
"sort": {
"pinyin": "Сортировать по пиньинь",
"pinyin.asc": "Сортировать по пиньинь (А-Я)",
"pinyin.desc": "Сортировать по пиньинь (Я-А)"
}
"variable_name": "Имя переменной",
"value": "Значение",
"no_variables_added": "Нет переменных",
"insert_variable_into_prompt": "Вставить переменную в промпт",
"variables": "Переменные",
"variables_help": "Добавьте переменные, которые нужно заменить в тексте, замена срабатывает в формате {{variable_name}} в документе замены"
},
"docs": {
"title": "Документация"
@@ -346,7 +334,7 @@
"files": {
"actions": "Действия",
"all": "Все файлы",
"count": "файлов",
"count": "Количество",
"created_at": "Дата создания",
"delete": "Удалить",
"delete.content": "Удаление файла удалит его из всех сообщений, вы уверены, что хотите удалить этот файл?",
@@ -715,6 +703,7 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent Hunyuan",
@@ -743,8 +732,7 @@
"yi": "Yi",
"zhinao": "360AI",
"zhipu": "ZHIPU AI",
"voyageai": "Voyage AI",
"qiniu": "Qiniu"
"voyageai": "Voyage AI"
},
"restore": {
"confirm": "Вы уверены, что хотите восстановить данные?",
@@ -804,24 +792,8 @@
"title": "Очистка кэша"
},
"data.title": "Каталог данных",
"divider.basic": "Основные настройки данных",
"divider.cloud_storage": "Настройки облачного резервирования",
"divider.export_settings": "Настройки экспорта",
"divider.third_party": "Сторонние подключения",
"hour_interval_one": "{{count}} час",
"hour_interval_other": "{{count}} часов",
"export_menu": {
"title": "Настройки меню экспорта",
"image": "Экспорт как изображение",
"markdown": "Экспорт в Markdown",
"markdown_reason": "Экспорт в Markdown (с рассуждениями)",
"notion": "Экспорт в Notion",
"yuque": "Экспорт в Yuque",
"obsidian": "Экспорт в Obsidian",
"siyuan": "Экспорт в SiYuan Note",
"joplin": "Экспорт в Joplin",
"docx": "Экспорт в Word"
},
"joplin": {
"check": {
"button": "Проверить",
@@ -1032,10 +1004,6 @@
"argsTooltip": "Каждый аргумент с новой строки",
"baseUrlTooltip": "Адрес удаленного URL",
"command": "Команда",
"sse": "События, отправляемые сервером (sse)",
"streamableHttp": "Потоковый HTTP (streamableHttp)",
"stdio": "Стандартный ввод/вывод (stdio)",
"inMemory": "Память",
"config_description": "Настройка серверов протокола контекста модели",
"deleteError": "Не удалось удалить сервер",
"deleteSuccess": "Сервер успешно удален",
@@ -1097,10 +1065,7 @@
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
"registry": "Реестр пакетов",
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
"registryDefault": "По умолчанию",
"not_support": "Модель не поддерживается",
"user": "Пользователь",
"system": "Система"
"registryDefault": "По умолчанию"
},
"messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений",
@@ -1112,7 +1077,6 @@
"messages.input.send_shortcuts": "Горячие клавиши для отправки",
"messages.input.show_estimated_tokens": "Показывать затраты токенов",
"messages.input.title": "Настройки ввода",
"messages.input.enable_quick_triggers": "Включите '/' и '@', чтобы вызвать быстрое меню.",
"messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown",
"messages.math_engine": "Математический движок",
"messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec",
@@ -1279,6 +1243,7 @@
"websearch": {
"blacklist": "Черный список",
"blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска",
"blacklist_tooltip": "Пожалуйста, используйте следующий формат (разделенный переносами строк)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"check": "проверка",
"check_failed": "Проверка не прошла",
"check_success": "Проверка успешна",
@@ -1297,47 +1262,9 @@
"description": "Tavily — это поисковая система, специально разработанная для ИИ-агентов, предоставляющая актуальные результаты, умные предложения по запросам и глубокие исследовательские возможности",
"title": "Tavily"
},
"title": "Поиск в Интернете",
"blacklist_tooltip": "Соответствующий шаблон: *://*.example.com/*\nРегулярное выражение: /example\\.(net|org)/",
"subscribe": "Черный список подписки",
"subscribe_update": "Обновить сейчас",
"subscribe_add": "Добавить подписку",
"subscribe_url": "Адрес источника подписки",
"subscribe_name": "альтернативное имя",
"subscribe_name.placeholder": "替代名称, используемый, когда загружаемый подписочный источник не имеет названия",
"subscribe_add_success": "Подписка добавлена успешно!",
"subscribe_delete": "Удалить источник подписки",
"overwrite": "Переопределить поставщика поиска",
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM",
"apikey": "Ключ API",
"free": "Бесплатно"
"title": "Поиск в Интернете"
},
"general.auto_check_update.title": "Включить автоматическую проверку обновлений",
"quickPhrase": {
"title": "Быстрые фразы",
"add": "Добавить фразу",
"edit": "Редактировать фразу",
"titleLabel": "Заголовок",
"contentLabel": "Содержание",
"titlePlaceholder": "Введите заголовок фразы",
"contentPlaceholder": "Введите содержание фразы, поддерживает использование переменных, и нажмите Tab для быстрого перехода к переменной для изменения. Например: \nПомоги мне спланировать маршрут от ${from} до ${to} и отправить его на ${email}.",
"delete": "Удалить фразу",
"deleteConfirm": "После удаления фраза не может быть восстановлена, продолжить?"
},
"quickPanel": {
"title": "Быстрое меню",
"close": "Закрыть",
"select": "Выбрать",
"page": "Страница",
"confirm": "Подтвердить",
"back": "Назад",
"forward": "Вперед",
"multiple": "Множественный выбор"
},
"privacy": {
"title": "Настройки приватности",
"enable_privacy_mode": "Анонимная отправка отчетов об ошибках и статистики"
}
"general.auto_check_update.title": "Включить автоматическую проверку обновлений"
},
"translate": {
"any.language": "Любой язык",
@@ -1362,10 +1289,7 @@
"scroll_sync.disable": "Отключить синхронизацию прокрутки",
"scroll_sync.enable": "Включить синхронизацию прокрутки",
"title": "Перевод",
"tooltip.newline": "Перевести",
"menu": {
"description": "Перевести содержимое текущего ввода"
}
"tooltip.newline": "Перевести"
},
"tray": {
"quit": "Выйти",

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