agent-zero/helpers/ui_server.py
frdel bebe6826cf
Some checks failed
Build And Publish Docker Images / plan (push) Has been cancelled
Build And Publish Docker Images / build (push) Has been cancelled
Improve WebUI bundling, manifest and stop command
Introduce a WebUI extension manifest and rewrite the asset bundler/server to support caller-supplied entry sets, extension-injected entries, and negotiated gzip. Key changes: add get_webui_extension_manifest() to helpers/extension, refactor Stop logic into stop_context() and reuse it from the connector `/stop` command, and add a new `/rename` slash command for chat naming. ui_bundler now accepts entry_urls, includes enabled extension entry files, raises the embedded text file size limit to 512 KiB, computes per-entry-set cache keys, and returns a bundle version based on the bundle signature. ui_server applies Starlette GZip middleware, adds routes (/, /index.html, /ui/index, /safe), serves splash/safe documents, injects the serialized webui_extension_manifest into the rendered index, and streamlines the /ui/asset-bundle endpoint with ETag and gzip handling. Also add multiple WebUI assets and fonts, new/updated plugin command YAML and Python command handlers, and corresponding tests covering bundling, commands, chat naming, and WebUI behaviors. Documentation (.dox.md) updated to reflect the new runtime contracts and guidance.
2026-07-29 19:49:07 +02:00

436 lines
15 KiB
Python

