Spaces:
Running
Running
| """ | |
| Open-LLM-VTuber Server | |
| ======================== | |
| This module contains the WebSocket server for Open-LLM-VTuber, which handles | |
| the WebSocket connections, serves static files, and manages the web tool. | |
| It uses FastAPI for the server and Starlette for static file serving. | |
| """ | |
| import os | |
| import shutil | |
| from fastapi import FastAPI | |
| from fastapi.middleware.gzip import GZipMiddleware | |
| from starlette.middleware.cors import CORSMiddleware | |
| from starlette.responses import Response | |
| from starlette.staticfiles import StaticFiles as StarletteStaticFiles | |
| from .routes import init_client_ws_route, init_webtool_routes, init_proxy_route | |
| from .service_context import ServiceContext | |
| from .config_manager.utils import Config | |
| # Create a custom StaticFiles class that adds CORS headers | |
| class CORSStaticFiles(StarletteStaticFiles): | |
| """ | |
| Static files handler that adds CORS headers to all responses. | |
| Needed because Starlette StaticFiles might bypass standard middleware. | |
| """ | |
| async def __call__(self, scope, receive, send) -> None: | |
| if scope["type"] != "http": | |
| if scope["type"] == "websocket": | |
| await send({"type": "websocket.close", "code": 1008}) | |
| return | |
| await super().__call__(scope, receive, send) | |
| async def get_response(self, path: str, scope): | |
| response = await super().get_response(path, scope) | |
| # Add CORS headers to all responses | |
| response.headers["Access-Control-Allow-Origin"] = "*" | |
| response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" | |
| response.headers["Access-Control-Allow-Headers"] = "*" | |
| if path.endswith(".js"): | |
| response.headers["Content-Type"] = "application/javascript" | |
| elif path.endswith(".wasm"): | |
| response.headers["Content-Type"] = "application/wasm" | |
| elif path.endswith(".onnx"): | |
| response.headers["Content-Type"] = "application/octet-stream" | |
| # Add caching headers for static assets | |
| if path.endswith((".glb", ".vrm", ".wasm", ".onnx")): | |
| response.headers["Cache-Control"] = "public, max-age=31536000, immutable" | |
| elif path.endswith((".jpg", ".jpeg", ".png", ".gif", ".css", ".js")): | |
| if "frontend-config.js" not in path and "avatar-config.js" not in path: | |
| response.headers["Cache-Control"] = "public, max-age=86400" | |
| return response | |
| class AvatarStaticFiles(CORSStaticFiles): | |
| """ | |
| Avatar files handler with security restrictions and CORS headers | |
| """ | |
| async def get_response(self, path: str, scope): | |
| allowed_extensions = (".jpg", ".jpeg", ".png", ".gif", ".svg") | |
| if not any(path.lower().endswith(ext) for ext in allowed_extensions): | |
| return Response("Forbidden file type", status_code=403) | |
| response = await super().get_response(path, scope) | |
| return response | |
| class WebSocketServer: | |
| """ | |
| API server for Open-LLM-VTuber. This contains the websocket endpoint for the client, hosts the web tool, and serves static files. | |
| Creates and configures a FastAPI app, registers all routes | |
| (WebSocket, web tools, proxy) and mounts static assets with CORS. | |
| Args: | |
| config (Config): Application configuration containing system settings. | |
| default_context_cache (ServiceContext, optional): | |
| Pre‑initialized service context for sessions' service context to reference to. | |
| **If omitted, `initialize()` method needs to be called to load service context.** | |
| Notes: | |
| - If default_context_cache is omitted, call `await initialize()` to load service context cache. | |
| - Use `clean_cache()` to clear and recreate the local cache directory. | |
| """ | |
| def __init__(self, config: Config, default_context_cache: ServiceContext = None): | |
| self.app = FastAPI(title="Open-LLM-VTuber Server") # Added title for clarity | |
| self.config = config | |
| self.default_context_cache = ( | |
| default_context_cache or ServiceContext() | |
| ) # Use provided context or initialize a new empty one waiting to be loaded | |
| # It will be populated during the initialize method call | |
| # Add global CORS middleware | |
| self.app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Add Gzip middleware to compress large assets (like GLB files) during transfer | |
| self.app.add_middleware( | |
| GZipMiddleware, | |
| minimum_size=1000, | |
| ) | |
| # Include routes, passing the context instance | |
| # The context will be populated during the initialize step | |
| self.app.include_router( | |
| init_client_ws_route(default_context_cache=self.default_context_cache), | |
| ) | |
| self.app.include_router( | |
| init_webtool_routes(default_context_cache=self.default_context_cache), | |
| ) | |
| async def get_frontend_config(): | |
| ws_url = getattr(self.config.system_config, "frontend_ws_url", "") | |
| base_url = getattr(self.config.system_config, "frontend_base_url", "") | |
| js_content = f'window.FRONTEND_CONFIG = {{ wsUrl: "{ws_url}", baseUrl: "{base_url}" }};' | |
| return Response(content=js_content, media_type="application/javascript") | |
| # Initialize and include proxy routes if proxy is enabled | |
| system_config = config.system_config | |
| if hasattr(system_config, "enable_proxy") and system_config.enable_proxy: | |
| # Construct the server URL for the proxy | |
| host = system_config.host | |
| port = system_config.port | |
| server_url = f"ws://{host}:{port}/client-ws" | |
| self.app.include_router( | |
| init_proxy_route(server_url=server_url), | |
| ) | |
| # Ensure required directories exist before mounting | |
| for directory in ["cache", "live3d-model", "backgrounds", "avatars", "web_tool", "frontend"]: | |
| if not os.path.exists(directory): | |
| os.makedirs(directory) | |
| self.app.mount( | |
| "/cache", | |
| CORSStaticFiles(directory="cache"), | |
| name="cache", | |
| ) | |
| # Mount static files with CORS-enabled handlers | |
| self.app.mount( | |
| "/live3d-model", | |
| CORSStaticFiles(directory="live3d-model"), | |
| name="live3d-model", | |
| ) | |
| self.app.mount( | |
| "/bg", | |
| CORSStaticFiles(directory="backgrounds"), | |
| name="backgrounds", | |
| ) | |
| self.app.mount( | |
| "/jpg", | |
| CORSStaticFiles(directory="backgrounds"), | |
| name="backgrounds_jpg", | |
| ) | |
| self.app.mount( | |
| "/backgrounds", | |
| CORSStaticFiles(directory="backgrounds"), | |
| name="backgrounds_dir", | |
| ) | |
| self.app.mount( | |
| "/avatars", | |
| AvatarStaticFiles(directory="avatars"), | |
| name="avatars", | |
| ) | |
| # Mount web tool directory separately from frontend | |
| self.app.mount( | |
| "/web-tool", | |
| CORSStaticFiles(directory="web_tool", html=True), | |
| name="web_tool", | |
| ) | |
| # Mount main frontend last (as catch-all) | |
| self.app.mount( | |
| "/", | |
| CORSStaticFiles(directory="frontend", html=True), | |
| name="frontend", | |
| ) | |
| async def initialize(self): | |
| """Asynchronously load the service context from config. | |
| Calling this function is needed if default_context_cache was not provided to the constructor.""" | |
| await self.default_context_cache.load_from_config(self.config) | |
| def clean_cache(): | |
| """Clean the cache directory by removing and recreating it.""" | |
| cache_dir = "cache" | |
| if os.path.exists(cache_dir): | |
| shutil.rmtree(cache_dir) | |
| os.makedirs(cache_dir) | |