From 58f379bc0cc1f0d1c71faf22bb9fd43bf46ef865 Mon Sep 17 00:00:00 2001 From: egg Date: Wed, 17 Dec 2025 10:06:29 +0800 Subject: [PATCH] feat: Add embedded backend packaging for all-in-one deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- DEPLOYMENT.md | 104 ++++++++++++ backend/app/config.py | 33 +++- backend/build.py | 112 ++++++++++++ backend/run_server.py | 145 ++++++++++++++++ client/config.json | 23 +++ client/package.json | 5 + client/src/main.js | 245 ++++++++++++++++++++++++++- client/src/pages/meeting-detail.html | 24 +++ client/src/preload.js | 10 +- scripts/build-client.bat | 161 +++++++++++++++++- sidecar/transcriber.py | 158 ++++++++++++++++- 11 files changed, 1003 insertions(+), 17 deletions(-) create mode 100644 backend/build.py create mode 100644 backend/run_server.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 0abed0e..1e90076 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -224,6 +224,110 @@ npm start 4. 輸入 `api_url` 參數(例如 `http://192.168.1.100:8000/api`) 5. 等待建置完成後下載 artifact +## All-in-One Deployment (全包部署模式) + +此模式將前端、後端、Whisper 全部打包成單一執行檔,用戶雙擊即可使用,無需額外設置後端服務。 + +### 適用場景 + +- 企業內部部署,簡化用戶操作 +- 無法獨立架設後端的環境 +- 快速測試和演示 + +### 打包方式 + +```batch +# Windows 全包打包 +.\scripts\build-client.bat build --embedded-backend --clean +``` + +### config.json 配置 + +全包模式需要在 `config.json` 中配置資料庫和 API 金鑰: + +```json +{ + "apiBaseUrl": "http://localhost:8000/api", + "uploadTimeout": 600000, + "appTitle": "Meeting Assistant", + "whisper": { + "model": "medium", + "device": "cpu", + "compute": "int8" + }, + "backend": { + "embedded": true, + "host": "127.0.0.1", + "port": 8000, + "database": { + "host": "mysql.theaken.com", + "port": 33306, + "user": "your_username", + "password": "your_password", + "database": "your_database" + }, + "externalApis": { + "authApiUrl": "https://pj-auth-api.vercel.app/api/auth/login", + "difyApiUrl": "https://dify.theaken.com/v1", + "difyApiKey": "app-xxxxxxxxxx", + "difySttApiKey": "app-xxxxxxxxxx" + }, + "auth": { + "adminEmail": "admin@example.com", + "jwtSecret": "your_secure_jwt_secret", + "jwtExpireHours": 24 + } + } +} +``` + +### 配置說明 + +| 區段 | 欄位 | 說明 | +|------|------|------| +| `backend.embedded` | `true`/`false` | 啟用/停用內嵌後端模式 | +| `backend.host` | IP | 後端監聽地址(通常為 127.0.0.1) | +| `backend.port` | 數字 | 後端監聽端口(預設 8000) | +| `backend.database.*` | 各欄位 | MySQL 資料庫連線資訊 | +| `backend.externalApis.*` | 各欄位 | 外部 API 設定(認證、Dify) | +| `backend.auth.*` | 各欄位 | 認證設定(管理員信箱、JWT 金鑰) | + +### 啟動流程 + +1. 用戶雙擊 exe +2. Electron 主程序啟動 +3. 讀取 `config.json` +4. 啟動內嵌後端 (FastAPI) +5. 健康檢查等待後端就緒(最多 30 秒) +6. 後端就緒後,載入前端頁面 +7. 啟動 Whisper Sidecar +8. 應用就緒 + +### 向後相容性 + +此功能完全向後相容,不影響既有部署方式: + +| 部署方式 | config.json 設定 | 說明 | +|---------|------------------|------| +| 分離部署(預設) | `backend.embedded: false` | 前端連接遠端後端,使用 `apiBaseUrl` | +| 全包部署(新增) | `backend.embedded: true` | 前端內嵌後端,雙擊即可使用 | + +### 安全注意事項 + +⚠️ **重要**:全包模式的 `config.json` 包含敏感資訊(資料庫密碼、API 金鑰),請確保: + +1. 不要將含有真實憑證的 config.json 提交到版本控制 +2. 部署時由 IT 管理員配置敏感資訊 +3. 考慮使用環境變數覆蓋敏感設定(環境變數優先級較高) + +### Whisper 模型下載進度 + +首次運行時,Whisper 模型(約 1.5GB)會自動下載。新版本會顯示下載進度: + +- `⬇️ Downloading medium: 45% (675/1530 MB)` - 下載中 +- `⏳ Loading medium...` - 載入模型中 +- `✅ Ready` - 就緒 + ## Transcription Sidecar ### 1. Setup diff --git a/backend/app/config.py b/backend/app/config.py index 77a9bd5..fce494c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,9 +1,20 @@ import os +import sys from dotenv import load_dotenv load_dotenv() +def get_base_dir() -> str: + """Get base directory, supporting PyInstaller frozen executables.""" + if getattr(sys, "frozen", False): + # Running as PyInstaller bundle + return os.path.dirname(sys.executable) + else: + # Running as script - go up two levels from app/config.py to backend/ + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + class Settings: # Server Configuration BACKEND_HOST: str = os.getenv("BACKEND_HOST", "0.0.0.0") @@ -49,16 +60,30 @@ class Settings: """Return supported audio formats as a set.""" return set(self.SUPPORTED_AUDIO_FORMATS.split(",")) - def get_template_dir(self, base_dir: str) -> str: - """Get template directory path, resolving relative paths.""" + def get_template_dir(self, base_dir: str | None = None) -> str: + """Get template directory path, resolving relative paths. + + Args: + base_dir: Base directory for relative paths. If None, uses get_base_dir() + which supports frozen executables. + """ + if base_dir is None: + base_dir = get_base_dir() if self.TEMPLATE_DIR: if os.path.isabs(self.TEMPLATE_DIR): return self.TEMPLATE_DIR return os.path.join(base_dir, self.TEMPLATE_DIR) return os.path.join(base_dir, "template") - def get_record_dir(self, base_dir: str) -> str: - """Get record directory path, resolving relative paths.""" + def get_record_dir(self, base_dir: str | None = None) -> str: + """Get record directory path, resolving relative paths. + + Args: + base_dir: Base directory for relative paths. If None, uses get_base_dir() + which supports frozen executables. + """ + if base_dir is None: + base_dir = get_base_dir() if self.RECORD_DIR: if os.path.isabs(self.RECORD_DIR): return self.RECORD_DIR diff --git a/backend/build.py b/backend/build.py new file mode 100644 index 0000000..9fcb28d --- /dev/null +++ b/backend/build.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Build script for creating standalone backend executable using PyInstaller. +Uses --onedir mode for faster startup compared to --onefile. +""" + +import subprocess +import sys +import os +import shutil + + +def build(): + """Build the backend executable.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + + # PyInstaller command with --onedir for faster startup + cmd = [ + sys.executable, "-m", "PyInstaller", + "--onedir", + "--name", "backend", + "--distpath", "dist", + "--workpath", "build", + "--specpath", "build", + # FastAPI and web framework + "--hidden-import", "uvicorn", + "--hidden-import", "uvicorn.logging", + "--hidden-import", "uvicorn.loops", + "--hidden-import", "uvicorn.loops.auto", + "--hidden-import", "uvicorn.protocols", + "--hidden-import", "uvicorn.protocols.http", + "--hidden-import", "uvicorn.protocols.http.auto", + "--hidden-import", "uvicorn.protocols.websockets", + "--hidden-import", "uvicorn.protocols.websockets.auto", + "--hidden-import", "uvicorn.lifespan", + "--hidden-import", "uvicorn.lifespan.on", + "--hidden-import", "uvicorn.lifespan.off", + "--hidden-import", "fastapi", + "--hidden-import", "starlette", + "--hidden-import", "pydantic", + "--hidden-import", "pydantic_core", + # Database + "--hidden-import", "mysql.connector", + "--hidden-import", "mysql.connector.pooling", + # HTTP client + "--hidden-import", "httpx", + "--hidden-import", "httpcore", + # Authentication + "--hidden-import", "jose", + "--hidden-import", "jose.jwt", + "--hidden-import", "cryptography", + # Excel export + "--hidden-import", "openpyxl", + # Multipart form handling + "--hidden-import", "multipart", + "--hidden-import", "python_multipart", + # Environment loading + "--hidden-import", "dotenv", + # Application modules + "--hidden-import", "app", + "--hidden-import", "app.main", + "--hidden-import", "app.config", + "--hidden-import", "app.database", + "--hidden-import", "app.auth", + "--hidden-import", "app.routers", + "--hidden-import", "app.routers.auth", + "--hidden-import", "app.routers.meetings", + "--hidden-import", "app.routers.dify", + "--hidden-import", "app.routers.health", + "--hidden-import", "app.routers.excel", + # Collect package data + "--collect-data", "pydantic", + "--collect-data", "uvicorn", + "run_server.py" + ] + + print("Building backend executable...") + print(f"Command: {' '.join(cmd)}") + + result = subprocess.run(cmd, cwd=script_dir) + + if result.returncode != 0: + print("\nBuild failed!") + sys.exit(1) + + # Copy template directory to dist + template_src = os.path.join(script_dir, "template") + template_dst = os.path.join(script_dir, "dist", "backend", "template") + + if os.path.exists(template_src): + print(f"\nCopying template directory to {template_dst}...") + if os.path.exists(template_dst): + shutil.rmtree(template_dst) + shutil.copytree(template_src, template_dst) + print("Template directory copied successfully.") + else: + print(f"\nWarning: Template directory not found at {template_src}") + + # Create empty record directory + record_dst = os.path.join(script_dir, "dist", "backend", "record") + os.makedirs(record_dst, exist_ok=True) + print(f"Created record directory at {record_dst}") + + print("\nBuild successful!") + print(f"Executable created at: dist/backend/backend.exe (Windows) or dist/backend/backend (Linux)") + print("\nTo run:") + print(" 1. Copy config.json to dist/backend/") + print(" 2. Run: dist/backend/backend.exe (Windows) or ./dist/backend/backend (Linux)") + + +if __name__ == "__main__": + build() diff --git a/backend/run_server.py b/backend/run_server.py new file mode 100644 index 0000000..f2acf23 --- /dev/null +++ b/backend/run_server.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Backend entry point for embedded deployment. +Loads configuration from config.json and starts uvicorn server. +""" + +import json +import os +import sys + + +def get_base_dir() -> str: + """Get base directory, supporting PyInstaller frozen executables.""" + if getattr(sys, "frozen", False): + # Running as PyInstaller bundle + return os.path.dirname(sys.executable) + else: + # Running as script + return os.path.dirname(os.path.abspath(__file__)) + + +def load_config(config_path: str | None = None) -> dict: + """Load configuration from config.json file.""" + if config_path is None: + base_dir = get_base_dir() + config_path = os.path.join(base_dir, "config.json") + + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + return {} + + +def apply_config_to_env(config: dict) -> None: + """ + Apply config.json values to environment variables. + Environment variables take precedence (already set values are not overwritten). + """ + backend_config = config.get("backend", {}) + + # Server configuration + if "host" in backend_config: + os.environ.setdefault("BACKEND_HOST", backend_config["host"]) + if "port" in backend_config: + os.environ.setdefault("BACKEND_PORT", str(backend_config["port"])) + + # Database configuration + db_config = backend_config.get("database", {}) + if "host" in db_config: + os.environ.setdefault("DB_HOST", db_config["host"]) + if "port" in db_config: + os.environ.setdefault("DB_PORT", str(db_config["port"])) + if "user" in db_config: + os.environ.setdefault("DB_USER", db_config["user"]) + if "password" in db_config: + os.environ.setdefault("DB_PASS", db_config["password"]) + if "database" in db_config: + os.environ.setdefault("DB_NAME", db_config["database"]) + if "poolSize" in db_config: + os.environ.setdefault("DB_POOL_SIZE", str(db_config["poolSize"])) + + # External API configuration + api_config = backend_config.get("externalApis", {}) + if "authApiUrl" in api_config: + os.environ.setdefault("AUTH_API_URL", api_config["authApiUrl"]) + if "difyApiUrl" in api_config: + os.environ.setdefault("DIFY_API_URL", api_config["difyApiUrl"]) + if "difyApiKey" in api_config: + os.environ.setdefault("DIFY_API_KEY", api_config["difyApiKey"]) + if "difySttApiKey" in api_config: + os.environ.setdefault("DIFY_STT_API_KEY", api_config["difySttApiKey"]) + + # Authentication configuration + auth_config = backend_config.get("auth", {}) + if "adminEmail" in auth_config: + os.environ.setdefault("ADMIN_EMAIL", auth_config["adminEmail"]) + if "jwtSecret" in auth_config: + os.environ.setdefault("JWT_SECRET", auth_config["jwtSecret"]) + if "jwtExpireHours" in auth_config: + os.environ.setdefault("JWT_EXPIRE_HOURS", str(auth_config["jwtExpireHours"])) + + # File configuration - set TEMPLATE_DIR and RECORD_DIR relative to base + base_dir = get_base_dir() + if not os.environ.get("TEMPLATE_DIR"): + template_dir = os.path.join(base_dir, "template") + if os.path.exists(template_dir): + os.environ["TEMPLATE_DIR"] = template_dir + + if not os.environ.get("RECORD_DIR"): + record_dir = os.path.join(base_dir, "record") + os.makedirs(record_dir, exist_ok=True) + os.environ["RECORD_DIR"] = record_dir + + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Meeting Assistant Backend Server") + parser.add_argument( + "--config", + type=str, + help="Path to config.json file", + ) + parser.add_argument( + "--host", + type=str, + help="Host to bind to (overrides config)", + ) + parser.add_argument( + "--port", + type=int, + help="Port to bind to (overrides config)", + ) + args = parser.parse_args() + + # Load and apply configuration + config = load_config(args.config) + apply_config_to_env(config) + + # Command line arguments override everything + if args.host: + os.environ["BACKEND_HOST"] = args.host + if args.port: + os.environ["BACKEND_PORT"] = str(args.port) + + # Get final host/port values + host = os.environ.get("BACKEND_HOST", "127.0.0.1") + port = int(os.environ.get("BACKEND_PORT", "8000")) + + print(f"Starting backend server on {host}:{port}", flush=True) + + # Import and run uvicorn + import uvicorn + + uvicorn.run( + "app.main:app", + host=host, + port=port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/client/config.json b/client/config.json index aab5be3..5fa502a 100644 --- a/client/config.json +++ b/client/config.json @@ -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 + } } } diff --git a/client/package.json b/client/package.json index 1139242..9ec1d55 100644 --- a/client/package.json +++ b/client/package.json @@ -33,6 +33,11 @@ "to": "sidecar/transcriber", "filter": ["**/*"] }, + { + "from": "../backend/dist/backend", + "to": "backend/backend", + "filter": ["**/*"] + }, { "from": "config.json", "to": "config.json" diff --git a/client/src/main.js b/client/src/main.js index 3ff9914..88c3c4e 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -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} - 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 () => { diff --git a/client/src/pages/meeting-detail.html b/client/src/pages/meeting-detail.html index 8b71563..35cca35 100644 --- a/client/src/pages/meeting-detail.html +++ b/client/src/pages/meeting-detail.html @@ -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 { diff --git a/client/src/preload.js b/client/src/preload.js index b7e35d1..3eda756 100644 --- a/client/src/preload.js +++ b/client/src/preload.js @@ -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), diff --git a/scripts/build-client.bat b/scripts/build-client.bat index fcba6ca..5c6d9e1 100644 --- a/scripts/build-client.bat +++ b/scripts/build-client.bat @@ -18,10 +18,13 @@ set "SCRIPT_DIR=%~dp0" set "PROJECT_DIR=%SCRIPT_DIR%.." set "CLIENT_DIR=%PROJECT_DIR%\client" set "SIDECAR_DIR=%PROJECT_DIR%\sidecar" +set "BACKEND_DIR=%PROJECT_DIR%\backend" set "BUILD_DIR=%PROJECT_DIR%\build" REM 預設配置 set "SKIP_SIDECAR=false" +set "SKIP_BACKEND=true" +set "EMBEDDED_BACKEND=false" set "CLEAN_BUILD=false" set "API_URL=" @@ -35,6 +38,8 @@ if /i "%~1"=="electron" (set "COMMAND=electron" & shift & goto :parse_args) if /i "%~1"=="clean" (set "COMMAND=clean" & shift & goto :parse_args) if /i "%~1"=="help" (set "COMMAND=help" & shift & goto :parse_args) if /i "%~1"=="--skip-sidecar" (set "SKIP_SIDECAR=true" & shift & goto :parse_args) +if /i "%~1"=="--skip-backend" (set "SKIP_BACKEND=true" & shift & goto :parse_args) +if /i "%~1"=="--embedded-backend" (set "EMBEDDED_BACKEND=true" & set "SKIP_BACKEND=false" & shift & goto :parse_args) if /i "%~1"=="--clean" (set "CLEAN_BUILD=true" & shift & goto :parse_args) if /i "%~1"=="--api-url" (set "API_URL=%~2" & shift & shift & goto :parse_args) echo %RED%[ERROR]%NC% 未知參數: %~1 @@ -126,6 +131,10 @@ if exist "%SIDECAR_DIR%\dist" rmdir /s /q "%SIDECAR_DIR%\dist" if exist "%SIDECAR_DIR%\build" rmdir /s /q "%SIDECAR_DIR%\build" if exist "%SIDECAR_DIR%\venv" rmdir /s /q "%SIDECAR_DIR%\venv" if exist "%SIDECAR_DIR%\*.spec" del /q "%SIDECAR_DIR%\*.spec" +if exist "%BACKEND_DIR%\dist" rmdir /s /q "%BACKEND_DIR%\dist" +if exist "%BACKEND_DIR%\build" rmdir /s /q "%BACKEND_DIR%\build" +if exist "%BACKEND_DIR%\venv" rmdir /s /q "%BACKEND_DIR%\venv" +if exist "%BACKEND_DIR%\*.spec" del /q "%BACKEND_DIR%\*.spec" echo %GREEN%[OK]%NC% 建置目錄已清理 goto :eof @@ -194,6 +203,127 @@ if exist "dist\transcriber" ( ) goto :eof +:setup_backend_venv +echo %BLUE%[STEP]%NC% 設置 Backend 建置環境... + +cd /d "%BACKEND_DIR%" + +if not exist "venv" ( + echo %BLUE%[INFO]%NC% 創建虛擬環境... + %PYTHON_CMD% -m venv venv +) + +echo %BLUE%[INFO]%NC% 安裝 Backend 依賴... +call venv\Scripts\activate.bat +python -m pip install --upgrade pip -q +python -m pip install -r requirements.txt -q + +echo %BLUE%[INFO]%NC% 安裝 PyInstaller... +python -m pip install pyinstaller -q + +echo %GREEN%[OK]%NC% Backend 建置環境就緒 +goto :eof + +:build_backend +echo %BLUE%[STEP]%NC% 打包 Backend (Python → 獨立執行檔)... + +cd /d "%BACKEND_DIR%" + +call venv\Scripts\activate.bat + +if not exist "dist" mkdir dist + +echo %BLUE%[INFO]%NC% 執行 PyInstaller... +echo %BLUE%[INFO]%NC% 這可能需要幾分鐘... + +pyinstaller ^ + --onedir ^ + --name backend ^ + --distpath dist ^ + --workpath build ^ + --specpath . ^ + --noconfirm ^ + --clean ^ + --log-level WARN ^ + --console ^ + --hidden-import=uvicorn ^ + --hidden-import=uvicorn.logging ^ + --hidden-import=uvicorn.loops ^ + --hidden-import=uvicorn.loops.auto ^ + --hidden-import=uvicorn.protocols ^ + --hidden-import=uvicorn.protocols.http ^ + --hidden-import=uvicorn.protocols.http.auto ^ + --hidden-import=uvicorn.protocols.websockets ^ + --hidden-import=uvicorn.protocols.websockets.auto ^ + --hidden-import=uvicorn.lifespan ^ + --hidden-import=uvicorn.lifespan.on ^ + --hidden-import=uvicorn.lifespan.off ^ + --hidden-import=fastapi ^ + --hidden-import=starlette ^ + --hidden-import=pydantic ^ + --hidden-import=pydantic_core ^ + --hidden-import=mysql.connector ^ + --hidden-import=mysql.connector.pooling ^ + --hidden-import=httpx ^ + --hidden-import=httpcore ^ + --hidden-import=jose ^ + --hidden-import=jose.jwt ^ + --hidden-import=cryptography ^ + --hidden-import=openpyxl ^ + --hidden-import=multipart ^ + --hidden-import=python_multipart ^ + --hidden-import=dotenv ^ + --hidden-import=app ^ + --hidden-import=app.main ^ + --hidden-import=app.config ^ + --hidden-import=app.database ^ + --hidden-import=app.auth ^ + --hidden-import=app.routers ^ + --hidden-import=app.routers.auth ^ + --hidden-import=app.routers.meetings ^ + --hidden-import=app.routers.dify ^ + --hidden-import=app.routers.health ^ + --hidden-import=app.routers.excel ^ + --collect-data=pydantic ^ + --collect-data=uvicorn ^ + run_server.py + +if exist "dist\backend" ( + echo %BLUE%[INFO]%NC% 複製 template 目錄... + if exist "template" ( + xcopy /s /e /y "template\*" "dist\backend\template\" >nul 2>&1 + ) + if not exist "dist\backend\record" mkdir "dist\backend\record" + echo %GREEN%[OK]%NC% Backend 打包完成: %BACKEND_DIR%\dist\backend +) else ( + echo %RED%[ERROR]%NC% Backend 打包失敗 + exit /b 1 +) +goto :eof + +:update_config_embedded +REM 更新 config.json 以啟用 embedded backend +if "%EMBEDDED_BACKEND%"=="false" goto :eof + +echo %BLUE%[STEP]%NC% 啟用內嵌後端模式... + +set "CONFIG_FILE=%CLIENT_DIR%\config.json" +if not exist "%CONFIG_FILE%" ( + echo %YELLOW%[WARN]%NC% 找不到 config.json,跳過內嵌模式設定 + goto :eof +) + +REM 使用 PowerShell 更新 backend.embedded = true +powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; if (-not $config.backend) { $config | Add-Member -NotePropertyName 'backend' -NotePropertyValue @{} }; $config.backend.embedded = $true; $config | ConvertTo-Json -Depth 10 | Set-Content '%CONFIG_FILE%' -Encoding UTF8" + +if errorlevel 1 ( + echo %RED%[ERROR]%NC% 更新 config.json embedded 設定失敗 + exit /b 1 +) + +echo %GREEN%[OK]%NC% 已啟用內嵌後端模式 +goto :eof + :setup_client echo %BLUE%[STEP]%NC% 設置前端建置環境... @@ -303,11 +433,19 @@ if "%CLEAN_BUILD%"=="true" call :do_clean REM 更新 API URL(如果有指定) call :update_config +REM 更新 embedded backend 設定(如果有指定) +call :update_config_embedded + if "%SKIP_SIDECAR%"=="false" ( call :setup_sidecar_venv call :build_sidecar ) +if "%SKIP_BACKEND%"=="false" ( + call :setup_backend_venv + call :build_backend +) + call :setup_client call :build_electron call :finalize_build @@ -364,19 +502,26 @@ echo clean 清理建置目錄 echo help 顯示此幫助訊息 echo. echo 選項: -echo --api-url URL 後端 API URL (預設: http://localhost:8000/api) -echo --skip-sidecar 跳過 Sidecar 打包 -echo --clean 建置前先清理 +echo --api-url URL 後端 API URL (預設: http://localhost:8000/api) +echo --skip-sidecar 跳過 Sidecar 打包 +echo --skip-backend 跳過 Backend 打包 (預設) +echo --embedded-backend 打包內嵌後端 (全包部署模式) +echo --clean 建置前先清理 echo. echo 範例: -echo %~nx0 build 完整建置 (使用預設 localhost) -echo %~nx0 build --api-url "http://192.168.1.100:8000/api" 指定後端 URL -echo %~nx0 build --api-url "https://api.company.com/api" 使用公司伺服器 -echo %~nx0 sidecar 僅打包 Sidecar -echo %~nx0 electron --skip-sidecar 僅打包 Electron +echo %~nx0 build 完整建置 (前端+Sidecar) +echo %~nx0 build --embedded-backend 全包部署 (含內嵌後端) +echo %~nx0 build --api-url "http://192.168.1.100:8000/api" 指定遠端後端 +echo %~nx0 sidecar 僅打包 Sidecar +echo %~nx0 electron --skip-sidecar 僅打包 Electron +echo. +echo 部署模式: +echo 分離部署(預設): 前端連接遠端後端,使用 --api-url 指定後端地址 +echo 全包部署: 使用 --embedded-backend 將後端打包進 exe,雙擊即可運行 echo. echo 注意: echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間 +echo - 全包部署需要額外約 50MB 空間用於後端 echo - 確保有足夠的磁碟空間 (建議 5GB+) echo. goto :eof diff --git a/sidecar/transcriber.py b/sidecar/transcriber.py index 2537b24..ccbcd95 100644 --- a/sidecar/transcriber.py +++ b/sidecar/transcriber.py @@ -31,6 +31,8 @@ try: from faster_whisper import WhisperModel import opencc import numpy as np + from huggingface_hub import snapshot_download, hf_hub_download + from huggingface_hub.utils import tqdm as hf_tqdm except ImportError as e: print(json.dumps({"error": f"Missing dependency: {e}"}), file=sys.stderr) sys.exit(1) @@ -43,6 +45,152 @@ except ImportError: ONNX_AVAILABLE = False +def check_and_download_whisper_model(model_size: str) -> bool: + """ + Check if Whisper model is cached, download with progress if not. + + Returns: + True if model is ready (cached or downloaded), False on error + """ + # faster-whisper model repository mapping + repo_id = f"Systran/faster-whisper-{model_size}" + + # Check if model is already cached + cache_dir = Path.home() / ".cache" / "huggingface" / "hub" + repo_cache_name = f"models--Systran--faster-whisper-{model_size}" + model_cache_path = cache_dir / repo_cache_name + + # Check if model files exist + if model_cache_path.exists(): + snapshots_dir = model_cache_path / "snapshots" + if snapshots_dir.exists() and any(snapshots_dir.iterdir()): + # Model is cached, no download needed + return True + + # Model not cached, need to download + print(json.dumps({ + "status": "downloading_model", + "model": model_size, + "repo": repo_id, + "progress": 0 + }), flush=True) + + try: + # Custom progress callback class + class DownloadProgressCallback: + def __init__(self): + self.total_files = 0 + self.downloaded_files = 0 + self.current_file_progress = 0 + self.last_reported_percent = -5 # Report every 5% + + def __call__(self, progress: float, total: float, filename: str = ""): + if total > 0: + percent = int((progress / total) * 100) + # Report every 5% or at completion + if percent >= self.last_reported_percent + 5 or percent == 100: + self.last_reported_percent = percent + downloaded_mb = progress / (1024 * 1024) + total_mb = total / (1024 * 1024) + print(json.dumps({ + "status": "downloading_model", + "model": model_size, + "progress": percent, + "downloaded_mb": round(downloaded_mb, 1), + "total_mb": round(total_mb, 1), + "file": filename + }), flush=True) + + # Use huggingface_hub to download with a simple approach + # We'll monitor the download by checking file sizes + import threading + import time + + download_complete = False + download_error = None + + def download_thread(): + nonlocal download_complete, download_error + try: + snapshot_download( + repo_id, + local_dir=None, # Use default cache + local_dir_use_symlinks=False, + ) + download_complete = True + except Exception as e: + download_error = str(e) + + # Start download in background thread + thread = threading.Thread(target=download_thread) + thread.start() + + # Monitor progress by checking cache directory + last_size = 0 + last_report_time = time.time() + estimated_size_mb = { + "tiny": 77, + "base": 145, + "small": 488, + "medium": 1530, + "large": 3100, + "large-v2": 3100, + "large-v3": 3100, + }.get(model_size, 1530) # Default to medium size + + while thread.is_alive(): + time.sleep(1) + try: + # Check current download size + current_size = 0 + if model_cache_path.exists(): + for file in model_cache_path.rglob("*"): + if file.is_file(): + current_size += file.stat().st_size + + current_mb = current_size / (1024 * 1024) + progress = min(99, int((current_mb / estimated_size_mb) * 100)) + + # Report progress every 5 seconds or if significant change + now = time.time() + if now - last_report_time >= 5 or (current_mb - last_size / (1024 * 1024)) > 50: + if current_size > last_size: + print(json.dumps({ + "status": "downloading_model", + "model": model_size, + "progress": progress, + "downloaded_mb": round(current_mb, 1), + "total_mb": estimated_size_mb + }), flush=True) + last_size = current_size + last_report_time = now + except Exception: + pass + + thread.join() + + if download_error: + print(json.dumps({ + "status": "download_error", + "error": download_error + }), flush=True) + return False + + print(json.dumps({ + "status": "model_downloaded", + "model": model_size + }), flush=True) + + return True + + except Exception as e: + print(json.dumps({ + "status": "download_error", + "error": str(e) + }), flush=True) + return False + + class ChinesePunctuator: """Rule-based Chinese punctuation processor.""" @@ -342,17 +490,21 @@ class Transcriber: self.vad_model: Optional[SileroVAD] = None try: - print(json.dumps({"status": "loading_model", "model": model_size}), file=sys.stderr) + # Check if model needs to be downloaded (with progress reporting) + check_and_download_whisper_model(model_size) + + # Now load the model + print(json.dumps({"status": "loading_model", "model": model_size}), flush=True) self.model = WhisperModel(model_size, device=device, compute_type=compute_type) self.converter = opencc.OpenCC("s2twp") - print(json.dumps({"status": "model_loaded"}), file=sys.stderr) + print(json.dumps({"status": "model_loaded"}), flush=True) # Pre-load VAD model at startup (not when streaming starts) if ONNX_AVAILABLE: self.vad_model = SileroVAD() except Exception as e: - print(json.dumps({"error": f"Failed to load model: {e}"}), file=sys.stderr) + print(json.dumps({"error": f"Failed to load model: {e}"}), flush=True) raise def transcribe_file(self, audio_path: str, add_punctuation: bool = False) -> str: