Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 105 additions & 7 deletions webUI/multimodal_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
class MultimodalRuntimeManager:
"""Install and resolve the GPU, CPU and relay-only Multimodal environments."""

SCHEMA_VERSION = 1
# Schema 1 could be written after merely finding python.exe, allowing a
# half-created ~100 KB venv to be reported as installed. Schema 2 is only
# written after _validate_runtime() succeeds.
SCHEMA_VERSION = 2
ADAPTER_DIR = ROOT_DIR / "NachoBot-Multimodal-Adapter"
RUNTIME_DIR = ADAPTER_DIR / ".runtime"
VALID_PROFILES = ("gpu", "cpu", "relay")
Expand Down Expand Up @@ -106,18 +109,101 @@ def _marker_valid(cls, profile: str) -> bool:
except Exception:
return False

@classmethod
def _local_payload_present(cls, profile: str) -> bool:
"""Cheaply reject empty or damaged GPU/CPU venvs without importing models."""
profile = cls.normalize_profile(profile)
if profile not in {"gpu", "cpu"}:
return False

env_dir = cls.env_dir(profile)
windows_site = env_dir / "Lib" / "site-packages"
posix_lib = env_dir / "lib"

site_packages: list[Path] = []
if windows_site.is_dir():
site_packages.append(windows_site)
if posix_lib.is_dir():
site_packages.extend(posix_lib.glob("python*/site-packages"))

required = ("torch", "transformers", "timm", "sherpa_onnx")
return any(
all((site / package).exists() for package in required)
for site in site_packages
)

@classmethod
def _validate_runtime(cls, profile: str) -> tuple[bool, str]:
"""Import critical dependencies from the selected venv and verify Torch flavor."""
profile = cls.normalize_profile(profile)
python = cls.python_path(profile)
if not python.exists():
return False, f"未找到 Python: {python}"

if profile == "relay":
check = (
"import aiohttp, fastapi, numpy, scipy; "
"print('runtime-ok')"
)
else:
expected_cuda = "True" if profile == "gpu" else "False"
check = (
"import torch, transformers, timm, sherpa_onnx; "
"has_cuda_build = torch.version.cuda is not None; "
f"assert has_cuda_build is {expected_cuda}, "
"f'unexpected torch build: {torch.__version__}, cuda={torch.version.cuda}'; "
"print(f'runtime-ok torch={torch.__version__} cuda={torch.version.cuda}')"
)

env = os.environ.copy()
env["PYTHONNOUSERSITE"] = "1"
try:
import subprocess

result = subprocess.run(
[str(python), "-c", check],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
env=env,
timeout=60,
check=False,
)
except Exception as exc:
return False, str(exc)

output = (result.stdout or "").strip()
if result.returncode != 0:
return False, output or f"验证进程退出码: {result.returncode}"
return True, output or "runtime-ok"

@classmethod
def get_status(cls, profile: str) -> dict[str, Any]:
profile = cls.normalize_profile(profile)
python = cls.python_path(profile)
marker_path = cls._marker_path(profile)
marker_valid = cls._marker_valid(profile)

# .venv predates runtime markers and is historically the CUDA project
# environment. Reuse it when present to avoid forcing a second CUDA
# download. New CPU/relay environments require a completed marker so a
# half-created venv is never reported as installed.
legacy_gpu = profile == "gpu" and python.exists() and not marker_valid
installed = python.exists() and (marker_valid or legacy_gpu)
# Local runtimes must contain the actual model stack even when a valid
# marker exists. This cheaply catches empty/damaged venvs without running
# imports during the launcher's frequent status polling.
local_payload = (
cls._local_payload_present(profile)
if profile in {"gpu", "cpu"}
else False
)
legacy_gpu = (
profile == "gpu"
and python.exists()
and not marker_path.exists()
and local_payload
)
if profile in {"gpu", "cpu"}:
installed = python.exists() and local_payload and (marker_valid or legacy_gpu)
else:
installed = python.exists() and marker_valid
return {
"id": profile,
"label": cls.PROFILE_META[profile]["label"],
Expand Down Expand Up @@ -264,6 +350,18 @@ async def install(
"message": f"环境安装完成但未找到 Python: {python}",
}

valid, validation_message = await asyncio.to_thread(cls._validate_runtime, profile)
if not valid:
return {
"status": "error",
"message": (
f"{cls.PROFILE_META[profile]['label']} 环境依赖验证失败,"
f"未写入安装标记: {validation_message}"
),
}
if callback:
await callback(f"[Runtime] 依赖验证通过: {validation_message}\n")

cls._marker_path(profile).write_text(
json.dumps(
{"schema": cls.SCHEMA_VERSION, "profile": profile},
Expand Down
6 changes: 6 additions & 0 deletions webUI/setup_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -1839,6 +1839,9 @@ async def _run_uv_sync(

env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
# Never allow the WebUI process' own uv environment override to redirect
# an ordinary project sync into an unrelated virtual environment.
env.pop("UV_PROJECT_ENVIRONMENT", None)
env["PYTHONNOUSERSITE"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONUTF8"] = "1"
Expand Down Expand Up @@ -1891,6 +1894,9 @@ async def _run_playwright_install(

env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
# Playwright must use the Core project environment, not an inherited
# UV_PROJECT_ENVIRONMENT belonging to the WebUI or another component.
env.pop("UV_PROJECT_ENVIRONMENT", None)
env["PYTHONNOUSERSITE"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONUTF8"] = "1"
Expand Down
14 changes: 14 additions & 0 deletions webUI/static/css/style-data.css
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,20 @@
color: #e2e8f0;
}

.btn.btn-primary.btn-sm {
background: var(--accent);
color: #fff;
border-color: transparent;
box-shadow: 0 2px 8px rgba(13,148,136,0.25);
}

.btn.btn-primary.btn-sm:hover {
background: var(--accent-dark);
color: #fff;
border-color: transparent;
box-shadow: 0 4px 14px rgba(13,148,136,0.35);
}

.btn-full { width: 100%; }

/* ===================================================================
Expand Down
14 changes: 0 additions & 14 deletions webUI/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -706,20 +706,6 @@ body {
color: var(--warning);
}

.launch-runtime-install.btn-primary {
background: var(--accent);
color: #fff;
border-color: transparent;
box-shadow: 0 2px 8px rgba(13, 148, 136, 0.25);
}

.launch-runtime-install.btn-primary:not(:disabled):hover {
background: var(--accent-dark);
color: #fff;
border-color: transparent;
box-shadow: 0 4px 14px rgba(13, 148, 136, 0.35);
}

.launch-runtime-warning {
margin-top: 12px;
padding: 10px 12px;
Expand Down
9 changes: 6 additions & 3 deletions webUI/static/js/visual-icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@
'💎': 'gem',
'🔗': 'link',
'✅': 'circle-check',
'?': 'circle-check',
'?': 'circle-check',
'?': 'circle-check',
'\u221A': 'circle-check',
'\u2713': 'circle-check',
'\u2714': 'circle-check',
'\u{1F3AE}': 'gamepad-2',
'\u{1F4CB}': 'clipboard',
'\u{1F4CA}': 'chart',
'⚠': 'alert-triangle',
'🔄': 'loader',
'🎉': 'sparkles',
Expand Down
1 change: 1 addition & 0 deletions webUI/static/nacho-ui-icons.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading