feat(desktop): 后端改为多服务可配置(8090-8099 各服务地址可指向本机或其它服务器)

This commit is contained in:
2026-10-14 15:21:00 +08:00
parent 89416f9551
commit 2617f4361c
5 changed files with 103 additions and 56 deletions
+4 -1
View File
@@ -8,7 +8,10 @@
- 静态托管 `renderer-dist`(即 `ui/dist` 的副本);
- 把 `/api` 反代到**可配置的后端地址**。
- 前端源码 `ui/` **零改动**、后端 **CORS 零改动**:相对路径 `/api` 与 SSE 流靠同源天然可用。
- 后端地址配置(`config.js`)优先级:`PLANTAI_BFF_URL` 环境变量 > `userData/config.json` > 默认 `http://127.0.0.1:8090`。可在应用内菜单「设置 → 后端地址…」修改(保存后重载)。
- 后端各服务地址配置(`config.js`,存 `userData/config.json` 的 `services` 对象;应用内菜单「设置 → 后端服务地址…」逐项修改,保存后重载):
- `bff`(8090,总入口)、`qa`(8091)、`rag`(8092)、`legacy`(8093)、`vstore`(8094)、`paper`(8097)、`bioflow`(8098)——每项都可指向本机或其它服务器。
- `PLANTAI_BFF_URL` 环境变量可覆盖 `bff`(脚本/CI 用)。
- **关于多服务路由**:前端只调用 `/api/*`,`bff`(BFF)是唯一入口;反代把 `/api` 转到 `bff`,由 BFF 内部再路由到 qa/rag/vstore/paper/bioflow。因此要把某个服务挪到别的服务器,需改 **BFF 自己的配置**(`sdk/app/config.py` 的 `qa_base_url` / `rag_base_url` / `rag_legacy_base_url` / `vstore_base_url` / `paper_writer_url` / `bioflow_service_url`,或用 `.env` 覆盖);桌面端设置里其余服务的地址当前仅作记录/预留。
## 环境要求
+41 -20
View File
@@ -1,39 +1,60 @@
// config.js —— 后端地址配置的读写(主进程)。
// 优先级:环境变量 PLANTAI_BFF_URL > userData/config.json > 默认 http://127.0.0.1:8090。
// config.js —— 后端各服务地址配置的读写(主进程)。
// 设置文件 userData/config.json 形如 { services: { bff, qa, rag, legacy, vstore, paper, bioflow } }。
// 每个地址都可指向本机或其它服务器。功能上桌面端反代只走 bff(BFF 是前端 /api/* 的唯一入口,
// 由 BFF 内部再路由到 qa/rag/vstore/paper/bioflow——见 README)。
// 环境变量 PLANTAI_BFF_URL 覆盖 bff(供脚本/CI 用)。
const fs = require('fs');
const path = require('path');
const DEFAULT_BACKEND_URL = 'http://127.0.0.1:8090';
const DEFAULT_SERVICES = {
bff: 'http://127.0.0.1:8090', // BFF 总入口(前端 /api/* 的唯一入口)
qa: 'http://127.0.0.1:8091', // QA 问答(qa_base_url)
rag: 'http://127.0.0.1:8092', // 方法学 RAG(rag_base_url)
legacy: 'http://127.0.0.1:8093', // 旧版 RAG(rag_legacy_base_url)
vstore: 'http://127.0.0.1:8094', // 文档向量库(vstore_base_url)
paper: 'http://127.0.0.1:8097', // 论文撰写(paper_writer_url)
bioflow: 'http://127.0.0.1:8098', // 生信 bioflow(bioflow_service_url)
};
const SERVICE_KEYS = Object.keys(DEFAULT_SERVICES);
function normalizeUrl(url) {
return String(url || '').trim().replace(/\/+$/, '');
}
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' };
function loadServices(userDataDir) {
const services = { ...DEFAULT_SERVICES };
// 设置文件覆盖默认
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' };
const saved = (cfg && cfg.services) || {};
for (const k of SERVICE_KEYS) {
const v = normalizeUrl(saved[k]);
if (v) services[k] = v;
}
} catch (_e) {
// 无配置文件或解析失败 → 落到默认
}
return { backendUrl: DEFAULT_BACKEND_URL, source: 'default' };
// 环境变量最高优先级,最后覆盖 bff
const envBff = normalizeUrl(process.env.PLANTAI_BFF_URL);
if (envBff) services.bff = envBff;
return services;
}
function saveConfig(userDataDir, backendUrl) {
function saveServices(userDataDir, services) {
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(
configPath(userDataDir),
JSON.stringify({ backendUrl: normalizeBackendUrl(backendUrl) }, null, 2),
'utf8'
);
const out = {};
for (const k of SERVICE_KEYS) {
const v = normalizeUrl(services && services[k]);
out[k] = v || DEFAULT_SERVICES[k];
}
fs.writeFileSync(configPath(userDataDir), JSON.stringify({ services: out }, null, 2), 'utf8');
return out;
}
module.exports = { loadConfig, saveConfig, DEFAULT_BACKEND_URL };
module.exports = { loadServices, saveServices, DEFAULT_SERVICES, SERVICE_KEYS };
+19 -17
View File
@@ -1,11 +1,13 @@
// main.js —— Electron 主进程(瘦客户端壳)。
// 启动流程:读后端地址配置 → 起本地静态 + /api 反代服务器(仅监听 127.0.0.1)→ 建窗口加载。
// 启动流程:读后端各服务地址配置 → 起本地静态 + /api 反代服务器(仅监听 127.0.0.1)→ 建窗口加载。
// 前端 ui/ 源码零改动:相对路径 /api 与 SSE 流靠「同源」天然可用,后端 CORS 也无需改。
// 反代功能上只走 bff(BFF 是前端唯一入口);qa/rag/vstore/paper/bioflow 地址在设置里可改、持久化,
// 实际的各服务再路由由 BFF 自己的 config/.env 完成(见 README)。
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');
const { loadServices, saveServices } = require('./config');
let mainWindow = null;
let settingsWindow = null;
@@ -19,9 +21,9 @@ function staticDir() {
: path.join(__dirname, 'renderer-dist');
}
function startServer(backendUrl) {
function startServer(bffUrl) {
return new Promise((resolve, reject) => {
const srv = createServer({ staticDir: staticDir(), backendUrl });
const srv = createServer({ staticDir: staticDir(), backendUrl: bffUrl });
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
server = srv;
@@ -37,9 +39,9 @@ function stopServer() {
}
}
async function restartServer(backendUrl) {
async function restartServer(bffUrl) {
stopServer();
currentOrigin = await startServer(backendUrl);
currentOrigin = await startServer(bffUrl);
return currentOrigin;
}
@@ -71,12 +73,12 @@ function openSettingsWindow() {
return;
}
settingsWindow = new BrowserWindow({
width: 460,
height: 300,
width: 500,
height: 560,
resizable: false,
minimizable: false,
maximizable: false,
title: '后端地址设置',
title: '后端服务设置',
parent: mainWindow || undefined,
modal: !!mainWindow,
autoHideMenuBar: true,
@@ -98,7 +100,7 @@ function buildMenu() {
{
label: '设置',
submenu: [
{ label: '后端地址…', click: openSettingsWindow },
{ label: '后端服务地址…', click: openSettingsWindow },
{ type: 'separator' },
{ role: 'reload' },
{ role: 'toggleDevTools' },
@@ -129,18 +131,18 @@ app.whenReady().then(async () => {
return;
}
ipcMain.handle('desktop:get-backend-url', () => loadConfig(app.getPath('userData')).backendUrl);
ipcMain.handle('desktop:set-backend-url', async (_event, url) => {
ipcMain.handle('desktop:get-config', () => loadServices(app.getPath('userData')));
ipcMain.handle('desktop:set-config', async (_event, services) => {
// 用户显式保存后,文件配置优先于环境变量(否则 env 会一直盖住用户设置)
delete process.env.PLANTAI_BFF_URL;
saveConfig(app.getPath('userData'), url);
await restartServer(loadConfig(app.getPath('userData')).backendUrl);
const saved = saveServices(app.getPath('userData'), services);
await restartServer(saved.bff);
if (mainWindow) await mainWindow.loadURL(currentOrigin);
return loadConfig(app.getPath('userData')).backendUrl;
return saved;
});
const { backendUrl } = loadConfig(app.getPath('userData'));
currentOrigin = await startServer(backendUrl);
const services = loadServices(app.getPath('userData'));
currentOrigin = await startServer(services.bff);
createMainWindow();
app.on('activate', () => {
+2 -2
View File
@@ -2,6 +2,6 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('desktopApi', {
getBackendUrl: () => ipcRenderer.invoke('desktop:get-backend-url'),
setBackendUrl: (url) => ipcRenderer.invoke('desktop:set-backend-url', url),
getConfig: () => ipcRenderer.invoke('desktop:get-config'),
setConfig: (services) => ipcRenderer.invoke('desktop:set-config', services),
});
+37 -16
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>后端地址设置</title>
<title>后端服务设置</title>
<style>
:root { color-scheme: dark; }
body {
@@ -12,24 +12,27 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: #0f1115;
color: #e6e8eb;
font-size: 14px;
font-size: 13px;
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; }
h1 { font-size: 15px; margin: 0 0 4px; font-weight: 600; }
p.desc { color: #9aa0a6; margin: 0 0 16px; font-size: 12px; line-height: 1.5; }
.field { display: flex; align-items: center; margin-bottom: 10px; gap: 10px; }
label { width: 120px; flex: none; color: #c6cad1; font-size: 12px; text-align: right; }
input {
width: 100%;
flex: 1;
box-sizing: border-box;
padding: 9px 10px;
font-size: 13px;
padding: 7px 10px;
font-size: 12px;
color: #e6e8eb;
background: #1a1d23;
border: 1px solid #2a2f38;
border-radius: 6px;
outline: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
input:focus { border-color: #3b82f6; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
button {
padding: 7px 16px;
font-size: 13px;
@@ -45,25 +48,43 @@
</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">
<h1>后端服务地址</h1>
<p class="desc">各服务地址可指向本机或其它服务器。前端统一经 BFF 总入口访问,由 BFF 内部再路由到其余服务。保存后立即重载。</p>
<div class="field"><label for="bff">BFF 总入口</label><input id="bff" spellcheck="false"></div>
<div class="field"><label for="qa">QA 问答</label><input id="qa" spellcheck="false"></div>
<div class="field"><label for="rag">方法学 RAG</label><input id="rag" spellcheck="false"></div>
<div class="field"><label for="legacy">旧版 RAG</label><input id="legacy" spellcheck="false"></div>
<div class="field"><label for="vstore">文档向量库</label><input id="vstore" spellcheck="false"></div>
<div class="field"><label for="paper">论文撰写</label><input id="paper" spellcheck="false"></div>
<div class="field"><label for="bioflow">生信 bioflow</label><input id="bioflow" spellcheck="false"></div>
<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 KEYS = ['bff', 'qa', 'rag', 'legacy', 'vstore', 'paper', 'bioflow'];
const hint = document.getElementById('hint');
window.desktopApi.getBackendUrl().then((url) => { input.value = url || ''; });
window.desktopApi.getConfig().then((services) => {
for (const k of KEYS) {
const el = document.getElementById(k);
if (el) el.value = (services && services[k]) || '';
}
});
document.getElementById('save').addEventListener('click', async () => {
const url = input.value.trim();
if (!url) { hint.textContent = '地址不能为空'; return; }
const services = {};
for (const k of KEYS) {
const el = document.getElementById(k);
services[k] = (el && el.value || '').trim();
}
if (!services.bff) { hint.textContent = 'BFF 总入口不能为空'; return; }
try {
await window.desktopApi.setBackendUrl(url);
await window.desktopApi.setConfig(services);
window.close();
} catch (e) {
hint.textContent = '保存失败:' + (e && e.message ? e.message : e);