feat: Add embedded backend packaging for all-in-one deployment

- Add backend/run_server.py entry point for embedded deployment
- Add backend/build.py PyInstaller script for backend packaging
- Modify config.py to support frozen executable paths
- Extend client/config.json with backend configuration section
- Add backend sidecar management in Electron main process
- Add Whisper model download progress reporting
- Update build-client.bat with --embedded-backend flag
- Update DEPLOYMENT.md with all-in-one deployment documentation

This enables packaging frontend and backend into a single executable
for simplified enterprise deployment. Backward compatible with
existing separate deployment mode (backend.embedded: false).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-17 10:06:29 +08:00
parent b1633fdcff
commit 58f379bc0c
11 changed files with 1003 additions and 17 deletions

View File

@@ -6,5 +6,28 @@
"model": "medium",
"device": "cpu",
"compute": "int8"
},
"backend": {
"embedded": false,
"host": "127.0.0.1",
"port": 8000,
"database": {
"host": "mysql.theaken.com",
"port": 33306,
"user": "",
"password": "",
"database": ""
},
"externalApis": {
"authApiUrl": "https://pj-auth-api.vercel.app/api/auth/login",
"difyApiUrl": "https://dify.theaken.com/v1",
"difyApiKey": "",
"difySttApiKey": ""
},
"auth": {
"adminEmail": "",
"jwtSecret": "",
"jwtExpireHours": 24
}
}
}

View File

