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:
@@ -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()
|
||||
Reference in New Issue
Block a user