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

@@ -224,6 +224,110 @@ npm start
4. 輸入 `api_url` 參數(例如 `http://192.168.1.100:8000/api` 4. 輸入 `api_url` 參數(例如 `http://192.168.1.100:8000/api`
5. 等待建置完成後下載 artifact 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 ## Transcription Sidecar
### 1. Setup ### 1. Setup

View File

@@ -1,9 +1,20 @@
import os import os
import sys
from dotenv import load_dotenv from dotenv import load_dotenv
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: class Settings:
# Server Configuration # Server Configuration
BACKEND_HOST: str = os.getenv("BACKEND_HOST", "0.0.0.0") 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 supported audio formats as a set."""
return set(self.SUPPORTED_AUDIO_FORMATS.split(",")) return set(self.SUPPORTED_AUDIO_FORMATS.split(","))
def get_template_dir(self, base_dir: str) -> str: def get_template_dir(self, base_dir: str | None = None) -> str:
"""Get template directory path, resolving relative paths.""" """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 self.TEMPLATE_DIR:
if os.path.isabs(self.TEMPLATE_DIR): if os.path.isabs(self.TEMPLATE_DIR):
return self.TEMPLATE_DIR return self.TEMPLATE_DIR
return os.path.join(base_dir, self.TEMPLATE_DIR) return os.path.join(base_dir, self.TEMPLATE_DIR)
return os.path.join(base_dir, "template") return os.path.join(base_dir, "template")
def get_record_dir(self, base_dir: str) -> str: def get_record_dir(self, base_dir: str | None = None) -> str:
"""Get record directory path, resolving relative paths.""" """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 self.RECORD_DIR:
if os.path.isabs(self.RECORD_DIR): if os.path.isabs(self.RECORD_DIR):
return self.RECORD_DIR return self.RECORD_DIR

112
backend/build.py Normal file
View File

@@ -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()

145
backend/run_server.py Normal file
View File

@@ -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()

View File

@@ -6,5 +6,28 @@
"model": "medium", "model": "medium",
"device": "cpu", "device": "cpu",
"compute": "int8" "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", "to": "sidecar/transcriber",
"filter": ["**/*"] "filter": ["**/*"]
}, },
{
"from": "../backend/dist/backend",
"to": "backend/backend",
"filter": ["**/*"]
},
{ {
"from": "config.json", "from": "config.json",
"to": "config.json" "to": "config.json"

View File

@@ -11,6 +11,10 @@ let streamingActive = false;
let appConfig = null; let appConfig = null;
let activeWhisperConfig = null; let activeWhisperConfig = null;
// Backend sidecar state
let backendProcess = null;
let backendReady = false;
/** /**
* Load configuration from external config.json * Load configuration from external config.json
* Config file location: * Config file location:
@@ -67,6 +71,189 @@ function loadConfig() {
return appConfig; 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() { function createWindow() {
// Set window title from config // Set window title from config
const windowTitle = appConfig?.appTitle || "Meeting Assistant"; const windowTitle = appConfig?.appTitle || "Meeting Assistant";
@@ -205,6 +392,26 @@ function startSidecar() {
if (msg.result !== undefined && mainWindow) { if (msg.result !== undefined && mainWindow) {
mainWindow.webContents.send("transcription-result", msg.result); 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) { } catch (e) {
console.log("Sidecar output:", line); console.log("Sidecar output:", line);
} }
@@ -229,10 +436,28 @@ function startSidecar() {
} }
} }
app.whenReady().then(() => { app.whenReady().then(async () => {
// Load configuration first // Load configuration first
loadConfig(); 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(); createWindow();
startSidecar(); startSidecar();
@@ -244,12 +469,17 @@ app.whenReady().then(() => {
}); });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
// Stop transcriber sidecar
if (sidecarProcess) { if (sidecarProcess) {
try { try {
sidecarProcess.stdin.write(JSON.stringify({ action: "quit" }) + "\n"); sidecarProcess.stdin.write(JSON.stringify({ action: "quit" }) + "\n");
} catch (e) {} } catch (e) {}
sidecarProcess.kill(); sidecarProcess.kill();
} }
// Stop backend sidecar
stopBackendSidecar();
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit(); 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 === // === Streaming Mode IPC Handlers ===
ipcMain.handle("start-recording-stream", async () => { ipcMain.handle("start-recording-stream", async () => {

View File

@@ -308,6 +308,30 @@
updateWhisperStatus(); updateWhisperStatus();
const whisperStatusInterval = setInterval(updateWhisperStatus, 5000); 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 // Load meeting data
async function loadMeeting() { async function loadMeeting() {
try { try {

View File

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

View File

@@ -18,10 +18,13 @@ set "SCRIPT_DIR=%~dp0"
set "PROJECT_DIR=%SCRIPT_DIR%.." set "PROJECT_DIR=%SCRIPT_DIR%.."
set "CLIENT_DIR=%PROJECT_DIR%\client" set "CLIENT_DIR=%PROJECT_DIR%\client"
set "SIDECAR_DIR=%PROJECT_DIR%\sidecar" set "SIDECAR_DIR=%PROJECT_DIR%\sidecar"
set "BACKEND_DIR=%PROJECT_DIR%\backend"
set "BUILD_DIR=%PROJECT_DIR%\build" set "BUILD_DIR=%PROJECT_DIR%\build"
REM 預設配置 REM 預設配置
set "SKIP_SIDECAR=false" set "SKIP_SIDECAR=false"
set "SKIP_BACKEND=true"
set "EMBEDDED_BACKEND=false"
set "CLEAN_BUILD=false" set "CLEAN_BUILD=false"
set "API_URL=" 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"=="clean" (set "COMMAND=clean" & shift & goto :parse_args)
if /i "%~1"=="help" (set "COMMAND=help" & 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-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"=="--clean" (set "CLEAN_BUILD=true" & shift & goto :parse_args)
if /i "%~1"=="--api-url" (set "API_URL=%~2" & shift & shift & goto :parse_args) if /i "%~1"=="--api-url" (set "API_URL=%~2" & shift & shift & goto :parse_args)
echo %RED%[ERROR]%NC% 未知參數: %~1 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%\build" rmdir /s /q "%SIDECAR_DIR%\build"
if exist "%SIDECAR_DIR%\venv" rmdir /s /q "%SIDECAR_DIR%\venv" if exist "%SIDECAR_DIR%\venv" rmdir /s /q "%SIDECAR_DIR%\venv"
if exist "%SIDECAR_DIR%\*.spec" del /q "%SIDECAR_DIR%\*.spec" 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% 建置目錄已清理 echo %GREEN%[OK]%NC% 建置目錄已清理
goto :eof goto :eof
@@ -194,6 +203,127 @@ if exist "dist\transcriber" (
) )
goto :eof 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 :setup_client
echo %BLUE%[STEP]%NC% 設置前端建置環境... echo %BLUE%[STEP]%NC% 設置前端建置環境...
@@ -303,11 +433,19 @@ if "%CLEAN_BUILD%"=="true" call :do_clean
REM 更新 API URL如果有指定 REM 更新 API URL如果有指定
call :update_config call :update_config
REM 更新 embedded backend 設定(如果有指定)
call :update_config_embedded
if "%SKIP_SIDECAR%"=="false" ( if "%SKIP_SIDECAR%"=="false" (
call :setup_sidecar_venv call :setup_sidecar_venv
call :build_sidecar call :build_sidecar
) )
if "%SKIP_BACKEND%"=="false" (
call :setup_backend_venv
call :build_backend
)
call :setup_client call :setup_client
call :build_electron call :build_electron
call :finalize_build call :finalize_build
@@ -366,17 +504,24 @@ echo.
echo 選項: echo 選項:
echo --api-url URL 後端 API URL (預設: http://localhost:8000/api) echo --api-url URL 後端 API URL (預設: http://localhost:8000/api)
echo --skip-sidecar 跳過 Sidecar 打包 echo --skip-sidecar 跳過 Sidecar 打包
echo --skip-backend 跳過 Backend 打包 (預設)
echo --embedded-backend 打包內嵌後端 (全包部署模式)
echo --clean 建置前先清理 echo --clean 建置前先清理
echo. echo.
echo 範例: echo 範例:
echo %~nx0 build 完整建置 (使用預設 localhost) echo %~nx0 build 完整建置 (前端+Sidecar)
echo %~nx0 build --api-url "http://192.168.1.100:8000/api" 指定後端 URL echo %~nx0 build --embedded-backend 全包部署 (含內嵌後端)
echo %~nx0 build --api-url "https://api.company.com/api" 使用公司伺服器 echo %~nx0 build --api-url "http://192.168.1.100:8000/api" 指定遠端後端
echo %~nx0 sidecar 僅打包 Sidecar echo %~nx0 sidecar 僅打包 Sidecar
echo %~nx0 electron --skip-sidecar 僅打包 Electron echo %~nx0 electron --skip-sidecar 僅打包 Electron
echo. echo.
echo 部署模式:
echo 分離部署(預設): 前端連接遠端後端,使用 --api-url 指定後端地址
echo 全包部署: 使用 --embedded-backend 將後端打包進 exe雙擊即可運行
echo.
echo 注意: echo 注意:
echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間 echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間
echo - 全包部署需要額外約 50MB 空間用於後端
echo - 確保有足夠的磁碟空間 (建議 5GB+) echo - 確保有足夠的磁碟空間 (建議 5GB+)
echo. echo.
goto :eof goto :eof

View File

@@ -31,6 +31,8 @@ try:
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
import opencc import opencc
import numpy as np 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: except ImportError as e:
print(json.dumps({"error": f"Missing dependency: {e}"}), file=sys.stderr) print(json.dumps({"error": f"Missing dependency: {e}"}), file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -43,6 +45,152 @@ except ImportError:
ONNX_AVAILABLE = False 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: class ChinesePunctuator:
"""Rule-based Chinese punctuation processor.""" """Rule-based Chinese punctuation processor."""
@@ -342,17 +490,21 @@ class Transcriber:
self.vad_model: Optional[SileroVAD] = None self.vad_model: Optional[SileroVAD] = None
try: 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.model = WhisperModel(model_size, device=device, compute_type=compute_type)
self.converter = opencc.OpenCC("s2twp") 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) # Pre-load VAD model at startup (not when streaming starts)
if ONNX_AVAILABLE: if ONNX_AVAILABLE:
self.vad_model = SileroVAD() self.vad_model = SileroVAD()
except Exception as e: 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 raise
def transcribe_file(self, audio_path: str, add_punctuation: bool = False) -> str: def transcribe_file(self, audio_path: str, add_punctuation: bool = False) -> str: