feat: 添加 WebUI 静态资源包提示和相关错误处理逻辑

This commit is contained in:
DrSmoothl
2026-04-05 12:05:30 +08:00
parent 56d1071c01
commit ead90cbdf3
7 changed files with 56 additions and 279 deletions

View File

@@ -80,6 +80,7 @@
"startup.webui_auto_build_tool_missing": "❌ No supported frontend build tool was found, cannot auto-build WebUI",
"startup.webui_cors_configured": "✅ CORS middleware configured",
"startup.webui_dashboard_source_missing": "❌ WebUI frontend source directory not found: {dashboard_root}",
"startup.webui_dashboard_package_hint": "💡 Install the prebuilt WebUI static asset package first, for example: {command}",
"startup.webui_disabled": "WebUI is disabled",
"startup.webui_index_missing": "❌ index.html not found: {index_path}",
"startup.webui_manual_build_hint": "💡 Automatic recovery could not restore the frontend assets. Install dependencies and build manually in the dashboard directory: {command}",
@@ -87,6 +88,7 @@
"startup.webui_robots_route_register_failed": "❌ Failed to register robots.txt route: {error}",
"startup.webui_robots_route_registered": "✅ robots.txt route registered",
"startup.webui_server_init_failed": "Failed to initialize WebUI server: {error}",
"startup.webui_static_assets_unavailable": "❌ No usable WebUI static assets were found (installed maibot-dashboard package or local dashboard/dist)",
"startup.webui_static_assets_try_auto_build": "⚠️ WebUI static assets are unavailable, attempting to auto-build the frontend...",
"startup.webui_static_dir_missing": "❌ WebUI static directory does not exist",
"startup.webui_static_dir_missing_with_path": "❌ WebUI static directory does not exist: {static_path}",

View File

@@ -80,6 +80,7 @@
"startup.webui_auto_build_tool_missing": "❌ 利用可能なフロントエンドビルドツールが見つからず、WebUI を自動ビルドできません",
"startup.webui_cors_configured": "✅ CORS ミドルウェアを設定しました",
"startup.webui_dashboard_source_missing": "❌ WebUI フロントエンドのソースディレクトリが見つかりません: {dashboard_root}",
"startup.webui_dashboard_package_hint": "💡 事前ビルド済みの WebUI 静的リソースパッケージを先にインストールしてください。例: {command}",
"startup.webui_disabled": "WebUI は無効です",
"startup.webui_index_missing": "❌ index.html が見つかりません: {index_path}",
"startup.webui_manual_build_hint": "💡 自動復旧でフロントエンド資産を復旧できなかったため、dashboard ディレクトリで依存関係をインストールして手動ビルドしてください: {command}",
@@ -87,6 +88,7 @@
"startup.webui_robots_route_register_failed": "❌ robots.txt ルートの登録に失敗しました: {error}",
"startup.webui_robots_route_registered": "✅ robots.txt ルートを登録しました",
"startup.webui_server_init_failed": "WebUI サーバーの初期化に失敗しました: {error}",
"startup.webui_static_assets_unavailable": "❌ 利用可能な WebUI 静的アセットが見つかりません(インストール済みの maibot-dashboard パッケージまたはローカルの dashboard/dist",
"startup.webui_static_assets_try_auto_build": "⚠️ WebUI の静的アセットが利用できないため、フロントエンドの自動ビルドを試行します...",
"startup.webui_static_dir_missing": "❌ WebUI の静的ディレクトリが存在しません",
"startup.webui_static_dir_missing_with_path": "❌ WebUI の静的ディレクトリが存在しません: {static_path}",

View File

