Compare commits

...

29 Commits

Author SHA1 Message Date
温州程序员劝退师
3499cd449b Merge pull request #3718 from GeekyWizKid/node-store
Node store
2025-03-21 13:33:01 +08:00
温州程序员劝退师
a97c3d9695 Merge branch 'main' into node-store 2025-03-21 13:28:23 +08:00
fullex
d0ddfce280 fix: miniWindow not sync with theme change (#3643)
* fix: miniWindow not sync theme change

* fix: mac: miniWindow theme display incorrect

* fix: mac: miniWindow display error when system dark+ app light
2025-03-21 13:11:21 +08:00
ousugo
707e713e73 fix(MessageMenubar): trim leading whitespace from message content before copying to clipboard 2025-03-21 13:00:53 +08:00
kangfenmao
5347df4840 feat(i18n): add WebDAV backup and restore translations for Japanese, Russian, and Traditional Chinese
- Updated localization files for ja-jp, ru-ru, and zh-tw to include new strings for WebDAV backup and restore modals.
- Enhanced user experience with additional prompts and confirmation messages for backup and restore actions.
2025-03-21 12:59:17 +08:00
kangfenmao
2ca0a62efa feat: update ESLint config and add socks-proxy-agent dependency
- Added 'local/**' to ESLint ignores
- Included 'socks-proxy-agent' package in dependencies
- Refactored download function to improve readability and maintainability
- Cleaned up unused code in messages state management
2025-03-21 11:26:51 +08:00
one
28c5231741 feat: make webdav state persistent, improve webdav autosync (#3690)
* feat: persist webdav state

* feat: schedule autosync by taking the last autosync time

* fix: correct scheduling behaviour with last error, improve messages

* refactor: delay setting lastSyncTime

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-03-21 11:18:38 +08:00
zhsama
994ffa224e feat: enhance WebDAV backup and restore functionality (#2522)
Co-authored-by: zhsama <zhcf1ess@qq.com>
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-03-21 11:13:44 +08:00
Chen Tao
ea990e78a5 feat: support jina reranker (#3658) 2025-03-20 22:32:54 +08:00
FischLu
6fd5ff991d fix(translate): 去除翻译页面中生成的翻译内容开始的空白行 (#3684)
fix(translate): trim whitespace from translated text before setting result
2025-03-20 21:31:13 +08:00
Hao He
cd6c0a1f66 fix: update file extensions for Fortran source files (#3683)
* Enhance update error logging and fix duplicate type import

- Improve error logging in AppUpdater with more detailed error information and timestamps
- Remove duplicate MCPServer type import in Inputbar component

* feat(constants): 添加 Fortran 源文件扩展名支持
2025-03-20 21:12:04 +08:00
温州程序员劝退师
9145e998c4 feat: Implement Node.js app management features
- Added IPC handlers for managing Node.js applications, including listing, adding, installing, updating, starting, stopping, and uninstalling apps.
- Introduced deployment options for Node.js apps from ZIP files and Git repositories.
- Enhanced the process utility to support environment variables during script execution.
- Updated preload API to expose Node.js app management functionalities.
- Added new UI components and routes for Node.js app management in the renderer.
- Included internationalization support for Node.js app features in both English and Chinese.
2025-03-20 17:13:51 +08:00
SuYao
8f1528b21c fix(reranker): fix reranking API integration with own parameters (#3629) 2025-03-20 14:50:09 +08:00
deadmau5v
d11f892c26 feat(i18n): Fix MCP i18n issues (#3651)
* feat(i18n): Fix MCP i18n issues

* feat(i18n): fix new translations for 'expand' and 'tools' in multiple languages
2025-03-20 14:49:12 +08:00
SuYao
63b4ecbadd fix(KnowledgeBase): pass full knowledgeBase API parameters (#3628) 2025-03-20 14:40:59 +08:00
LiuVaayne
f6cb501119 fix[MCP]: enhance tool call handling in OpenAIProvider (#3642) 2025-03-20 11:51:25 +08:00
SuYao
70ba8df57c feat(MCP, Proxy): proxy uv/bun install script (#3621)
* WIP

* refactor(download):  improved socsk proxy download uv/bun
2025-03-20 11:21:49 +08:00
自由的世界人
89508162b7 fix: readme number error 2025-03-20 00:32:32 +08:00
Suiji
f107fb0c78 fix: readme serial number error (#3624) 2025-03-19 23:39:00 +08:00
Suiji
a183a9a21e update: readme mcp server (#3623) 2025-03-19 23:31:48 +08:00
Vaayne
dffcaa11c3 fix: correct typo in properties variable and add null check 2025-03-19 22:43:03 +08:00
LiuVaayne
0fe7d559c8 feat[MCP]: Optimize list tool performance. (#3598)
* refactor: remove unused filterMCPTools function calls from providers

* fix: ensure enabledMCPs is checked for length before processing tools

* feat: implement caching for tools retrieved from MCP server
2025-03-19 20:09:05 +08:00
fullex
eef141cbe7 feat: export to Joplin (#3607) 2025-03-19 20:07:53 +08:00
shiquda
424eb09995 feat(MCP): add external MCP search website link in MCP settings 2025-03-19 20:07:29 +08:00
TangZhiZzz
c29cab7daa fix: Unknown event handler property onsuccess . (#3603)
* chore(version): 1.1.8

* Update OAuthButton.tsx

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-03-19 20:06:31 +08:00
Roland
592484af95 chore: upgrade eslint version to 9.x (#3608)
* chore(eslint): upgrade eslint version to 9.x

* style: enhance ESLint configuration for compatibility with ESLint 8.x
2025-03-19 20:04:33 +08:00
自由的世界人
e9c9f3b488 Update AddMcpServerPopup.tsx (#3604) 2025-03-19 19:17:41 +08:00
Asurada
a2a3760c95 fix: update API URL for together provider (#3605) 2025-03-19 18:37:33 +08:00
one
62de293194 fix: race condition in new context event 2025-03-19 17:57:52 +08:00
78 changed files with 7563 additions and 3231 deletions

View File

@@ -1,5 +0,0 @@
node_modules
dist
out
.gitignore
scripts/cloudflare-worker.js

View File

@@ -1,22 +0,0 @@
module.exports = {
plugins: ['unused-imports', 'simple-import-sort'],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'@electron-toolkit/eslint-config-ts/recommended',
'@electron-toolkit/eslint-config-prettier'
],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'unused-imports/no-unused-imports': 'error',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'react/prop-types': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'react/no-is-mounted': 'off',
'prettier/prettier': ['error', { endOfLine: 'auto' }]
}
}

View File

@@ -52,6 +52,7 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
- 🔤 AI-powered Translation
- 🎯 Drag-and-drop Sorting
- 🔌 Mini Program Support
- ⚙️ MCP(Model Context Protocol) Server
5. **Enhanced User Experience**:

View File

@@ -53,6 +53,7 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
- 🔤 AI による翻訳機能
- 🎯 ドラッグ&ドロップによる整理
- 🔌 ミニプログラム対応
- ⚙️ MCPモデルコンテキストプロトコル サービス
5. **優れたユーザー体験**

View File

@@ -52,6 +52,7 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
- 🔤 AI 驱动的翻译功能
- 🎯 拖拽排序
- 🔌 小程序支持
- ⚙️ MCP(模型上下文协议) 服务
5. **优质使用体验**

58
eslint.config.mjs Normal file
View File

@@ -0,0 +1,58 @@
import electronConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import unusedImports from 'eslint-plugin-unused-imports'
export default defineConfig([
eslint.configs.recommended,
tseslint.configs.recommended,
electronConfigPrettier,
eslintReact.configs['recommended-typescript'],
reactHooks.configs['recommended-latest'],
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'prettier/prettier': ['error', { endOfLine: 'auto' }]
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
...[
{
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': ['error', { caughtErrors: 'none' }],
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off',
'@eslint-react/web-api/no-leaked-event-listener': 'off',
'@eslint-react/web-api/no-leaked-timeout': 'off',
'@eslint-react/no-unknown-property': 'off',
'@eslint-react/no-nested-component-definitions': 'off',
'@eslint-react/dom/no-dangerously-set-innerhtml': 'off',
'@eslint-react/no-array-index-key': 'off',
'@eslint-react/no-unstable-default-props': 'off',
'@eslint-react/no-unstable-context-value': 'off',
'@eslint-react/hooks-extra/prefer-use-state-lazy-initialization': 'off',
'@eslint-react/hooks-extra/no-unnecessary-use-prefix': 'off',
'@eslint-react/no-children-to-array': 'off'
}
}
],
{
ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js']
}
])

View File

@@ -85,6 +85,7 @@
"npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1",
"p-queue": "^8.1.0",
"socks-proxy-agent": "^8.0.3",
"tar": "^7.4.3",
"tokenx": "^0.4.1",
"undici": "^7.4.0",
@@ -93,10 +94,12 @@
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.38.0",
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^1.0.1",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@llm-tools/embedjs-loader-image": "^0.1.28",
@@ -130,11 +133,10 @@
"electron-vite": "^2.3.0",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.0.0",
"eslint-plugin-unused-imports": "^4.1.4",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"i18next": "^23.11.5",

13
packages/artifacts/package-lock.json generated Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "@cherry-studio/artifacts",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cherry-studio/artifacts",
"version": "0.1.0",
"license": "ISC"
}
}
}

1358
packages/database/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -103,7 +103,10 @@ export const textExts = [
'.cxx', // C++ 源文件
'.cppm', // C++20 模块接口文件
'.ipp', // 模板实现文件
'.ixx' // C++20 模块实现文件
'.ixx', // C++20 模块实现文件
'.f90', // Fortran 90 源文件
'.f', // Fortran 固定格式源代码文件
'.f03' // Fortran 2003+ 源代码文件
]
export const ZOOM_SHORTCUTS = [

View File

@@ -0,0 +1,52 @@
const { ProxyAgent } = require('undici')
const { SocksProxyAgent } = require('socks-proxy-agent')
const https = require('https')
const fs = require('fs')
const { pipeline } = require('stream/promises')
/**
* Downloads a file from a URL with redirect handling
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<void>} Promise that resolves when download is complete
*/
async function downloadWithRedirects(url, destinationPath) {
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY
if (proxyUrl.startsWith('socks')) {
const proxyAgent = new SocksProxyAgent(proxyUrl)
return new Promise((resolve, reject) => {
const request = (url) => {
https
.get(url, { agent: proxyAgent }, (response) => {
if (response.statusCode == 301 || response.statusCode == 302) {
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`))
return
}
const file = fs.createWriteStream(destinationPath)
response.pipe(file)
file.on('finish', () => resolve())
})
.on('error', (err) => {
reject(err)
})
}
request(url)
})
} else {
const proxyAgent = new ProxyAgent(proxyUrl)
const response = await fetch(url, {
dispatcher: proxyAgent
})
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`)
}
const file = fs.createWriteStream(destinationPath)
await pipeline(response.body, file)
}
}
module.exports = { downloadWithRedirects }

View File

@@ -2,8 +2,8 @@ const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const https = require('https')
const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading bun binaries
const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download'
@@ -24,41 +24,6 @@ const BUN_PACKAGES = {
'linux-musl-arm64': 'bun-linux-aarch64-musl.zip'
}
/**
* Downloads a file from a URL with redirect handling
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<void>}
*/
async function downloadWithRedirects(url, destinationPath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destinationPath)
const request = (url) => {
https
.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: ${response.statusCode}`))
return
}
response.pipe(file)
file.on('finish', () => {
file.close(resolve)
})
})
.on('error', reject)
}
request(url)
})
}
/**
* Downloads and extracts the bun binary for the specified platform and architecture
* @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux')

View File

@@ -0,0 +1,314 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const https = require('https')
const { execSync } = require('child_process')
// 配置
const NODE_VERSION = process.env.NODE_VERSION || '18.18.0' // 默认版本
const NODE_RELEASE_BASE_URL = 'https://nodejs.org/dist'
// 平台映射
const NODE_PACKAGES = {
'darwin-arm64': `node-v${NODE_VERSION}-darwin-arm64.tar.gz`,
'darwin-x64': `node-v${NODE_VERSION}-darwin-x64.tar.gz`,
'win32-x64': `node-v${NODE_VERSION}-win32-x64.zip`,
'win32-ia32': `node-v${NODE_VERSION}-win32-x86.zip`,
'linux-x64': `node-v${NODE_VERSION}-linux-x64.tar.gz`,
'linux-arm64': `node-v${NODE_VERSION}-linux-arm64.tar.gz`,
}
// 辅助函数 - 递归复制目录
function copyFolderRecursiveSync(source, target) {
// 检查目标目录是否存在,不存在则创建
if (!fs.existsSync(target)) {
fs.mkdirSync(target, { recursive: true });
}
// 读取源目录中的所有文件和文件夹
const files = fs.readdirSync(source);
// 循环处理每个文件/文件夹
for (const file of files) {
const sourcePath = path.join(source, file);
const targetPath = path.join(target, file);
// 检查是文件还是文件夹
if (fs.statSync(sourcePath).isDirectory()) {
// 如果是文件夹,递归复制
copyFolderRecursiveSync(sourcePath, targetPath);
} else {
// 如果是文件,直接复制
fs.copyFileSync(sourcePath, targetPath);
}
}
}
// 二进制文件存放目录
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
// 创建二进制文件存放目录
async function createBinariesDir() {
if (!fs.existsSync(binariesDir)) {
console.log(`Creating binaries directory at ${binariesDir}`)
fs.mkdirSync(binariesDir, { recursive: true })
}
}
// 获取当前平台对应的包名
function getPackageForPlatform() {
const platform = os.platform()
const arch = os.arch()
const key = `${platform}-${arch}`
console.log(`Current platform: ${platform}, architecture: ${arch}`)
if (!NODE_PACKAGES[key]) {
throw new Error(`Unsupported platform/architecture: ${key}`)
}
return NODE_PACKAGES[key]
}
// 下载 Node.js
async function downloadNodeJs() {
const packageName = getPackageForPlatform()
const downloadUrl = `${NODE_RELEASE_BASE_URL}/v${NODE_VERSION}/${packageName}`
const tempFilePath = path.join(os.tmpdir(), packageName)
console.log(`Downloading Node.js v${NODE_VERSION} from ${downloadUrl}`)
console.log(`Temp file path: ${tempFilePath}`)
// 如果临时文件已存在,先删除
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath)
}
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(tempFilePath)
https.get(downloadUrl, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`))
return
}
console.log(`Download started, status code: ${response.statusCode}`)
response.pipe(file)
file.on('finish', () => {
file.close()
console.log('Download completed')
resolve(tempFilePath)
})
file.on('error', (err) => {
fs.unlinkSync(tempFilePath)
reject(err)
})
}).on('error', (err) => {
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath)
}
reject(err)
})
})
}
// 解压 Node.js 包
async function extractNodeJs(filePath) {
const platform = os.platform()
const extractDir = path.join(os.tmpdir(), `node-v${NODE_VERSION}-extract`)
if (fs.existsSync(extractDir)) {
console.log(`Removing existing extract directory: ${extractDir}`)
fs.rmSync(extractDir, { recursive: true, force: true })
}
console.log(`Creating extract directory: ${extractDir}`)
fs.mkdirSync(extractDir, { recursive: true })
console.log(`Extracting to ${extractDir}`)
if (platform === 'win32') {
// Windows 使用内置的解压工具
try {
const AdmZip = require('adm-zip')
console.log(`Using adm-zip to extract ${filePath}`)
const zip = new AdmZip(filePath)
zip.extractAllTo(extractDir, true)
console.log(`Extraction completed using adm-zip`)
} catch (error) {
console.error(`Error using adm-zip: ${error}`)
throw error
}
} else {
// Linux/Mac 使用 tar
try {
console.log(`Using tar to extract ${filePath} to ${extractDir}`)
execSync(`tar -xzf "${filePath}" -C "${extractDir}"`, { stdio: 'inherit' })
console.log(`Extraction completed using tar`)
} catch (error) {
console.error(`Error using tar: ${error}`)
throw error
}
}
return extractDir
}
// 安装 Node.js
async function installNodeJs(extractDir) {
const platform = os.platform()
console.log(`Finding extracted Node.js directory in ${extractDir}`)
const items = fs.readdirSync(extractDir)
console.log(`Found items in extract directory: ${items.join(', ')}`)
// 找到包含"node-v"的目录名
const folderName = items.find(item => item.startsWith('node-v'))
if (!folderName) {
throw new Error(`Could not find Node.js directory in ${extractDir}`)
}
console.log(`Found Node.js directory: ${folderName}`)
const nodeBinPath = path.join(extractDir, folderName, 'bin')
console.log(`Node.js bin path: ${nodeBinPath}`)
// 复制 node 和 npm
if (platform === 'win32') {
// Windows
console.log('Installing Node.js binaries for Windows')
fs.copyFileSync(
path.join(extractDir, folderName, 'node.exe'),
path.join(binariesDir, 'node.exe')
)
console.log(`Copied node.exe to ${path.join(binariesDir, 'node.exe')}`)
fs.copyFileSync(
path.join(extractDir, folderName, 'npm.cmd'),
path.join(binariesDir, 'npm.cmd')
)
console.log(`Copied npm.cmd to ${path.join(binariesDir, 'npm.cmd')}`)
fs.copyFileSync(
path.join(extractDir, folderName, 'npx.cmd'),
path.join(binariesDir, 'npx.cmd')
)
console.log(`Copied npx.cmd to ${path.join(binariesDir, 'npx.cmd')}`)
} else {
// Linux/Mac
console.log('Installing Node.js binaries for Linux/Mac')
fs.copyFileSync(
path.join(nodeBinPath, 'node'),
path.join(binariesDir, 'node')
)
console.log(`Copied node to ${path.join(binariesDir, 'node')}`)
// 创建npm脚本指向正确路径
const npmScript = `#!/usr/bin/env node
require("./node_modules/npm/lib/cli.js")(process)`;
fs.writeFileSync(path.join(binariesDir, 'npm'), npmScript);
console.log(`Created npm script at ${path.join(binariesDir, 'npm')}`);
// 创建npx脚本指向正确路径
const npxScript = `#!/usr/bin/env node
require("./node_modules/npm/bin/npx-cli.js")`;
fs.writeFileSync(path.join(binariesDir, 'npx'), npxScript);
console.log(`Created npx script at ${path.join(binariesDir, 'npx')}`);
// 设置执行权限
execSync(`chmod +x "${path.join(binariesDir, 'node')}"`)
execSync(`chmod +x "${path.join(binariesDir, 'npm')}"`)
execSync(`chmod +x "${path.join(binariesDir, 'npx')}"`)
console.log('Set executable permissions for Node.js binaries')
}
// 复制 npm 相关文件和目录
const npmDir = path.join(binariesDir, 'node_modules', 'npm')
fs.mkdirSync(npmDir, { recursive: true })
console.log(`Created npm directory at ${npmDir}`)
// 复制 npm 目录的内容
const srcNpmDir = path.join(extractDir, folderName, 'lib', 'node_modules', 'npm')
console.log(`Copying npm files from ${srcNpmDir} to ${npmDir}`)
const files = fs.readdirSync(srcNpmDir)
for (const file of files) {
const srcPath = path.join(srcNpmDir, file)
const destPath = path.join(npmDir, file)
if (fs.lstatSync(srcPath).isDirectory()) {
// 使用自定义函数代替fs.cpSync确保兼容性
console.log(`Copying directory: ${file}`)
copyFolderRecursiveSync(srcPath, destPath)
} else {
console.log(`Copying file: ${file}`)
fs.copyFileSync(srcPath, destPath)
}
}
console.log('Node.js installation completed successfully')
}
// 清理临时文件
async function cleanup(filePath, extractDir) {
try {
if (fs.existsSync(filePath)) {
console.log(`Cleaning up temp file: ${filePath}`)
fs.unlinkSync(filePath)
}
if (fs.existsSync(extractDir)) {
console.log(`Cleaning up extract directory: ${extractDir}`)
fs.rmSync(extractDir, { recursive: true, force: true })
}
console.log('Cleaned up temporary files')
} catch (error) {
console.error('Error during cleanup:', error)
}
}
// 主安装函数
async function install() {
try {
console.log(`Starting Node.js v${NODE_VERSION} installation...`)
await createBinariesDir()
console.log('Binary directory created/verified')
const filePath = await downloadNodeJs()
console.log(`Downloaded Node.js to ${filePath}`)
const extractDir = await extractNodeJs(filePath)
console.log(`Extracted Node.js to ${extractDir}`)
await installNodeJs(extractDir)
console.log('Installed Node.js binaries')
await cleanup(filePath, extractDir)
console.log('Cleanup completed')
console.log(`Node.js v${NODE_VERSION} has been installed successfully at ${binariesDir}`)
return true
} catch (error) {
console.error('Installation failed:', error)
throw error
}
}
// 执行安装
install()
.then(() => {
console.log('Installation process completed successfully')
process.exit(0)
})
.catch((error) => {
console.error('Fatal error during installation:', error)
process.exit(1)
})

View File

@@ -2,9 +2,9 @@ const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const https = require('https')
const tar = require('tar')
const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading uv binaries
const UV_RELEASE_BASE_URL = 'https://github.com/astral-sh/uv/releases/download'
@@ -32,41 +32,6 @@ const UV_PACKAGES = {
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
}
/**
* Downloads a file from a URL with redirect handling
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<void>}
*/
async function downloadWithRedirects(url, destinationPath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destinationPath)
const request = (url) => {
https
.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: ${response.statusCode}`))
return
}
response.pipe(file)
file.on('finish', () => {
file.close(resolve)
})
})
.on('error', reject)
}
request(url)
})
}
/**
* Downloads and extracts the uv binary for the specified platform and architecture
* @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux')

16
src/@types/index.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
export interface NodeAppType {
id: string
name: string
type: string
description?: string
author?: string
homepage?: string
repositoryUrl?: string
port?: number
installCommand?: string
buildCommand?: string
startCommand?: string
isInstalled: boolean
isRunning: boolean
url?: string
}

View File

@@ -1,6 +1,6 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app } from 'electron'
import { app, ipcMain } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
@@ -44,6 +44,9 @@ if (!app.requestSingleInstanceLock()) {
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
ipcMain.handle('system:getDeviceType', () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
})
})
// Listen for second instance

View File

@@ -24,6 +24,7 @@ import { getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
import NodeAppService from './services/NodeAppService'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
@@ -84,8 +85,21 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// theme
ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => {
ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => {
if (theme === configManager.getTheme()) return
configManager.setTheme(theme)
// should sync theme change to all windows
const senderWindowId = event.sender.id
const windows = BrowserWindow.getAllWindows()
// 向其他窗口广播主题变化
windows.forEach((win) => {
if (win.webContents.id !== senderWindowId) {
win.webContents.send('theme:change', theme)
}
})
mainWindow?.setTitleBarOverlay &&
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
})
@@ -130,6 +144,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('backup:restore', backupManager.restore)
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
// file
ipcMain.handle('file:open', fileManager.open)
@@ -243,6 +258,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('mcp:cleanup', async () => mcpService.cleanup())
// Shell API
ipcMain.handle('shell:openExternal', async (_, url: string) => {
try {
log.info(`Opening external URL: ${url}`)
return await shell.openExternal(url)
} catch (error) {
log.error('Error opening external URL:', error)
throw error
}
})
ipcMain.handle('app:is-binary-exist', (_, name: string) => isBinaryExists(name))
ipcMain.handle('app:get-binary-path', (_, name: string) => getBinaryPath(name))
ipcMain.handle('app:install-uv-binary', () => runInstallScript('install-uv.js'))
@@ -262,4 +288,53 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('copilot:get-token', CopilotService.getToken)
ipcMain.handle('copilot:logout', CopilotService.logout)
ipcMain.handle('copilot:get-user', CopilotService.getUser)
// Node app management
const nodeAppService = NodeAppService.getInstance()
ipcMain.handle('nodeapp:list', async () => await nodeAppService.getAllApps())
ipcMain.handle('nodeapp:add', async (_, app) => await nodeAppService.addApp(app))
ipcMain.handle('nodeapp:install', async (_, appId) => await nodeAppService.installApp(appId))
ipcMain.handle('nodeapp:update', async (_, appId) => await nodeAppService.updateApp(appId))
ipcMain.handle('nodeapp:start', async (_, appId) => await nodeAppService.startApp(appId))
ipcMain.handle('nodeapp:stop', async (_, appId) => await nodeAppService.stopApp(appId))
ipcMain.handle('nodeapp:uninstall', async (_, appId) => await nodeAppService.uninstallApp(appId))
ipcMain.handle('nodeapp:deploy-zip', async (_, zipPath, options) => await nodeAppService.deployFromZip(zipPath, options))
ipcMain.handle('nodeapp:deploy-git', async (_, repoUrl, options) => await nodeAppService.deployFromGit(repoUrl, options))
ipcMain.handle('nodeapp:check-node', async () => {
const isNodeInstalled = await isBinaryExists('node')
return isNodeInstalled
})
ipcMain.handle('nodeapp:install-node', async () => {
return await nodeAppService.installNodeJs()
})
// Listen for changes in Node.js apps and notify renderer
nodeAppService.on('apps-updated', (apps) => {
mainWindow?.webContents.send('nodeapp:updated', apps)
})
app.on('before-quit', () => nodeAppService.cleanup())
// 运行简单命令
ipcMain.handle('app:run-command', async (_, command: string) => {
try {
const { execSync } = require('child_process')
const result = execSync(command).toString()
return result
} catch (error) {
log.error('Error running command:', error)
throw error
}
})
}

View File

@@ -13,7 +13,7 @@ export default abstract class BaseReranker {
public defaultHeaders() {
return {
Authorization: `Bearer ${this.base.apiKey}`,
Authorization: `Bearer ${this.base.rerankApiKey}`,
'Content-Type': 'application/json'
}
}

View File

@@ -0,0 +1,48 @@
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
export default class JinaReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
const baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
query,
documents: searchResults.map((doc) => doc.pageContent),
top_n: this.base.topN
}
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const rerankResults = data.results
console.log(rerankResults)
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error) {
console.error('Jina Reranker API 错误:', error)
throw error
}
}
}

View File

@@ -2,12 +2,15 @@ import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker'
import DefaultReranker from './DefaultReranker'
import JinaReranker from './JinaReranker'
import SiliconFlowReranker from './SiliconFlowReranker'
export default class RerankerFactory {
static create(base: KnowledgeBaseParams): BaseReranker {
if (base.rerankModelProvider === 'silicon') {
return new SiliconFlowReranker(base)
} else if (base.rerankModelProvider === 'jina') {
return new JinaReranker(base)
}
return new DefaultReranker(base)
}

View File

@@ -10,37 +10,41 @@ export default class SiliconFlowReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
const url = `${this.base.baseURL}/rerank`
const baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
const url = `${baseURL}/rerank`
const { data } = await axios.post(
url,
{
model: this.base.rerankModel,
query,
documents: searchResults.map((doc) => doc.pageContent),
top_n: this.base.topN,
max_chunks_per_doc: this.base.chunkSize,
overlap_tokens: this.base.chunkOverlap
},
{
headers: this.defaultHeaders()
}
)
const requestBody = {
model: this.base.rerankModel,
query,
documents: searchResults.map((doc) => doc.pageContent),
top_n: this.base.topN,
max_chunks_per_doc: this.base.chunkSize,
overlap_tokens: this.base.chunkOverlap
}
const rerankResults = data.results
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
const rerankResults = data.results
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error) {
console.error('SiliconFlow Reranker API 错误:', error)
throw error
}
}
}

View File

@@ -5,6 +5,7 @@ import { app } from 'electron'
import Logger from 'electron-log'
import * as fs from 'fs-extra'
import * as path from 'path'
import { createClient, FileStat } from 'webdav'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -18,6 +19,7 @@ class BackupManager {
this.restore = this.restore.bind(this)
this.backupToWebdav = this.backupToWebdav.bind(this)
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this)
}
private async setWritableRecursive(dirPath: string): Promise<void> {
@@ -117,10 +119,10 @@ class BackupManager {
await fs.remove(this.tempDir)
onProgress({ stage: 'completed', progress: 100, total: 100 })
Logger.log('Backup completed successfully')
Logger.log('[BackupManager] Backup completed successfully')
return backupedFilePath
} catch (error) {
Logger.error('Backup failed:', error)
Logger.error('[BackupManager] Backup failed:', error)
throw error
}
}
@@ -186,7 +188,7 @@ class BackupManager {
}
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
const filename = 'cherry-studio.backup.zip'
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const backupedFilePath = await this.backup(_, filename, data)
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
@@ -195,18 +197,48 @@ class BackupManager {
}
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const filename = 'cherry-studio.backup.zip'
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const webdavClient = new WebDav(webdavConfig)
const retrievedFile = await webdavClient.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
try {
const retrievedFile = await webdavClient.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true })
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true })
}
// sync为同步写无须await
fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
return await this.restore(_, backupedFilePath)
} catch (error: any) {
Logger.error('[backup] Failed to restore from WebDAV:', error)
throw new Error(error.message || 'Failed to restore backup file')
}
}
await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
try {
const client = createClient(config.webdavHost, {
username: config.webdavUser,
password: config.webdavPass
})
return await this.restore(_, backupedFilePath)
const response = await client.getDirectoryContents(config.webdavPath)
const files = Array.isArray(response) ? response : response.data
return files
.filter((file: FileStat) => file.type === 'file' && file.basename.endsWith('.zip'))
.map((file: FileStat) => ({
fileName: file.basename,
modifiedTime: file.lastmod,
size: file.size
}))
.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
} catch (error: any) {
Logger.error('Failed to list WebDAV files:', error)
throw new Error(error.message || 'Failed to list backup files')
}
}
private async getDirSize(dirPath: string): Promise<number> {

View File

@@ -8,6 +8,7 @@ import log from 'electron-log'
import { EventEmitter } from 'events'
import { v4 as uuidv4 } from 'uuid'
import { CacheService } from './CacheService'
import { windowService } from './WindowService'
/**
@@ -446,14 +447,33 @@ export default class MCPService extends EventEmitter {
if (!this.clients[serverName]) {
throw new Error(`MCP Client ${serverName} not found`)
}
const cacheKey = `mcp:list_tool:${serverName}`
if (CacheService.has(cacheKey)) {
log.info(`[MCP] Tools from ${serverName} loaded from cache`)
// Check if cache is still valid
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
if (cachedTools && cachedTools.length > 0) {
return cachedTools
}
CacheService.remove(cacheKey)
}
const { tools } = await this.clients[serverName].listTools()
log.info(`[MCP] Tools from ${serverName}:`, tools)
return tools.map((tool: any) => ({
const transformedTools = tools.map((tool: any) => ({
...tool,
serverName,
id: 'f' + uuidv4().replace(/-/g, '')
}))
// Cache the tools for 5 minutes
if (transformedTools.length > 0) {
CacheService.set(cacheKey, transformedTools, 5 * 60 * 1000)
}
log.info(`[MCP] Tools from ${serverName}:`, transformedTools)
return transformedTools
}
/**

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,13 @@ import path from 'path'
import { getResourcePath } from '.'
export function runInstallScript(scriptPath: string): Promise<void> {
export function runInstallScript(scriptPath: string, env?: NodeJS.ProcessEnv): Promise<void> {
return new Promise<void>((resolve, reject) => {
const installScriptPath = path.join(getResourcePath(), 'scripts', scriptPath)
log.info(`Running script at: ${installScriptPath}`)
const nodeProcess = spawn(process.execPath, [installScriptPath], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', ...env }
})
nodeProcess.stdout.on('data', (data) => {

View File

@@ -6,7 +6,12 @@ import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious,
import type { LoaderReturn } from '@shared/config/types'
import type { OpenDialogOptions } from 'electron'
import type { UpdateInfo } from 'electron-updater'
import { Readable } from 'stream'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
declare global {
interface Window {
@@ -24,6 +29,9 @@ declare global {
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
reload: () => void
clearCache: () => Promise<{ success: boolean; error?: string }>
system: {
getDeviceType: () => Promise<'mac' | 'windows' | 'linux'>
}
zip: {
compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string>
@@ -33,6 +41,7 @@ declare global {
restore: (backupPath: string) => Promise<string>
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
}
file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
@@ -68,8 +77,8 @@ declare global {
update: (shortcuts: Shortcut[]) => Promise<void>
}
knowledgeBase: {
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => Promise<void>
reset: ({ base }: { base: KnowledgeBaseParams }) => Promise<void>
create: (base: KnowledgeBaseParams) => Promise<void>
reset: (base: KnowledgeBaseParams) => Promise<void>
delete: (id: string) => Promise<void>
add: ({
base,
@@ -154,10 +163,37 @@ declare global {
logout: () => Promise<void>
getUser: (token: string) => Promise<{ login: string; avatar: string }>
}
nodeapp: {
list: () => Promise<any[]>
add: (app: any) => Promise<any>
install: (appId: string) => Promise<any | null>
update: (appId: string) => Promise<any | null>
start: (appId: string) => Promise<{ port: number; url: string } | null>
stop: (appId: string) => Promise<boolean>
uninstall: (appId: string) => Promise<boolean>
deployZip: (zipPath: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => Promise<{ port: number; url: string } | null>
deployGit: (repoUrl: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => Promise<{ port: number; url: string } | null>
checkNode: () => Promise<boolean>
installNode: () => Promise<boolean>
onUpdated: (callback: (apps: any[]) => void) => () => void
}
isBinaryExist: (name: string) => Promise<boolean>
getBinaryPath: (name: string) => Promise<string>
installUVBinary: () => Promise<void>
installBunBinary: () => Promise<void>
run: (command: string) => Promise<string>
}
}
}

View File

@@ -17,6 +17,9 @@ const api = {
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('system:getDeviceType')
},
zip: {
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
@@ -27,7 +30,40 @@ const api = {
restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath),
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig)
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig),
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig)
},
nodeapp: {
list: () => ipcRenderer.invoke('nodeapp:list'),
add: (app: any) => ipcRenderer.invoke('nodeapp:add', app),
install: (appId: string) => ipcRenderer.invoke('nodeapp:install', appId),
update: (appId: string) => ipcRenderer.invoke('nodeapp:update', appId),
start: (appId: string) => ipcRenderer.invoke('nodeapp:start', appId),
stop: (appId: string) => ipcRenderer.invoke('nodeapp:stop', appId),
uninstall: (appId: string) => ipcRenderer.invoke('nodeapp:uninstall', appId),
deployZip: (zipPath: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => ipcRenderer.invoke('nodeapp:deploy-zip', zipPath, options),
deployGit: (repoUrl: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => ipcRenderer.invoke('nodeapp:deploy-git', repoUrl, options),
checkNode: () => ipcRenderer.invoke('nodeapp:check-node'),
installNode: () => ipcRenderer.invoke('nodeapp:install-node'),
onUpdated: (callback: (apps: any[]) => void) => {
const eventListener = (_: any, apps: any[]) => callback(apps)
ipcRenderer.on('nodeapp:updated', eventListener)
return () => {
ipcRenderer.removeListener('nodeapp:updated', eventListener)
}
}
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
@@ -60,9 +96,8 @@ const api = {
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
},
knowledgeBase: {
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) =>
ipcRenderer.invoke('knowledge-base:create', { id, model, apiKey, baseURL }),
reset: ({ base }: { base: KnowledgeBaseParams }) => ipcRenderer.invoke('knowledge-base:reset', { base }),
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,
@@ -121,7 +156,7 @@ const api = {
cleanup: () => ipcRenderer.invoke('mcp:cleanup')
},
shell: {
openExternal: shell.openExternal
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url)
},
copilot: {
getAuthMessage: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-auth-message', headers),
@@ -137,7 +172,8 @@ const api = {
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')
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'),
run: (command: string) => ipcRenderer.invoke('app:run-command', command)
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

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

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/no-unknown-property */
import { CloseOutlined, CodeOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons'
import { isMac, isWindows } from '@renderer/config/constant'
import { AppLogo } from '@renderer/config/env'

View File

@@ -9,28 +9,27 @@ interface Props extends ButtonProps {
onSuccess?: (key: string) => void
}
const OAuthButton: FC<Props> = ({ provider, ...props }) => {
const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
const { t } = useTranslation()
const onAuth = () => {
const onSuccess = (key: string) => {
const handleSuccess = (key: string) => {
if (key.trim()) {
props.onSuccess?.(key)
onSuccess?.(key)
window.message.success({ content: t('auth.get_key_success'), key: 'auth-success' })
}
}
if (provider.id === 'silicon') {
oauthWithSiliconFlow(onSuccess)
oauthWithSiliconFlow(handleSuccess)
}
if (provider.id === 'aihubmix') {
oauthWithAihubmix(onSuccess)
oauthWithAihubmix(handleSuccess)
}
}
return (
<Button onClick={onAuth} {...props}>
<Button onClick={onAuth} {...buttonProps}>
{t('auth.get_key')}
</Button>
)

View File

@@ -130,6 +130,7 @@ const MainMenus: FC = () => {
paintings: <PictureOutlined style={{ fontSize: 16 }} />,
translate: <TranslationOutlined />,
minapp: <i className="iconfont icon-appstore" />,
nodeapps: <i className="iconfont icon-code" />,
knowledge: <FileSearchOutlined />,
files: <FolderOutlined />
}
@@ -140,6 +141,7 @@ const MainMenus: FC = () => {
paintings: '/paintings',
translate: '/translate',
minapp: '/apps',
nodeapps: '/nodeapps',
knowledge: '/knowledge',
files: '/files'
}

View File

@@ -186,7 +186,7 @@ export const PROVIDER_CONFIG = {
},
together: {
api: {
url: 'https://api.tohgether.xyz'
url: 'https://api.together.xyz'
},
websites: {
official: 'https://www.together.ai/',

View File

@@ -45,7 +45,15 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
useEffect(() => {
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
}, [])
// listen theme change from main process from other windows
const themeChangeListenerRemover = window.electron.ipcRenderer.on('theme:change', (_, newTheme) => {
setTheme(newTheme)
})
return () => {
themeChangeListenerRemover()
}
})
return <ThemeContext.Provider value={{ theme: _theme, toggleTheme }}>{children}</ThemeContext.Provider>
}

View File

@@ -20,5 +20,7 @@ declare global {
keyv: KeyvStorage
mermaid: any
store: any
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<{ data: string; success: boolean }>
}
}

View File

@@ -0,0 +1,88 @@
import { NodeAppType } from '@renderer/types'
import { useCallback, useEffect, useState } from 'react'
export function useNodeApps() {
const [apps, setApps] = useState<NodeAppType[]>([])
const [loading, setLoading] = useState(true)
// Load apps
const loadApps = useCallback(async () => {
try {
setLoading(true)
const result = await window.api.nodeapp.list()
setApps(result || [])
} catch (error) {
console.error('Error loading node apps:', error)
} finally {
setLoading(false)
}
}, [])
// Add app
const addApp = useCallback(async (app: NodeAppType) => {
const result = await window.api.nodeapp.add(app)
await loadApps()
return result
}, [loadApps])
// Install app
const installApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.install(appId)
await loadApps()
return result
}, [loadApps])
// Update app
const updateApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.update(appId)
await loadApps()
return result
}, [loadApps])
// Start app
const startApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.start(appId)
await loadApps()
return result
}, [loadApps])
// Stop app
const stopApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.stop(appId)
await loadApps()
return result
}, [loadApps])
// Uninstall app
const uninstallApp = useCallback(async (appId: string) => {
const result = await window.api.nodeapp.uninstall(appId)
await loadApps()
return result
}, [loadApps])
// Initialize
useEffect(() => {
loadApps()
// Subscribe to app updates
const unsubscribe = window.api.nodeapp.onUpdated((updatedApps) => {
setApps(updatedApps || [])
})
return () => {
unsubscribe()
}
}, [loadApps])
return {
apps,
loading,
addApp,
installApp,
updateApp,
startApp,
stopApp,
uninstallApp,
refresh: loadApps
}
}

View File

@@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "Export failed",
"topics.export.obsidian_show_md_files": "Show MD Files",
"topics.export.obsidian_selected_path": "Selected Path",
"topics.export.joplin": "Export to Joplin",
"topics.list": "Topic List",
"topics.move_to": "Move to",
"topics.pinned": "Pinned Topics",
@@ -236,7 +237,8 @@
"copied": "Copied",
"confirm": "Confirm",
"more": "More",
"advanced_settings": "Advanced Settings"
"advanced_settings": "Advanced Settings",
"expand": "Expand"
},
"docs": {
"title": "Docs"
@@ -441,6 +443,8 @@
"error.notion.no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
"error.yuque.export": "Failed to export to Yuque. Please check connection status and configuration according to documentation",
"error.yuque.no_config": "Yuque Token or Yuque Url is not configured",
"error.joplin.no_config": "Joplin Authorization Token or URL is not configured",
"error.joplin.export": "Failed to export to Joplin. Please keep Joplin running and check connection status or configuration",
"group.delete.content": "Deleting a group message will delete the user's question and all assistant's answers",
"group.delete.title": "Delete Group Message",
"ignore.knowledge.base": "Web search mode is enabled, ignore knowledge base",
@@ -473,18 +477,32 @@
"success.markdown.export.specified": "Successfully exported the Markdown file",
"success.notion.export": "Successfully exported to Notion",
"success.yuque.export": "Successfully exported to Yuque",
"success.joplin.export": "Successfully exported to Joplin",
"switch.disabled": "Please wait for the current reply to complete",
"topic.added": "New topic added",
"upgrade.success.button": "Restart",
"upgrade.success.content": "Please restart the application to complete the upgrade",
"upgrade.success.title": "Upgrade successfully",
"warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!",
"warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again."
"warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again.",
"tools": {
"invoking": "Invoking",
"completed": "Completed"
},
"nextJsInfo": "Next.js Application Note",
"nextJsDescription": "Next.js applications require a build step before they can be started. Make sure to check 'This is a Next.js application' or manually add 'npm run build' as the build command."
},
"minapp": {
"sidebar.add.title": "Add to sidebar",
"sidebar.remove.title": "Remove from sidebar",
"title": "MinApp"
"add": "Add",
"apps.tab.search": "Search apps",
"apps.tab.title": "Apps",
"empty": "No mini apps",
"find": "Find more",
"more": "More",
"settings.disabled_apps": "Disabled Apps",
"sidebar.add.title": "Add to Sidebar",
"sidebar.remove.title": "Remove from Sidebar",
"title": "Web Apps"
},
"miniwindow": {
"clipboard": {
@@ -753,6 +771,12 @@
"password": "WebDAV Password",
"path": "WebDAV Path",
"path.placeholder": "/backup",
"backup.modal.title": "Backup to WebDAV",
"backup.modal.filename.placeholder": "Please enter backup filename",
"restore.modal.title": "Restore from WebDAV",
"restore.modal.select.placeholder": "Please select a backup file to restore",
"restore.confirm.title": "Confirm Restore",
"restore.confirm.content": "Restoring from WebDAV will overwrite current data. Do you want to continue?",
"restore.button": "Restore from WebDAV",
"restore.content": "Restore from WebDAV will overwrite the current data, continue?",
"restore.title": "Restore from WebDAV",
@@ -790,6 +814,21 @@
"title": "Obsidian Configuration",
"api_key": "Obsidian API Key",
"api_key_placeholder": "Please enter the Obsidian API Key"
},
"joplin": {
"check": {
"button": "Check",
"empty_url": "Please enter Joplin Clipper Service URL",
"empty_token": "Please enter Joplin Authorization Token",
"fail": "Joplin connection verification failed",
"success": "Joplin connection verification successful"
},
"title": "Joplin Configuration",
"help": "In Joplin options, enable the web clipper (no browser extension needed), confirm the port, and copy the auth token here.",
"url": "Joplin Web Clipper Service URL",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Joplin Authorization Token",
"token_placeholder": "Joplin Authorization Token"
}
},
"display.assistant.title": "Assistant Settings",
@@ -856,6 +895,7 @@
"editServer": "Edit Server",
"env": "Environment Variables",
"envTooltip": "Format: KEY=value, one per line",
"findMore": "Find More MCP Servers",
"name": "Name",
"nameRequired": "Please enter a server name",
"noServers": "No servers configured",
@@ -1084,6 +1124,74 @@
"title": "Tavily"
},
"title": "Web Search"
},
"nodeRequired": "Node.js Required",
"nodeSettings": {
"title": "Node.js Environment Settings",
"description": "Manage the built-in Node.js environment for Cherry Studio. You can select which version of Node.js to install for optimal compatibility.",
"status": "Status",
"checking": "Checking...",
"installed": "Installed",
"notInstalled": "Not Installed",
"refresh": "Refresh",
"version": "Node.js Version",
"versionHelp": "Select the version of Node.js to install",
"customVersion": "Custom Version",
"customVersionHelp": "If you need a specific version, enter it here (e.g., 18.16.1)",
"install": "Install Node.js",
"reinstall": "Reinstall Node.js",
"installSuccess": "Node.js v{{version}} installed successfully",
"installFailed": "Failed to install Node.js"
},
"nodeSettingsTab": "Node.js Environment",
"appsManagerTab": "Apps Manager",
"packageDeployerTab": "Deploy Package",
"packageDeployer": {
"advancedOptions": "Advanced Options",
"deploy": "Deploy",
"deployFailed": "Failed to deploy package",
"deploySuccess": "{{name}} has been successfully deployed on port {{port}}",
"description": "Upload a ZIP file containing Node.js application code. The package will be automatically extracted and installed.",
"fileSelectError": "Error selecting file",
"installNode": "Install Node.js",
"installNodePrompt": "Node.js is required to deploy applications. Would you like to install it now?",
"namePlaceholder": "Enter a name for your deployed application",
"nodeInstallFailed": "Failed to install Node.js",
"nodeInstallSuccess": "Node.js installed successfully",
"nodeNeeded": "Built-in Node.js is required to run applications.",
"nodeNotAvailable": "Node.js is not available",
"nodeRequired": "Node.js Required",
"noFileSelected": "Please select a ZIP file to deploy",
"open": "Open in Browser",
"selectZip": "Click to select ZIP file",
"title": "Deploy Code Package",
"moduleTypeError": "Module Type Error",
"esModuleError": "ES module syntax detected. Set \"type\": \"module\" in package.json or use .mjs extension.",
"convertToCommonJS": "Convert to CommonJS syntax",
"nextJsDetected": "Next.js Application Detected",
"buildStepAdded": "Build step has been automatically added for Next.js application.",
"nextJsInfo": "Next.js Application Note",
"nextJsDescription": "Next.js applications require a build step before they can be started. Make sure to check 'This is a Next.js application' or manually add 'npm run build' as the build command.",
"deployPackage": "Deploy Package",
"deployFromZip": "From ZIP",
"deployFromGit": "From Git",
"selectZipFile": "Select ZIP File",
"appName": "Application Name",
"appNamePlaceholder": "My Application",
"port": "Port",
"portPlaceholder": "3000",
"portTooltip": "The port on which your application will run. Leave empty for automatic port assignment.",
"showAdvanced": "Show Advanced Options",
"hideAdvanced": "Hide Advanced Options",
"installCommand": "Install Command",
"buildCommand": "Build Command",
"startCommand": "Start Command",
"isNextJs": "This is a Next.js application",
"deploy": "Deploy",
"repoUrl": "Git Repository URL",
"repoUrlRequired": "Please enter a Git repository URL",
"noRepoUrlProvided": "Please provide a Git repository URL",
"packageRequired": "Please select a package file"
}
},
"translate": {
@@ -1121,6 +1229,146 @@
"quit": "Quit",
"show_window": "Show Window",
"visualization": "Visualization"
},
"nodeapp": {
"add": "Add App",
"addNew": "Add New Node.js App",
"addSuccess": "App added successfully",
"author": "Author",
"codeRunner": {
"description": "Enter your Node.js code below and click 'Run' to execute it. Your code will be run in a temporary Node.js environment.",
"emptyCode": "Please enter some code to run",
"open": "Open in Browser",
"output": "Output",
"placeholder": "// Enter your Node.js code here\n// Example:\nconst http = require('http');\n\nconst server = http.createServer((req, res) => {\n res.writeHead(200, { 'Content-Type': 'text/html' });\n res.end('<h1>Hello from Cherry Studio!</h1>');\n});\n\nconst PORT = process.env.PORT || 3000;\nserver.listen(PORT, () => {\n console.log(`Server running on port ${PORT}`);\n});",
"run": "Run Code",
"success": "Code is running on port {{port}}",
"title": "Code Runner"
},
"codeRunnerTab": "Code Runner",
"empty": "No Node.js apps found",
"featured": "Featured Apps",
"form": {
"author": "Author",
"authorPlaceholder": "The author of the app",
"description": "Description",
"descriptionPlaceholder": "Brief description of the app's functionality",
"homepage": "Homepage",
"homepagePlaceholder": "Homepage URL for the application",
"installCommand": "Install Command",
"installCommandHelp": "Command to install dependencies (defaults to 'npm install')",
"buildCommand": "Build Command",
"buildCommandHelp": "Command to build the application before starting (e.g. 'npm run build')",
"isNextJs": "This is a Next.js application",
"nextJsHelp": "Apply Next.js-specific optimizations for deployment",
"name": "App Name",
"nameRequired": "App name is required",
"namePlaceholder": "Name of your Node.js application",
"port": "Port",
"portHelp": "Port the app will run on (detected automatically if not specified)"
},
"install": "Install",
"installSuccess": "{{name}} installed successfully",
"installed": "Installed",
"marketplaceTab": "Marketplace",
"more": "More",
"notInstalled": "Not Installed",
"packageDeployer": {
"advancedOptions": "Advanced Options",
"deploy": "Deploy",
"deployFailed": "Failed to deploy package",
"deploySuccess": "{{name}} has been successfully deployed on port {{port}}",
"description": "Upload a ZIP file containing Node.js application code. The package will be automatically extracted and installed.",
"fileSelectError": "Error selecting file",
"installNode": "Install Node.js",
"installNodePrompt": "Node.js is required to deploy applications. Would you like to install it now?",
"namePlaceholder": "Enter a name for your deployed application",
"nodeInstallFailed": "Failed to install Node.js",
"nodeInstallSuccess": "Node.js installed successfully",
"nodeNeeded": "Built-in Node.js is required to run applications.",
"nodeNotAvailable": "Node.js is not available",
"nodeRequired": "Node.js Required",
"noFileSelected": "Please select a ZIP file to deploy",
"open": "Open in Browser",
"selectZip": "Click to select ZIP file",
"title": "Deploy Code Package",
"moduleTypeError": "Module Type Error",
"esModuleError": "ES module syntax detected. Set \"type\": \"module\" in package.json or use .mjs extension.",
"convertToCommonJS": "Convert to CommonJS syntax",
"nextJsDetected": "Next.js Application Detected",
"buildStepAdded": "Build step has been automatically added for Next.js application.",
"nextJsInfo": "Next.js Application Note",
"nextJsDescription": "Next.js applications require a build step before they can be started. Make sure to check 'This is a Next.js application' or manually add 'npm run build' as the build command.",
"deployPackage": "Deploy Package",
"deployFromZip": "From ZIP",
"deployFromGit": "From Git",
"selectZipFile": "Select ZIP File",
"appName": "Application Name",
"appNamePlaceholder": "My Application",
"port": "Port",
"portPlaceholder": "3000",
"portTooltip": "The port on which your application will run. Leave empty for automatic port assignment.",
"showAdvanced": "Show Advanced Options",
"hideAdvanced": "Hide Advanced Options",
"installCommand": "Install Command",
"buildCommand": "Build Command",
"startCommand": "Start Command",
"isNextJs": "This is a Next.js application",
"deploy": "Deploy",
"repoUrl": "Git Repository URL",
"repoUrlRequired": "Please enter a Git repository URL",
"noRepoUrlProvided": "Please provide a Git repository URL",
"packageRequired": "Please select a package file"
},
"packageDeployerTab": "Deploy Package",
"running": "Running",
"start": "Start",
"startSuccess": "{{name}} started on port {{port}}",
"stop": "Stop",
"stopSuccess": "{{name}} stopped successfully",
"title": "Node.js Apps",
"uninstall": "Uninstall",
"uninstallSuccess": "{{name}} uninstalled successfully",
"update": "Update",
"updateSuccess": "{{name}} updated successfully",
"version": "Version",
"viewRepository": "View Repository"
},
"model": {
"add_parameter": "Add Parameter",
"all": "All",
"custom_parameters": "Custom Parameters",
"dimensions": "Dimensions {{dimensions}}",
"edit": "Edit Model",
"embedding": "Embedding",
"embedding_model": "Embedding Model",
"embedding_model_tooltip": "Add in Settings->Model Provider->Manage",
"free": "Free",
"no_matches": "No models available",
"parameter_name": "Parameter Name",
"parameter_type": {
"boolean": "Boolean",
"json": "JSON",
"number": "Number",
"string": "Text"
},
"pinned": "Pinned",
"reasoning": "Reasoning",
"search": "Search models...",
"stream_output": "Stream output",
"function_calling": "Function Calling",
"type": {
"embedding": "Embedding",
"reasoning": "Reasoning",
"select": "Select Model Types",
"text": "Text",
"vision": "Vision",
"function_calling": "Function Calling"
},
"vision": "Vision",
"websearch": "WebSearch",
"rerank_model": "Reordering Model",
"rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add."
}
}
}

View File

@@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "エクスポート失敗",
"topics.export.obsidian_show_md_files": "mdファイルを表示",
"topics.export.obsidian_selected_path": "選択済みパス",
"topics.export.joplin": "Joplin にエクスポート",
"topics.list": "トピックリスト",
"topics.move_to": "移動先",
"topics.pinned": "トピックを固定",
@@ -236,7 +237,8 @@
"copied": "コピーされました",
"confirm": "確認",
"more": "もっと",
"advanced_settings": "詳細設定"
"advanced_settings": "詳細設定",
"expand": "展開"
},
"docs": {
"title": "ドキュメント"
@@ -441,6 +443,8 @@
"error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
"error.yuque.export": "語雀へのエクスポートに失敗しました。接続状態と設定を確認してください",
"error.yuque.no_config": "語雀Token または 知識ベースID が設定されていません",
"error.joplin.no_config": "Joplin 認証トークン または URL が設定されていません",
"error.joplin.export": "Joplin へのエクスポートに失敗しました。Joplin が実行中であることを確認してください",
"group.delete.content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
"group.delete.title": "分組メッセージを削除",
"loading.notion.preparing": "Notionへのエクスポートを準備中...",
@@ -473,13 +477,18 @@
"success.markdown.export.specified": "Markdown ファイルを正常にエクスポートしました",
"success.notion.export": "Notionへのエクスポートに成功しました",
"success.yuque.export": "語雀へのエクスポートに成功しました",
"success.joplin.export": "Joplin へのエクスポートに成功しました",
"switch.disabled": "現在の応答が完了するまで切り替えを無効にします",
"topic.added": "新しいトピックが追加されました",
"upgrade.success.button": "再起動",
"upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください",
"upgrade.success.title": "アップグレードに成功しました",
"warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ",
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。"
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。",
"tools": {
"invoking": "呼び出し中",
"completed": "完了"
}
},
"minapp": {
"sidebar.add.title": "サイドバーに追加",
@@ -759,7 +768,13 @@
"syncError": "バックアップエラー",
"syncStatus": "バックアップ状態",
"title": "WebDAV",
"user": "WebDAVユーザー"
"user": "WebDAVユーザー",
"backup.modal.title": "WebDAV にバックアップ",
"backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
"restore.modal.title": "WebDAV から復元",
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
"restore.confirm.title": "復元を確認",
"restore.confirm.content": "WebDAV から復元すると現在のデータが上書きされます。続行しますか?"
},
"yuque": {
"check": {
@@ -790,6 +805,21 @@
"title": "Obsidian 設定",
"api_key": "Obsidian API Key",
"api_key_placeholder": "Obsidian API Key を入力してください"
},
"joplin": {
"check": {
"button": "確認",
"empty_url": "Joplin 剪輯服務 URL を先に入力してください",
"empty_token": "Joplin 認証トークン を先に入力してください",
"fail": "Joplin 接続確認に失敗しました",
"success": "Joplin 接続確認に成功しました"
},
"title": "Joplin 設定",
"help": "Joplin オプションで、剪輯サービスを有効にしてください。ポート番号を確認し、認証トークンをコピーしてください",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Joplin 認証トークン",
"token_placeholder": "Joplin 認証トークンを入力してください"
}
},
"display.assistant.title": "アシスタント設定",
@@ -856,6 +886,7 @@
"editServer": "サーバーを編集",
"env": "環境変数",
"envTooltip": "形式: KEY=value, 1行に1つ",
"findMore": "MCP サーバーを見つける",
"name": "名前",
"nameRequired": "サーバー名を入力してください",
"noServers": "サーバーが設定されていません",

View File

@@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "Экспорт не удалось",
"topics.export.obsidian_show_md_files": "Показать файлы MD",
"topics.export.obsidian_selected_path": "Выбранный путь",
"topics.export.joplin": "Экспорт в Joplin",
"topics.list": "Список топиков",
"topics.move_to": "Переместить в",
"topics.pinned": "Закрепленные темы",
@@ -236,7 +237,8 @@
"confirm": "Подтверждение",
"copied": "Скопировано",
"more": "Ещё",
"advanced_settings": "Дополнительные настройки"
"advanced_settings": "Дополнительные настройки",
"expand": "Развернуть"
},
"docs": {
"title": "Документация"
@@ -447,6 +449,8 @@
"error.notion.no_api_key": "Notion ApiKey или Notion DatabaseID не настроен",
"error.yuque.export": "Ошибка экспорта в Yuque, пожалуйста, проверьте состояние подключения и настройки в документации",
"error.yuque.no_config": "Yuque Token или Yuque Url не настроен",
"error.joplin.no_config": "Joplin Authorization Token или URL не настроен",
"error.joplin.export": "Не удалось экспортировать в Joplin, пожалуйста, убедитесь, что Joplin запущен и проверьте состояние подключения или настройки",
"group.delete.content": "Удаление группы сообщений удалит пользовательский вопрос и все ответы помощника",
"group.delete.title": "Удалить группу сообщений",
"ignore.knowledge.base": "Режим сети включен, игнорировать базу знаний",
@@ -479,13 +483,18 @@
"success.markdown.export.specified": "Файл Markdown успешно экспортирован",
"success.notion.export": "Успешный экспорт в Notion",
"success.yuque.export": "Успешный экспорт в Yuque",
"success.joplin.export": "Успешный экспорт в Joplin",
"switch.disabled": "Пожалуйста, дождитесь завершения текущего ответа",
"topic.added": "Новый топик добавлен",
"upgrade.success.button": "Перезапустить",
"upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления",
"upgrade.success.title": "Обновление успешно",
"warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!",
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова."
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова.",
"tools": {
"invoking": "Вызов",
"completed": "Завершено"
}
},
"minapp": {
"sidebar.add.title": "Добавить в боковую панель",
@@ -759,7 +768,13 @@
"syncError": "Ошибка резервного копирования",
"syncStatus": "Статус резервного копирования",
"title": "WebDAV",
"user": "Пользователь WebDAV"
"user": "Пользователь WebDAV",
"backup.modal.title": "Резервное копирование на WebDAV",
"backup.modal.filename.placeholder": "Введите имя файла резервной копии",
"restore.modal.title": "Восстановление с WebDAV",
"restore.modal.select.placeholder": "Выберите файл резервной копии для восстановления",
"restore.confirm.title": "Подтверждение восстановления",
"restore.confirm.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?"
},
"yuque": {
"check": {
@@ -790,6 +805,21 @@
"title": "Настройка Obsidian",
"api_key": "API Key Obsidian",
"api_key_placeholder": "Введите API Key Obsidian"
},
"joplin": {
"check": {
"button": "Проверить",
"empty_url": "Сначала введите URL Joplin",
"empty_token": "Сначала введите токен Joplin",
"fail": "Не удалось проверить подключение к Joplin",
"success": "Подключение к Joplin успешно проверено"
},
"title": "Настройка Joplin",
"help": "Включите Joplin опцию, проверьте порт и скопируйте токен",
"url": "URL Joplin",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Токен Joplin",
"token_placeholder": "Введите токен Joplin"
}
},
"display.assistant.title": "Настройки ассистентов",
@@ -856,6 +886,7 @@
"editServer": "Редактировать сервер",
"env": "Переменные окружения",
"envTooltip": "Формат: KEY=value, по одной на строку",
"findMore": "Найти больше MCP серверов",
"name": "Имя",
"nameRequired": "Пожалуйста, введите имя сервера",
"noServers": "Серверы не настроены",

View File

@@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "导出失败",
"topics.export.obsidian_show_md_files": "显示md文件",
"topics.export.obsidian_selected_path": "已选择路径",
"topics.export.joplin": "导出到 Joplin",
"topics.list": "话题列表",
"topics.move_to": "移动到",
"topics.pinned": "固定话题",
@@ -236,7 +237,8 @@
"warning": "警告",
"you": "用户",
"more": "更多",
"advanced_settings": "高级设置"
"advanced_settings": "高级设置",
"expand": "展开"
},
"docs": {
"title": "帮助文档"
@@ -441,6 +443,8 @@
"error.notion.no_api_key": "未配置 Notion API Key 或 Notion Database ID",
"error.yuque.export": "导出语雀错误,请检查连接状态并对照文档检查配置",
"error.yuque.no_config": "未配置语雀 Token 或 知识库 URL",
"error.joplin.no_config": "未配置 Joplin 授权令牌 或 URL",
"error.joplin.export": "导出 Joplin 失败,请保持 Joplin 已运行并检查连接状态或检查配置",
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答",
"group.delete.title": "删除分组消息",
"ignore.knowledge.base": "联网模式开启,忽略知识库",
@@ -473,13 +477,18 @@
"success.markdown.export.specified": "成功导出 Markdown 文件",
"success.notion.export": "成功导出到 Notion",
"success.yuque.export": "成功导出到语雀",
"success.joplin.export": "成功导出到 Joplin",
"switch.disabled": "请等待当前回复完成后操作",
"topic.added": "话题添加成功",
"upgrade.success.button": "重启",
"upgrade.success.content": "重启用以完成升级",
"upgrade.success.title": "升级成功",
"warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!",
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试"
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试",
"tools": {
"invoking": "调用中",
"completed": "已完成"
}
},
"minapp": {
"sidebar.add.title": "添加到侧边栏",
@@ -753,6 +762,12 @@
"password": "WebDAV 密码",
"path": "WebDAV 路径",
"path.placeholder": "/backup",
"backup.modal.title": "备份到 WebDAV",
"backup.modal.filename.placeholder": "请输入备份文件名",
"restore.modal.title": "从 WebDAV 恢复",
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
"restore.confirm.title": "确认恢复",
"restore.confirm.content": "从 WebDAV 恢复将会覆盖当前数据,是否继续?",
"restore.button": "从 WebDAV 恢复",
"restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
"restore.title": "从 WebDAV 恢复",
@@ -790,6 +805,21 @@
"title": "Obsidian 配置",
"api_key": "Obsidian API Key",
"api_key_placeholder": "请输入 Obsidian API Key"
},
"joplin": {
"check": {
"button": "检查",
"empty_url": "请先输入 Joplin 剪裁服务监听 URL",
"empty_token": "请先输入 Joplin 授权令牌",
"fail": "Joplin 连接验证失败",
"success": "Joplin 连接验证成功"
},
"title": "Joplin 配置",
"help": "在 Joplin 选项中,启用网页剪裁服务(无需安装浏览器插件),确认端口号,并复制授权令牌",
"url": "Joplin 剪裁服务监听 URL",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Joplin 授权令牌",
"token_placeholder": "请输入 Joplin 授权令牌"
}
},
"display.assistant.title": "助手设置",
@@ -856,6 +886,7 @@
"editServer": "编辑服务器",
"env": "环境变量",
"envTooltip": "格式KEY=value每行一个",
"findMore": "更多 MCP 服务器",
"name": "名称",
"nameRequired": "请输入服务器名称",
"noServers": "未配置服务器",
@@ -1084,6 +1115,53 @@
"title": "Tavily"
},
"title": "网络搜索"
},
"nodeRequired": "需要 Node.js",
"nodeSettings": {
"title": "Node.js 环境设置",
"description": "管理 Cherry Studio 内置的 Node.js 环境。您可以选择要安装的 Node.js 版本,以确保最佳的兼容性。",
"status": "状态",
"checking": "检查中...",
"installed": "已安装",
"notInstalled": "未安装",
"refresh": "刷新",
"version": "Node.js 版本",
"versionHelp": "选择要安装的 Node.js 版本",
"customVersion": "自定义版本",
"customVersionHelp": "如果您需要特定版本,请在此输入版本号(如 18.16.1",
"install": "安装 Node.js",
"reinstall": "重新安装 Node.js",
"installSuccess": "Node.js v{{version}} 安装成功",
"installFailed": "Node.js 安装失败"
},
"nodeSettingsTab": "Node.js 环境",
"appsManagerTab": "应用管理",
"packageDeployerTab": "部署代码包",
"packageDeployer": {
"advancedOptions": "高级选项",
"deploy": "部署",
"deployFailed": "部署包失败",
"deploySuccess": "{{name}} 已成功部署在端口 {{port}} 上",
"description": "上传包含 Node.js 应用程序代码的 ZIP 文件。该包将被自动解压和安装。",
"fileSelectError": "选择文件时出错",
"installNode": "安装 Node.js",
"installNodePrompt": "部署应用程序需要 Node.js。您要现在安装吗",
"namePlaceholder": "为您部署的应用输入名称",
"nodeInstallFailed": "安装 Node.js 失败",
"nodeInstallSuccess": "Node.js 安装成功",
"nodeNeeded": "运行应用程序需要内置 Node.js。",
"nodeNotAvailable": "Node.js 不可用",
"noFileSelected": "请选择要部署的 ZIP 文件",
"open": "在浏览器中打开",
"selectZip": "点击选择 ZIP 文件",
"title": "部署代码包",
"moduleTypeError": "模块类型错误",
"esModuleError": "发现 ES 模块语法。请在 package.json 中设置 \"type\": \"module\" 或使用 .mjs 扩展名。",
"convertToCommonJS": "转换为 CommonJS 语法",
"nextJsDetected": "检测到 Next.js 应用",
"buildStepAdded": "已自动添加构建步骤:将在启动应用前执行 'npm run build'。",
"nextJsInfo": "Next.js 应用注意事项",
"nextJsDescription": "Next.js 应用需要先构建后才能启动。请确保勾选\"这是一个 Next.js 应用\"或手动添加\"npm run build\"作为构建命令。"
}
},
"translate": {
@@ -1121,6 +1199,32 @@
"quit": "退出",
"show_window": "显示窗口",
"visualization": "可视化"
},
"nodeapp": {
"add": "添加",
"addApp": "添加应用",
"appName": "应用名称",
"appsManager": {
"confirmDelete": "确定要删除此应用吗?",
"confirmStop": "确定要停止此应用吗?",
"description": "管理应用",
"install": "安装",
"noApps": "暂无应用,请添加新应用或从代码部署",
"port": "端口",
"repository": "仓库",
"start": "启动",
"status": "状态",
"stop": "停止",
"title": "应用管理",
"uninstall": "卸载",
"update": "更新",
"updateProgress": "更新进度",
"updateSuccess": "{{name}} 更新成功",
"version": "版本",
"viewRepository": "查看仓库"
},
"nextJsInfo": "Next.js 应用注意事项",
"nextJsDescription": "Next.js 应用需要先构建后才能启动。请确保勾选\"这是一个 Next.js 应用\"或手动添加\"npm run build\"作为构建命令。"
}
}
}

View File

@@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "匯出失敗",
"topics.export.obsidian_show_md_files": "顯示md文件",
"topics.export.obsidian_selected_path": "已選擇路徑",
"topics.export.joplin": "匯出到 Joplin",
"topics.list": "話題列表",
"topics.move_to": "移動到",
"topics.pinned": "固定話題",
@@ -236,7 +237,8 @@
"copied": "已複製",
"confirm": "確認",
"more": "更多",
"advanced_settings": "進階設定"
"advanced_settings": "進階設定",
"expand": "展開"
},
"docs": {
"title": "說明文件"
@@ -441,6 +443,8 @@
"error.notion.no_api_key": "未設定 Notion API Key 或 Notion Database ID",
"error.yuque.export": "匯出語雀錯誤,請檢查連接狀態並對照文件檢查設定",
"error.yuque.no_config": "未設定語雀 Token 或知識庫 Url",
"error.joplin.no_config": "未設定 Joplin 授權Token 或 URL",
"error.joplin.export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定",
"group.delete.content": "刪除分組訊息會刪除使用者提問和所有助手的回答",
"group.delete.title": "刪除分組訊息",
"ignore.knowledge.base": "網路模式開啟,忽略知識庫",
@@ -473,13 +477,18 @@
"success.markdown.export.specified": "成功導出 Markdown 文件",
"success.notion.export": "成功匯出到 Notion",
"success.yuque.export": "成功匯出到語雀",
"success.joplin.export": "成功匯出到 Joplin",
"switch.disabled": "請等待當前回覆完成",
"topic.added": "新話題已新增",
"upgrade.success.button": "重新啟動",
"upgrade.success.content": "請重新啟動程式以完成升級",
"upgrade.success.title": "升級成功",
"warn.notion.exporting": "正在匯出到 Notion請勿重複請求匯出",
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試"
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試",
"tools": {
"invoking": "調用中",
"completed": "已完成"
}
},
"minapp": {
"sidebar.add.title": "新增到側邊欄",
@@ -759,7 +768,13 @@
"syncError": "備份錯誤",
"syncStatus": "備份狀態",
"title": "WebDAV",
"user": "WebDAV 使用者名稱"
"user": "WebDAV 使用者名稱",
"backup.modal.title": "備份到 WebDAV",
"backup.modal.filename.placeholder": "請輸入備份文件名",
"restore.modal.title": "從 WebDAV 恢復",
"restore.modal.select.placeholder": "請選擇要恢復的備份文件",
"restore.confirm.title": "復元確認",
"restore.confirm.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?"
},
"yuque": {
"check": {
@@ -790,6 +805,21 @@
"title": "Obsidian 設定",
"api_key": "Obsidian API Key",
"api_key_placeholder": "請輸入 Obsidian API Key"
},
"joplin": {
"check": {
"button": "檢查",
"empty_url": "請先輸入 Joplin 剪輯服務 URL",
"empty_token": "請先輸入 Joplin 授權Token",
"fail": "Joplin 連接驗證失敗",
"success": "Joplin 連接驗證成功"
},
"title": "Joplin 設定",
"help": "在 Joplin 選項中啟用剪輯服務無需安裝瀏覽器外掛確認埠編號並複製授權Token",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Joplin 授權Token",
"token_placeholder": "請輸入 Joplin 授權Token"
}
},
"display.assistant.title": "助手設定",
@@ -856,6 +886,7 @@
"editServer": "編輯伺服器",
"env": "環境變數",
"envTooltip": "格式KEY=value每行一個",
"findMore": "更多 MCP 伺服器",
"name": "名稱",
"nameRequired": "請輸入伺服器名稱",
"noServers": "未設定伺服器",

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import React, { useEffect, useRef } from 'react'
import MermaidPopup from './MermaidPopup'
interface Props {

View File

@@ -24,6 +24,7 @@ import { Message, Model } from '@renderer/types'
import { Assistant, Topic } from '@renderer/types'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTrailingDoubleSpaces } from '@renderer/utils'
import {
exportMarkdownToJoplin,
exportMarkdownToNotion,
exportMarkdownToYuque,
exportMessageAsMarkdown,
@@ -69,7 +70,7 @@ const MessageMenubar: FC<Props> = (props) => {
const onCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content.trimStart()))
window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
@@ -222,6 +223,15 @@ const MessageMenubar: FC<Props> = (props) => {
const title = getMessageTitle(message)
await ObsidianExportPopup.show({ title, markdown })
}
},
{
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: async () => {
const title = getMessageTitle(message)
const markdown = messageToMarkdown(message)
exportMarkdownToJoplin(title, markdown)
}
}
]
}

View File

@@ -60,7 +60,7 @@ const MessageTools: FC<Props> = ({ message }) => {
<TitleContent>
<ToolName>{tool.name}</ToolName>
<StatusIndicator $isInvoking={isInvoking}>
{isInvoking ? t('tools.invoking') : t('tools.completed')}
{isInvoking ? t('message.tools.invoking') : t('message.tools.completed')}
{isInvoking && <LoadingOutlined spin style={{ marginLeft: 6 }} />}
{isDone && <CheckOutlined style={{ marginLeft: 6 }} />}
</StatusIndicator>

View File

@@ -46,6 +46,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
const [displayMessages, setDisplayMessages] = useState<Message[]>([])
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isProcessingContext, setIsProcessingContext] = useState(false)
const { messages, displayCount, loading, updateMessages, clearTopicMessages, deleteMessage } =
useMessageOperations(topic)
@@ -107,25 +108,32 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
}
}),
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, async () => {
const messages = messagesRef.current
if (isProcessingContext) return
setIsProcessingContext(true)
if (messages.length === 0) {
return
}
try {
const messages = messagesRef.current
const lastMessage = last(messages)
if (messages.length === 0) {
return
}
const lastMessage = last(messages)
if (lastMessage?.type === 'clear') {
await deleteMessage(lastMessage)
scrollToBottom()
return
}
const clearMessage = getUserMessage({ assistant, topic, type: 'clear' })
const newMessages = [...messages, clearMessage]
await updateMessages(newMessages)
if (lastMessage?.type === 'clear') {
deleteMessage(lastMessage)
scrollToBottom()
return
} finally {
setIsProcessingContext(false)
}
const clearMessage = getUserMessage({ assistant, topic, type: 'clear' })
const newMessages = [...messages, clearMessage]
await updateMessages(newMessages)
scrollToBottom()
}),
EventEmitter.on(EVENT_NAMES.NEW_BRANCH, async (index: number) => {
const newTopic = getDefaultTopic(assistant.id)
@@ -151,7 +159,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
return () => unsubscribes.forEach((unsub) => unsub())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assistant, dispatch, scrollToBottom, topic])
}, [assistant, dispatch, scrollToBottom, topic, isProcessingContext])
useEffect(() => {
runAsyncFunction(async () => {

View File

@@ -26,6 +26,7 @@ import { Assistant, Topic } from '@renderer/types'
import { removeSpecialCharactersForFileName } from '@renderer/utils'
import { copyTopicAsMarkdown } from '@renderer/utils/copy'
import {
exportMarkdownToJoplin,
exportMarkdownToYuque,
exportTopicAsMarkdown,
exportTopicToNotion,
@@ -263,6 +264,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
const markdown = await topicToMarkdown(topic)
await ObsidianExportPopup.show({ title: topic.name, markdown })
}
},
{
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToJoplin(topic.name, markdown)
}
}
]
}

View File

@@ -142,10 +142,10 @@ const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
name="rerankModel"
label={t('models.rerank_model')}
tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }}
initialValue={getModelUniqId(base.rerankModel) || undefined}
rules={[{ required: false, message: t('message.error.enter.model') }]}>
<Select
style={{ width: '100%' }}
defaultValue={getModelUniqId(base.rerankModel) || undefined}
options={rerankSelectOptions}
placeholder={t('settings.models.empty')}
allowClear

View File

@@ -0,0 +1,301 @@
import { DownloadOutlined, GithubOutlined, LoadingOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons'
import { useNodeApps } from '@renderer/hooks/useNodeApps'
import { NodeAppType } from '@renderer/types'
import { Avatar, Button, Card, Dropdown, Menu, Space, Tag, Tooltip, Typography, notification } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const { Title, Paragraph, Text } = Typography
interface Props {
app: NodeAppType
}
const NodeApp: FC<Props> = ({ app }) => {
const { t } = useTranslation()
const { installApp, updateApp, startApp, stopApp, uninstallApp } = useNodeApps()
const [loading, setLoading] = useState(false)
const [actionType, setActionType] = useState<string>('')
// Handle installation
const handleInstall = async () => {
try {
setLoading(true)
setActionType('install')
await installApp(app.id as string)
notification.success({
message: t('common.success'),
description: t('nodeapp.installSuccess', { name: app.name })
})
} catch (err) {
notification.error({
message: t('common.error'),
description: err instanceof Error ? err.message : String(err)
})
} finally {
setLoading(false)
setActionType('')
}
}
// Handle update
const handleUpdate = async () => {
try {
setLoading(true)
setActionType('update')
await updateApp(app.id as string)
notification.success({
message: t('common.success'),
description: t('nodeapp.updateSuccess', { name: app.name })
})
} catch (err) {
notification.error({
message: t('common.error'),
description: err instanceof Error ? err.message : String(err)
})
} finally {
setLoading(false)
setActionType('')
}
}
// Handle start
const handleStart = async () => {
try {
setLoading(true)
setActionType('start')
const result = await startApp(app.id as string)
if (result) {
notification.success({
message: t('common.success'),
description: t('nodeapp.startSuccess', { name: app.name, port: result.port })
})
window.api.openWebsite(result.url)
}
} catch (err) {
notification.error({
message: t('common.error'),
description: err instanceof Error ? err.message : String(err)
})
} finally {
setLoading(false)
setActionType('')
}
}
// Handle stop
const handleStop = async () => {
try {
setLoading(true)
setActionType('stop')
await stopApp(app.id as string)
notification.success({
message: t('common.success'),
description: t('nodeapp.stopSuccess', { name: app.name })
})
} catch (err) {
notification.error({
message: t('common.error'),
description: err instanceof Error ? err.message : String(err)
})
} finally {
setLoading(false)
setActionType('')
}
}
// Handle uninstall
const handleUninstall = async () => {
try {
setLoading(true)
setActionType('uninstall')
await uninstallApp(app.id as string)
notification.success({
message: t('common.success'),
description: t('nodeapp.uninstallSuccess', { name: app.name })
})
} catch (err) {
notification.error({
message: t('common.error'),
description: err instanceof Error ? err.message : String(err)
})
} finally {
setLoading(false)
setActionType('')
}
}
// Open GitHub repository
const openRepository = () => {
if (app.repositoryUrl) {
window.api.openWebsite(app.repositoryUrl)
}
}
// Open app homepage
const openHomepage = () => {
if (app.homepage) {
window.api.openWebsite(app.homepage)
}
}
// Render app status tag
const renderStatusTag = () => {
if (app.isRunning) {
return <Tag color="green">{t('nodeapp.running')}</Tag>
}
if (app.isInstalled) {
return <Tag color="blue">{t('nodeapp.installed')}</Tag>
}
return <Tag color="default">{t('nodeapp.notInstalled')}</Tag>
}
return (
<Card
hoverable
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
cover={
<CardCoverContainer>
{app.logo ? (
<img alt={app.name} src={app.logo} style={{ width: '100%', height: '140px', objectFit: 'cover' }} />
) : (
<AppLogo>
<Avatar size={64} style={{ backgroundColor: '#1890ff' }}>
{app.name.substring(0, 2).toUpperCase()}
</Avatar>
</AppLogo>
)}
{renderStatusTag()}
</CardCoverContainer>
}
actions={[
// Show different actions based on app status
app.isInstalled ? (
app.isRunning ? (
<Tooltip title={t('nodeapp.stop')}>
<Button
type="text"
icon={loading && actionType === 'stop' ? <LoadingOutlined /> : <StopOutlined />}
onClick={handleStop}
loading={loading && actionType === 'stop'}
disabled={loading}
/>
</Tooltip>
) : (
<Tooltip title={t('nodeapp.start')}>
<Button
type="text"
icon={loading && actionType === 'start' ? <LoadingOutlined /> : <PlayCircleOutlined />}
onClick={handleStart}
loading={loading && actionType === 'start'}
disabled={loading}
/>
</Tooltip>
)
) : (
<Tooltip title={t('nodeapp.install')}>
<Button
type="text"
icon={loading && actionType === 'install' ? <LoadingOutlined /> : <DownloadOutlined />}
onClick={handleInstall}
loading={loading && actionType === 'install'}
disabled={loading}
/>
</Tooltip>
),
app.isInstalled && (
<Tooltip title={t('nodeapp.update')}>
<Button
type="text"
icon={loading && actionType === 'update' ? <LoadingOutlined /> : <ReloadOutlined />}
onClick={handleUpdate}
loading={loading && actionType === 'update'}
disabled={loading}
/>
</Tooltip>
),
app.repositoryUrl && (
<Tooltip title={t('nodeapp.viewRepository')}>
<Button
type="text"
icon={<GithubOutlined />}
onClick={openRepository}
disabled={loading}
/>
</Tooltip>
)
].filter(Boolean)}
>
<Card.Meta
title={<Title level={5}>{app.name}</Title>}
description={
<div style={{ minHeight: '100px' }}>
<Paragraph ellipsis={{ rows: 3 }}>{app.description}</Paragraph>
{app.author && (
<Space style={{ marginTop: '8px' }}>
<Text type="secondary">{t('nodeapp.author')}:</Text>
<Text>{app.author}</Text>
</Space>
)}
{app.version && (
<Space style={{ marginTop: '4px' }}>
<Text type="secondary">{t('nodeapp.version')}:</Text>
<Text>{app.version}</Text>
</Space>
)}
{app.isInstalled && (
<div style={{ marginTop: '12px' }}>
<Dropdown
menu={{
items: [
{
key: 'uninstall',
danger: true,
label: t('nodeapp.uninstall'),
onClick: handleUninstall
}
]
}}
trigger={['click']}
>
<Button type="link" size="small" danger>
{t('nodeapp.more')}
</Button>
</Dropdown>
</div>
)}
</div>
}
/>
</Card>
)
}
const CardCoverContainer = styled.div`
position: relative;
min-height: 140px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f0f2f5;
.ant-tag {
position: absolute;
top: 8px;
right: 8px;
}
`
const AppLogo = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 140px;
`
export default NodeApp

View File

@@ -0,0 +1,118 @@
import { NodeAppType } from '@renderer/types'
import { Button, Form, Input, Space } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
onSubmit: (values: NodeAppType) => void
onCancel: () => void
loading: boolean
initialValues?: Partial<NodeAppType>
}
const NodeAppForm: FC<Props> = ({ onSubmit, onCancel, loading, initialValues }) => {
const { t } = useTranslation()
const [form] = Form.useForm()
const handleSubmit = (values: any) => {
onSubmit({
...values,
type: 'node',
isInstalled: false,
isRunning: false
} as NodeAppType)
}
return (
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={initialValues}
autoComplete="off"
>
<Form.Item
name="name"
label={t('nodeapp.form.name')}
rules={[{ required: true, message: t('nodeapp.form.nameRequired') }]}
>
<Input placeholder={t('nodeapp.form.namePlaceholder')} />
</Form.Item>
<Form.Item
name="repositoryUrl"
label={t('nodeapp.form.repositoryUrl')}
rules={[
{ required: true, message: t('nodeapp.form.repositoryUrlRequired') },
{
pattern: /^https?:\/\/github\.com\/[\w-]+\/[\w.-]+\/?$/,
message: t('nodeapp.form.repositoryUrlInvalid')
}
]}
>
<Input placeholder={t('nodeapp.form.repositoryUrlPlaceholder')} />
</Form.Item>
<Form.Item
name="description"
label={t('nodeapp.form.description')}
>
<Input.TextArea
rows={3}
placeholder={t('nodeapp.form.descriptionPlaceholder')}
/>
</Form.Item>
<Form.Item
name="author"
label={t('nodeapp.form.author')}
>
<Input placeholder={t('nodeapp.form.authorPlaceholder')} />
</Form.Item>
<Form.Item
name="homepage"
label={t('nodeapp.form.homepage')}
>
<Input placeholder={t('nodeapp.form.homepagePlaceholder')} />
</Form.Item>
<Form.Item
name="installCommand"
label={t('nodeapp.form.installCommand')}
help={t('nodeapp.form.installCommandHelp')}
>
<Input placeholder="npm install" />
</Form.Item>
<Form.Item
name="startCommand"
label={t('nodeapp.form.startCommand')}
help={t('nodeapp.form.startCommandHelp')}
>
<Input placeholder="npm start" />
</Form.Item>
<Form.Item
name="port"
label={t('nodeapp.form.port')}
help={t('nodeapp.form.portHelp')}
>
<Input placeholder="3000" type="number" />
</Form.Item>
<Form.Item>
<Space style={{ float: 'right' }}>
<Button onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{t('common.submit')}
</Button>
</Space>
</Form.Item>
</Form>
)
}
export default NodeAppForm

View File

@@ -0,0 +1,171 @@
import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { Center } from '@renderer/components/Layout'
import { useNodeApps } from '@renderer/hooks/useNodeApps'
import { NodeAppType } from '@renderer/types'
import { Button, Col, Empty, Input, Modal, Row, Spin, Tabs, Typography, notification } from 'antd'
import { isEmpty } from 'lodash'
import React, { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import NodeApp from './NodeApp'
import NodeAppForm from './NodeAppForm'
import PackageDeployer from './PackageDeployer'
const { Title } = Typography
const { TabPane } = Tabs
const NodeAppsPage: FC = () => {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const { apps, loading, addApp, refresh } = useNodeApps()
const [isModalVisible, setIsModalVisible] = useState(false)
const [formLoading, setFormLoading] = useState(false)
// Filter apps based on search
const filteredApps = search
? apps.filter(
(app) =>
app.name.toLowerCase().includes(search.toLowerCase()) ||
app.description?.toLowerCase().includes(search.toLowerCase()) ||
app.author?.toLowerCase().includes(search.toLowerCase())
)
: apps
// Handle adding a new app
const handleAddApp = useCallback(async (values: NodeAppType) => {
try {
setFormLoading(true)
await addApp({
...values,
type: 'node'
})
setIsModalVisible(false)
notification.success({
message: t('common.success'),
description: t('nodeapp.addSuccess', { name: values.name })
})
} catch (err) {
notification.error({
message: t('common.error'),
description: err instanceof Error ? err.message : String(err)
})
} finally {
setFormLoading(false)
}
}, [addApp, t])
// Disable right-click menu in blank area
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
}
// Handle successful package deployment
const handleDeployed = useCallback(() => {
refresh()
}, [refresh])
return (
<Container onContextMenu={handleContextMenu}>
<Navbar>
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'space-between' }}>
{t('nodeapp.title')}
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Input
placeholder={t('common.search')}
className="nodrag"
style={{ width: '250px', height: 28 }}
size="small"
variant="filled"
suffix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => setIsModalVisible(true)}
>
{t('nodeapp.add')}
</Button>
</div>
<div style={{ width: 80 }} />
</NavbarCenter>
</Navbar>
<ContentContainer id="content-container">
<Tabs defaultActiveKey="apps" style={{ height: '100%' }}>
<TabPane tab={t('nodeapp.marketplaceTab')} key="apps">
{loading ? (
<Center>
<Spin size="large" />
</Center>
) : isEmpty(filteredApps) ? (
<Center>
<Empty
description={
<div style={{ marginTop: '16px' }}>
<p>{t('nodeapp.empty')}</p>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsModalVisible(true)}
>
{t('nodeapp.add')}
</Button>
</div>
}
/>
</Center>
) : (
<div style={{ padding: '16px' }}>
<Title level={5} style={{ marginBottom: '16px' }}>{t('nodeapp.featured')}</Title>
<Row gutter={[16, 16]}>
{filteredApps.map((app) => (
<Col key={app.id} xs={24} sm={12} md={8} lg={6}>
<NodeApp app={app} />
</Col>
))}
</Row>
</div>
)}
</TabPane>
<TabPane tab={t('nodeapp.packageDeployerTab')} key="packageDeployer">
<PackageDeployer onDeployed={handleDeployed} />
</TabPane>
</Tabs>
</ContentContainer>
<Modal
title={t('nodeapp.addNew')}
open={isModalVisible}
onCancel={() => setIsModalVisible(false)}
footer={null}
width={600}
>
<NodeAppForm
onSubmit={handleAddApp}
onCancel={() => setIsModalVisible(false)}
loading={formLoading}
/>
</Modal>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
`
const ContentContainer = styled.div`
flex: 1;
overflow: auto;
padding-bottom: 20px;
`
export default NodeAppsPage

View File

@@ -0,0 +1,198 @@
import React, { FC, useEffect, useState } from 'react'
import { Button, Card, Form, Input, Select, Typography, notification, Space } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { LoadingOutlined, SyncOutlined } from '@ant-design/icons'
const { Title, Text } = Typography
const { Option } = Select
const NodeSettings: FC = () => {
const { t } = useTranslation()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [checkingVersion, setCheckingVersion] = useState(false)
const [nodeInstalled, setNodeInstalled] = useState(false)
const [currentVersion, setCurrentVersion] = useState('')
// 默认提供的Node.js版本选项
const nodeVersions = [
{ value: '20.11.1', label: 'v20.11.1 (LTS)' },
{ value: '18.18.0', label: 'v18.18.0 (LTS)' },
{ value: '16.20.2', label: 'v16.20.2 (LTS)' },
{ value: '14.21.3', label: 'v14.21.3 (LTS)' }
]
// 检查Node.js是否已安装
const checkNodeStatus = async () => {
try {
setCheckingVersion(true)
const isNodeInstalled = await window.api.nodeapp.checkNode()
setNodeInstalled(isNodeInstalled)
if (isNodeInstalled) {
try {
// 获取当前安装的Node.js版本
// 使用ipc调用获取Node.js版本
const versionFromConfig = await window.api.config.get('NODE_VERSION')
if (versionFromConfig) {
setCurrentVersion(versionFromConfig)
} else {
setCurrentVersion('Unknown')
}
} catch (error) {
console.error('Error getting Node.js version:', error)
setCurrentVersion('Unknown')
}
}
} catch (error) {
console.error('Error checking Node.js status:', error)
} finally {
setCheckingVersion(false)
}
}
// 组件加载时检查状态
useEffect(() => {
checkNodeStatus()
}, [])
// 安装Node.js
const handleInstall = async (values: any) => {
try {
setLoading(true)
// 设置环境变量来指定要安装的Node.js版本
if (values.nodeVersion) {
await window.api.config.set('NODE_VERSION', values.nodeVersion)
}
const success = await window.api.nodeapp.installNode()
if (success) {
notification.success({
message: t('common.success'),
description: t('nodeapp.nodeSettings.installSuccess', {
version: values.nodeVersion
})
})
// 重新检查状态
await checkNodeStatus()
} else {
notification.error({
message: t('common.error'),
description: t('nodeapp.nodeSettings.installFailed')
})
}
} catch (error) {
console.error('Error installing Node.js:', error)
notification.error({
message: t('common.error'),
description: error instanceof Error ? error.message : String(error)
})
} finally {
setLoading(false)
}
}
return (
<Container>
<Card title={<Title level={4}>{t('nodeapp.nodeSettings.title')}</Title>}>
<Space direction="vertical" style={{ width: '100%' }}>
<Text>{t('nodeapp.nodeSettings.description')}</Text>
<StatusSection>
<Text strong>{t('nodeapp.nodeSettings.status')}: </Text>
{checkingVersion ? (
<Text type="secondary">
<LoadingOutlined style={{ marginRight: 8 }} />
{t('nodeapp.nodeSettings.checking')}
</Text>
) : nodeInstalled ? (
<Text type="success">
{t('nodeapp.nodeSettings.installed')}
{currentVersion && ` (${currentVersion})`}
</Text>
) : (
<Text type="warning">{t('nodeapp.nodeSettings.notInstalled')}</Text>
)}
<Button
type="text"
size="small"
icon={<SyncOutlined />}
onClick={checkNodeStatus}
loading={checkingVersion}
>
{t('nodeapp.nodeSettings.refresh')}
</Button>
</StatusSection>
<Form
form={form}
layout="vertical"
onFinish={handleInstall}
initialValues={{
nodeVersion: '18.18.0'
}}
>
<Form.Item
name="nodeVersion"
label={t('nodeapp.nodeSettings.version')}
help={t('nodeapp.nodeSettings.versionHelp')}
>
<Select>
{nodeVersions.map((version) => (
<Option key={version.value} value={version.value}>
{version.label}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="customVersion"
label={t('nodeapp.nodeSettings.customVersion')}
help={t('nodeapp.nodeSettings.customVersionHelp')}
>
<Input
placeholder="20.12.1"
onChange={(e) => {
if (e.target.value) {
form.setFieldsValue({ nodeVersion: e.target.value })
}
}}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
>
{nodeInstalled ? t('nodeapp.nodeSettings.reinstall') : t('nodeapp.nodeSettings.install')}
</Button>
</Form.Item>
</Form>
</Space>
</Card>
</Container>
)
}
const Container = styled.div`
margin: 16px;
`
const StatusSection = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin: 16px 0;
padding: 12px;
background-color: #f5f5f5;
border-radius: 4px;
`
export default NodeSettings

View File

@@ -0,0 +1,580 @@
import { CloudUploadOutlined, GithubOutlined, LoadingOutlined, SettingOutlined, InfoCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import { FileType } from '@renderer/types'
import {
Alert,
Button,
Card,
Collapse,
Form,
Input,
Modal,
Space,
Spin,
Tabs,
Typography,
Upload,
notification,
Popover,
Select,
Checkbox
} from 'antd'
import React, { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const { Title, Text } = Typography
const { Panel } = Collapse
const { TabPane } = Tabs
const { Option } = Select
interface Props {
onDeployed?: (result: { port: number; url: string }) => void
}
const PackageDeployer: FC<Props> = ({ onDeployed }) => {
const { t } = useTranslation()
const [form] = Form.useForm()
const [gitForm] = Form.useForm()
const [loading, setLoading] = useState(false)
const [gitLoading, setGitLoading] = useState(false)
const [file, setFile] = useState<FileType | null>(null)
const [advancedVisible, setAdvancedVisible] = useState(false)
const [gitAdvancedVisible, setGitAdvancedVisible] = useState(false)
const [isNodeAvailable, setIsNodeAvailable] = useState<boolean | null>(null)
const [isInstallingNode, setIsInstallingNode] = useState(false)
const [uploadUrl, setUploadUrl] = useState('')
const [activeTab, setActiveTab] = useState('zip')
// Check if Node.js is available
useEffect(() => {
const checkNodeAvailability = async () => {
try {
const isAvailable = await window.api.nodeapp.checkNode()
setIsNodeAvailable(isAvailable)
} catch (error) {
console.error('Error checking Node.js availability:', error)
setIsNodeAvailable(false)
}
}
checkNodeAvailability()
}, [])
// Handle Node.js installation
const handleInstallNode = async () => {
try {
setIsInstallingNode(true)
const success = await window.api.nodeapp.installNode()
if (success) {
setIsNodeAvailable(true)
notification.success({
message: t('common.success'),
description: t('nodeapp.packageDeployer.nodeInstallSuccess')
})
} else {
notification.error({
message: t('common.error'),
description: t('nodeapp.packageDeployer.nodeInstallFailed')
})
}
} catch (error) {
console.error('Error installing Node.js:', error)
notification.error({
message: t('common.error'),
description: error instanceof Error ? error.message : String(error)
})
} finally {
setIsInstallingNode(false)
}
}
const handleFileSelect = async () => {
try {
const files = await window.api.file.select({
filters: [
{ name: 'ZIP Files', extensions: ['zip'] }
],
properties: ['openFile']
})
if (files && files.length > 0) {
setFile(files[0])
form.setFieldsValue({
name: files[0].name.replace(/\.zip$/, ''),
file: files[0]
})
setUploadUrl(files[0].path)
}
} catch (error) {
console.error('Error selecting file:', error)
}
}
const handleDeploy = async (values: any) => {
// First check if Node.js is available
if (isNodeAvailable === false) {
Modal.confirm({
title: t('nodeapp.packageDeployer.nodeRequired'),
content: t('nodeapp.packageDeployer.installNodePrompt'),
okText: t('nodeapp.packageDeployer.installNode'),
cancelText: t('common.cancel'),
onOk: handleInstallNode
})
return
}
if (!file) {
notification.warning({
message: t('common.warning'),
description: t('nodeapp.packageDeployer.noFileSelected')
})
return
}
try {
setLoading(true)
// 检测是否为Next.js应用如果文件名包含next或文件是从next.js项目导出的
const isNextJs = file.name.toLowerCase().includes('next') ||
(values.isNextJs === true);
// 如果是Next.js应用自动设置构建步骤
if (isNextJs && !values.buildCommand) {
values.buildCommand = 'npm run build';
notification.info({
message: t('nodeapp.packageDeployer.nextJsDetected'),
description: t('nodeapp.packageDeployer.buildStepAdded'),
duration: 5
});
}
// Display note about ES modules compatibility
if (file.name.includes('react') || file.name.includes('next') || file.name.includes('vue')) {
notification.info({
message: t('nodeapp.packageDeployer.moduleTypeError'),
description: t('nodeapp.packageDeployer.esModuleError'),
duration: 8
})
}
// Deploy the ZIP package
const result = await window.api.nodeapp.deployZip(file.path, {
name: values.name,
port: values.port ? parseInt(values.port) : undefined,
startCommand: values.startCommand,
installCommand: values.installCommand,
buildCommand: values.buildCommand
})
if (result) {
notification.success({
message: t('common.success'),
description: t('nodeapp.packageDeployer.deploySuccess', {
name: values.name,
port: result.port
}),
btn: (
<Button type="primary" size="small" onClick={() => window.api.openWebsite(result.url)}>
{t('nodeapp.packageDeployer.open')}
</Button>
)
})
// Reset form and state
form.resetFields()
setFile(null)
setAdvancedVisible(false)
setUploadUrl('')
// Notify parent
if (onDeployed) {
onDeployed(result)
}
} else {
notification.error({
message: t('common.error'),
description: t('nodeapp.packageDeployer.deployFailed')
})
}
} catch (error) {
console.error('Error deploying ZIP:', error)
notification.error({
message: t('common.error'),
description: error instanceof Error ? error.message : String(error)
})
} finally {
setLoading(false)
}
}
const handleDeployGit = async (values: any) => {
// First check if Node.js is available
if (isNodeAvailable === false) {
Modal.confirm({
title: t('nodeapp.packageDeployer.nodeRequired'),
content: t('nodeapp.packageDeployer.installNodePrompt'),
okText: t('nodeapp.packageDeployer.installNode'),
cancelText: t('common.cancel'),
onOk: handleInstallNode
})
return
}
if (!values.repoUrl) {
notification.warning({
message: t('common.warning'),
description: t('nodeapp.packageDeployer.noRepoUrlProvided')
})
return
}
try {
setGitLoading(true)
// 检测是否为Next.js应用
const isNextJs = values.repoUrl.toLowerCase().includes('next') ||
(values.isNextJs === true);
// 如果是Next.js应用自动设置构建步骤
if (isNextJs && !values.buildCommand) {
values.buildCommand = 'npm run build';
notification.info({
message: t('nodeapp.packageDeployer.nextJsDetected'),
description: t('nodeapp.packageDeployer.buildStepAdded'),
duration: 5
});
}
// Deploy from Git repository
const result = await window.api.nodeapp.deployGit(values.repoUrl, {
name: values.name,
port: values.port ? parseInt(values.port) : undefined,
startCommand: values.startCommand,
installCommand: values.installCommand,
buildCommand: values.buildCommand
})
if (result) {
notification.success({
message: t('common.success'),
description: t('nodeapp.packageDeployer.deploySuccess', {
name: values.name || 'Git App',
port: result.port
}),
btn: (
<Button type="primary" size="small" onClick={() => window.api.openWebsite(result.url)}>
{t('nodeapp.packageDeployer.open')}
</Button>
)
})
// Reset form and state
gitForm.resetFields()
setGitAdvancedVisible(false)
// Notify parent
if (onDeployed) {
onDeployed(result)
}
} else {
notification.error({
message: t('common.error'),
description: t('nodeapp.packageDeployer.deployFailed')
})
}
} catch (error) {
console.error('Error deploying from Git:', error)
notification.error({
message: t('common.error'),
description: error instanceof Error ? error.message : String(error)
})
} finally {
setGitLoading(false)
}
}
return (
<Container>
<Card title={t('nodeapp.packageDeployer.deployPackage')}>
{isNodeAvailable === false && (
<Alert
type="warning"
message={t('nodeapp.packageDeployer.nodeNotAvailable')}
description={
<Space>
<Text>{t('nodeapp.packageDeployer.nodeNeeded')}</Text>
<Button
type="primary"
onClick={handleInstallNode}
loading={isInstallingNode}
>
{t('nodeapp.packageDeployer.installNode')}
</Button>
</Space>
}
style={{ marginBottom: 16 }}
/>
)}
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane
tab={
<span>
<CloudUploadOutlined /> {t('nodeapp.packageDeployer.deployFromZip')}
</span>
}
key="zip"
>
<Form form={form} layout="vertical" onFinish={handleDeploy}>
<Space direction="vertical" style={{ width: '100%' }}>
<Text>{t('nodeapp.packageDeployer.description')}</Text>
<UploadContainer>
{file ? (
<FileInfo>
<div>
<CloudUploadOutlined style={{ fontSize: 24, marginRight: 8 }} />
<Text strong>{file.name}</Text>
</div>
<Button size="small" onClick={() => {
setFile(null);
form.setFieldsValue({ file: null });
setUploadUrl('');
}}>
{t('common.remove')}
</Button>
</FileInfo>
) : (
<UploadButton onClick={handleFileSelect}>
<CloudUploadOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<div>{t('nodeapp.packageDeployer.selectZip')}</div>
</UploadButton>
)}
</UploadContainer>
<Form.Item
name="name"
label={t('nodeapp.form.name')}
rules={[{ required: true, message: t('nodeapp.form.nameRequired') }]}
>
<Input placeholder={t('nodeapp.packageDeployer.namePlaceholder')} />
</Form.Item>
<Collapse
ghost
activeKey={advancedVisible ? ['1'] : []}
onChange={() => setAdvancedVisible(!advancedVisible)}
>
<Panel
header={
<div style={{ display: 'flex', alignItems: 'center' }}>
<SettingOutlined style={{ marginRight: 8 }} />
{t('nodeapp.packageDeployer.advancedOptions')}
</div>
}
key="1"
>
<Form.Item
name="port"
label={t('nodeapp.form.port')}
help={t('nodeapp.form.portHelp')}
>
<Input placeholder="3000" type="number" />
</Form.Item>
<Form.Item
name="installCommand"
label={t('nodeapp.form.installCommand')}
help={t('nodeapp.form.installCommandHelp')}
>
<Input placeholder="npm install" />
</Form.Item>
<Form.Item
name="buildCommand"
label={t('nodeapp.form.buildCommand')}
help={t('nodeapp.form.buildCommandHelp')}
>
<Input placeholder="npm run build" />
</Form.Item>
<Form.Item
name="startCommand"
label={t('nodeapp.form.startCommand')}
help={t('nodeapp.form.startCommandHelp')}
>
<Input placeholder="npm start" />
</Form.Item>
<Form.Item
name="isNextJs"
valuePropName="checked"
>
<Checkbox onChange={(e) => {
if (e.target.checked) {
form.setFieldsValue({
buildCommand: 'npm run build',
startCommand: 'npm run start',
installCommand: 'npm install --legacy-peer-deps'
});
}
}}>{t('nodeapp.form.isNextJs')}</Checkbox>
</Form.Item>
<Alert
message={t('nodeapp.packageDeployer.nextJsInfo')}
description={t('nodeapp.packageDeployer.nextJsDescription')}
type="info"
showIcon
style={{ marginBottom: '16px' }}
/>
</Panel>
</Collapse>
<Form.Item>
<Button
type="primary"
htmlType="submit"
disabled={!file}
loading={loading}
icon={loading ? <Spin size="small" /> : <CloudUploadOutlined />}
>
{t('nodeapp.packageDeployer.deploy')}
</Button>
</Form.Item>
</Space>
</Form>
</TabPane>
<TabPane
tab={
<span>
<GithubOutlined /> {t('nodeapp.packageDeployer.deployFromGit')}
</span>
}
key="git"
>
<Form form={gitForm} layout="vertical" onFinish={handleDeployGit}>
<Form.Item
name="repoUrl"
label={t('nodeapp.packageDeployer.repoUrl')}
rules={[{ required: true, message: t('nodeapp.packageDeployer.repoUrlRequired') }]}
>
<Input placeholder="https://github.com/username/repo" />
</Form.Item>
<Form.Item name="name" label={t('nodeapp.form.name')}>
<Input placeholder={t('nodeapp.packageDeployer.namePlaceholder')} />
</Form.Item>
<Form.Item
name="port"
label={
<span>
{t('nodeapp.form.port')}
<Popover
content={t('nodeapp.form.portHelp')}
title={t('common.tips')}
>
<QuestionCircleOutlined style={{ marginLeft: 8 }} />
</Popover>
</span>
}
>
<Input placeholder="3000" type="number" />
</Form.Item>
<Button
type="link"
onClick={() => setGitAdvancedVisible(!gitAdvancedVisible)}
style={{ paddingLeft: 0, marginBottom: 16 }}
>
{gitAdvancedVisible
? t('nodeapp.packageDeployer.hideAdvanced')
: t('nodeapp.packageDeployer.showAdvanced')}
</Button>
{gitAdvancedVisible && (
<Collapse ghost>
<Panel header={t('nodeapp.packageDeployer.advancedOptions')} key="1">
<Form.Item
name="installCommand"
label={t('nodeapp.form.installCommand')}
help={t('nodeapp.form.installCommandHelp')}
>
<Input placeholder="npm install" />
</Form.Item>
<Form.Item name="buildCommand" label={t('nodeapp.form.buildCommand')}
help={t('nodeapp.form.buildCommandHelp')}>
<Input placeholder="npm run build" />
</Form.Item>
<Form.Item name="startCommand" label={t('nodeapp.form.startCommand')}
help={t('nodeapp.form.startCommandHelp')}>
<Input placeholder="npm start" />
</Form.Item>
<Form.Item name="isNextJs" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox onChange={(e) => {
if (e.target.checked) {
gitForm.setFieldsValue({
buildCommand: 'npm run build',
startCommand: 'npm run start',
installCommand: 'npm install --legacy-peer-deps'
});
}
}}>{t('nodeapp.form.isNextJs')}</Checkbox>
</Form.Item>
</Panel>
</Collapse>
)}
<Form.Item>
<Button type="primary" htmlType="submit" loading={gitLoading}>
{t('nodeapp.packageDeployer.deploy')}
</Button>
</Form.Item>
</Form>
</TabPane>
</Tabs>
</Card>
</Container>
)
}
const Container = styled.div`
margin: 16px;
`
const UploadContainer = styled.div`
margin-bottom: 16px;
border: 1px dashed #d9d9d9;
border-radius: 4px;
background-color: #fafafa;
transition: border-color 0.3s;
&:hover {
border-color: #1890ff;
}
`
const UploadButton = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100px;
cursor: pointer;
padding: 16px;
`
const FileInfo = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
`
export default PackageDeployer

View File

@@ -0,0 +1,39 @@
import React, { useState } from 'react'
import { Tabs } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AppsManager from './AppsManager'
import PackageDeployer from './PackageDeployer'
import NodeSettings from './NodeSettings'
const NodeAppsPage: React.FC = () => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState('apps')
const handleTabChange = (key: string) => {
setActiveTab(key)
}
return (
<Container>
<Tabs activeKey={activeTab} onChange={handleTabChange}>
<Tabs.TabPane tab={t('nodeapp.appsManagerTab')} key="apps">
<AppsManager />
</Tabs.TabPane>
<Tabs.TabPane tab={t('nodeapp.packageDeployerTab')} key="deploy">
<PackageDeployer />
</Tabs.TabPane>
<Tabs.TabPane tab={t('nodeapp.nodeSettingsTab')} key="settings">
<NodeSettings />
</Tabs.TabPane>
</Tabs>
</Container>
)
}
const Container = styled.div`
padding: 0;
height: 100%;
`
export default NodeAppsPage

View File

@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import JoplinSettings from './JoplinSettings'
import MarkdownExportSettings from './MarkdownExportSettings'
import NotionSettings from './NotionSettings'
import ObsidianSettings from './ObsidianSettings'
@@ -35,6 +36,13 @@ const DataSettings: FC = () => {
const { theme } = useTheme()
const [menu, setMenu] = useState<string>('data')
//joplin icon needs to be updated into iconfont
const JoplinIcon = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="grey" xmlns="http://www.w3.org/2000/svg">
<path d="M20.97 0h-8.9a.15.15 0 00-.16.15v2.83c0 .1.08.17.18.17h1.22c.49 0 .89.38.93.86V17.4l-.01.36-.05.29-.04.13a2.06 2.06 0 01-.38.7l-.02.03a2.08 2.08 0 01-.37.34c-.5.35-1.17.5-1.92.43a4.66 4.66 0 01-2.67-1.22 3.96 3.96 0 01-1.34-2.42c-.1-.78.14-1.47.65-1.93l.07-.05c.37-.31.84-.5 1.39-.55a.09.09 0 00.01 0l.3-.01.35.01h.02a4.39 4.39 0 011.5.44c.15.08.17 0 .18-.06V9.63a.26.26 0 00-.2-.26 7.5 7.5 0 00-6.76 1.61 6.37 6.37 0 00-2.03 5.5 8.18 8.18 0 002.71 5.08A9.35 9.35 0 0011.81 24c1.88 0 3.62-.64 4.9-1.81a6.32 6.32 0 002.06-4.3l.01-10.86V4.08a.95.95 0 01.95-.93h1.22a.17.17 0 00.17-.17V.15a.15.15 0 00-.15-.15z" />
</svg>
)
const menuItems = [
{ key: 'data', title: 'settings.data.data.title', icon: <DatabaseOutlined style={{ fontSize: 16 }} /> },
{ key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
@@ -53,6 +61,12 @@ const DataSettings: FC = () => {
key: 'obsidian',
title: 'settings.data.obsidian.title',
icon: <i className="iconfont icon-obsidian" />
},
{
key: 'joplin',
title: 'settings.data.joplin.title',
//joplin icon needs to be updated into iconfont
icon: <JoplinIcon />
}
]
@@ -191,6 +205,7 @@ const DataSettings: FC = () => {
{menu === 'notion' && <NotionSettings />}
{menu === 'yuque' && <YuqueSettings />}
{menu === 'obsidian' && <ObsidianSettings />}
{menu === 'joplin' && <JoplinSettings />}
</SettingContainer>
</Container>
)

View File

@@ -0,0 +1,117 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import MinApp from '@renderer/components/MinApp'
import { useTheme } from '@renderer/context/ThemeProvider'
import { RootState, useAppDispatch } from '@renderer/store'
import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
import { Button, Tooltip } from 'antd'
import Input from 'antd/es/input/Input'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const JoplinSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
const handleJoplinTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setJoplinToken(e.target.value))
}
const handleJoplinUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setJoplinUrl(e.target.value))
}
const handleJoplinUrlBlur = (e: React.FocusEvent<HTMLInputElement>) => {
let url = e.target.value
// 确保URL以/结尾,但只在失去焦点时执行
if (url && !url.endsWith('/')) {
url = `${url}/`
dispatch(setJoplinUrl(url))
}
}
const handleJoplinConnectionCheck = async () => {
try {
if (!joplinToken) {
window.message.error(t('settings.data.joplin.check.empty_token'))
return
}
if (!joplinUrl) {
window.message.error(t('settings.data.joplin.check.empty_url'))
return
}
const response = await fetch(`${joplinUrl}notes?limit=1&token=${joplinToken}`)
const data = await response.json()
if (!response.ok || data?.error) {
window.message.error(t('settings.data.joplin.check.fail'))
return
}
window.message.success(t('settings.data.joplin.check.success'))
} catch (e) {
window.message.error(t('settings.data.joplin.check.fail'))
}
}
const handleJoplinHelpClick = () => {
MinApp.start({
id: 'joplin-help',
name: 'Joplin Help',
url: 'https://joplinapp.org/help/apps/clipper'
})
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.joplin.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.joplin.url')}</SettingRowTitle>
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
<Input
type="text"
value={joplinUrl || ''}
onChange={handleJoplinUrlChange}
onBlur={handleJoplinUrlBlur}
style={{ width: 315 }}
placeholder={t('settings.data.joplin.url_placeholder')}
/>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
<span>{t('settings.data.joplin.token')}</span>
<Tooltip title={t('settings.data.joplin.help')} placement="left">
<InfoCircleOutlined
style={{ color: 'var(--color-text-2)', cursor: 'pointer', marginLeft: 4 }}
onClick={handleJoplinHelpClick}
/>
</Tooltip>
</SettingRowTitle>
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
<Input
type="password"
value={joplinToken || ''}
onChange={handleJoplinTokenChange}
style={{ width: 250 }}
placeholder={t('settings.data.joplin.token_placeholder')}
/>
<Button onClick={handleJoplinConnectionCheck}>{t('settings.data.joplin.check.button')}</Button>
</HStack>
</SettingRow>
</SettingGroup>
)
}
export default JoplinSettings

View File

@@ -1,10 +1,9 @@
import { FolderOpenOutlined, SaveOutlined, SyncOutlined } from '@ant-design/icons'
import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { backupToWebdav, restoreFromWebdav, startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
import { useAppDispatch } from '@renderer/store'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setWebdavAutoSync,
setWebdavHost as _setWebdavHost,
@@ -13,13 +12,19 @@ import {
setWebdavSyncInterval as _setWebdavSyncInterval,
setWebdavUser as _setWebdavUser
} from '@renderer/store/settings'
import { Button, Input, Select } from 'antd'
import { Button, Input, Modal, Select, Spin, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
const WebDavSettings: FC = () => {
const {
webdavHost: webDAVHost,
@@ -38,45 +43,22 @@ const WebDavSettings: FC = () => {
const [backuping, setBackuping] = useState(false)
const [restoring, setRestoring] = useState(false)
const [isModalVisible, setIsModalVisible] = useState(false)
const [customFileName, setCustomFileName] = useState('')
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [selectedFile, setSelectedFile] = useState<string>('')
const [loadingFiles, setLoadingFiles] = useState(false)
const dispatch = useAppDispatch()
const { theme } = useTheme()
const { t } = useTranslation()
const { webdavSync } = useRuntime()
const { webdavSync } = useAppSelector((state) => state.backup)
// 把之前备份的文件定时上传到 webdav首先先配置 webdav 的 host, port, user, pass, path
const onBackup = async () => {
if (!webdavHost) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setBackuping(true)
await backupToWebdav({ showMessage: true })
setBackuping(false)
}
const onRestore = async () => {
if (!webdavHost) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setRestoring(true)
await restoreFromWebdav()
setRestoring(false)
}
const onPressRestore = () => {
window.modal.confirm({
title: t('settings.data.webdav.restore.title'),
content: t('settings.data.webdav.restore.content'),
centered: true,
onOk: onRestore
})
}
const onSyncIntervalChange = (value: number) => {
setSyncInterval(value)
dispatch(_setWebdavSyncInterval(value))
@@ -99,20 +81,102 @@ const WebDavSettings: FC = () => {
return (
<HStack gap="5px" alignItems="center">
{webdavSync.syncing && <SyncOutlined spin />}
{!webdavSync.syncing && webdavSync.lastSyncError && (
<Tooltip title={`${t('settings.data.webdav.syncError')}: ${webdavSync.lastSyncError}`}>
<WarningOutlined style={{ color: 'red' }} />
</Tooltip>
)}
{webdavSync.lastSyncTime && (
<span style={{ color: 'var(--text-secondary)' }}>
{t('settings.data.webdav.lastSync')}: {dayjs(webdavSync.lastSyncTime).format('HH:mm:ss')}
</span>
)}
{webdavSync.lastSyncError && (
<span style={{ color: 'var(--error-color)' }}>
{t('settings.data.webdav.syncError')}: {webdavSync.lastSyncError}
</span>
)}
</HStack>
)
}
const showBackupModal = async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}
const handleBackup = async () => {
setBackuping(true)
try {
await backupToWebdav({ showMessage: true, customFileName })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
const handleCancel = () => {
setIsModalVisible(false)
}
const showRestoreModal = async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listWebdavFiles({
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({ content: error.message, key: 'list-files-error' })
} finally {
setLoadingFiles(false)
}
}
const handleRestore = async () => {
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
content: t('settings.data.webdav.restore.confirm.content'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await restoreFromWebdav(selectedFile)
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({ content: error.message, key: 'restore-error' })
} finally {
setRestoring(false)
}
}
})
}
const formatFileOption = (file: BackupFile) => {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = `${(file.size / 1024).toFixed(2)} KB`
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.webdav.title')}</SettingTitle>
@@ -165,10 +229,10 @@ const WebDavSettings: FC = () => {
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button onClick={onBackup} icon={<SaveOutlined />} loading={backuping}>
<Button onClick={showBackupModal} icon={<SaveOutlined />} loading={backuping}>
{t('settings.data.webdav.backup.button')}
</Button>
<Button onClick={onPressRestore} icon={<FolderOpenOutlined />} loading={restoring}>
<Button onClick={showRestoreModal} icon={<FolderOpenOutlined />} loading={restoring}>
{t('settings.data.webdav.restore.button')}
</Button>
</HStack>
@@ -198,6 +262,46 @@ const WebDavSettings: FC = () => {
</SettingRow>
</>
)}
<>
<Modal
title={t('settings.data.webdav.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')}
/>
</Modal>
<Modal
title={t('settings.data.webdav.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={() => setIsRestoreModalVisible(false)}
okButtonProps={{ loading: restoring }}
width={600}>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
</>
</SettingGroup>
)
}

View File

@@ -3,7 +3,7 @@ import { useAppSelector } from '@renderer/store'
import { MCPServer } from '@renderer/types'
import { Form, Input, Modal, Radio, Switch } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface ShowParams {
@@ -131,12 +131,10 @@ const PopupContainer: React.FC<Props> = ({ server, create, resolve }) => {
}
const onCancel = () => {
console.log('onCancel')
setOpen(false)
}
const onClose = () => {
console.log('onClose')
resolve({})
}
@@ -153,7 +151,8 @@ const PopupContainer: React.FC<Props> = ({ server, create, resolve }) => {
maskClosable={false}
width={600}
transitionName="ant-move-down"
centered>
centered
bodyStyle={{ maxHeight: '70vh', overflowY: 'auto' }}>
<Form form={form} layout="vertical">
<Form.Item
name="name"

View File

@@ -1,4 +1,11 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import {
DeleteOutlined,
EditOutlined,
LinkOutlined,
PlusOutlined,
QuestionCircleOutlined,
SearchOutlined
} from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppSelector } from '@renderer/store'
@@ -50,6 +57,10 @@ const MCPSettings: FC = () => {
}
}
const handleOpenMCPServers = () => {
window.open('https://glama.ai/mcp/servers', '_blank')
}
const columns = [
{
title: t('settings.mcp.name'),
@@ -153,6 +164,9 @@ const MCPSettings: FC = () => {
<Button icon={<EditOutlined />} onClick={() => EditMcpJsonPopup.show()}>
{t('settings.mcp.editJson')}
</Button>
<Button icon={<SearchOutlined />} onClick={handleOpenMCPServers}>
{t('settings.mcp.findMore')} <LinkOutlined />
</Button>
</HStack>
<Table
dataSource={mcpServers}

View File

@@ -306,7 +306,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
</Space.Compact>
{apiKeyWebsite && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<HStack gap={5}>
<HStack>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>

View File

@@ -112,8 +112,8 @@ const TranslatePage: FC = () => {
message,
assistant,
onResponse: (text) => {
translatedText = text
setResult(text)
translatedText = text.replace(/^\s*\n+/g, '')
setResult(translatedText)
}
})
} catch (error) {

View File

@@ -20,7 +20,6 @@ import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import {
anthropicToolUseToMcpTool,
callMCPTool,
filterMCPTools,
mcpToolsToAnthropicTools,
upsertMCPToolResponse
} from '@renderer/utils/mcp-tools'
@@ -180,7 +179,6 @@ export default class AnthropicProvider extends BaseProvider {
const userMessages = flatten(userMessagesParams)
const lastUserMessage = _messages.findLast((m) => m.role === 'user')
mcpTools = filterMCPTools(mcpTools, lastUserMessage?.enabledMCPs)
const tools = mcpTools ? mcpToolsToAnthropicTools(mcpTools) : undefined
const body: MessageCreateParamsNonStreaming = {

View File

@@ -27,7 +27,6 @@ import { Assistant, FileType, FileTypes, MCPToolResponse, Message, Model, Provid
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import {
callMCPTool,
filterMCPTools,
geminiFunctionCallToMcpTool,
mcpToolsToGeminiTools,
upsertMCPToolResponse
@@ -197,7 +196,6 @@ export default class GeminiProvider extends BaseProvider {
history.push(await this.getMessageContents(message))
}
mcpTools = filterMCPTools(mcpTools, userLastMessage?.enabledMCPs)
const tools = mcpToolsToGeminiTools(mcpTools)
const toolResponses: MCPToolResponse[] = []

View File

@@ -29,7 +29,6 @@ import {
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import {
callMCPTool,
filterMCPTools,
mcpToolsToOpenAITools,
openAIToolsToMcpTool,
upsertMCPToolResponse
@@ -426,7 +425,6 @@ export default class OpenAIProvider extends BaseProvider {
const { signal } = abortController
await this.checkIsCopilot()
mcpTools = filterMCPTools(mcpTools, lastUserMessage?.enabledMCPs)
const tools = mcpTools && mcpTools.length > 0 ? mcpToolsToOpenAITools(mcpTools) : undefined
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter(
@@ -499,7 +497,7 @@ export default class OpenAIProvider extends BaseProvider {
}
}
if (finishReason === 'tool_calls') {
if (finishReason === 'tool_calls' || (finishReason === 'stop' && Object.keys(final_tool_calls).length > 0)) {
const toolCalls = Object.values(final_tool_calls).map(this.cleanToolCallArgs)
console.log('start invoke tools', toolCalls)
if (this.isZhipuTool(model)) {

View File

@@ -3,7 +3,7 @@ import { SEARCH_SUMMARY_PROMPT } from '@renderer/config/prompts'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import { Assistant, Message, Model, Provider, Suggestion } from '@renderer/types'
import { Assistant, MCPTool, Message, Model, Provider, Suggestion } from '@renderer/types'
import { formatMessageError, isAbortError } from '@renderer/utils/error'
import { cloneDeep, findLast, isEmpty } from 'lodash'
@@ -97,7 +97,15 @@ export async function fetchChatCompletion({
}
}
const allMCPTools = await window.api.mcp.listTools()
const lastUserMessage = findLast(messages, (m) => m.role === 'user')
// Get MCP tools
let mcpTools: MCPTool[] = []
const enabledMCPs = lastUserMessage?.enabledMCPs
if (enabledMCPs && enabledMCPs.length > 0) {
const allMCPTools = await window.api.mcp.listTools()
mcpTools = allMCPTools.filter((tool) => enabledMCPs.some((mcp) => mcp.name === tool.serverName))
}
await AI.completions({
messages: filterUsefulMessages(messages),
@@ -131,7 +139,7 @@ export async function fetchChatCompletion({
onResponse({ ...message, status: 'pending' })
},
mcpTools: allMCPTools
mcpTools: mcpTools
})
message.status = 'success'

View File

@@ -1,8 +1,9 @@
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { setWebDAVSyncState } from '@renderer/store/runtime'
import { setWebDAVSyncState } from '@renderer/store/backup'
import dayjs from 'dayjs'
import Logger from 'electron-log'
export async function backup() {
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
@@ -59,16 +60,27 @@ export async function reset() {
}
// 备份到 webdav
export async function backupToWebdav({ showMessage = false }: { showMessage?: boolean } = {}) {
export async function backupToWebdav({
showMessage = false,
customFileName = ''
}: { showMessage?: boolean; customFileName?: string } = {}) {
if (isManualBackupRunning) {
console.log('[Backup] Manual backup already in progress')
Logger.log('[Backup] Manual backup already in progress')
return
}
store.dispatch(setWebDAVSyncState({ syncing: true, lastSyncError: null }))
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
let deviceType = 'unknown'
try {
deviceType = (await window.api.system.getDeviceType()) || 'unknown'
} catch (error) {
Logger.error('[Backup] Failed to get device type:', error)
}
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
const backupData = await getBackupData()
// 上传文件
@@ -77,43 +89,47 @@ export async function backupToWebdav({ showMessage = false }: { showMessage?: bo
webdavHost,
webdavUser,
webdavPass,
webdavPath
webdavPath,
fileName: finalFileName
})
if (success) {
store.dispatch(
setWebDAVSyncState({
lastSyncTime: Date.now(),
lastSyncError: null
})
)
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
} else {
store.dispatch(setWebDAVSyncState({ lastSyncError: 'Backup failed' }))
showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
}
} catch (error: any) {
store.dispatch(setWebDAVSyncState({ lastSyncError: error.message }))
console.error('[backup] backupToWebdav: Error uploading file to WebDAV:', error)
showMessage &&
window.modal.error({
title: i18n.t('message.backup.failed'),
content: error.message
})
console.error('[Backup] backupToWebdav: Error uploading file to WebDAV:', error)
window.modal.error({
title: i18n.t('message.backup.failed'),
content: error.message
})
} finally {
store.dispatch(setWebDAVSyncState({ syncing: false }))
store.dispatch(
setWebDAVSyncState({
lastSyncTime: Date.now(),
syncing: false
})
)
isManualBackupRunning = false
}
}
// 从 webdav 恢复
export async function restoreFromWebdav() {
export async function restoreFromWebdav(fileName?: string) {
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
let data = ''
try {
data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath })
data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath, fileName })
} catch (error: any) {
console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
console.error('[Backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
window.modal.error({
title: i18n.t('message.restore.failed'),
content: error.message
@@ -123,7 +139,7 @@ export async function restoreFromWebdav() {
try {
await handleData(JSON.parse(data))
} catch (error) {
console.error('[backup] Error downloading file from WebDAV:', error)
console.error('[Backup] Error downloading file from WebDAV:', error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
}
}
@@ -158,6 +174,7 @@ export function startAutoSync() {
}
const { webdavSyncInterval } = store.getState().settings
const { webdavSync } = store.getState().backup
if (webdavSyncInterval <= 0) {
console.log('[AutoSync] Invalid sync interval, auto sync disabled')
@@ -165,9 +182,21 @@ export function startAutoSync() {
return
}
syncTimeout = setTimeout(performAutoBackup, webdavSyncInterval * 60 * 1000)
// 用户指定的自动备份时间间隔(毫秒)
const requiredInterval = webdavSyncInterval * 60 * 1000
console.log(`[AutoSync] Next sync scheduled in ${webdavSyncInterval} minutes`)
// 如果存在最后一次同步WebDAV的时间以它为参考计算下一次同步的时间
const timeUntilNextSync = webdavSync?.lastSyncTime
? Math.max(1000, webdavSync.lastSyncTime + requiredInterval - Date.now())
: requiredInterval
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
console.log(
`[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
(timeUntilNextSync / 1000) % 60
)} seconds`
)
}
async function performAutoBackup() {
@@ -179,7 +208,7 @@ export function startAutoSync() {
isAutoBackupRunning = true
try {
console.log('[AutoSync] Performing auto backup...')
console.log('[AutoSync] Starting auto backup...')
await backupToWebdav({ showMessage: false })
} catch (error) {
console.error('[AutoSync] Auto backup failed:', error)

View File

@@ -11,10 +11,12 @@ import FileManager from './FileManager'
export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams => {
const provider = getProviderByModel(base.model)
const rerankProvider = getProviderByModel(base.rerankModel)
const aiProvider = new AiProvider(provider)
const rerankAiProvider = new AiProvider(rerankProvider)
let host = aiProvider.getBaseURL()
const rerankHost = rerankAiProvider.getBaseURL()
if (provider.type === 'gemini') {
host = host + '/v1beta/openai/'
}
@@ -40,6 +42,8 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
baseURL: host,
chunkSize,
chunkOverlap: base.chunkOverlap,
rerankBaseURL: rerankHost,
rerankApiKey: rerankAiProvider.getApiKey() || 'secret',
rerankModel: base.rerankModel?.id,
rerankModelProvider: base.rerankModel?.provider,
topN: base.topN

View File

@@ -0,0 +1,32 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface WebDAVSyncState {
lastSyncTime: number | null
syncing: boolean
lastSyncError: string | null
}
export interface BackupState {
webdavSync: WebDAVSyncState
}
const initialState: BackupState = {
webdavSync: {
lastSyncTime: null,
syncing: false,
lastSyncError: null
}
}
const backupSlice = createSlice({
name: 'backup',
initialState,
reducers: {
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.webdavSync = { ...state.webdavSync, ...action.payload }
}
}
})
export const { setWebDAVSyncState } = backupSlice.actions
export default backupSlice.reducer

View File

@@ -5,6 +5,7 @@ import storage from 'redux-persist/lib/storage'
import agents from './agents'
import assistants from './assistants'
import backup from './backup'
import copilot from './copilot'
import knowledge from './knowledge'
import llm from './llm'
@@ -12,6 +13,7 @@ import mcp from './mcp'
import messagesReducer from './messages'
import migrate from './migrate'
import minapps from './minapps'
import nodeapps from './nodeapps'
import paintings from './paintings'
import runtime from './runtime'
import settings from './settings'
@@ -21,6 +23,7 @@ import websearch from './websearch'
const rootReducer = combineReducers({
assistants,
agents,
backup,
paintings,
llm,
settings,
@@ -31,6 +34,7 @@ const rootReducer = combineReducers({
websearch,
mcp,
copilot,
nodeapps,
messages: messagesReducer
})
@@ -38,7 +42,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 81,
version: 82,
blacklist: ['runtime', 'messages'],
migrate
},

View File

@@ -28,31 +28,6 @@ const initialState: MessagesState = {
error: null
}
// const MAX_RECENT_TOPICS = 10
// // 只初始化最近的会话消息
// export const initializeMessagesState = createAsyncThunk('messages/initialize', async () => {
// try {
// // 获取所有会话的基本信息
// const recentTopics = await TopicManager.getTopicLimit(MAX_RECENT_TOPICS)
// console.log('recentTopics', recentTopics)
// const messagesByTopic: Record<string, Message[]> = {}
// // 只加载最近会话的消息
// for (const topic of recentTopics) {
// if (topic.messages && topic.messages.length > 0) {
// const messages = topic.messages.map((msg) => ({ ...msg }))
// messagesByTopic[topic.id] = messages
// }
// }
// return messagesByTopic
// } catch (error) {
// console.error('Failed to initialize recent messages:', error)
// return {}
// }
// })
// 新增准备会话消息的函数,实现懒加载机制
export const prepareTopicMessages = createAsyncThunk(
'messages/prepareTopic',
@@ -144,7 +119,7 @@ const messagesSlice = createSlice({
if (message) {
Object.assign(message, updates)
db.topics.update(topicId, {
messages: topicMessages.map((m) => (m.id === message.id ? cloneDeep(message) : m))
messages: topicMessages.map((m) => (m.id === message.id ? cloneDeep(message) : cloneDeep(m)))
})
}
}
@@ -210,19 +185,6 @@ const messagesSlice = createSlice({
}
}
}
// extraReducers: (builder) => {
// builder
// .addCase(initializeMessagesState.pending, (state) => {
// state.error = null
// })
// .addCase(initializeMessagesState.fulfilled, (state, action) => {
// console.log('initializeMessagesState.fulfilled', action.payload)
// state.messagesByTopic = action.payload
// })
// .addCase(initializeMessagesState.rejected, (state, action) => {
// state.error = action.error.message || 'Failed to load messages'
// })
// }
})
const handleResponseMessageUpdate = (

View File

@@ -772,6 +772,22 @@ const migrateConfig = {
'81': (state: RootState) => {
addProvider(state, 'copilot')
return state
},
'82': (state: RootState) => {
const runtimeState = state.runtime as any
if (runtimeState?.webdavSync) {
state.backup = state.backup || {}
state.backup = {
...state.backup,
webdavSync: {
lastSyncTime: runtimeState.webdavSync.lastSyncTime || null,
syncing: runtimeState.webdavSync.syncing || false,
lastSyncError: runtimeState.webdavSync.lastSyncError || null
}
}
delete runtimeState.webdavSync
}
return state
}
}

View File

@@ -0,0 +1,71 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { NodeAppType } from '@renderer/types'
// Define featured Node.js apps - you can add more as needed
export const FEATURED_NODE_APPS: NodeAppType[] = [
{
id: 'writing-helper',
name: 'Writing Helper',
url: 'http://localhost:3000',
type: 'node',
repositoryUrl: 'https://github.com/GeekyWizKid/writing-helper',
description: 'AI writing assistant supporting multiple LLM APIs with rich style customization features.',
author: 'GeekyWizKid',
homepage: 'https://github.com/GeekyWizKid/writing-helper',
installCommand: 'npm install',
startCommand: 'npm run dev',
isInstalled: false,
isRunning: false
}
]
export interface NodeAppsState {
apps: NodeAppType[]
loading: boolean
error: string | null
}
const initialState: NodeAppsState = {
apps: [...FEATURED_NODE_APPS],
loading: false,
error: null
}
const nodeAppsSlice = createSlice({
name: 'nodeApps',
initialState,
reducers: {
setNodeApps: (state, action: PayloadAction<NodeAppType[]>) => {
state.apps = action.payload
},
addNodeApp: (state, action: PayloadAction<NodeAppType>) => {
state.apps.push(action.payload)
},
updateNodeApp: (state, action: PayloadAction<NodeAppType>) => {
const index = state.apps.findIndex(app => app.id === action.payload.id)
if (index !== -1) {
state.apps[index] = action.payload
}
},
removeNodeApp: (state, action: PayloadAction<string>) => {
state.apps = state.apps.filter(app => app.id !== action.payload)
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload
}
}
})
export const {
setNodeApps,
addNodeApp,
updateNodeApp,
removeNodeApp,
setLoading,
setError
} = nodeAppsSlice.actions
export default nodeAppsSlice.reducer

View File

@@ -11,12 +11,6 @@ export interface UpdateState {
available: boolean
}
export interface WebDAVSyncState {
lastSyncTime: number | null
syncing: boolean
lastSyncError: string | null
}
export interface RuntimeState {
avatar: string
generating: boolean
@@ -25,7 +19,6 @@ export interface RuntimeState {
filesPath: string
resourcesPath: string
update: UpdateState
webdavSync: WebDAVSyncState
export: ExportState
}
@@ -48,11 +41,6 @@ const initialState: RuntimeState = {
downloadProgress: 0,
available: false
},
webdavSync: {
lastSyncTime: null,
syncing: false,
lastSyncError: null
},
export: {
isExporting: false
}
@@ -83,9 +71,6 @@ const runtimeSlice = createSlice({
setUpdateState: (state, action: PayloadAction<Partial<UpdateState>>) => {
state.update = { ...state.update, ...action.payload }
},
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.webdavSync = { ...state.webdavSync, ...action.payload }
},
setExportState: (state, action: PayloadAction<Partial<ExportState>>) => {
state.export = { ...state.export, ...action.payload }
}
@@ -100,7 +85,6 @@ export const {
setFilesPath,
setResourcesPath,
setUpdateState,
setWebDAVSyncState,
setExportState
} = runtimeSlice.actions

View File

@@ -4,7 +4,7 @@ import { CodeStyleVarious, LanguageVarious, ThemeMode, TranslateLanguageVarious
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter'
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files'
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'nodeapps' | 'knowledge' | 'files'
export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
'assistants',
@@ -12,6 +12,7 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
'paintings',
'translate',
'minapp',
'nodeapps',
'knowledge',
'files'
]
@@ -84,6 +85,8 @@ export interface SettingsState {
yuqueRepoId: string | null
obsidianApiKey: string | null
obsidianUrl: string | null
joplinToken: string | null
joplinUrl: string | null
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@@ -152,7 +155,9 @@ const initialState: SettingsState = {
yuqueUrl: '',
yuqueRepoId: '',
obsidianApiKey: '',
obsidianUrl: ''
obsidianUrl: '',
joplinToken: '',
joplinUrl: ''
}
const settingsSlice = createSlice({
@@ -353,6 +358,12 @@ const settingsSlice = createSlice({
},
setObsidianUrl: (state, action: PayloadAction<string>) => {
state.obsidianUrl = action.payload
},
setJoplinToken: (state, action: PayloadAction<string>) => {
state.joplinToken = action.payload
},
setJoplinUrl: (state, action: PayloadAction<string>) => {
state.joplinUrl = action.payload
}
}
})
@@ -420,7 +431,9 @@ export const {
setYuqueRepoId,
setYuqueUrl,
setObsidianApiKey,
setObsidianUrl
setObsidianUrl,
setJoplinToken,
setJoplinUrl
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@@ -164,6 +164,21 @@ export type MinAppType = {
style?: React.CSSProperties
}
export type NodeAppType = MinAppType & {
type: 'node'
repositoryUrl?: string
version?: string
description?: string
author?: string
homepage?: string
installCommand?: string
buildCommand?: string
startCommand?: string
port?: number
isInstalled?: boolean
isRunning?: boolean
}
export interface FileType {
id: string
name: string
@@ -203,6 +218,7 @@ export type WebDavConfig = {
webdavUser: string
webdavPass: string
webdavPath: string
fileName?: string
}
export type AppInfo = {
@@ -270,6 +286,8 @@ export type KnowledgeBaseParams = {
baseURL: string
chunkSize?: number
chunkOverlap?: number
rerankApiKey?: string
rerankBaseURL?: string
rerankModel?: string
rerankModelProvider?: string
topN?: number

View File

@@ -378,3 +378,42 @@ export const exportMarkdownToObsidian = async (
window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
}
}
export const exportMarkdownToJoplin = async (title: string, content: string) => {
const { joplinUrl, joplinToken } = store.getState().settings
if (!joplinUrl || !joplinToken) {
window.message.error(i18n.t('message.error.joplin.no_config'))
return
}
try {
const baseUrl = joplinUrl.endsWith('/') ? joplinUrl : `${joplinUrl}/`
const response = await fetch(`${baseUrl}notes?token=${joplinToken}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: title,
body: content,
source: 'Cherry Studio'
})
})
if (!response.ok) {
throw new Error('service not available')
}
const data = await response.json()
if (data?.error) {
throw new Error('response error')
}
window.message.success(i18n.t('message.success.joplin.export'))
return
} catch (error) {
window.message.error(i18n.t('message.error.joplin.export'))
return
}
}

View File

@@ -18,7 +18,10 @@ const supportedAttributes = [
]
function filterPropertieAttributes(tool: MCPTool, filterNestedObj = false) {
const roperties = tool.inputSchema.properties
const properties = tool.inputSchema.properties
if (!properties) {
return {}
}
const getSubMap = (obj: Record<string, any>, keys: string[]) => {
const filtered = Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key)))
@@ -46,10 +49,10 @@ function filterPropertieAttributes(tool: MCPTool, filterNestedObj = false) {
return filtered
}
for (const [key, val] of Object.entries(roperties)) {
roperties[key] = getSubMap(val, supportedAttributes)
for (const [key, val] of Object.entries(properties)) {
properties[key] = getSubMap(val, supportedAttributes)
}
return roperties
return properties
}
export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {

View File

@@ -29,7 +29,7 @@ const HomeWindow: FC = () => {
const textChange = useState(() => {})[1]
const { defaultAssistant } = useDefaultAssistant()
const { defaultModel: model } = useDefaultModel()
const { language, readClipboardAtStartup } = useSettings()
const { language, readClipboardAtStartup, windowStyle, theme } = useSettings()
const { t } = useTranslation()
const inputBarRef = useRef<HTMLDivElement>(null)
const featureMenusRef = useRef<FeatureMenusRef>(null)
@@ -201,9 +201,24 @@ const HomeWindow: FC = () => {
}
}, [route])
const backgroundColor = () => {
// ONLY MAC: when transparent style + light theme: use vibrancy effect
// because the dark style under mac's vibrancy effect has not been implemented
if (
isMac &&
windowStyle === 'transparent' &&
theme === 'light' &&
!window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return 'transparent'
}
return 'var(--color-background)'
}
if (['chat', 'summary', 'explanation'].includes(route)) {
return (
<Container>
<Container style={{ backgroundColor: backgroundColor() }}>
{route === 'chat' && (
<>
<InputBar
@@ -232,7 +247,7 @@ const HomeWindow: FC = () => {
if (route === 'translate') {
return (
<Container>
<Container style={{ backgroundColor: backgroundColor() }}>
<TranslateWindow text={referenceText} />
<Divider style={{ margin: '10px 0' }} />
<Footer route={route} onExit={() => setRoute('home')} />
@@ -241,7 +256,7 @@ const HomeWindow: FC = () => {
}
return (
<Container>
<Container style={{ backgroundColor: backgroundColor() }}>
<InputBar
text={text}
model={model}
@@ -280,7 +295,6 @@ const Container = styled.div`
flex-direction: column;
-webkit-app-region: drag;
padding: 8px 10px;
background-color: ${isMac ? 'transparent' : 'var(--color-background)'};
`
const Main = styled.main`

1838
yarn.lock

File diff suppressed because it is too large Load Diff