Compare commits

..

4 Commits

Author SHA1 Message Date
Soulter
a6ef5131cb fix: fall back to stale WebUI when repair fails 2026-06-19 23:42:33 +08:00
Soulter
e198122e4d fix: remove stale WebUI dist robustly 2026-06-19 22:42:14 +08:00
Soulter
6df2f199c9 fix: import dashboard version before bundled fallback 2026-06-19 22:32:42 +08:00
Soulter
edeb6ad589 fix: keep WebUI assets in sync with core version 2026-06-19 22:17:45 +08:00
14 changed files with 115 additions and 249 deletions

View File

@@ -1,3 +1,3 @@
import logging
from .core.log import LogManager
logger = logging.getLogger("astrbot")
logger = LogManager.GetLogger(log_name="astrbot")

View File

@@ -1,32 +1,3 @@
import re
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from pathlib import Path
from astrbot.core.config.default import VERSION
try:
import tomllib
except ModuleNotFoundError:
tomllib = None
try:
__version__ = package_version("astrbot")
except PackageNotFoundError:
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
try:
if tomllib is None:
match = re.search(
r"(?m)^version\s*=\s*[\"']([^\"']+)[\"']",
pyproject_path.read_text(encoding="utf-8"),
)
__version__ = match.group(1) if match else "0.0.0"
else:
with pyproject_path.open("rb") as f:
__version__ = tomllib.load(f)["project"]["version"]
except (FileNotFoundError, IndexError, KeyError, TypeError, ValueError):
__version__ = "0.0.0"
match = re.match(r"^(\d+(?:\.\d+)*)(a|b|rc)(\d+)$", __version__)
if match:
release, prerelease, number = match.groups()
prerelease = {"a": "alpha", "b": "beta", "rc": "rc"}[prerelease]
__version__ = f"{release}-{prerelease}.{number}"
__version__ = VERSION

View File

@@ -1,11 +1,16 @@
import json
import os
import zoneinfo
from collections.abc import Callable
from typing import Any
import click
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
hash_md5_dashboard_password,
validate_dashboard_password,
)
from ..utils import check_astrbot_root, get_astrbot_root
@@ -39,8 +44,6 @@ def _validate_dashboard_username(value: str) -> str:
def _validate_dashboard_password(value: str) -> str:
"""Validate Dashboard password"""
from astrbot.core.utils.auth_password import validate_dashboard_password
try:
validate_dashboard_password(value)
except ValueError as e:
@@ -86,7 +89,6 @@ def _load_config() -> dict[str, Any]:
raise click.ClickException(
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
os.environ["ASTRBOT_ROOT"] = str(root)
config_path = root / "data" / "cmd_config.json"
if not config_path.exists():
@@ -105,8 +107,7 @@ def _load_config() -> dict[str, Any]:
def _save_config(config: dict[str, Any]) -> None:
"""Save config file"""
root = get_astrbot_root()
config_path = root / "data" / "cmd_config.json"
config_path = get_astrbot_root() / "data" / "cmd_config.json"
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
@@ -138,11 +139,6 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None:
"""Set dashboard password hashes and clear password migration flags."""
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
hash_md5_dashboard_password,
)
_set_nested_item(
config,
"dashboard.pbkdf2_password",

View File

@@ -21,16 +21,17 @@ def _initialize_config_from_env(astrbot_root: Path) -> None:
async def initialize_astrbot(astrbot_root: Path) -> None:
"""Execute AstrBot initialization logic.
Args:
astrbot_root: Runtime root directory to initialize.
"""
"""Execute AstrBot initialization logic"""
dot_astrbot = astrbot_root / ".astrbot"
if not dot_astrbot.exists():
dot_astrbot.touch()
click.echo(f"Created {dot_astrbot}")
if click.confirm(
f"Install AstrBot to this directory? {astrbot_root}",
default=True,
abort=True,
):
dot_astrbot.touch()
click.echo(f"Created {dot_astrbot}")
paths = {
"data": astrbot_root / "data",
@@ -40,9 +41,8 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
}
for name, path in paths.items():
path_exists = path.exists()
path.mkdir(parents=True, exist_ok=True)
click.echo(f"{'Directory exists' if path_exists else 'Created'}: {path}")
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
_initialize_config_from_env(astrbot_root)
@@ -53,25 +53,7 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
def init() -> None:
"""Initialize AstrBot"""
click.echo("Initializing AstrBot...")
if os.environ.get("ASTRBOT_ROOT"):
astrbot_root = get_astrbot_root()
click.echo(f"Using ASTRBOT_ROOT: {astrbot_root}")
else:
user_root = (Path.home() / ".astrbot").resolve()
current_root = Path.cwd().resolve()
click.echo("Choose AstrBot runtime directory:")
click.echo(f"1. {user_root} (recommended)")
click.echo(f"2. Current directory: {current_root}")
choice = click.prompt(
"Select",
type=click.Choice(["1", "2"]),
default="1",
show_choices=False,
)
astrbot_root = user_root if choice == "1" else current_root
astrbot_root.mkdir(parents=True, exist_ok=True)
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
astrbot_root = get_astrbot_root()
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
@@ -83,8 +65,6 @@ def init() -> None:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running"
)
except click.Abort:
raise
except Exception as e:
raise click.ClickException(f"Initialization failed: {e!s}")