@@ -80,6 +80,7 @@
"startup.webui_auto_build_tool_missing": "❌ 사용 가능한 프론트엔드 빌드 도구를 찾을 수 없어 WebUI를 자동으로 빌드할 수 없습니다",
"startup.webui_cors_configured": "✅ CORS 미들웨어 설정 완료",
"startup.webui_dashboard_source_missing": "❌ WebUI 프론트엔드 소스 디렉터리를 찾을 수 없습니다: {dashboard_root}",
"startup.webui_dashboard_package_hint": "💡 미리 빌드된 WebUI 정적 리소스 패키지를 먼저 설치하세요. 예: {command}",
"startup.webui_disabled": "WebUI가 비활성화되었습니다",
"startup.webui_index_missing": "❌ index.html을 찾을 수 없습니다: {index_path}",
"startup.webui_manual_build_hint": "💡 자동 복구로 프론트엔드 리소스를 수정하지 못했습니다. dashboard 디렉터리에서 의존성을 설치하고 수동으로 빌드하세요: {command}",
@@ -87,6 +88,7 @@
"startup.webui_robots_route_register_failed": "❌ robots.txt 라우트 등록 실패: {error}",
"startup.webui_robots_route_registered": "✅ robots.txt 라우트 등록 완료",
"startup.webui_server_init_failed": "WebUI 서버 초기화 실패: {error}",
"startup.webui_static_assets_unavailable": "❌ 사용할 수 있는 WebUI 정적 리소스를 찾을 수 없습니다(설치된 maibot-dashboard 패키지 또는 로컬 dashboard/dist)",
"startup.webui_static_assets_try_auto_build": "⚠️ WebUI 정적 리소스를 사용할 수 없어 프론트엔드 자동 빌드를 시도합니다...",
"startup.webui_static_dir_missing": "❌ WebUI 정적 파일 디렉터리가 존재하지 않습니다",
"startup.webui_static_dir_missing_with_path": "❌ WebUI 정적 파일 디렉터리가 존재하지 않습니다: {static_path}",

View File

@@ -80,6 +80,7 @@
"startup.webui_auto_build_tool_missing": "❌ 未找到可用的前端构建工具,无法自动构建 WebUI",
"startup.webui_cors_configured": "✅ CORS 中间件已配置",
"startup.webui_dashboard_source_missing": "❌ 未找到 WebUI 前端源码目录: {dashboard_root}",
"startup.webui_dashboard_package_hint": "💡 请安装预构建的 WebUI 静态资源包,例如: {command}",
"startup.webui_disabled": "WebUI 已禁用",
"startup.webui_index_missing": "❌ 未找到 index.html: {index_path}",
"startup.webui_manual_build_hint": "💡 自动恢复未能修复前端资源,请在 dashboard 目录安装依赖并手动构建: {command}",
@@ -87,6 +88,7 @@
"startup.webui_robots_route_register_failed": "❌ 注册 robots.txt 路由失败: {error}",
"startup.webui_robots_route_registered": "✅ robots.txt 路由已注册",
"startup.webui_server_init_failed": "初始化 WebUI 服务器失败: {error}",
"startup.webui_static_assets_unavailable": "❌ 未找到可用的 WebUI 静态资源(已安装的 maibot-dashboard 包或本地 dashboard/dist",
"startup.webui_static_assets_try_auto_build": "⚠️ WebUI 静态资源不可用,尝试自动构建前端...",
"startup.webui_static_dir_missing": "❌ WebUI 静态文件目录不存在",
"startup.webui_static_dir_missing_with_path": "❌ WebUI 静态文件目录不存在: {static_path}",

View File