from dataclasses import dataclass, field
from datetime import timedelta
import asyncio
import gzip
import json
import logging
import os
import secrets
import threading
import time
from typing import Any
from flask import (
Flask,
Response,
redirect,
render_template_string,
request,
send_file,
session,
url_for,
)
from socketio import ASGIApp
from starlette.applications import Starlette
from starlette.middleware.gzip import GZipMiddleware
from starlette.routing import Mount
from uvicorn.middleware.wsgi import WSGIMiddleware
from werkzeug.wrappers.request import Request as WerkzeugRequest
import socketio # type: ignore[import-untyped]
from helpers import dotenv, fasta2a_server, files, git, login, mcp_server, runtime
from helpers.api import get_safe_next_url, register_api_route, requires_auth
from helpers.extension import extensible, get_webui_extension_manifest
from helpers.files import get_abs_path
from helpers.print_style import PrintStyle
from helpers.server_startup import StartupMonitor
from helpers.ui_bundler import (
get_ui_asset_bundle,
serialize_ui_asset_bundle,
)
from helpers import settings as settings_helper
from helpers.ws import register_ws_namespace, validate_ws_origin
from helpers.ws_manager import WsManager, set_shared_ws_manager
UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024
SOCKETIO_PING_INTERVAL_SECONDS = 45
SOCKETIO_PING_TIMEOUT_SECONDS = 120
GZIP_MINIMUM_RESPONSE_BYTES = 1024
GZIP_COMPRESSION_LEVEL = 6
UI_INDEX_ASSET_URL = "/index.html"
def _positive_int_env(name: str, default: int) -> int:
raw_value = os.getenv(name)
if raw_value is None:
return default
try:
value = int(raw_value)
except (TypeError, ValueError):
return default
return value if value > 0 else default
def configure_process_environment() -> None:
logging.getLogger().setLevel(logging.WARNING)
os.environ["TOKENIZERS_PARALLELISM"] = "false"
from helpers.localization import Localization
Localization.get().apply_process_timezone()
@dataclass
class UiServerRuntime:
webapp: Flask
socketio_server: socketio.AsyncServer
ws_manager: WsManager
lock: threading.RLock
settings_snapshot: dict[str, Any]
_routes_registered: bool = False
_transport_registered: bool = False
_route_handlers: "UiRouteHandlers | None" = field(default=None, init=False)
@classmethod
def create(cls) -> "UiServerRuntime":
webapp = Flask("app", static_folder=get_abs_path("./webui"), static_url_path="/")
webapp.secret_key = os.getenv("FLASK_SECRET_KEY") or secrets.token_hex(32)
WerkzeugRequest.max_form_memory_size = UPLOAD_LIMIT_BYTES
webapp.config.update(
JSON_SORT_KEYS=False,
SESSION_COOKIE_NAME="session_" + runtime.get_runtime_id(),
SESSION_COOKIE_SAMESITE="Lax",
SESSION_PERMANENT=True,
PERMANENT_SESSION_LIFETIME=timedelta(days=1),
MAX_CONTENT_LENGTH=int(
os.getenv("FLASK_MAX_CONTENT_LENGTH", str(UPLOAD_LIMIT_BYTES))
),
MAX_FORM_MEMORY_SIZE=int(
os.getenv("FLASK_MAX_FORM_MEMORY_SIZE", str(UPLOAD_LIMIT_BYTES))
),
)
lock = threading.RLock()
socketio_server = socketio.AsyncServer(
async_mode="asgi",
namespaces="*",
cors_allowed_origins=lambda _origin, environ: validate_ws_origin(environ)[0],
logger=False,
engineio_logger=False,
ping_interval=_positive_int_env(
"A0_SOCKETIO_PING_INTERVAL_SECONDS",
SOCKETIO_PING_INTERVAL_SECONDS,
),
ping_timeout=_positive_int_env(
"A0_SOCKETIO_PING_TIMEOUT_SECONDS",
SOCKETIO_PING_TIMEOUT_SECONDS,
),
max_http_buffer_size=50 * 1024 * 1024,
)
ws_manager = WsManager(socketio_server, lock)
set_shared_ws_manager(ws_manager)
server_runtime = cls(
webapp=webapp,
socketio_server=socketio_server,
ws_manager=ws_manager,
lock=lock,
settings_snapshot={},
)
server_runtime.refresh_runtime_settings()
return server_runtime
def refresh_runtime_settings(self) -> None:
self.settings_snapshot = settings_helper.get_settings()
settings_helper.set_runtime_settings_snapshot(self.settings_snapshot)
self.ws_manager.set_server_restart_broadcast(
self.settings_snapshot.get("websocket_server_restart_enabled", True)
)
def register_http_routes(self) -> None:
if self._routes_registered:
return
handlers = UiRouteHandlers(self)
self._route_handlers = handlers
self.webapp.add_url_rule(
"/login",
"login_handler",
handlers.login_handler,
methods=["GET", "POST"],
)
self.webapp.add_url_rule(
"/logout",
"logout_handler",
handlers.logout_handler,
methods=["GET"],
)
self.webapp.add_url_rule(
"/",
"serve_index",
handlers.serve_splash,
methods=["GET"],
)
self.webapp.add_url_rule(
"/index.html",
"serve_app_index",
handlers.serve_index,
methods=["GET"],
)
self.webapp.add_url_rule(
"/ui/index",
"serve_bootstrap_index",
handlers.serve_index,
methods=["GET"],
)
self.webapp.add_url_rule(
"/safe",
"serve_safe",
handlers.serve_safe,
methods=["GET"],
)
self.webapp.add_url_rule(
"/ui/asset-bundle",
"serve_ui_asset_bundle",
handlers.serve_ui_asset_bundle,
methods=["GET"],
)
self.webapp.add_url_rule(
"/plugins/<plugin_name>/<path:asset_path>",
"serve_builtin_plugin_asset",
handlers.serve_builtin_plugin_asset,
methods=["GET"],
)
self.webapp.add_url_rule(
"/usr/plugins/<plugin_name>/<path:asset_path>",
"serve_plugin_asset",
handlers.serve_plugin_asset,
methods=["GET"],
)
self.webapp.add_url_rule(
"/extensions/webui/<path:asset_path>",
"serve_extension_asset",
handlers.serve_extension_asset,
methods=["GET"],
)
self._routes_registered = True
def register_transport_handlers(self) -> None:
if self._transport_registered:
return
register_api_route(self.webapp, self.lock)
register_ws_namespace(
self.socketio_server,
self.webapp,
self.lock,
manager=self.ws_manager,
)
self._transport_registered = True
def build_asgi_app(self, startup_monitor: StartupMonitor):
with startup_monitor.stage("wsgi.middleware.create"):
wsgi_app = WSGIMiddleware(self.webapp)
with startup_monitor.stage("mcp.proxy.init"):
mcp_app = mcp_server.DynamicMcpProxy.get_instance()
with startup_monitor.stage("a2a.proxy.init"):
a2a_app = fasta2a_server.DynamicA2AProxy.get_instance()
with startup_monitor.stage("starlette.app.create"):
starlette_app = Starlette(
routes=[
Mount("/mcp", app=mcp_app),
Mount("/a2a", app=a2a_app),
Mount("/", app=wsgi_app),
],
lifespan=startup_monitor.lifespan(),
)
compressed_http_app = GZipMiddleware(
starlette_app,
minimum_size=GZIP_MINIMUM_RESPONSE_BYTES,
compresslevel=GZIP_COMPRESSION_LEVEL,
)
with startup_monitor.stage("socketio.asgi.create"):
return ASGIApp(self.socketio_server, other_asgi_app=compressed_http_app)
def access_log_enabled(self) -> bool:
return self.settings_snapshot.get("uvicorn_access_logs_enabled", False)
class UiRouteHandlers:
def __init__(self, runtime_state: UiServerRuntime) -> None:
self.runtime = runtime_state
@extensible
async def login_handler(self):
error = None
fallback_url = url_for("serve_index")
next_url = get_safe_next_url(
request.form.get("next") if request.method == "POST" else request.args.get("next"),
fallback_url,
)
if request.method == "POST":
user = dotenv.get_dotenv_value("AUTH_LOGIN")
password = dotenv.get_dotenv_value("AUTH_PASSWORD")
if request.form["username"] == user and request.form["password"] == password:
session["authentication"] = login.get_credentials_hash()
return redirect(next_url or fallback_url)
else:
await asyncio.sleep(1)
error = "Invalid Credentials. Please try again."
login_page_content = files.read_file("webui/login.html")
return render_template_string(login_page_content, error=error, next=next_url)
@extensible
async def logout_handler(self):
session.pop("authentication", None)
return redirect(url_for("login_handler"))
@requires_auth
async def serve_splash(self):
return Response(
files.read_file("webui/splash.html"),
content_type="text/html; charset=utf-8",
headers={"Cache-Control": "no-store"},
)
@requires_auth
async def serve_safe(self):
if request.args.get("__direct") == "1":
return await self.serve_index()
return Response(
files.read_file("webui/safe.html"),
content_type="text/html; charset=utf-8",
headers={"Cache-Control": "no-store"},
)
@requires_auth
@extensible
async def serve_index(self):
try:
gitinfo = git.get_git_info()
except Exception:
gitinfo = {
"version": "unknown",
"commit_time": "unknown",
}
try:
user_timezone_setting = str(settings_helper.get_settings().get("timezone", "auto"))
except Exception:
user_timezone_setting = "auto"
try:
user_time_format_setting = str(settings_helper.get_settings().get("time_format", "12h"))
except Exception:
user_time_format_setting = "12h"
try:
user_ui_control_visibility = json.dumps(
settings_helper.get_settings()["ui_control_visibility"],
separators=(",", ":"),
)
except Exception:
user_ui_control_visibility = json.dumps(settings_helper.UI_CONTROL_VISIBILITY_DEFAULTS)
try:
webui_extension_manifest = json.dumps(
get_webui_extension_manifest(agent=None),
separators=(",", ":"),
)
webui_extension_manifest = (
webui_extension_manifest.replace("&", "\\u0026")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
)
except Exception:
webui_extension_manifest = "null"
index = files.read_file("webui/index.html")
return files.replace_placeholders_text(
_content=index,
version_no=gitinfo["version"],
version_time=gitinfo["commit_time"],
runtime_id=runtime.get_runtime_id(),
runtime_is_development=("true" if runtime.is_development() else "false"),
logged_in=("true" if login.get_credentials_hash() else "false"),
user_timezone_setting=user_timezone_setting,
user_time_format_setting=user_time_format_setting,
user_ui_control_visibility=user_ui_control_visibility,
webui_extension_manifest=webui_extension_manifest,
)
@requires_auth
async def serve_ui_asset_bundle(self):
try:
bundle = get_ui_asset_bundle([UI_INDEX_ASSET_URL], agent=None)
return self._serve_ui_asset_payload(bundle)
except Exception as error:
PrintStyle.warning(f"Unable to build WebUI asset bundle: {error}")
return Response(
'{"error":"WebUI asset bundle unavailable"}',
status=503,
content_type="application/json; charset=utf-8",
headers={"Cache-Control": "no-store"},
)
def _serve_ui_asset_payload(self, asset_payload: dict):
version = str(asset_payload.get("version") or "")
if not version:
raise ValueError("WebUI asset payload has no version")
if request.if_none_match.contains_weak(version):
response = Response(status=304)
response.headers["Vary"] = "Accept-Encoding"
response.set_etag(version, weak=True)
response.cache_control.private = True
response.cache_control.no_cache = True
return response
payload = serialize_ui_asset_bundle(asset_payload).encode("utf-8")
use_gzip = request.accept_encodings["gzip"] > 0
response = Response(
gzip.compress(payload) if use_gzip else payload,
content_type="application/json; charset=utf-8",
)
if use_gzip:
response.headers["Content-Encoding"] = "gzip"
response.headers["Vary"] = "Accept-Encoding"
response.set_etag(version, weak=True)
response.cache_control.private = True
response.cache_control.no_cache = True
return response
@requires_auth
async def serve_builtin_plugin_asset(self, plugin_name, asset_path):
return await self._serve_plugin_asset(plugin_name, asset_path)
@requires_auth
async def serve_plugin_asset(self, plugin_name, asset_path):
return await self._serve_plugin_asset(plugin_name, asset_path)
@requires_auth
async def serve_extension_asset(self, asset_path):
exts = files.get_abs_path("extensions/webui")
path = files.get_abs_path(exts, asset_path)
if not files.is_in_dir(path, exts):
return Response("Access denied", 403)
return send_file(path)
@extensible
async def _serve_plugin_asset(self, plugin_name, asset_path):
from helpers import plugins
plugin_dir = plugins.find_plugin_dir(plugin_name)
if not plugin_dir:
return Response("Plugin not found", 404)
try:
asset_file = files.get_abs_path(plugin_dir, asset_path)
webui_dir = files.get_abs_path(plugin_dir, "webui")
webui_extensions_dir = files.get_abs_path(plugin_dir, "extensions/webui")
if not files.is_in_dir(str(asset_file), str(webui_dir)) and not files.is_in_dir(
str(asset_file), str(webui_extensions_dir)
):
return Response("Access denied", 403)
if not files.is_file(asset_file):
return Response("Asset not found", 404)
return send_file(str(asset_file))
except Exception as e:
PrintStyle.error(f"Error serving plugin asset: {e}")
return Response("Error serving asset", 500)