View File

@@ -1,4 +1,3 @@
import os
from pathlib import Path
import click
@@ -8,14 +7,7 @@ _BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist"
def check_astrbot_root(path: str | Path) -> bool:
"""Check whether a path is an AstrBot root directory.
Args:
path: Directory path to inspect.
Returns:
Whether the directory contains the AstrBot root marker.
"""
"""Check if the path is an AstrBot root directory"""
if not isinstance(path, Path):
path = Path(path)
if not path.exists() or not path.is_dir():
@@ -26,24 +18,8 @@ def check_astrbot_root(path: str | Path) -> bool:
def get_astrbot_root() -> Path:
"""Get the AstrBot root directory path.
Returns:
The explicit root, current local root, default user root, or current
directory when no initialized root exists.
"""
if root := os.environ.get("ASTRBOT_ROOT"):
return Path(root).expanduser().resolve()
current_root = Path.cwd().resolve()
if check_astrbot_root(current_root):
return current_root
user_root = (Path.home() / ".astrbot").resolve()
if check_astrbot_root(user_root):
return user_root
return current_root
"""Get the AstrBot root directory path"""
return Path.cwd()
async def check_dashboard(astrbot_root: Path) -> None:

View File

@@ -278,11 +278,10 @@ async def _apply_kb(
)
if not kb_result:
return
req.extra_user_content_parts.append(
TextPart(
text=f"[Related Knowledge Base Results]:\n{kb_result}",
).mark_as_temp()
)
if req.system_prompt is not None:
req.system_prompt += (
f"\n\n[Related Knowledge Base Results]:\n{kb_result}"
)
except Exception as exc: # noqa: BLE001
logger.error("Error occurred while retrieving knowledge base: %s", exc)
else:

View File

@@ -203,10 +203,19 @@ class AstrBotDashboard:
) or is_dashboard_dist_compatible(bundled_dist, VERSION):
self.data_path = str(bundled_dist)
logger.info("Using bundled dashboard dist: %s", self.data_path)
elif (
os.path.exists(user_dist) and (Path(user_dist) / "index.html").is_file()
):
logger.warning(
"Using existing data/dist as a fallback even though WebUI version mismatches core: %s, expected v%s. "
"Some dashboard features may not work until the matching WebUI is available.",
user_version,
VERSION,
)
self.data_path = os.path.abspath(user_dist)
elif os.path.exists(user_dist):
logger.warning(
"Ignoring data/dist because WebUI version mismatches core: %s, expected v%s.",
user_version,
"Ignoring data/dist because WebUI files are incomplete for core v%s.",
VERSION,
)
self.data_path = None

View File