@@ -19,6 +19,7 @@ dependencies = [
"jieba>=0.42.1",
"json-repair>=0.47.6",
"maim-message>=0.6.2",
"maibot-dashboard==1.0.0.dev2026040439",
"maibot-plugin-sdk>=2.3.0",
"mcp",
"msgpack>=1.1.2",

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -10,54 +11,42 @@ def test_ensure_static_path_ready_uses_existing_static_path(tmp_path) -> None:
static_path.mkdir()
(static_path / "index.html").write_text("<html></html>", encoding="utf-8")
with (
patch.object(webui_app, "_resolve_static_path", return_value=static_path),
patch.object(webui_app, "_try_build_dashboard") as build_mock,
):
with patch.object(webui_app, "_resolve_static_path", return_value=static_path):
result = webui_app._ensure_static_path_ready()
assert result == static_path
build_mock.assert_not_called()
def test_ensure_static_path_ready_retries_after_auto_build(tmp_path) -> None:
static_path = tmp_path / "dist"
static_path.mkdir()
(static_path / "index.html").write_text("<html></html>", encoding="utf-8")
with (
patch.object(webui_app, "_resolve_static_path", side_effect=[None, static_path]),
patch.object(
webui_app,
"_try_build_dashboard",
return_value=webui_app.DashboardAutoRecoveryResult(succeeded=True),
) as build_mock,
):
result = webui_app._ensure_static_path_ready()
assert result == static_path
build_mock.assert_called_once_with()
def test_ensure_static_path_ready_logs_manual_hint_when_auto_build_fails() -> None:
def test_ensure_static_path_ready_logs_install_hint_when_static_assets_are_missing() -> None:
with (
patch.object(webui_app, "_resolve_static_path", return_value=None),
patch.object(
webui_app,
"_try_build_dashboard",
return_value=webui_app.DashboardAutoRecoveryResult(
succeeded=False,
manual_recovery_command=webui_app._MANUAL_BUILD_COMMAND,
),
),
patch.object(webui_app.logger, "warning") as warning_mock,
):
result = webui_app._ensure_static_path_ready()
assert result is None
warning_mock.assert_any_call(webui_app.t("startup.webui_auto_recovery_failed"))
warning_mock.assert_any_call(webui_app.t("startup.webui_static_assets_unavailable"))
warning_mock.assert_any_call(
webui_app.t("startup.webui_manual_build_hint", command=webui_app._MANUAL_BUILD_COMMAND)
webui_app.t("startup.webui_dashboard_package_hint", command=webui_app._MANUAL_INSTALL_COMMAND)
)
def test_ensure_static_path_ready_logs_index_error_when_static_path_is_invalid(tmp_path) -> None:
static_path = tmp_path / "dist"
static_path.mkdir()
with (
patch.object(webui_app, "_resolve_static_path", return_value=static_path),
patch.object(webui_app.logger, "warning") as warning_mock,
):
result = webui_app._ensure_static_path_ready()
assert result is None
warning_mock.assert_any_call(
webui_app.t("startup.webui_index_missing", index_path=static_path / "index.html")
)
warning_mock.assert_any_call(
webui_app.t("startup.webui_dashboard_package_hint", command=webui_app._MANUAL_INSTALL_COMMAND)
)
@@ -73,43 +62,21 @@ def test_setup_static_files_does_not_duplicate_warning_when_static_path_is_unava
warning_mock.assert_not_called()
def test_get_dashboard_build_command_defaults_to_npm(tmp_path) -> None:
(tmp_path / "package.json").write_text("{}", encoding="utf-8")
def test_resolve_static_path_prefers_installed_dashboard_package(monkeypatch, tmp_path) -> None:
package_dist = tmp_path / "site-packages" / "maibot_dashboard" / "dist"
package_dist.mkdir(parents=True)
with patch.object(
webui_app.shutil,
"which",
side_effect=lambda tool_name: "/usr/bin/npm" if tool_name == "npm" else None,
):
command = webui_app._get_dashboard_build_command(tmp_path)
class _DashboardModule:
@staticmethod
def get_dist_path() -> Path:
return package_dist
assert command == ["npm", "run", "build"]
monkeypatch.setattr(webui_app, "_get_project_root", lambda: tmp_path)
with patch.object(webui_app, "import_module", return_value=_DashboardModule()):
resolved_path = webui_app._resolve_static_path()
def test_try_build_dashboard_installs_missing_dependencies_before_build(monkeypatch, tmp_path) -> None:
(tmp_path / "package.json").write_text("{}", encoding="utf-8")
run_results = [
webui_app.CompletedProcess(args=["npm", "install", "--no-package-lock"], returncode=0, stdout="", stderr=""),
webui_app.CompletedProcess(args=["npm", "run", "build"], returncode=0, stdout="", stderr=""),
]
monkeypatch.setattr(webui_app, "_get_dashboard_root", lambda: tmp_path)
monkeypatch.setattr(webui_app, "_should_auto_install_dashboard_dependencies", lambda dashboard_root: True)
with (
patch.object(webui_app, "_get_dashboard_build_command", return_value=["npm", "run", "build"]),
patch.object(webui_app, "run", side_effect=run_results) as run_mock,
):
result = webui_app._try_build_dashboard()
assert result.succeeded is True
assert run_mock.call_count == 2
install_call = run_mock.call_args_list[0]
build_call = run_mock.call_args_list[1]
assert install_call.args[0] == ["npm", "install", "--no-package-lock"]
assert install_call.kwargs["cwd"] == tmp_path
assert build_call.args[0] == ["npm", "run", "build"]
assert build_call.kwargs["cwd"] == tmp_path
assert resolved_path == package_dist
def test_resolve_static_path_uses_dashboard_dist(monkeypatch, tmp_path) -> None:

View File

@@ -1,12 +1,10 @@
"""FastAPI 应用工厂 - 创建和配置 WebUI 应用实例"""
import mimetypes
import shutil
from dataclasses import dataclass
from importlib import import_module
from pathlib import Path
from subprocess import CompletedProcess, TimeoutExpired, run
from typing import Any, Dict, List, Tuple
from typing import Any, Dict, Tuple
import mimetypes
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
@@ -17,28 +15,8 @@ from src.common.logger import get_logger
logger = get_logger("webui.app")
_DASHBOARD_BUILD_TIMEOUT_SECONDS = 300
_DASHBOARD_INSTALL_TIMEOUT_SECONDS = 600
_MANUAL_BUILD_COMMAND = "cd dashboard && npm install && npm run build"
_DASHBOARD_BUILD_COMMANDS = {
"bun": ["bun", "run", "build"],
"npm": ["npm", "run", "build"],
"pnpm": ["pnpm", "build"],
"yarn": ["yarn", "build"],
}
_DASHBOARD_INSTALL_COMMANDS = {
"bun": ["bun", "install"],
"npm": ["npm", "install", "--no-package-lock"],
"pnpm": ["pnpm", "install"],
"yarn": ["yarn", "install"],
}
@dataclass(frozen=True)
class DashboardAutoRecoveryResult:
succeeded: bool
manual_recovery_command: str | None = None
_DASHBOARD_PACKAGE_NAME = "maibot-dashboard"
_MANUAL_INSTALL_COMMAND = f"pip install {_DASHBOARD_PACKAGE_NAME}"
def _resolve_safe_static_file_path(static_path: Path, full_path: str) -> Path | None:
@@ -58,15 +36,6 @@ def _get_project_root() -> Path:
return Path(__file__).resolve().parents[2]
def _get_dashboard_root() -> Path:
return _get_project_root() / "dashboard"
def _format_dashboard_shell_commands(*commands: List[str]) -> str:
formatted_commands = " && ".join(" ".join(command) for command in commands)
return f"cd dashboard && {formatted_commands}"
def _validate_static_path(static_path: Path | None) -> Tuple[str, Dict[str, Any]] | None:
if static_path is None:
return "startup.webui_static_dir_missing", {}
@@ -81,185 +50,16 @@ def _validate_static_path(static_path: Path | None) -> Tuple[str, Dict[str, Any]
return None
def _summarize_command_output(command_result: CompletedProcess[str] | TimeoutExpired) -> str:
output_chunks: List[str] = []
stdout = command_result.stdout
stderr = command_result.stderr
if isinstance(stdout, str) and stdout.strip():
output_chunks.append(stdout.strip())
if isinstance(stderr, str) and stderr.strip():
output_chunks.append(stderr.strip())
if not output_chunks:
return ""
combined_output = "\n".join(output_chunks)
max_output_length = 2000
if len(combined_output) <= max_output_length:
return combined_output
return combined_output[-max_output_length:]
def _get_preferred_dashboard_package_manager(dashboard_root: Path) -> str:
if (dashboard_root / "pnpm-lock.yaml").exists():
return "pnpm"
if (dashboard_root / "yarn.lock").exists():
return "yarn"
if (dashboard_root / "bun.lock").exists() or (dashboard_root / "bun.lockb").exists():
return "bun"
return "npm"
def _get_dashboard_build_command(dashboard_root: Path) -> List[str] | None:
if not (dashboard_root / "package.json").exists():
return None
preferred_package_manager = _get_preferred_dashboard_package_manager(dashboard_root)
package_managers = [
preferred_package_manager,
*[
package_manager
for package_manager in _DASHBOARD_BUILD_COMMANDS
if package_manager != preferred_package_manager
],
]
for package_manager in package_managers:
if shutil.which(package_manager):
return _DASHBOARD_BUILD_COMMANDS[package_manager]
return None
def _get_dashboard_manual_recovery_command(dashboard_root: Path, build_command: List[str] | None = None) -> str:
package_manager = (
build_command[0] if build_command is not None else _get_preferred_dashboard_package_manager(dashboard_root)
)
install_command = _DASHBOARD_INSTALL_COMMANDS.get(package_manager)
selected_build_command = _DASHBOARD_BUILD_COMMANDS.get(package_manager)
if install_command is None or selected_build_command is None:
return _MANUAL_BUILD_COMMAND
return _format_dashboard_shell_commands(install_command, selected_build_command)
def _should_auto_install_dashboard_dependencies(dashboard_root: Path) -> bool:
return not (dashboard_root / "node_modules").exists()
def _try_build_dashboard() -> DashboardAutoRecoveryResult:
dashboard_root = _get_dashboard_root()
if not dashboard_root.exists():
logger.warning(t("startup.webui_dashboard_source_missing", dashboard_root=dashboard_root))
return DashboardAutoRecoveryResult(succeeded=False)
build_command = _get_dashboard_build_command(dashboard_root)
if build_command is None:
logger.warning(t("startup.webui_auto_build_tool_missing"))
manual_recovery_command = _get_dashboard_manual_recovery_command(dashboard_root)
return DashboardAutoRecoveryResult(succeeded=False, manual_recovery_command=manual_recovery_command)
manual_recovery_command = _get_dashboard_manual_recovery_command(dashboard_root, build_command)
if _should_auto_install_dashboard_dependencies(dashboard_root):
install_command = _DASHBOARD_INSTALL_COMMANDS[build_command[0]]
logger.info(t("startup.webui_auto_install_started", command=" ".join(install_command)))
try:
install_result = run(
install_command,
capture_output=True,
check=False,
cwd=dashboard_root,
text=True,
timeout=_DASHBOARD_INSTALL_TIMEOUT_SECONDS,
)
except TimeoutExpired as exc:
logger.warning(
t("startup.webui_auto_install_timeout", timeout_seconds=_DASHBOARD_INSTALL_TIMEOUT_SECONDS),
)
install_output = _summarize_command_output(exc)
if install_output:
logger.warning(t("startup.webui_auto_install_failed_output", output=install_output))
return DashboardAutoRecoveryResult(succeeded=False, manual_recovery_command=manual_recovery_command)
except OSError as exc:
logger.warning(t("startup.webui_auto_install_exec_failed", error=exc))
return DashboardAutoRecoveryResult(succeeded=False, manual_recovery_command=manual_recovery_command)
if install_result.returncode != 0:
logger.warning(t("startup.webui_auto_install_failed", return_code=install_result.returncode))
install_output = _summarize_command_output(install_result)
if install_output:
logger.warning(t("startup.webui_auto_install_failed_output", output=install_output))
return DashboardAutoRecoveryResult(succeeded=False, manual_recovery_command=manual_recovery_command)
logger.info(t("startup.webui_auto_install_succeeded"))
logger.info(t("startup.webui_auto_build_started", command=" ".join(build_command)))
try:
build_result = run(
build_command,
capture_output=True,
check=False,
cwd=dashboard_root,
text=True,
timeout=_DASHBOARD_BUILD_TIMEOUT_SECONDS,
)
except TimeoutExpired as exc:
logger.warning(
t("startup.webui_auto_build_timeout", timeout_seconds=_DASHBOARD_BUILD_TIMEOUT_SECONDS),
)
build_output = _summarize_command_output(exc)
if build_output:
logger.warning(t("startup.webui_auto_build_failed_output", output=build_output))
return DashboardAutoRecoveryResult(succeeded=False, manual_recovery_command=manual_recovery_command)
except OSError as exc:
logger.warning(t("startup.webui_auto_build_exec_failed", error=exc))
return DashboardAutoRecoveryResult(succeeded=False, manual_recovery_command=manual_recovery_command)
if build_result.returncode != 0:
logger.warning(t("startup.webui_auto_build_failed", return_code=build_result.returncode))
build_output = _summarize_command_output(build_result)
if build_output:
logger.warning(t("startup.webui_auto_build_failed_output", output=build_output))
return DashboardAutoRecoveryResult(succeeded=False, manual_recovery_command=manual_recovery_command)
logger.info(t("startup.webui_auto_build_succeeded"))
return DashboardAutoRecoveryResult(succeeded=True, manual_recovery_command=manual_recovery_command)
def _ensure_static_path_ready() -> Path | None:
static_path = _resolve_static_path()
validation_error = _validate_static_path(static_path)
if validation_error is None:
return static_path
logger.info(t("startup.webui_static_assets_try_auto_build"))
auto_recovery_result = _try_build_dashboard()
if auto_recovery_result.succeeded:
static_path = _resolve_static_path()
validation_error = _validate_static_path(static_path)
if validation_error is None:
return static_path
logger.warning(t("startup.webui_auto_build_artifacts_invalid"))
error_key, error_kwargs = validation_error
logger.warning(t(error_key, **error_kwargs))
logger.warning(
t(
"startup.webui_manual_build_hint",
command=auto_recovery_result.manual_recovery_command or _MANUAL_BUILD_COMMAND,
)
)
return None
if auto_recovery_result.manual_recovery_command is not None:
logger.warning(t("startup.webui_auto_recovery_failed"))
logger.warning(t("startup.webui_manual_build_hint", command=auto_recovery_result.manual_recovery_command))
logger.warning(t("startup.webui_static_assets_unavailable"))
error_key, error_kwargs = validation_error
logger.warning(t(error_key, **error_kwargs))
logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND))
return None
@@ -371,12 +171,12 @@ def _setup_static_files(app: FastAPI):
if not static_path.exists():
logger.warning(t("startup.webui_static_dir_missing_with_path", static_path=static_path))
logger.warning(t("startup.webui_manual_build_hint", command=_MANUAL_BUILD_COMMAND))
logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND))
return
if not (static_path / "index.html").exists():
logger.warning(t("startup.webui_index_missing", index_path=static_path / "index.html"))
logger.warning(t("startup.webui_manual_build_hint", command=_MANUAL_BUILD_COMMAND))
logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND))
return
@app.get("/{full_path:path}", include_in_schema=False)
@@ -415,6 +215,7 @@ def _resolve_static_path() -> Path | None:
except Exception:
pass
# 开发环境允许复用仓库里的现成 dist但不再在用户机器上触发任何前端自愈构建。
base_dir = _get_project_root()
static_path = base_dir / "dashboard" / "dist"
if static_path.exists():