Compare commits
29 Commits
v1.1.8
...
feat/node-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3499cd449b | ||
|
|
a97c3d9695 | ||
|
|
d0ddfce280 | ||
|
|
707e713e73 | ||
|
|
5347df4840 | ||
|
|
2ca0a62efa | ||
|
|
28c5231741 | ||
|
|
994ffa224e | ||
|
|
ea990e78a5 | ||
|
|
6fd5ff991d | ||
|
|
cd6c0a1f66 | ||
|
|
9145e998c4 | ||
|
|
8f1528b21c | ||
|
|
d11f892c26 | ||
|
|
63b4ecbadd | ||
|
|
f6cb501119 | ||
|
|
70ba8df57c | ||
|
|
89508162b7 | ||
|
|
f107fb0c78 | ||
|
|
a183a9a21e | ||
|
|
dffcaa11c3 | ||
|
|
0fe7d559c8 | ||
|
|
eef141cbe7 | ||
|
|
424eb09995 | ||
|
|
c29cab7daa | ||
|
|
592484af95 | ||
|
|
e9c9f3b488 | ||
|
|
a2a3760c95 | ||
|
|
62de293194 |
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.gitignore
|
||||
scripts/cloudflare-worker.js
|
||||
@@ -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' }]
|
||||
}
|
||||
}
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
|
||||
- 🔤 AI による翻訳機能
|
||||
- 🎯 ドラッグ&ドロップによる整理
|
||||
- 🔌 ミニプログラム対応
|
||||
- ⚙️ MCP(モデルコンテキストプロトコル) サービス
|
||||
|
||||
5. **優れたユーザー体験**:
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
- 🔤 AI 驱动的翻译功能
|
||||
- 🎯 拖拽排序
|
||||
- 🔌 小程序支持
|
||||
- ⚙️ MCP(模型上下文协议) 服务
|
||||
|
||||
5. **优质使用体验**:
|
||||
|
||||
|
||||
58
eslint.config.mjs
Normal file
58
eslint.config.mjs
Normal 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']
|
||||
}
|
||||
])
|
||||
14
package.json
14
package.json
@@ -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
13
packages/artifacts/package-lock.json
generated
Normal 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
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
@@ -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 = [
|
||||
|
||||
52
resources/scripts/download.js
Normal file
52
resources/scripts/download.js
Normal 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 }
|
||||
@@ -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')
|
||||
|
||||
314
resources/scripts/install-node.js
Normal file
314
resources/scripts/install-node.js
Normal 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)
|
||||
})
|
||||
@@ -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
16
src/@types/index.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
48
src/main/reranker/JinaReranker.ts
Normal file
48
src/main/reranker/JinaReranker.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1351
src/main/services/NodeAppService.ts
Normal file
1351
src/main/services/NodeAppService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) => {
|
||||
|
||||
42
src/preload/index.d.ts
vendored
42
src/preload/index.d.ts
vendored
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
2
src/renderer/src/env.d.ts
vendored
2
src/renderer/src/env.d.ts
vendored
@@ -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 }>
|
||||
}
|
||||
}
|
||||
|
||||
88
src/renderer/src/hooks/useNodeApps.ts
Normal file
88
src/renderer/src/hooks/useNodeApps.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "サーバーが設定されていません",
|
||||
|
||||
@@ -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": "Серверы не настроены",
|
||||
|
||||
@@ -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\"作为构建命令。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "未設定伺服器",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
301
src/renderer/src/pages/nodeapps/NodeApp.tsx
Normal file
301
src/renderer/src/pages/nodeapps/NodeApp.tsx
Normal 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
|
||||
118
src/renderer/src/pages/nodeapps/NodeAppForm.tsx
Normal file
118
src/renderer/src/pages/nodeapps/NodeAppForm.tsx
Normal 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
|
||||
171
src/renderer/src/pages/nodeapps/NodeAppsPage.tsx
Normal file
171
src/renderer/src/pages/nodeapps/NodeAppsPage.tsx
Normal 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
|
||||
198
src/renderer/src/pages/nodeapps/NodeSettings.tsx
Normal file
198
src/renderer/src/pages/nodeapps/NodeSettings.tsx
Normal 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
|
||||
580
src/renderer/src/pages/nodeapps/PackageDeployer.tsx
Normal file
580
src/renderer/src/pages/nodeapps/PackageDeployer.tsx
Normal 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
|
||||
39
src/renderer/src/pages/nodeapps/index.tsx
Normal file
39
src/renderer/src/pages/nodeapps/index.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
117
src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx
Normal file
117
src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
32
src/renderer/src/store/backup.ts
Normal file
32
src/renderer/src/store/backup.ts
Normal 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
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
src/renderer/src/store/nodeapps.ts
Normal file
71
src/renderer/src/store/nodeapps.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user