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:
104
DEPLOYMENT.md
104
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
|
||||
|
||||
@@ -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
|
||||
|
||||
112
backend/build.py
Normal file
112
backend/build.py
Normal 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
145
backend/run_server.py
Normal 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()
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user