@@ -48,55 +48,6 @@ function attachAxiosHeaders(config: InternalAxiosRequestConfig) {
}
function normalizeAxiosError(error: AxiosError) {
if (error.response?.status === 401) {
let requestPath = '';
try {
const url = error.config?.url || '';
const baseURL = error.config?.baseURL;
const resolvedUrl =
url && baseURL && !/^([a-z][a-z\d+\-.]*:)?\/\//i.test(url)
? `${baseURL.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`
: url;
const requestUrl = new URL(resolvedUrl || '/', window.location.origin);
if (requestUrl.origin === window.location.origin) {
requestPath = requestUrl.pathname;
}
} catch {
requestPath = '';
}
const isAuthChallenge =
[
'/api/auth/login',
'/api/auth/setup',
'/api/auth/setup-status',
'/api/v1/auth/login',
'/api/v1/auth/setup',
'/api/v1/auth/setup-status',
].includes(requestPath) ||
Boolean(
(
error.response.data as
| { data?: { totp_required?: boolean } }
| undefined
)?.data?.totp_required,
);
if (requestPath.startsWith('/api/') && !isAuthChallenge) {
[
'user',
'token',
'change_pwd_hint',
'md5_pwd_hint',
'password_upgrade_required',
].forEach((key) => localStorage.removeItem(key));
if (!window.location.hash.startsWith('#/auth/login')) {
window.location.hash = '/auth/login';
}
}
}
if (error.response?.status === 429) {
const data = error.response.data as { message?: string } | undefined;
if (data?.message) {

View File

@@ -160,6 +160,14 @@ async def check_dashboard_files(webui_dir: str | None = None):
)
except Exception as e:
logger.critical(f"下载管理面板文件失败: {e}")
if (data_dist_path / "index.html").is_file():
logger.warning(
"Falling back to existing data/dist WebUI %s even though core expects v%s. "
"Some dashboard features may not work until the matching WebUI is available.",
v or "unknown",
VERSION,
)
return str(data_dist_path)
return None
logger.info("管理面板下载完成。")
return str(data_dist_path)

View File

@@ -1,11 +1,6 @@
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
from click.testing import CliRunner
from astrbot.cli.commands import cmd_init
from astrbot.core.utils.auth_password import verify_dashboard_password
@@ -19,7 +14,6 @@ async def test_init_without_initial_password_env_does_not_create_config(
async def fake_check_dashboard(_data_path):
return None
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
monkeypatch.delenv(cmd_init.DASHBOARD_INITIAL_PASSWORD_ENV, raising=False)
monkeypatch.setattr(cmd_init, "check_dashboard", fake_check_dashboard)
(tmp_path / ".astrbot").touch()
@@ -38,7 +32,6 @@ async def test_init_uses_initial_password_env_to_create_config(
return None
initial_password = "AstrBotInitialPassword123"
monkeypatch.setenv("ASTRBOT_ROOT", str(tmp_path))
monkeypatch.setenv(cmd_init.DASHBOARD_INITIAL_PASSWORD_ENV, initial_password)
monkeypatch.setattr(cmd_init, "check_dashboard", fake_check_dashboard)
(tmp_path / ".astrbot").touch()
@@ -59,71 +52,3 @@ async def test_init_uses_initial_password_env_to_create_config(
)
assert dashboard_config["password_change_required"] is True
assert dashboard_config["password_storage_upgraded"] is True
def test_cli_main_import_does_not_create_cwd_data(tmp_path):
repo_root = Path(__file__).resolve().parents[1]
env = os.environ.copy()
env.pop("ASTRBOT_ROOT", None)
env["HOME"] = str(tmp_path / "home")
env["PYTHONPATH"] = (
str(repo_root)
if not env.get("PYTHONPATH")
else f"{repo_root}{os.pathsep}{env['PYTHONPATH']}"
)
result = subprocess.run(
[sys.executable, "-c", "import astrbot.cli.__main__"],
cwd=tmp_path,
env=env,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert not (tmp_path / "data").exists()
def test_init_defaults_to_user_runtime(monkeypatch, tmp_path):
async def fake_check_dashboard(_data_path):
return None
home = tmp_path / "home"
workdir = tmp_path / "workdir"
home.mkdir()
workdir.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
monkeypatch.chdir(workdir)
monkeypatch.setattr(cmd_init, "check_dashboard", fake_check_dashboard)
result = CliRunner().invoke(cmd_init.init, input="\n", env={"ASTRBOT_ROOT": ""})
assert result.exit_code == 0, result.output
assert (home / ".astrbot" / ".astrbot").exists()
assert (home / ".astrbot" / "data" / "config").is_dir()
assert not (workdir / "data").exists()
def test_init_can_install_to_current_directory(monkeypatch, tmp_path):
async def fake_check_dashboard(_data_path):
return None
home = tmp_path / "home"
workdir = tmp_path / "workdir"
home.mkdir()
workdir.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
monkeypatch.chdir(workdir)
monkeypatch.setattr(cmd_init, "check_dashboard", fake_check_dashboard)
result = CliRunner().invoke(cmd_init.init, input="2\n", env={"ASTRBOT_ROOT": ""})
assert result.exit_code == 0, result.output
assert (workdir / ".astrbot").exists()
assert (workdir / "data" / "config").is_dir()
assert not (home / ".astrbot").exists()

View File

@@ -30,7 +30,6 @@ def _read_config(config_path):
def test_password_command_changes_dashboard_password(monkeypatch, tmp_path):
config_path = _write_config(tmp_path)
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
monkeypatch.chdir(tmp_path)
runner = CliRunner()
@@ -56,7 +55,6 @@ def test_password_command_changes_dashboard_password(monkeypatch, tmp_path):
def test_password_command_can_update_dashboard_username(monkeypatch, tmp_path):
config_path = _write_config(tmp_path)
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
monkeypatch.chdir(tmp_path)
runner = CliRunner()
@@ -73,7 +71,6 @@ def test_password_command_can_update_dashboard_username(monkeypatch, tmp_path):
def test_conf_set_dashboard_password_updates_password_state(monkeypatch, tmp_path):
config_path = _write_config(tmp_path)
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
monkeypatch.chdir(tmp_path)
runner = CliRunner()

View File

@@ -294,7 +294,34 @@ def test_dashboard_uses_bundled_dist_when_data_dist_is_stale(
assert server.data_path == str(bundled_dist)
def test_dashboard_ignores_mismatched_data_dist_without_bundled(
def test_dashboard_falls_back_to_mismatched_data_dist_without_bundled(
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
tmp_path,
):
data_dir = tmp_path / "data"
user_dist = data_dir / "dist"
bundled_dist = tmp_path / "bundled-dist"
(user_dist / "assets").mkdir(parents=True)
(user_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8")
(user_dist / "index.html").write_text("stale", encoding="utf-8")
monkeypatch.setattr(
"astrbot.dashboard.server.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.dashboard.server.get_bundled_dashboard_dist_path",
lambda: bundled_dist,
)
shutdown_event = asyncio.Event()
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
assert server.data_path == str(user_dist)
def test_dashboard_ignores_incomplete_mismatched_data_dist_without_bundled(
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
tmp_path,

View File

@@ -246,6 +246,44 @@ async def test_check_dashboard_files_exists_but_version_mismatch_downloads(tmp_p
assert "WebUI version mismatch" in call_args[0]
@pytest.mark.asyncio
async def test_check_dashboard_files_falls_back_to_stale_dist_when_download_fails(
tmp_path,
):
"""Tests stale dashboard fallback when the matching WebUI cannot be downloaded."""
from main import VERSION
data_dir = tmp_path / "data"
data_dist = data_dir / "dist"
bundled_dist = tmp_path / "bundled-dist"
(data_dist / "assets").mkdir(parents=True)
(data_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8")
(data_dist / "index.html").write_text("stale", encoding="utf-8")
with mock.patch("main.get_astrbot_data_path", return_value=str(data_dir)):
with mock.patch(
"main.get_bundled_dashboard_dist_path",
return_value=bundled_dist,
):
with mock.patch(
"main.download_dashboard",
side_effect=RuntimeError("missing dashboard asset"),
) as mock_download:
with mock.patch("main.logger.warning") as mock_logger_warning:
result = await check_dashboard_files()
assert result == str(data_dist)
mock_download.assert_called_once_with(
version=f"v{VERSION}",
latest=False,
allow_insecure_ssl_fallback=False,
)
assert any(
"Falling back to existing data/dist WebUI" in call.args[0]
for call in mock_logger_warning.call_args_list
)
@pytest.mark.asyncio
async def test_check_dashboard_files_downloads_when_matching_dist_is_incomplete(
tmp_path,

View File

@@ -8,7 +8,6 @@ import pytest
from astrbot.core import astr_main_agent as ama
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.message import Message, dump_messages_with_checkpoints
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.conversation_mgr import Conversation
from astrbot.core.message.components import File, Image, Plain, Reply, Video
@@ -378,18 +377,8 @@ class TestApplyKb:
):
await module._apply_kb(mock_event, req, mock_context, config)
assert req.system_prompt == "System prompt"
assert len(req.extra_user_content_parts) == 1
kb_part = req.extra_user_content_parts[0]
assert kb_part.text == "[Related Knowledge Base Results]:\nKB result"
message = Message.model_validate(await req.assemble_context())
assert isinstance(message.content, list)
assert message.content[0].text == "test question"
assert message.content[1].text == "[Related Knowledge Base Results]:\nKB result"
assert dump_messages_with_checkpoints([message]) == [
{"role": "user", "content": [{"type": "text", "text": "test question"}]}
]
assert "[Related Knowledge Base Results]:" in req.system_prompt
assert "KB result" in req.system_prompt
@pytest.mark.asyncio
async def test_apply_kb_with_agentic_mode(self, mock_event, mock_context):