diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..8fbc6a0 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,4 @@ +# 桌面端本地产物(不入库) +node_modules/ +renderer-dist/ +dist/ diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..62f4a11 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,40 @@ +# PlantAI 桌面端(Electron 瘦客户端) + +把 `ui/` 的 Vue 单页应用封装成 Electron 桌面 App。**后端(Python/BFF/向量库)不打包进 App**,桌面端只是一个指向后端的瘦客户端。 + +## 原理 + +- Electron 主进程内起一个**只监听 `127.0.0.1`** 的本地 HTTP 服务(`server.js`): + - 静态托管 `renderer-dist`(即 `ui/dist` 的副本); + - 把 `/api` 反代到**可配置的后端地址**。 +- 前端源码 `ui/` **零改动**、后端 **CORS 零改动**:相对路径 `/api` 与 SSE 流靠同源天然可用。 +- 后端地址配置(`config.js`)优先级:`PLANTAI_BFF_URL` 环境变量 > `userData/config.json` > 默认 `http://127.0.0.1:8090`。可在应用内菜单「设置 → 后端地址…」修改(保存后重载)。 + +## 环境要求 + +- Node.js ≥ 18、npm。 + +## 开发运行 + +```bash +cd desktop +npm install +npm run build:ui # 先构建前端(ui/)并复制产物到 renderer-dist/ +npm start # 启动 Electron 窗口 +``` + +## 打包 + +```bash +cd desktop +npm run dist:linux # AppImage + deb +npm run dist:win # NSIS 安装包 + portable +npm run dist:mac # dmg + zip(必须在 macOS 上构建) +``` + +## 跨平台注意事项 + +- **macOS 安装包必须在 macOS 机器上构建**(或 macOS CI);Linux 无法产出 dmg。 +- **Windows 可从 Linux 交叉打包**,个别步骤(图标/rcedit)可能依赖 wine;更稳的方式是用 CI 三平台矩阵产出。 +- 应用图标:`build/icon.png`(512×512,PNG)。electron-builder 会据此自动派生 `.ico`/`.icns`;未提供时使用默认 Electron 图标。 +- **后端可达性**:桌面端要能访问配置的后端地址(公网 HTTPS / VPN / SSH 隧道均可),属网络环境问题,非代码范围。 diff --git a/desktop/config.js b/desktop/config.js new file mode 100644 index 0000000..dd4b95d --- /dev/null +++ b/desktop/config.js @@ -0,0 +1,39 @@ +// config.js —— 后端地址配置的读写(主进程)。 +// 优先级:环境变量 PLANTAI_BFF_URL > userData/config.json > 默认 http://127.0.0.1:8090。 +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_BACKEND_URL = 'http://127.0.0.1:8090'; + +function configPath(userDataDir) { + return path.join(userDataDir, 'config.json'); +} + +function normalizeBackendUrl(url) { + return String(url || '').trim().replace(/\/+$/, ''); +} + +function loadConfig(userDataDir) { + const envUrl = normalizeBackendUrl(process.env.PLANTAI_BFF_URL); + if (envUrl) return { backendUrl: envUrl, source: 'env' }; + try { + const raw = fs.readFileSync(configPath(userDataDir), 'utf8'); + const cfg = JSON.parse(raw); + const fileUrl = normalizeBackendUrl(cfg && cfg.backendUrl); + if (fileUrl) return { backendUrl: fileUrl, source: 'file' }; + } catch (_e) { + // 无配置文件或解析失败 → 落到默认 + } + return { backendUrl: DEFAULT_BACKEND_URL, source: 'default' }; +} + +function saveConfig(userDataDir, backendUrl) { + fs.mkdirSync(userDataDir, { recursive: true }); + fs.writeFileSync( + configPath(userDataDir), + JSON.stringify({ backendUrl: normalizeBackendUrl(backendUrl) }, null, 2), + 'utf8' + ); +} + +module.exports = { loadConfig, saveConfig, DEFAULT_BACKEND_URL }; diff --git a/desktop/main.js b/desktop/main.js new file mode 100644 index 0000000..ece5fd6 --- /dev/null +++ b/desktop/main.js @@ -0,0 +1,155 @@ +// main.js —— Electron 主进程(瘦客户端壳)。 +// 启动流程:读后端地址配置 → 起本地静态 + /api 反代服务器(仅监听 127.0.0.1)→ 建窗口加载。 +// 前端 ui/ 源码零改动:相对路径 /api 与 SSE 流靠「同源」天然可用,后端 CORS 也无需改。 +const { app, BrowserWindow, Menu, ipcMain, dialog } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const { createServer } = require('./server'); +const { loadConfig, saveConfig } = require('./config'); + +let mainWindow = null; +let settingsWindow = null; +let server = null; +let currentOrigin = null; + +/** 前端构建产物目录:开发态在 desktop/renderer-dist,打包态在 resources/renderer-dist(extraResources)。 */ +function staticDir() { + return app.isPackaged + ? path.join(process.resourcesPath, 'renderer-dist') + : path.join(__dirname, 'renderer-dist'); +} + +function startServer(backendUrl) { + return new Promise((resolve, reject) => { + const srv = createServer({ staticDir: staticDir(), backendUrl }); + srv.once('error', reject); + srv.listen(0, '127.0.0.1', () => { + server = srv; + resolve(`http://127.0.0.1:${srv.address().port}`); + }); + }); +} + +function stopServer() { + if (server) { + server.close(); + server = null; + } +} + +async function restartServer(backendUrl) { + stopServer(); + currentOrigin = await startServer(backendUrl); + return currentOrigin; +} + +function createMainWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 860, + minWidth: 960, + minHeight: 640, + title: '海古纪 PlantAI', + backgroundColor: '#0f1115', + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + mainWindow.loadURL(currentOrigin); + mainWindow.once('ready-to-show', () => mainWindow.show()); + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +function openSettingsWindow() { + if (settingsWindow) { + settingsWindow.focus(); + return; + } + settingsWindow = new BrowserWindow({ + width: 460, + height: 300, + resizable: false, + minimizable: false, + maximizable: false, + title: '后端地址设置', + parent: mainWindow || undefined, + modal: !!mainWindow, + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + settingsWindow.loadFile(path.join(__dirname, 'settings.html')); + settingsWindow.on('closed', () => { + settingsWindow = null; + }); +} + +function buildMenu() { + const template = [ + { + label: '设置', + submenu: [ + { label: '后端地址…', click: openSettingsWindow }, + { type: 'separator' }, + { role: 'reload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'quit', label: '退出' }, + ], + }, + ]; + if (process.platform === 'darwin') { + template.unshift({ + label: app.name, + submenu: [{ role: 'about' }, { type: 'separator' }, { role: 'quit' }], + }); + } + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +app.whenReady().then(async () => { + buildMenu(); + + const dir = staticDir(); + if (!fs.existsSync(path.join(dir, 'index.html'))) { + dialog.showErrorBox( + '缺少前端构建产物', + `未找到 ${dir}\n\n请先构建前端:\n cd ui && npm run build\n cd desktop && npm run build:ui` + ); + app.quit(); + return; + } + + ipcMain.handle('desktop:get-backend-url', () => loadConfig(app.getPath('userData')).backendUrl); + ipcMain.handle('desktop:set-backend-url', async (_event, url) => { + // 用户显式保存后,文件配置优先于环境变量(否则 env 会一直盖住用户设置) + delete process.env.PLANTAI_BFF_URL; + saveConfig(app.getPath('userData'), url); + await restartServer(loadConfig(app.getPath('userData')).backendUrl); + if (mainWindow) await mainWindow.loadURL(currentOrigin); + return loadConfig(app.getPath('userData')).backendUrl; + }); + + const { backendUrl } = loadConfig(app.getPath('userData')); + currentOrigin = await startServer(backendUrl); + createMainWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); + +app.on('will-quit', stopServer); diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..057cd76 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,53 @@ +{ + "name": "plantai-desktop", + "version": "0.1.0", + "description": "海古纪 PlantAI 桌面端(Electron 瘦客户端,前端 ui/ + 可配置后端反代)", + "private": true, + "main": "main.js", + "scripts": { + "build:ui": "npm --prefix ../ui run build && node scripts/copy-ui.js", + "start": "electron .", + "dist": "npm run build:ui && electron-builder", + "dist:linux": "npm run build:ui && electron-builder --linux", + "dist:win": "npm run build:ui && electron-builder --win", + "dist:mac": "npm run build:ui && electron-builder --mac" + }, + "dependencies": { + "express": "^4.21.2", + "http-proxy-middleware": "^3.0.3" + }, + "devDependencies": { + "electron": "^33.0.0", + "electron-builder": "^25.1.8" + }, + "build": { + "appId": "com.haiguji.plantai", + "productName": "PlantAI", + "files": [ + "**/*", + "!renderer-dist/**" + ], + "extraResources": [ + { "from": "renderer-dist", "to": "renderer-dist" } + ], + "directories": { + "output": "dist" + }, + "linux": { + "target": ["AppImage", "deb"], + "category": "Science", + "maintainer": "haiguji" + }, + "win": { + "target": ["nsis", "portable"] + }, + "mac": { + "target": ["dmg", "zip"], + "category": "public.app-category.productivity" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } + } +} diff --git a/desktop/preload.js b/desktop/preload.js new file mode 100644 index 0000000..f7e055d --- /dev/null +++ b/desktop/preload.js @@ -0,0 +1,7 @@ +// preload.js —— 设置窗口的桥接:仅暴露最小 API,不暴露任何 Node 能力。 +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('desktopApi', { + getBackendUrl: () => ipcRenderer.invoke('desktop:get-backend-url'), + setBackendUrl: (url) => ipcRenderer.invoke('desktop:set-backend-url', url), +}); diff --git a/desktop/scripts/copy-ui.js b/desktop/scripts/copy-ui.js new file mode 100644 index 0000000..1e39b1c --- /dev/null +++ b/desktop/scripts/copy-ui.js @@ -0,0 +1,15 @@ +// scripts/copy-ui.js —— 把 ui/dist 复制到 renderer-dist(打包/运行前准备)。 +const fs = require('fs'); +const path = require('path'); + +const src = path.join(__dirname, '..', '..', 'ui', 'dist'); +const dest = path.join(__dirname, '..', 'renderer-dist'); + +if (!fs.existsSync(path.join(src, 'index.html'))) { + console.error(`未找到前端构建产物:${src}\n请先运行: cd ui && npm run build`); + process.exit(1); +} + +fs.rmSync(dest, { recursive: true, force: true }); +fs.cpSync(src, dest, { recursive: true }); +console.log(`已复制 ${src} -> ${dest}`); diff --git a/desktop/server.js b/desktop/server.js new file mode 100644 index 0000000..e78f4c5 --- /dev/null +++ b/desktop/server.js @@ -0,0 +1,50 @@ +// server.js —— 主进程内嵌的本地 HTTP 服务: +// 静态托管 renderer-dist(前端构建产物)+ 把 /api 反代到可配置后端。 +// 仅监听 127.0.0.1,不做公网暴露。反代参数对齐 deploy/nginx/plantai.conf: +// SSE 流(问答/生信)不缓冲、长超时由后端自控,大文件上传不受限。 +const path = require('path'); +const express = require('express'); +const { createProxyMiddleware } = require('http-proxy-middleware'); + +function createServer({ staticDir, backendUrl }) { + const app = express(); + + app.use( + '/api', + createProxyMiddleware({ + target: backendUrl, + changeOrigin: true, + ws: false, + xfwd: true, + // http-proxy 侧不设响应/套接字超时(SSE 看门狗 1200s / 上传 600s 由后端自断) + proxyTimeout: 0, + timeout: 0, + on: { + error: (err, req, res) => { + // 后端不可达时返回结构化 502 信封(对齐前端 http.js 的错误契约),避免进程崩溃。 + // 注意:http-proxy 的 error 事件里 res 可能是 net.Socket(连接失败),需防御式判断。 + if (!res || typeof res.writeHead !== 'function') return; + if (res.headersSent) return res.end(); + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end( + JSON.stringify({ + ok: false, + error: { code: 'NETWORK', message: '后端不可达', degraded: false }, + }) + ); + }, + }, + }) + ); + + // 静态托管 + SPA fallback(前端无 vue-router,单页;仍兜底到 index.html) + app.use(express.static(staticDir)); + app.use((req, res, next) => { + if (req.method !== 'GET' || req.path.startsWith('/api')) return next(); + res.sendFile(path.join(staticDir, 'index.html')); + }); + + return app; +} + +module.exports = { createServer }; diff --git a/desktop/settings.html b/desktop/settings.html new file mode 100644 index 0000000..8cb6cfc --- /dev/null +++ b/desktop/settings.html @@ -0,0 +1,76 @@ + + + + + + 后端地址设置 + + + +

后端地址

+

PlantAI 后端(BFF)地址,例如 http://127.0.0.1:8090。保存后立即重载页面。

+ +
+ + +
+

+ + +