Compare commits

...

3 Commits

Author SHA1 Message Date
温州程序员劝退师
3499cd449b Merge pull request #3718 from GeekyWizKid/node-store
Node store
2025-03-21 13:33:01 +08:00
温州程序员劝退师
a97c3d9695 Merge branch 'main' into node-store 2025-03-21 13:28:23 +08:00
温州程序员劝退师
9145e998c4 feat: Implement Node.js app management features
- Added IPC handlers for managing Node.js applications, including listing, adding, installing, updating, starting, stopping, and uninstalling apps.
- Introduced deployment options for Node.js apps from ZIP files and Git repositories.
- Enhanced the process utility to support environment variables during script execution.
- Updated preload API to expose Node.js app management functionalities.
- Added new UI components and routes for Node.js app management in the renderer.
- Included internationalization support for Node.js app features in both English and Chinese.
2025-03-20 17:13:51 +08:00
26 changed files with 5921 additions and 1648 deletions

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

@@ -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()
@@ -257,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'))
@@ -276,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
}
})
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -163,10 +163,37 @@ declare global {
logout: () => Promise<void>
getUser: (token: string) => Promise<{ login: string; avatar: string }>
}
nodeapp: {
list: () => Promise<any[]>
add: (app: any) => Promise<any>
install: (appId: string) => Promise<any | null>
update: (appId: string) => Promise<any | null>
start: (appId: string) => Promise<{ port: number; url: string } | null>
stop: (appId: string) => Promise<boolean>
uninstall: (appId: string) => Promise<boolean>
deployZip: (zipPath: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => Promise<{ port: number; url: string } | null>
deployGit: (repoUrl: string, options?: {
name?: string;
port?: number;
startCommand?: string;
installCommand?: string;
buildCommand?: string;
}) => Promise<{ port: number; url: string } | null>
checkNode: () => Promise<boolean>
installNode: () => Promise<boolean>
onUpdated: (callback: (apps: any[]) => void) => () => void
}
isBinaryExist: (name: string) => Promise<boolean>
getBinaryPath: (name: string) => Promise<string>
installUVBinary: () => Promise<void>
installBunBinary: () => Promise<void>
run: (command: string) => Promise<string>
}
}
}

View File

