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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"to": "sidecar/transcriber",
|
||||
"filter": ["**/*"]
|
||||
},
|
||||
{
|
||||
"from": "../backend/dist/backend",
|
||||
"to": "backend/backend",
|
||||
"filter": ["**/*"]
|
||||
},
|
||||
{
|
||||
"from": "config.json",
|
||||
"to": "config.json"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user