diff --git a/.python-version b/.python-version index fdcfcfdfca..e4fba21835 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 \ No newline at end of file +3.12 diff --git a/astrbot/cli/commands/cmd_init.py b/astrbot/cli/commands/cmd_init.py index 6c0c34b99c..4f520a4cfd 100644 --- a/astrbot/cli/commands/cmd_init.py +++ b/astrbot/cli/commands/cmd_init.py @@ -34,8 +34,13 @@ async def initialize_astrbot(astrbot_root: Path) -> None: for name, path in paths.items(): path.mkdir(parents=True, exist_ok=True) click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}") - - await check_dashboard(astrbot_root / "data") + if click.confirm( + "是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)", + default=True, + ): + await check_dashboard(astrbot_root) + else: + click.echo("你可以使用在线面版(v4.14.4+),填写后端地址的方式来控制。") @click.command() diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py index 23665dff3d..cea2e442b2 100644 --- a/astrbot/cli/commands/cmd_run.py +++ b/astrbot/cli/commands/cmd_run.py @@ -15,7 +15,8 @@ async def run_astrbot(astrbot_root: Path) -> None: from astrbot.core import LogBroker, LogManager, db_helper, logger from astrbot.core.initial_loader import InitialLoader - await check_dashboard(astrbot_root / "data") + if os.environ.get("DASHBOARD_ENABLE") == "True": + await check_dashboard(astrbot_root) log_broker = LogBroker() LogManager.set_queue_handler(logger, log_broker) @@ -27,9 +28,17 @@ async def run_astrbot(astrbot_root: Path) -> None: @click.option("--reload", "-r", is_flag=True, help="插件自动重载") -@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str) +@click.option( + "--host", "-H", help="Astrbot Dashboard Host,默认::", required=False, type=str +) +@click.option( + "--port", "-p", help="Astrbot Dashboard端口,默认6185", required=False, type=str +) +@click.option( + "--backend-only", is_flag=True, default=False, help="禁用WEBUI,仅启动后端" +) @click.command() -def run(reload: bool, port: str) -> None: +def run(reload: bool, host: str, port: str, backend_only: bool) -> None: """运行 AstrBot""" try: os.environ["ASTRBOT_CLI"] = "1" @@ -43,8 +52,11 @@ def run(reload: bool, port: str) -> None: os.environ["ASTRBOT_ROOT"] = str(astrbot_root) sys.path.insert(0, str(astrbot_root)) - if port: + if port is not None: os.environ["DASHBOARD_PORT"] = port + if host is not None: + os.environ["DASHBOARD_HOST"] = host + os.environ["DASHBOARD_ENABLE"] = str(not backend_only) if reload: click.echo("启用插件自动重载") diff --git a/astrbot/cli/utils/basic.py b/astrbot/cli/utils/basic.py index 5dbe290065..55322e7fc0 100644 --- a/astrbot/cli/utils/basic.py +++ b/astrbot/cli/utils/basic.py @@ -39,7 +39,7 @@ async def check_dashboard(astrbot_root: Path) -> None: click.echo("正在安装管理面板...") await download_dashboard( path="data/dashboard.zip", - extract_path=str(astrbot_root), + extract_path=str(astrbot_root / "data"), version=f"v{VERSION}", latest=False, ) @@ -54,7 +54,7 @@ async def check_dashboard(astrbot_root: Path) -> None: click.echo(f"管理面板版本: {version}") await download_dashboard( path="data/dashboard.zip", - extract_path=str(astrbot_root), + extract_path=str(astrbot_root / "data"), version=f"v{VERSION}", latest=False, ) @@ -65,8 +65,8 @@ async def check_dashboard(astrbot_root: Path) -> None: click.echo("初始化管理面板目录...") try: await download_dashboard( - path=str(astrbot_root / "dashboard.zip"), - extract_path=str(astrbot_root), + path=str(astrbot_root / "data" / "dashboard.zip"), + extract_path=str(astrbot_root / "data"), version=f"v{VERSION}", latest=False, ) diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 6647db89f0..4f3b6a9afe 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -407,7 +407,7 @@ async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None: abm.message = [Image(file=path, url=path)] elif msgtype == "voice": media_id = msg.get("voice", {}).get("media_id", "") - resp: Response = await asyncio.get_event_loop().run_in_executor( + resp = await asyncio.get_event_loop().run_in_executor( None, self.client.media.download, media_id, diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 0ce3624e81..911224dfe9 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -1,3 +1,4 @@ +import asyncio import base64 import logging import os @@ -7,6 +8,7 @@ import time import uuid import zipfile +from ipaddress import IPv4Address, IPv6Address, ip_address from pathlib import Path import aiohttp @@ -206,18 +208,53 @@ def file_to_base64(file_path: str) -> str: return "base64://" + base64_str -def get_local_ip_addresses(): +def get_local_ip_addresses() -> list[IPv4Address | IPv6Address]: net_interfaces = psutil.net_if_addrs() - network_ips = [] + network_ips: list[IPv4Address | IPv6Address] = [] - for interface, addrs in net_interfaces.items(): + for _, addrs in net_interfaces.items(): for addr in addrs: - if addr.family == socket.AF_INET: # 使用 socket.AF_INET 代替 psutil.AF_INET - network_ips.append(addr.address) + if addr.family == socket.AF_INET: + network_ips.append(ip_address(addr.address)) + elif addr.family == socket.AF_INET6: + # 过滤掉 IPv6 的 link-local 地址(fe80:...) + ip = ip_address(addr.address.split("%")[0]) # 处理带 zone index 的情况 + if not ip.is_link_local: + network_ips.append(ip) return network_ips +async def get_public_ip_address() -> list[IPv4Address | IPv6Address]: + urls = [ + "https://api64.ipify.org", + "https://ident.me", + "https://ifconfig.me", + "https://icanhazip.com", + ] + found_ips: dict[int, IPv4Address | IPv6Address] = {} + + async def fetch(session: aiohttp.ClientSession, url: str): + try: + async with session.get(url, timeout=3) as resp: + if resp.status == 200: + raw_ip = (await resp.text()).strip() + ip = ip_address(raw_ip) + if ip.version not in found_ips: + found_ips[ip.version] = ip + except Exception as e: + # Ignore errors from individual services so that a single failing + # endpoint does not prevent discovering the public IP from others. + logger.debug("Failed to fetch public IP from %s: %s", url, e) + + async with aiohttp.ClientSession() as session: + tasks = [fetch(session, url) for url in urls] + await asyncio.gather(*tasks) + + # 返回找到的所有 IP 对象列表 + return list(found_ips.values()) + + async def get_dashboard_version(): dist_dir = os.path.join(get_astrbot_data_path(), "dist") if os.path.exists(dist_dir): diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index fbbd0c7a08..7e6e79146e 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -9,16 +9,19 @@ from .cron import CronRoute from .file import FileRoute from .knowledge_base import KnowledgeBaseRoute +from .live_chat import LiveChatRoute from .log import LogRoute from .open_api import OpenApiRoute from .persona import PersonaRoute from .platform import PlatformRoute from .plugin import PluginRoute +from .route import Response, RouteContext from .session_management import SessionManagementRoute from .skills import SkillsRoute from .stat import StatRoute from .static_file import StaticFileRoute from .subagent import SubAgentRoute +from .t2i import T2iRoute from .tools import ToolsRoute from .update import UpdateRoute @@ -46,4 +49,8 @@ "ToolsRoute", "SkillsRoute", "UpdateRoute", + "T2iRoute", + "LiveChatRoute", + "Response", + "RouteContext", ] diff --git a/astrbot/dashboard/routes/route.py b/astrbot/dashboard/routes/route.py index 53c6234439..4fdc37971a 100644 --- a/astrbot/dashboard/routes/route.py +++ b/astrbot/dashboard/routes/route.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import asdict, dataclass from quart import Quart @@ -57,3 +57,7 @@ def ok(self, data: dict | list | None = None, message: str | None = None): self.data = data self.message = message return self + + def to_json(self): + # Return a plain dict so callers can safely wrap with jsonify() + return asdict(self) diff --git a/astrbot/dashboard/routes/static_file.py b/astrbot/dashboard/routes/static_file.py index e056b6c5ac..15fec95d1c 100644 --- a/astrbot/dashboard/routes/static_file.py +++ b/astrbot/dashboard/routes/static_file.py @@ -5,6 +5,9 @@ class StaticFileRoute(Route): def __init__(self, context: RouteContext) -> None: super().__init__(context) + if "index" in self.app.view_functions: + return + index_ = [ "/", "/auth/login", diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index a9650cd06b..b9ff47fa45 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -2,9 +2,12 @@ import hashlib import logging import os +import platform import socket +from collections.abc import Callable +from ipaddress import IPv4Address, IPv6Address, ip_address from pathlib import Path -from typing import Protocol, cast +from typing import Protocol import jwt import psutil @@ -13,6 +16,7 @@ from hypercorn.config import Config as HyperConfig from quart import Quart, g, jsonify, request from quart.logging import default_handler +from quart_cors import cors from astrbot.core import logger from astrbot.core.config.default import VERSION @@ -23,13 +27,6 @@ from .routes import * from .routes.api_key import ALL_OPEN_API_SCOPES -from .routes.backup import BackupRoute -from .routes.live_chat import LiveChatRoute -from .routes.platform import PlatformRoute -from .routes.route import Response, RouteContext -from .routes.session_management import SessionManagementRoute -from .routes.subagent import SubAgentRoute -from .routes.t2i import T2iRoute class _AddrWithPort(Protocol): @@ -46,6 +43,16 @@ def _parse_env_bool(value: str | None, default: bool) -> bool: class AstrBotDashboard: + """AstrBot Web Dashboard""" + + ALLOWED_ENDPOINT_PREFIXES = ( + "/api/auth/login", + "/api/file", + "/api/platform/webhook", + "/api/stat/start-time", + "/api/backup/download", + ) + def __init__( self, core_lifecycle: AstrBotCoreLifecycle, @@ -56,67 +63,123 @@ def __init__( self.core_lifecycle = core_lifecycle self.config = core_lifecycle.astrbot_config self.db = db + self.shutdown_event = shutdown_event + + self.enable_webui = self._check_webui_enabled() + + self._init_paths(webui_dir) + self._init_app() + self.context = RouteContext(self.config, self.app) + + self._init_routes(db) + self._init_plugin_route_index() + self._init_jwt_secret() + + # ------------------------------------------------------------------ + # 初始化阶段 + # ------------------------------------------------------------------ - # 参数指定webui目录 + def _check_webui_enabled(self) -> bool: + cfg = self.config.get("dashboard", {}) + _env = os.environ.get("DASHBOARD_ENABLE") + if _env is not None: + return _env.lower() in ("true", "1", "yes") + return cfg.get("enable", True) + + def _init_paths(self, webui_dir: str | None): if webui_dir and os.path.exists(webui_dir): self.data_path = os.path.abspath(webui_dir) else: self.data_path = os.path.abspath( - os.path.join(get_astrbot_data_path(), "dist"), + os.path.join(get_astrbot_data_path(), "dist") ) - self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/") - APP = self.app # noqa - self.app.config["MAX_CONTENT_LENGTH"] = ( - 128 * 1024 * 1024 - ) # 将 Flask 允许的最大上传文件体大小设置为 128 MB - cast(DefaultJSONProvider, self.app.json).sort_keys = False + def _init_app(self): + """初始化 Quart 应用""" + global APP + self.app = Quart( + "AstrBotDashboard", + static_folder=self.data_path, + static_url_path="/", + ) + APP = self.app + self.app.json_provider_class = DefaultJSONProvider + self.app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16MB + + # 配置 CORS + self.app = cors( + self.app, + allow_origin="*", + allow_headers=["Authorization", "Content-Type", "X-API-Key"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + ) + + @self.app.route("/") + async def index(): + if not self.enable_webui: + return "WebUI is disabled." + return await self.app.send_static_file("index.html") + + @self.app.errorhandler(404) + async def not_found(e): + if not self.enable_webui: + return "WebUI is disabled." + if request.path.startswith("/api/"): + return jsonify(Response().error("Not Found").to_json()), 404 + return await self.app.send_static_file("index.html") + + @self.app.before_serving + async def startup(): + pass + + @self.app.after_serving + async def shutdown(): + pass + self.app.before_request(self.auth_middleware) - # token 用于验证请求 logging.getLogger(self.app.name).removeHandler(default_handler) - self.context = RouteContext(self.config, self.app) - self.ur = UpdateRoute( - self.context, - core_lifecycle.astrbot_updator, - core_lifecycle, + + def _init_routes(self, db: BaseDatabase): + UpdateRoute( + self.context, self.core_lifecycle.astrbot_updator, self.core_lifecycle ) - self.sr = StatRoute(self.context, db, core_lifecycle) - self.pr = PluginRoute( - self.context, - core_lifecycle, - core_lifecycle.plugin_manager, + StatRoute(self.context, db, self.core_lifecycle) + PluginRoute( + self.context, self.core_lifecycle, self.core_lifecycle.plugin_manager ) self.command_route = CommandRoute(self.context) - self.cr = ConfigRoute(self.context, core_lifecycle) - self.lr = LogRoute(self.context, core_lifecycle.log_broker) + self.cr = ConfigRoute(self.context, self.core_lifecycle) + self.lr = LogRoute(self.context, self.core_lifecycle.log_broker) self.sfr = StaticFileRoute(self.context) self.ar = AuthRoute(self.context) self.api_key_route = ApiKeyRoute(self.context, db) - self.chat_route = ChatRoute(self.context, db, core_lifecycle) + self.chat_route = ChatRoute(self.context, db, self.core_lifecycle) self.open_api_route = OpenApiRoute( self.context, db, - core_lifecycle, + self.core_lifecycle, self.chat_route, ) self.chatui_project_route = ChatUIProjectRoute(self.context, db) - self.tools_root = ToolsRoute(self.context, core_lifecycle) - self.subagent_route = SubAgentRoute(self.context, core_lifecycle) - self.skills_route = SkillsRoute(self.context, core_lifecycle) - self.conversation_route = ConversationRoute(self.context, db, core_lifecycle) + self.tools_root = ToolsRoute(self.context, self.core_lifecycle) + self.subagent_route = SubAgentRoute(self.context, self.core_lifecycle) + self.skills_route = SkillsRoute(self.context, self.core_lifecycle) + self.conversation_route = ConversationRoute( + self.context, db, self.core_lifecycle + ) self.file_route = FileRoute(self.context) self.session_management_route = SessionManagementRoute( self.context, db, - core_lifecycle, + self.core_lifecycle, ) - self.persona_route = PersonaRoute(self.context, db, core_lifecycle) - self.cron_route = CronRoute(self.context, core_lifecycle) - self.t2i_route = T2iRoute(self.context, core_lifecycle) - self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle) - self.platform_route = PlatformRoute(self.context, core_lifecycle) - self.backup_route = BackupRoute(self.context, db, core_lifecycle) - self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle) + self.persona_route = PersonaRoute(self.context, db, self.core_lifecycle) + self.cron_route = CronRoute(self.context, self.core_lifecycle) + self.t2i_route = T2iRoute(self.context, self.core_lifecycle) + self.kb_route = KnowledgeBaseRoute(self.context, self.core_lifecycle) + self.platform_route = PlatformRoute(self.context, self.core_lifecycle) + self.backup_route = BackupRoute(self.context, db, self.core_lifecycle) + self.live_chat_route = LiveChatRoute(self.context, db, self.core_lifecycle) self.app.add_url_rule( "/api/plug/", @@ -124,20 +187,35 @@ def __init__( methods=["GET", "POST"], ) - self.shutdown_event = shutdown_event - - self._init_jwt_secret() + def _init_plugin_route_index(self): + """将插件路由索引,避免 O(n) 查找""" + self._plugin_route_map: dict[tuple[str, str], Callable] = {} + + for ( + route, + handler, + methods, + _, + ) in self.core_lifecycle.star_context.registered_web_apis: + for method in methods: + self._plugin_route_map[(route, method)] = handler + + def _init_jwt_secret(self): + dashboard_cfg = self.config.setdefault("dashboard", {}) + if not dashboard_cfg.get("jwt_secret"): + dashboard_cfg["jwt_secret"] = os.urandom(32).hex() + self.config.save_config() + logger.info("Initialized random JWT secret for dashboard.") + self._jwt_secret = dashboard_cfg["jwt_secret"] - async def srv_plug_route(self, subpath, *args, **kwargs): - """插件路由""" - registered_web_apis = self.core_lifecycle.star_context.registered_web_apis - for api in registered_web_apis: - route, view_handler, methods, _ = api - if route == f"/{subpath}" and request.method in methods: - return await view_handler(*args, **kwargs) - return jsonify(Response().error("未找到该路由").__dict__) + # ------------------------------------------------------------------ + # Middleware中间件 + # ------------------------------------------------------------------ async def auth_middleware(self): + # 放行CORS预检请求 + if request.method == "OPTIONS": + return None if not request.path.startswith("/api"): return None if request.path.startswith("/api/v1"): @@ -174,33 +252,46 @@ async def auth_middleware(self): await self.db.touch_api_key(api_key.key_id) return None - allowed_endpoints = [ - "/api/auth/login", - "/api/file", - "/api/platform/webhook", - "/api/stat/start-time", - "/api/backup/download", # 备份下载使用 URL 参数传递 token - ] - if any(request.path.startswith(prefix) for prefix in allowed_endpoints): + if any(request.path.startswith(p) for p in self.ALLOWED_ENDPOINT_PREFIXES): return None - # 声明 JWT + token = request.headers.get("Authorization") if not token: - r = jsonify(Response().error("未授权").__dict__) - r.status_code = 401 - return r - token = token.removeprefix("Bearer ") + return self._unauthorized("未授权") + try: - payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"]) + payload = jwt.decode( + token.removeprefix("Bearer "), + self._jwt_secret, + algorithms=["HS256"], + options={"require": ["username"]}, + ) g.username = payload["username"] except jwt.ExpiredSignatureError: - r = jsonify(Response().error("Token 过期").__dict__) - r.status_code = 401 - return r - except jwt.InvalidTokenError: - r = jsonify(Response().error("Token 无效").__dict__) - r.status_code = 401 - return r + return self._unauthorized("Token 过期") + except jwt.PyJWTError: + return self._unauthorized("Token 无效") + + @staticmethod + def _unauthorized(msg: str): + r = jsonify(Response().error(msg).to_json()) + r.status_code = 401 + return r + + # ------------------------------------------------------------------ + # 插件路由 + # ------------------------------------------------------------------ + + async def srv_plug_route(self, subpath: str, *args, **kwargs): + handler = self._plugin_route_map.get((f"/{subpath}", request.method)) + if not handler: + return jsonify(Response().error("未找到该路由").to_json()) + + try: + return await handler(*args, **kwargs) + except Exception: + logger.exception("插件 Web API 执行异常") + return jsonify(Response().error("插件 Web API 执行异常").to_json()) @staticmethod def _extract_raw_api_key() -> str | None: @@ -230,126 +321,102 @@ def _get_required_open_api_scope(path: str) -> str | None: } return scope_map.get(path) - def check_port_in_use(self, port: int) -> bool: + def check_port_in_use(self, host: str, port: int) -> bool: """跨平台检测端口是否被占用""" + family = socket.AF_INET6 if ":" in host else socket.AF_INET try: - # 创建 IPv4 TCP Socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - # 设置超时时间 - sock.settimeout(2) - result = sock.connect_ex(("127.0.0.1", port)) - sock.close() - # result 为 0 表示端口被占用 - return result == 0 - except Exception as e: - logger.warning(f"检查端口 {port} 时发生错误: {e!s}") - # 如果出现异常,保守起见认为端口可能被占用 + with socket.socket(family, socket.SOCK_STREAM) as s: + # 设置 SO_REUSEADDR 避免 TIME_WAIT 导致误判 + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if family == socket.AF_INET6: + # 对于 IPv6,通常需要设置 IPV6_V6ONLY=0 以便同时监听 IPv4 (如果系统支持双栈) + # 但这里主要是检测占用,只要能 bind 就说明没被占 + pass + s.bind((host, port)) + return False + except OSError: return True def get_process_using_port(self, port: int) -> str: - """获取占用端口的进程详细信息""" + """获取占用端口的进程信息""" try: - for conn in psutil.net_connections(kind="inet"): - if cast(_AddrWithPort, conn.laddr).port == port: - try: - process = psutil.Process(conn.pid) - # 获取详细信息 - proc_info = [ - f"进程名: {process.name()}", - f"PID: {process.pid}", - f"执行路径: {process.exe()}", - f"工作目录: {process.cwd()}", - f"启动命令: {' '.join(process.cmdline())}", - ] - return "\n ".join(proc_info) - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - return f"无法获取进程详细信息(可能需要管理员权限): {e!s}" - return "未找到占用进程" + for proc in psutil.process_iter(["pid", "name"]): + try: + connections = proc.net_connections() + for conn in connections: + if conn.laddr.port == port: + return f"PID: {proc.info['pid']}, Name: {proc.info['name']}" # type: ignore + except ( + psutil.NoSuchProcess, + psutil.AccessDenied, + psutil.ZombieProcess, + ): + pass except Exception as e: return f"获取进程信息失败: {e!s}" + return "未知进程" - def _init_jwt_secret(self) -> None: - if not self.config.get("dashboard", {}).get("jwt_secret", None): - # 如果没有设置 JWT 密钥,则生成一个新的密钥 - jwt_secret = os.urandom(32).hex() - self.config["dashboard"]["jwt_secret"] = jwt_secret - self.config.save_config() - logger.info("Initialized random JWT secret for dashboard.") - self._jwt_secret = self.config["dashboard"]["jwt_secret"] - - def run(self): - ip_addr = [] - dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {}) - port = ( - os.environ.get("DASHBOARD_PORT") - or os.environ.get("ASTRBOT_DASHBOARD_PORT") - or dashboard_config.get("port", 6185) + # ------------------------------------------------------------------ + # 启动与运行 + # ------------------------------------------------------------------ + + async def run(self) -> None: + """Run dashboard server (blocking)""" + if not self.enable_webui: + logger.warning( + "WebUI 已禁用 (dashboard.enable=false or DASHBOARD_ENABLE=false)" + ) + + dashboard_config = self.config.get("dashboard", {}) + host = os.environ.get("DASHBOARD_HOST") or dashboard_config.get( + "host", "0.0.0.0" ) - host = ( - os.environ.get("DASHBOARD_HOST") - or os.environ.get("ASTRBOT_DASHBOARD_HOST") - or dashboard_config.get("host", "0.0.0.0") + port = int( + os.environ.get("DASHBOARD_PORT") or dashboard_config.get("port", 6185) ) - enable = dashboard_config.get("enable", True) ssl_config = dashboard_config.get("ssl", {}) - if not isinstance(ssl_config, dict): - ssl_config = {} ssl_enable = _parse_env_bool( - os.environ.get("DASHBOARD_SSL_ENABLE") - or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"), - bool(ssl_config.get("enable", False)), + os.environ.get("DASHBOARD_SSL_ENABLE"), + ssl_config.get("enable", False), ) - scheme = "https" if ssl_enable else "http" - if not enable: - logger.info("WebUI 已被禁用") - return None + scheme = "https" if ssl_enable else "http" + display_host = f"[{host}]" if ":" in host else host - logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}") - if host == "0.0.0.0": + if self.enable_webui: logger.info( - "提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)", + "正在启动 WebUI + API, 监听地址: %s://%s:%s", + scheme, + display_host, + port, ) - - if host not in ["localhost", "127.0.0.1"]: - try: - ip_addr = get_local_ip_addresses() - except Exception as _: - pass - if isinstance(port, str): - port = int(port) - - if self.check_port_in_use(port): - process_info = self.get_process_using_port(port) - logger.error( - f"错误:端口 {port} 已被占用\n" - f"占用信息: \n {process_info}\n" - f"请确保:\n" - f"1. 没有其他 AstrBot 实例正在运行\n" - f"2. 端口 {port} 没有被其他程序占用\n" - f"3. 如需使用其他端口,请修改配置文件", + else: + logger.info( + "正在启动 API Server (WebUI 已分离), 监听地址: %s://%s:%s", + scheme, + display_host, + port, ) - raise Exception(f"端口 {port} 已被占用") - - parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"] - parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n") - for ip in ip_addr: - parts.append(f" ➜ 网络: {scheme}://{ip}:{port}\n") - parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n") - display = "".join(parts) - - if not ip_addr: - display += ( - "可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n" - ) + check_hosts = {host} + if host not in ("127.0.0.1", "localhost", "::1"): + check_hosts.add("127.0.0.1") + for check_host in check_hosts: + if self.check_port_in_use(check_host, port): + info = self.get_process_using_port(port) + raise RuntimeError(f"端口 {port} 已被占用\n{info}") - logger.info(display) + if self.enable_webui: + self._print_access_urls(host, port, scheme) # 配置 Hypercorn config = HyperConfig() - config.bind = [f"{host}:{port}"] + binds: list[str] = [self._build_bind(host, port)] + # 参考:https://github.com/pgjones/hypercorn/issues/85 + if host == "::" and platform.system() in ("Windows", "Darwin"): + binds.append(self._build_bind("0.0.0.0", port)) + config.bind = binds + if ssl_enable: cert_file = ( os.environ.get("DASHBOARD_SSL_CERT") @@ -392,12 +459,46 @@ def run(self): if disable_access_log: config.accesslog = None else: - # 启用访问日志,使用简洁格式 config.accesslog = "-" config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s" - return serve(self.app, config, shutdown_trigger=self.shutdown_trigger) + await serve(self.app, config, shutdown_trigger=self.shutdown_trigger) + + @staticmethod + def _build_bind(host: str, port: int) -> str: + try: + ip: IPv4Address | IPv6Address = ip_address(host) + return f"[{ip}]:{port}" if ip.version == 6 else f"{ip}:{port}" + except ValueError: + return f"{host}:{port}" + + def _print_access_urls(self, host: str, port: int, scheme: str = "http") -> None: + local_ips: list[IPv4Address | IPv6Address] = get_local_ip_addresses() + + parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动\n\n"] + + parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n") + + if host in ("::", "0.0.0.0"): + for ip in local_ips: + if ip.is_loopback: + continue + + if ip.version == 6: + display_url = f"{scheme}://[{ip}]:{port}" + else: + display_url = f"{scheme}://{ip}:{port}" + + parts.append(f" ➜ 网络: {display_url}\n") + + parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n") + + if not local_ips: + parts.append( + "可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n" + ) + + logger.info("".join(parts)) - async def shutdown_trigger(self) -> None: + async def shutdown_trigger(self): await self.shutdown_event.wait() - logger.info("AstrBot WebUI 已经被优雅地关闭") diff --git a/dashboard/.gitignore b/dashboard/.gitignore index 6e03962af6..f17c691296 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -1,3 +1,5 @@ node_modules/ .DS_Store -dist/ \ No newline at end of file +dist/ +bun.lock +pmpm-lock.yaml diff --git a/dashboard/env.d.ts b/dashboard/env.d.ts index b4b3508300..a90bd47be0 100644 --- a/dashboard/env.d.ts +++ b/dashboard/env.d.ts @@ -7,3 +7,9 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/dashboard/package.json b/dashboard/package.json index 7b4a7f071a..e66f092ca3 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -64,7 +64,7 @@ "sass": "1.66.1", "sass-loader": "13.3.2", "typescript": "5.1.6", - "vite": "4.4.9", + "vite": "7.3.1", "vue-cli-plugin-vuetify": "2.5.8", "vue-tsc": "1.8.8", "vuetify-loader": "^2.0.0-alpha.9" diff --git a/dashboard/public/config.json b/dashboard/public/config.json new file mode 100644 index 0000000000..0d7e84a8ad --- /dev/null +++ b/dashboard/public/config.json @@ -0,0 +1,13 @@ +{ + "apiBaseUrl": "", + "presets": [ + { + "name": "Default (Auto)", + "url": "" + }, + { + "name": "Localhost", + "url": "http://localhost:6185" + } + ] +} diff --git a/dashboard/src/components/chat/LiveMode.vue b/dashboard/src/components/chat/LiveMode.vue index 2740459d96..2e11277adb 100644 --- a/dashboard/src/components/chat/LiveMode.vue +++ b/dashboard/src/components/chat/LiveMode.vue @@ -1,65 +1,110 @@ diff --git a/dashboard/src/components/chat/StandaloneChat.vue b/dashboard/src/components/chat/StandaloneChat.vue index 69fac13f9b..ba0cd4eeb9 100644 --- a/dashboard/src/components/chat/StandaloneChat.vue +++ b/dashboard/src/components/chat/StandaloneChat.vue @@ -1,165 +1,177 @@ diff --git a/dashboard/src/i18n/locales/en-US/core/header.json b/dashboard/src/i18n/locales/en-US/core/header.json index 4da98b8dd8..a4593d2a21 100644 --- a/dashboard/src/i18n/locales/en-US/core/header.json +++ b/dashboard/src/i18n/locales/en-US/core/header.json @@ -10,7 +10,8 @@ "theme": { "light": "Light Mode", "dark": "Dark Mode" - } + }, + "logout": "Log Out" }, "updateDialog": { "title": "Update AstrBot", diff --git a/dashboard/src/i18n/locales/en-US/features/auth.json b/dashboard/src/i18n/locales/en-US/features/auth.json index 5c44558a03..c59deb2a0d 100644 --- a/dashboard/src/i18n/locales/en-US/features/auth.json +++ b/dashboard/src/i18n/locales/en-US/features/auth.json @@ -10,5 +10,16 @@ "theme": { "switchToDark": "Switch to Dark Theme", "switchToLight": "Switch to Light Theme" + }, + "serverConfig": { + "title": "Server Configuration", + "description": "If the backend is not on the same origin (host/port), please specify the full URL here.", + "label": "API Base URL", + "placeholder": "e.g. http://localhost:6185", + "hint": "Empty for default (relative path)", + "presetLabel": "Quick Select Preset", + "save": "Save & Reload", + "cancel": "Cancel", + "tooltip": "Server Configuration" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index 19232125f9..27a6c2b761 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -1,6 +1,24 @@ { "network": { "title": "Network", + "proxy": { + "title": "Proxy", + "subtitle": "Configure proxy for network requests" + }, + "server": { + "title": "Server Address", + "subtitle": "Configure backend API URL", + "label": "API Base URL", + "placeholder": "e.g. http://localhost:6185", + "hint": "Empty for default (relative path)", + "save": "Save & Reload", + "presets": "Presets", + "preset": { + "add": "Add Preset", + "name": "Name", + "url": "URL" + } + }, "githubProxy": { "title": "GitHub Proxy Address", "subtitle": "Set the GitHub proxy address used when downloading plugins or updating AstrBot. This is effective in mainland China's network environment. Can be customized, input takes effect in real time. All addresses do not guarantee stability. If errors occur when updating plugins/projects, please first check if the proxy address is working properly.", @@ -26,6 +44,20 @@ "reset": "Reset to Default" } }, + "style": { + "title": "Theme", + "color": { + "title": "Theme Colors", + "subtitle": "Customize theme primary and secondary colors. Changes apply immediately and are stored locally in your browser.", + "primary": "Primary Color", + "secondary": "Secondary Color" + } + }, + "reset": { + "title": "Reset to Default", + "subtitle": "Reset theme colors to default settings", + "button": "Reset" + }, "system": { "title": "System", "restart": { @@ -33,6 +65,11 @@ "subtitle": "Restart AstrBot", "button": "Restart" }, + "logout": { + "title": "Log Out", + "subtitle": "Log out of the current account", + "button": "Log Out" + }, "migration": { "title": "Data Migration to v4.0.0", "subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant", @@ -55,6 +92,10 @@ } }, "backup": { + "title": "Backup", + "subtitle": "Manage data backups", + "operate": "Backup Operations", + "open": "Open Backup Manager", "dialog": { "title": "Backup Manager" }, @@ -135,11 +176,12 @@ "subtitle": "Create API keys for external developers to call open HTTP APIs.", "name": "Key Name", "expiresInDays": "Expiration", - "expiryOptions": { - "day1": "1 day", - "day7": "7 days", - "day30": "30 days", - "day90": "90 days", + "expiry": { + "7days": "7 days", + "30days": "30 days", + "90days": "90 days", + "180days": "180 days", + "365days": "365 days", "permanent": "Permanent" }, "permanentWarning": "Permanent API keys are high risk. Store them securely and use only when necessary.", diff --git a/dashboard/src/i18n/locales/en-US/features/welcome.json b/dashboard/src/i18n/locales/en-US/features/welcome.json index 670d0a66de..6f5a8f00df 100644 --- a/dashboard/src/i18n/locales/en-US/features/welcome.json +++ b/dashboard/src/i18n/locales/en-US/features/welcome.json @@ -12,6 +12,8 @@ "onboard": { "title": "Quick Onboarding", "subtitle": "Complete initialization directly on the welcome page.", + "step0Title": "Configure Backend URL", + "step0Desc": "Configure the backend API URL for AstrBot.", "step1Title": "Configure Platform Bot", "step1Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.", "step2Title": "Configure AI Model", diff --git a/dashboard/src/i18n/locales/zh-CN/core/header.json b/dashboard/src/i18n/locales/zh-CN/core/header.json index 3bb9850331..8823e0f7da 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/header.json +++ b/dashboard/src/i18n/locales/zh-CN/core/header.json @@ -10,7 +10,8 @@ "theme": { "light": "浅色模式", "dark": "深色模式" - } + }, + "logout": "退出登录" }, "updateDialog": { "title": "更新 AstrBot", diff --git a/dashboard/src/i18n/locales/zh-CN/features/auth.json b/dashboard/src/i18n/locales/zh-CN/features/auth.json index d6da999430..4318eca953 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/auth.json +++ b/dashboard/src/i18n/locales/zh-CN/features/auth.json @@ -10,5 +10,16 @@ "theme": { "switchToDark": "切换到深色主题", "switchToLight": "切换到浅色主题" + }, + "serverConfig": { + "title": "服务器配置", + "description": "如果后端服务不在同源(主机/端口不同),请在此指定完整 URL。", + "label": "API 基础地址", + "placeholder": "例如:http://localhost:6185", + "hint": "留空以使用默认设置(相对路径)", + "presetLabel": "快速选择预设", + "save": "保存并刷新", + "cancel": "取消", + "tooltip": "服务器配置" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 19c1c7c41e..e80eae4e90 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -1,6 +1,24 @@ { "network": { "title": "网络", + "proxy": { + "title": "代理设置", + "subtitle": "配置网络请求代理" + }, + "server": { + "title": "服务器地址", + "subtitle": "配置后端 API 地址", + "label": "API 基础地址", + "placeholder": "例如:http://localhost:6185", + "hint": "留空以使用默认设置(相对路径)", + "save": "保存并刷新", + "presets": "预设列表", + "preset": { + "add": "添加预设", + "name": "名称", + "url": "URL" + } + }, "githubProxy": { "title": "GitHub 加速地址", "subtitle": "设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效。所有地址均不保证稳定性,如果在更新插件/项目时出现报错,请首先检查加速地址是否能正常使用。", @@ -26,6 +44,20 @@ "reset": "恢复默认" } }, + "style": { + "title": "主题", + "color": { + "title": "主题颜色", + "subtitle": "自定义主题主色与辅助色。修改后立即生效,并保存在浏览器本地。", + "primary": "主色", + "secondary": "辅助色" + } + }, + "reset": { + "title": "恢复默认", + "subtitle": "恢复主题颜色为默认设置", + "button": "恢复默认" + }, "system": { "title": "系统", "restart": { @@ -33,6 +65,11 @@ "subtitle": "重启 AstrBot", "button": "重启" }, + "logout": { + "title": "退出登录", + "subtitle": "退出当前账号,回到登录界面", + "button": "退出登录" + }, "migration": { "title": "数据迁移到 v4.0.0 格式", "subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手", @@ -55,6 +92,10 @@ } }, "backup": { + "title": "备份", + "subtitle": "管理数据备份", + "operate": "备份操作", + "open": "打开备份管理", "dialog": { "title": "备份管理" }, @@ -135,11 +176,12 @@ "subtitle": "为外部开发者创建 API Key,用于调用开放 HTTP API。", "name": "Key 名称", "expiresInDays": "有效期", - "expiryOptions": { - "day1": "1 天", - "day7": "7 天", - "day30": "30 天", - "day90": "90 天", + "expiry": { + "7days": "7 天", + "30days": "30 天", + "90days": "90 天", + "180days": "180 天", + "365days": "365 天", "permanent": "永久" }, "permanentWarning": "永久有效的 API Key 风险较高,请妥善保存并建议仅在必要场景使用。", diff --git a/dashboard/src/i18n/locales/zh-CN/features/welcome.json b/dashboard/src/i18n/locales/zh-CN/features/welcome.json index 1eb23d7ca4..7ddac7d256 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/welcome.json +++ b/dashboard/src/i18n/locales/zh-CN/features/welcome.json @@ -12,6 +12,8 @@ "onboard": { "title": "快速引导", "subtitle": "欢迎页可直接完成初始化。", + "step0Title": "配置后端地址", + "step0Desc": "配置 AstrBot 的后端 API 地址。", "step1Title": "配置平台机器人", "step1Desc": "将 AstrBot 连接到 QQ、飞书、企业微信、Telegram 等 IM 平台。", "step2Title": "配置 AI 模型", diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 47905d4ff8..9ec2d89458 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -1,72 +1,73 @@