@@ -33,6 +33,38 @@ const api = {
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),
upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
@@ -124,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),
@@ -140,7 +172,8 @@ const api = {
isBinaryExist: (name: string) => ipcRenderer.invoke('app:is-binary-exist', name),
getBinaryPath: (name: string) => ipcRenderer.invoke('app:get-binary-path', name),
installUVBinary: () => ipcRenderer.invoke('app:install-uv-binary'),
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary')
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'),
run: (command: string) => ipcRenderer.invoke('app:run-command', command)
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

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

View File

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

View File

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

View File

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

View File

@@ -488,12 +488,21 @@
"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": {
@@ -1115,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": {
@@ -1152,6 +1229,146 @@
"quit": "Quit",
"show_window": "Show Window",
"visualization": "Visualization"
},
"nodeapp": {
"add": "Add App",
"addNew": "Add New Node.js App",
"addSuccess": "App added successfully",
"author": "Author",
"codeRunner": {
"description": "Enter your Node.js code below and click 'Run' to execute it. Your code will be run in a temporary Node.js environment.",
"emptyCode": "Please enter some code to run",
"open": "Open in Browser",
"output": "Output",
"placeholder": "// Enter your Node.js code here\n// Example:\nconst http = require('http');\n\nconst server = http.createServer((req, res) => {\n res.writeHead(200, { 'Content-Type': 'text/html' });\n res.end('<h1>Hello from Cherry Studio!</h1>');\n});\n\nconst PORT = process.env.PORT || 3000;\nserver.listen(PORT, () => {\n console.log(`Server running on port ${PORT}`);\n});",
"run": "Run Code",
"success": "Code is running on port {{port}}",
"title": "Code Runner"
},
"codeRunnerTab": "Code Runner",
"empty": "No Node.js apps found",
"featured": "Featured Apps",
"form": {
"author": "Author",
"authorPlaceholder": "The author of the app",
"description": "Description",
"descriptionPlaceholder": "Brief description of the app's functionality",
"homepage": "Homepage",
"homepagePlaceholder": "Homepage URL for the application",
"installCommand": "Install Command",
"installCommandHelp": "Command to install dependencies (defaults to 'npm install')",
"buildCommand": "Build Command",
"buildCommandHelp": "Command to build the application before starting (e.g. 'npm run build')",
"isNextJs": "This is a Next.js application",
"nextJsHelp": "Apply Next.js-specific optimizations for deployment",
"name": "App Name",
"nameRequired": "App name is required",
"namePlaceholder": "Name of your Node.js application",
"port": "Port",
"portHelp": "Port the app will run on (detected automatically if not specified)"
},
"install": "Install",
"installSuccess": "{{name}} installed successfully",
"installed": "Installed",
"marketplaceTab": "Marketplace",
"more": "More",
"notInstalled": "Not Installed",
"packageDeployer": {
"advancedOptions": "Advanced Options",
"deploy": "Deploy",
"deployFailed": "Failed to deploy package",
"deploySuccess": "{{name}} has been successfully deployed on port {{port}}",
"description": "Upload a ZIP file containing Node.js application code. The package will be automatically extracted and installed.",
"fileSelectError": "Error selecting file",
"installNode": "Install Node.js",
"installNodePrompt": "Node.js is required to deploy applications. Would you like to install it now?",
"namePlaceholder": "Enter a name for your deployed application",
"nodeInstallFailed": "Failed to install Node.js",
"nodeInstallSuccess": "Node.js installed successfully",
"nodeNeeded": "Built-in Node.js is required to run applications.",
"nodeNotAvailable": "Node.js is not available",
"nodeRequired": "Node.js Required",
"noFileSelected": "Please select a ZIP file to deploy",
"open": "Open in Browser",
"selectZip": "Click to select ZIP file",
"title": "Deploy Code Package",
"moduleTypeError": "Module Type Error",
"esModuleError": "ES module syntax detected. Set \"type\": \"module\" in package.json or use .mjs extension.",
"convertToCommonJS": "Convert to CommonJS syntax",
"nextJsDetected": "Next.js Application Detected",
"buildStepAdded": "Build step has been automatically added for Next.js application.",
"nextJsInfo": "Next.js Application Note",
"nextJsDescription": "Next.js applications require a build step before they can be started. Make sure to check 'This is a Next.js application' or manually add 'npm run build' as the build command.",
"deployPackage": "Deploy Package",
"deployFromZip": "From ZIP",
"deployFromGit": "From Git",
"selectZipFile": "Select ZIP File",
"appName": "Application Name",
"appNamePlaceholder": "My Application",
"port": "Port",
"portPlaceholder": "3000",
"portTooltip": "The port on which your application will run. Leave empty for automatic port assignment.",
"showAdvanced": "Show Advanced Options",
"hideAdvanced": "Hide Advanced Options",
"installCommand": "Install Command",
"buildCommand": "Build Command",
"startCommand": "Start Command",
"isNextJs": "This is a Next.js application",
"deploy": "Deploy",
"repoUrl": "Git Repository URL",
"repoUrlRequired": "Please enter a Git repository URL",
"noRepoUrlProvided": "Please provide a Git repository URL",
"packageRequired": "Please select a package file"
},
"packageDeployerTab": "Deploy Package",
"running": "Running",
"start": "Start",
"startSuccess": "{{name}} started on port {{port}}",
"stop": "Stop",
"stopSuccess": "{{name}} stopped successfully",
"title": "Node.js Apps",
"uninstall": "Uninstall",
"uninstallSuccess": "{{name}} uninstalled successfully",
"update": "Update",
"updateSuccess": "{{name}} updated successfully",
"version": "Version",
"viewRepository": "View Repository"
},
"model": {
"add_parameter": "Add Parameter",
"all": "All",
"custom_parameters": "Custom Parameters",
"dimensions": "Dimensions {{dimensions}}",
"edit": "Edit Model",
"embedding": "Embedding",
"embedding_model": "Embedding Model",
"embedding_model_tooltip": "Add in Settings->Model Provider->Manage",
"free": "Free",
"no_matches": "No models available",
"parameter_name": "Parameter Name",
"parameter_type": {
"boolean": "Boolean",
"json": "JSON",
"number": "Number",
"string": "Text"
},
"pinned": "Pinned",
"reasoning": "Reasoning",
"search": "Search models...",
"stream_output": "Stream output",
"function_calling": "Function Calling",
"type": {
"embedding": "Embedding",
"reasoning": "Reasoning",
"select": "Select Model Types",
"text": "Text",
"vision": "Vision",
"function_calling": "Function Calling"
},
"vision": "Vision",
"websearch": "WebSearch",
"rerank_model": "Reordering Model",
"rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add."
}
}
}

View File

@@ -1115,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": {
@@ -1152,6 +1199,32 @@
"quit": "退出",
"show_window": "显示窗口",
"visualization": "可视化"
},
"nodeapp": {
"add": "添加",
"addApp": "添加应用",
"appName": "应用名称",
"appsManager": {
"confirmDelete": "确定要删除此应用吗?",
"confirmStop": "确定要停止此应用吗?",
"description": "管理应用",
"install": "安装",
"noApps": "暂无应用,请添加新应用或从代码部署",
"port": "端口",
"repository": "仓库",
"start": "启动",
"status": "状态",
"stop": "停止",
"title": "应用管理",
"uninstall": "卸载",
"update": "更新",
"updateProgress": "更新进度",
"updateSuccess": "{{name}} 更新成功",
"version": "版本",
"viewRepository": "查看仓库"
},
"nextJsInfo": "Next.js 应用注意事项",
"nextJsDescription": "Next.js 应用需要先构建后才能启动。请确保勾选\"这是一个 Next.js 应用\"或手动添加\"npm run build\"作为构建命令。"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,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'
@@ -33,6 +34,7 @@ const rootReducer = combineReducers({
websearch,
mcp,
copilot,
nodeapps,
messages: messagesReducer
})

View File

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

View File

@@ -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'
]

View File

@@ -164,6 +164,21 @@ export type MinAppType = {
style?: React.CSSProperties
}
export type NodeAppType = MinAppType & {
type: 'node'
repositoryUrl?: string
version?: string
description?: string
author?: string
homepage?: string
installCommand?: string
buildCommand?: string
startCommand?: string
port?: number
isInstalled?: boolean
isRunning?: boolean
}
export interface FileType {
id: string
name: string