@@ -33,6 +33,11 @@
"to": "sidecar/transcriber",
"filter": ["**/*"]
},
{
"from": "../backend/dist/backend",
"to": "backend/backend",
"filter": ["**/*"]
},
{
"from": "config.json",
"to": "config.json"

View File

@@ -11,6 +11,10 @@ let streamingActive = false;
let appConfig = null;
let activeWhisperConfig = null;
// Backend sidecar state
let backendProcess = null;
let backendReady = false;
/**
* Load configuration from external config.json
* Config file location:
@@ -67,6 +71,189 @@ function loadConfig() {
return appConfig;
}
/**
* Get config.json path for backend sidecar
*/
function getConfigPath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, "config.json");
}
return path.join(__dirname, "..", "config.json");
}
/**
* Start backend sidecar process (FastAPI server)
* Only starts if backend.embedded is true in config
*/
function startBackendSidecar() {
const backendConfig = appConfig?.backend || {};
// Check if embedded backend is enabled
if (!backendConfig.embedded) {
console.log("Backend embedded mode disabled, using remote backend at:", appConfig?.apiBaseUrl);
backendReady = true; // Assume remote backend is ready
return;
}
console.log("Starting embedded backend sidecar...");
const backendDir = app.isPackaged
? path.join(process.resourcesPath, "backend")
: path.join(__dirname, "..", "..", "backend");
// Determine the backend executable path
let backendExecutable;
let backendArgs = [];
if (app.isPackaged) {
// Packaged app: use PyInstaller-built executable
if (process.platform === "win32") {
backendExecutable = path.join(backendDir, "backend", "backend.exe");
} else {
backendExecutable = path.join(backendDir, "backend", "backend");
}
// Pass config path
backendArgs = ["--config", getConfigPath()];
} else {
// Development mode: use Python script with venv
const backendScript = path.join(backendDir, "run_server.py");
if (!fs.existsSync(backendScript)) {
console.log("Backend script not found at:", backendScript);
console.log("Backend sidecar will not be available.");
return;
}
// Check for virtual environment Python
let venvPython;
if (process.platform === "win32") {
venvPython = path.join(backendDir, "venv", "Scripts", "python.exe");
} else {
venvPython = path.join(backendDir, "venv", "bin", "python");
}
backendExecutable = fs.existsSync(venvPython) ? venvPython : "python3";
backendArgs = [backendScript, "--config", getConfigPath()];
}
if (!fs.existsSync(backendExecutable) && app.isPackaged) {
console.log("Backend executable not found at:", backendExecutable);
console.log("Backend sidecar will not be available.");
return;
}
try {
console.log("Starting backend with:", backendExecutable, backendArgs.join(" "));
backendProcess = spawn(backendExecutable, backendArgs, {
cwd: backendDir,
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
});
backendProcess.stdout.on("data", (data) => {
console.log("Backend:", data.toString().trim());
});
backendProcess.stderr.on("data", (data) => {
console.log("Backend:", data.toString().trim());
});
backendProcess.on("close", (code) => {
console.log(`Backend exited with code ${code}`);
backendReady = false;
backendProcess = null;
});
backendProcess.on("error", (err) => {
console.error("Backend error:", err.message);
backendReady = false;
});
} catch (error) {
console.error("Failed to start backend:", error);
}
}
/**
* Wait for backend to be ready by polling health endpoint
* @param {number} maxAttempts - Maximum number of attempts (default 30)
* @param {number} intervalMs - Interval between attempts in ms (default 1000)
* @returns {Promise<boolean>} - True if backend is ready, false if timeout
*/
async function waitForBackendReady(maxAttempts = 30, intervalMs = 1000) {
const backendConfig = appConfig?.backend || {};
// If embedded mode is disabled, assume backend is ready
if (!backendConfig.embedded) {
backendReady = true;
return true;
}
const host = backendConfig.host || "127.0.0.1";
const port = backendConfig.port || 8000;
const healthUrl = `http://${host}:${port}/api/health`;
console.log(`Waiting for backend at ${healthUrl}...`);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const http = require("http");
const ready = await new Promise((resolve) => {
const req = http.get(healthUrl, (res) => {
resolve(res.statusCode === 200);
});
req.on("error", () => resolve(false));
req.setTimeout(2000, () => {
req.destroy();
resolve(false);
});
});
if (ready) {
console.log(`Backend ready after ${attempt} attempt(s)`);
backendReady = true;
return true;
}
} catch (e) {
// Ignore errors, will retry
}
console.log(`Backend health check attempt ${attempt}/${maxAttempts} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
console.error(`Backend did not become ready after ${maxAttempts} attempts`);
return false;
}
/**
* Stop backend sidecar process
*/
function stopBackendSidecar() {
if (!backendProcess) {
return;
}
console.log("Stopping backend sidecar...");
// Send SIGTERM first
backendProcess.kill("SIGTERM");
// Force kill after 5 seconds if still running
const forceKillTimeout = setTimeout(() => {
if (backendProcess) {
console.log("Force killing backend sidecar...");
backendProcess.kill("SIGKILL");
}
}, 5000);
backendProcess.on("close", () => {
clearTimeout(forceKillTimeout);
backendProcess = null;
backendReady = false;
});
}
function createWindow() {
// Set window title from config
const windowTitle = appConfig?.appTitle || "Meeting Assistant";
@@ -205,6 +392,26 @@ function startSidecar() {
if (msg.result !== undefined && mainWindow) {
mainWindow.webContents.send("transcription-result", msg.result);
}
// Forward model download progress to renderer
if (msg.status === "downloading_model" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward model downloaded status
if (msg.status === "model_downloaded" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward model loading status
if (msg.status === "loading_model" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
// Forward model loaded status
if (msg.status === "model_loaded" && mainWindow) {
mainWindow.webContents.send("model-download-progress", msg);
}
} catch (e) {
console.log("Sidecar output:", line);
}
@@ -229,10 +436,28 @@ function startSidecar() {
}
}
app.whenReady().then(() => {
app.whenReady().then(async () => {
// Load configuration first
loadConfig();
// Start backend sidecar if embedded mode is enabled
startBackendSidecar();
// Wait for backend to be ready before creating window
const backendConfig = appConfig?.backend || {};
if (backendConfig.embedded) {
const ready = await waitForBackendReady();
if (!ready) {
const { dialog } = require("electron");
dialog.showErrorBox(
"Backend Startup Failed",
"The backend server failed to start within 30 seconds. Please check the logs for details."
);
app.quit();
return;
}
}
createWindow();
startSidecar();
@@ -244,12 +469,17 @@ app.whenReady().then(() => {
});
app.on("window-all-closed", () => {
// Stop transcriber sidecar
if (sidecarProcess) {
try {
sidecarProcess.stdin.write(JSON.stringify({ action: "quit" }) + "\n");
} catch (e) {}
sidecarProcess.kill();
}
// Stop backend sidecar
stopBackendSidecar();
if (process.platform !== "darwin") {
app.quit();
}
@@ -274,6 +504,19 @@ ipcMain.handle("get-sidecar-status", () => {
};
});
// Get backend status (for renderer to check backend readiness)
ipcMain.handle("get-backend-status", () => {
const backendConfig = appConfig?.backend || {};
const host = backendConfig.host || "127.0.0.1";
const port = backendConfig.port || 8000;
return {
ready: backendReady,
embedded: backendConfig.embedded || false,
url: `http://${host}:${port}`
};
});
// === Streaming Mode IPC Handlers ===
ipcMain.handle("start-recording-stream", async () => {

View File

@@ -308,6 +308,30 @@
updateWhisperStatus();
const whisperStatusInterval = setInterval(updateWhisperStatus, 5000);
// Listen for model download progress events
window.electronAPI.onModelDownloadProgress((progress) => {
console.log('Model download progress:', progress);
if (progress.status === 'downloading_model') {
const percent = progress.progress || 0;
const downloadedMb = progress.downloaded_mb || 0;
const totalMb = progress.total_mb || 0;
whisperStatusEl.textContent = `⬇️ Downloading ${progress.model}: ${percent}% (${downloadedMb}/${totalMb} MB)`;
whisperStatusEl.style.color = '#ff9800';
} else if (progress.status === 'model_downloaded') {
whisperStatusEl.textContent = `${progress.model} downloaded`;
whisperStatusEl.style.color = '#28a745';
} else if (progress.status === 'loading_model') {
whisperStatusEl.textContent = `⏳ Loading ${progress.model}...`;
whisperStatusEl.style.color = '#ffc107';
} else if (progress.status === 'model_loaded') {
whisperStatusEl.textContent = `✅ Ready`;
whisperStatusEl.style.color = '#28a745';
// Trigger a status refresh
updateWhisperStatus();
}
});
// Load meeting data
async function loadMeeting() {
try {

View File

@@ -7,9 +7,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Navigation
navigate: (page) => ipcRenderer.invoke("navigate", page),
// Sidecar status
// Sidecar status (Whisper transcriber)
getSidecarStatus: () => ipcRenderer.invoke("get-sidecar-status"),
// Backend status (FastAPI server)
getBackendStatus: () => ipcRenderer.invoke("get-backend-status"),
// === Streaming Mode APIs ===
startRecordingStream: () => ipcRenderer.invoke("start-recording-stream"),
streamAudioChunk: (base64Audio) => ipcRenderer.invoke("stream-audio-chunk", base64Audio),
@@ -26,6 +29,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("stream-stopped", (event, data) => callback(data));
},
// Model download progress events
onModelDownloadProgress: (callback) => {
ipcRenderer.on("model-download-progress", (event, progress) => callback(progress));
},
// === Legacy File-based APIs (fallback) ===
saveAudioFile: (arrayBuffer) => ipcRenderer.invoke("save-audio-file", arrayBuffer),
transcribeAudio: (filePath) => ipcRenderer.invoke("transcribe-audio", filePath),