Compare commits
4
Commits
ac07cb2008
...
89416f9551
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89416f9551 | ||
|
|
23073abf7e | ||
|
|
4cff8ca4d0 | ||
|
|
2408def7c1 |
@@ -0,0 +1,4 @@
|
||||
# 桌面端本地产物(不入库)
|
||||
node_modules/
|
||||
renderer-dist/
|
||||
dist/
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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 上构建)
|
||||
```
|
||||
|
||||
## 跨平台注意事项
|
||||
|
||||
- `package.json` 的 `author` / `homepage` 为占位值,发布前请替换为真实作者与主页(deb 等安装包会写入这些元数据)。
|
||||
|
||||
- **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 隧道均可),属网络环境问题,非代码范围。
|
||||
@@ -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 };
|
||||
+155
@@ -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);
|
||||
Generated
+5612
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "plantai-desktop",
|
||||
"version": "0.1.0",
|
||||
"description": "海古纪 PlantAI 桌面端(Electron 瘦客户端,前端 ui/ + 可配置后端反代)",
|
||||
"author": "Haiguji",
|
||||
"homepage": "https://github.com/haiguji/PlantAI-rag-new",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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();
|
||||
|
||||
const proxy = 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 },
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 只代理 /api,并保留完整路径(/api/xxx → backend/api/xxx)。
|
||||
// 注意:不能写成 app.use('/api', proxy) —— Express 会剥掉 /api 前缀,导致后端收到 /xxx 而 404。
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/api')) return proxy(req, res, next);
|
||||
next();
|
||||
});
|
||||
|
||||
// 静态托管 + 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 };
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>后端地址设置</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #0f1115;
|
||||
color: #e6e8eb;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
h1 { font-size: 15px; margin: 0 0 8px; font-weight: 600; }
|
||||
p.desc { color: #9aa0a6; margin: 0 0 14px; font-size: 12px; line-height: 1.5; }
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px;
|
||||
font-size: 13px;
|
||||
color: #e6e8eb;
|
||||
background: #1a1d23;
|
||||
border: 1px solid #2a2f38;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus { border-color: #3b82f6; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
button {
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2a2f38;
|
||||
cursor: pointer;
|
||||
background: #1a1d23;
|
||||
color: #e6e8eb;
|
||||
}
|
||||
button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
p.hint { color: #9aa0a6; font-size: 12px; margin: 10px 0 0; min-height: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>后端地址</h1>
|
||||
<p class="desc">PlantAI 后端(BFF)地址,例如 http://127.0.0.1:8090。保存后立即重载页面。</p>
|
||||
<input id="url" type="text" placeholder="http://127.0.0.1:8090" spellcheck="false">
|
||||
<div class="actions">
|
||||
<button id="cancel">取消</button>
|
||||
<button id="save" class="primary">保存</button>
|
||||
</div>
|
||||
<p id="hint" class="hint"></p>
|
||||
<script>
|
||||
const input = document.getElementById('url');
|
||||
const hint = document.getElementById('hint');
|
||||
|
||||
window.desktopApi.getBackendUrl().then((url) => { input.value = url || ''; });
|
||||
|
||||
document.getElementById('save').addEventListener('click', async () => {
|
||||
const url = input.value.trim();
|
||||
if (!url) { hint.textContent = '地址不能为空'; return; }
|
||||
try {
|
||||
await window.desktopApi.setBackendUrl(url);
|
||||
window.close();
|
||||
} catch (e) {
|
||||
hint.textContent = '保存失败:' + (e && e.message ? e.message : e);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('cancel').addEventListener('click', () => window.close());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user