diff --git a/.python-version b/.python-version index fdcfcfdfc..e4fba2183 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 6c0c34b99..0adbf3288 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 / "data") + 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 23665dff3..3641d31c4 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 / "data") 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/core/config/default.py b/astrbot/core/config/default.py index fa9d71d74..af2829733 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -195,7 +195,7 @@ DEFAULT_CONFIG = { "username": "astrbot", "password": "77b90590a8945a7d36c963981a307dc9", "jwt_secret": "", - "host": "0.0.0.0", + "host": "::", "port": 6185, "disable_access_log": True, "ssl": { diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 45114382f..2f720dd1c 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -419,7 +419,7 @@ class AiocqhttpAdapter(Platform): def run(self) -> Awaitable[Any]: if not self.host or not self.port: logger.warning( - "aiocqhttp: 未配置 ws_reverse_host 或 ws_reverse_port,将使用默认值:http://0.0.0.0:6199", + "aiocqhttp: 未配置 ws_reverse_host 或 ws_reverse_port,将使用默认值:http://[::]:6199", ) self.host = "0.0.0.0" self.port = 6199 diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 5f35471ee..e1c5d457a 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -21,7 +21,7 @@ class QQOfficialWebhook: self.secret = config["secret"] self.port = config.get("port", 6196) self.is_sandbox = config.get("is_sandbox", False) - self.callback_server_host = config.get("callback_server_host", "0.0.0.0") + self.callback_server_host = config.get("callback_server_host", "::") if isinstance(self.port, str): self.port = int(self.port) diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 6647db89f..c73e15a08 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -43,7 +43,7 @@ class WecomServer: def __init__(self, event_queue: asyncio.Queue, config: dict) -> None: self.server = quart.Quart(__name__) self.port = int(cast(str, config.get("port"))) - self.callback_server_host = config.get("callback_server_host", "0.0.0.0") + self.callback_server_host = config.get("callback_server_host", "::") self.server.add_url_rule( "/callback/command", view_func=self.verify, @@ -407,7 +407,7 @@ class WecomPlatformAdapter(Platform): 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 0ce3624e8..911224dfe 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 ssl 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 fbbd0c7a0..652a9feef 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -9,16 +9,20 @@ from .conversation import ConversationRoute 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 .response import Response +from .route import 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 +50,8 @@ __all__ = [ "ToolsRoute", "SkillsRoute", "UpdateRoute", + "T2iRoute", + "LiveChatRoute", + "Response", + "RouteContext", ] diff --git a/astrbot/dashboard/routes/route.py b/astrbot/dashboard/routes/route.py index 53c623443..4fdc37971 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 @@ class Response: 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 e056b6c5a..15fec95d1 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 a9650cd06..e7fab8742 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -2,9 +2,12 @@ import asyncio 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.asyncio import serve 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 astrbot.core.utils.io import get_local_ip_addresses 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 @@ class AstrBotDashboard: self.core_lifecycle = core_lifecycle self.config = core_lifecycle.astrbot_config self.db = db + self.shutdown_event = shutdown_event - # 参数指定webui目录 + 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() + + # ------------------------------------------------------------------ + # 初始化阶段 + # ------------------------------------------------------------------ + + 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 - 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_app(self): + """初始化 Quart 应用""" + global APP + self.app = Quart( + "AstrBotDashboard", + static_folder=self.data_path, + static_url_path="/", ) - self.sr = StatRoute(self.context, db, core_lifecycle) - self.pr = PluginRoute( - self.context, - core_lifecycle, - core_lifecycle.plugin_manager, + 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) + logging.getLogger(self.app.name).removeHandler(default_handler) + + def _init_routes(self, db: BaseDatabase): + UpdateRoute( + self.context, self.core_lifecycle.astrbot_updator, self.core_lifecycle + ) + 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 @@ class AstrBotDashboard: methods=["GET", "POST"], ) - self.shutdown_event = shutdown_event + def _init_plugin_route_index(self): + """将插件路由索引,避免 O(n) 查找""" + self._plugin_route_map: dict[tuple[str, str], Callable] = {} - self._init_jwt_secret() + for ( + route, + handler, + methods, + _, + ) in self.core_lifecycle.star_context.registered_web_apis: + for method in methods: + self._plugin_route_map[(route, method)] = handler - 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__) + 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"] + + # ------------------------------------------------------------------ + # 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 @@ class AstrBotDashboard: 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,87 @@ class AstrBotDashboard: } 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: """跨平台检测端口是否被占用""" - 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}") - # 如果出现异常,保守起见认为端口可能被占用 - return True + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + 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", "connections"]): + for conn in proc.info["connections"] or []: # type: ignore + if conn.laddr.port == port: + return f"PID: {proc.info['pid']}, Name: {proc.info['name']}" # type: ignore 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) + 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" + display_host = f"[{host}]" if ":" in host else host - if not enable: - logger.info("WebUI 已被禁用") - return None - - 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, + ) + else: + logger.info( + "正在启动 API Server (WebUI 已分离), 监听地址: %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) + 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}") - 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. 如需使用其他端口,请修改配置文件", - ) - - 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" - ) - - 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 +444,48 @@ class AstrBotDashboard: 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) + return asyncio.run( + serve(self.app, config, shutdown_trigger=self.shutdown_trigger) + ) - async def shutdown_trigger(self) -> None: + @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): await self.shutdown_event.wait() - logger.info("AstrBot WebUI 已经被优雅地关闭") diff --git a/dashboard/env.d.ts b/dashboard/env.d.ts index b4b350830..a90bd47be 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/public/config.json b/dashboard/public/config.json new file mode 100644 index 000000000..0d7e84a8a --- /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 2740459d9..2e11277ad 100644 --- a/dashboard/src/components/chat/LiveMode.vue +++ b/dashboard/src/components/chat/LiveMode.vue @@ -1,65 +1,110 @@ diff --git a/dashboard/src/i18n/locales/en-US/features/auth.json b/dashboard/src/i18n/locales/en-US/features/auth.json index 5c44558a0..c59deb2a0 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 19232125f..0c616c3d0 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -1,6 +1,14 @@ { "network": { "title": "Network", + "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" + }, "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.", diff --git a/dashboard/src/i18n/locales/zh-CN/features/auth.json b/dashboard/src/i18n/locales/zh-CN/features/auth.json index d6da99943..4318eca95 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 19c1c7c41..f9a703f7e 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -1,6 +1,14 @@ { "network": { "title": "网络", + "server": { + "title": "服务器地址", + "subtitle": "配置后端 API 地址", + "label": "API 基础地址", + "placeholder": "例如:http://localhost:6185", + "hint": "留空以使用默认设置(相对路径)", + "save": "保存并刷新" + }, "githubProxy": { "title": "GitHub 加速地址", "subtitle": "设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效。所有地址均不保证稳定性,如果在更新插件/项目时出现报错,请首先检查加速地址是否能正常使用。", diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index 687166654..08a5aeadd 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -1,116 +1,181 @@ -import { createApp } from 'vue'; -import { createPinia } from 'pinia'; -import App from './App.vue'; -import { router } from './router'; -import vuetify from './plugins/vuetify'; -import confirmPlugin from './plugins/confirmPlugin'; -import { setupI18n } from './i18n/composables'; -import '@/scss/style.scss'; -import VueApexCharts from 'vue3-apexcharts'; +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import App from "./App.vue"; +import { router } from "./router"; +import vuetify from "./plugins/vuetify"; +import confirmPlugin from "./plugins/confirmPlugin"; +import { setupI18n } from "./i18n/composables"; +import "@/scss/style.scss"; +import VueApexCharts from "vue3-apexcharts"; -import print from 'vue3-print-nb'; -import { loader } from '@guolao/vue-monaco-editor' -import axios from 'axios'; +import print from "vue3-print-nb"; +import { loader } from "@guolao/vue-monaco-editor"; +import axios from "axios"; + +// 1. 定义加载配置的函数 +async function loadAppConfig() { + try { + // 加上时间戳防止浏览器缓存 config.json + const response = await fetch(`/config.json?t=${new Date().getTime()}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.warn("Failed to load config.json, falling back to default.", error); + return {}; + } +} + +function mountApp(app: any, pinia: any) { + app.mount("#app"); -// 初始化新的i18n系统,等待完成后再挂载应用 -setupI18n().then(() => { - console.log('🌍 新i18n系统初始化完成'); - - const app = createApp(App); - app.use(router); - const pinia = createPinia(); - app.use(pinia); - app.use(print); - app.use(VueApexCharts); - app.use(vuetify); - app.use(confirmPlugin); - app.mount('#app'); - // 挂载后同步 Vuetify 主题 - import('./stores/customizer').then(({ useCustomizerStore }) => { + import("./stores/customizer").then(({ useCustomizerStore }) => { const customizer = useCustomizerStore(pinia); vuetify.theme.global.name.value = customizer.uiTheme; - const storedPrimary = localStorage.getItem('themePrimary'); - const storedSecondary = localStorage.getItem('themeSecondary'); + const storedPrimary = localStorage.getItem("themePrimary"); + const storedSecondary = localStorage.getItem("themeSecondary"); if (storedPrimary || storedSecondary) { const themes = vuetify.theme.themes.value; - ['PurpleTheme', 'PurpleThemeDark'].forEach((name) => { + ["PurpleTheme", "PurpleThemeDark"].forEach((name) => { const theme = themes[name]; if (!theme?.colors) return; if (storedPrimary) theme.colors.primary = storedPrimary; if (storedSecondary) theme.colors.secondary = storedSecondary; - if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary; - if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary; + if (storedPrimary && theme.colors.darkprimary) + theme.colors.darkprimary = storedPrimary; + if (storedSecondary && theme.colors.darksecondary) + theme.colors.darksecondary = storedSecondary; }); } }); -}).catch(error => { - console.error('❌ 新i18n系统初始化失败:', error); - - // 即使i18n初始化失败,也要挂载应用(使用回退机制) - const app = createApp(App); - app.use(router); - const pinia = createPinia(); - app.use(pinia); - app.use(print); - app.use(VueApexCharts); - app.use(vuetify); - app.use(confirmPlugin); - app.mount('#app'); - - // 挂载后同步 Vuetify 主题 - import('./stores/customizer').then(({ useCustomizerStore }) => { - const customizer = useCustomizerStore(pinia); - vuetify.theme.global.name.value = customizer.uiTheme; - const storedPrimary = localStorage.getItem('themePrimary'); - const storedSecondary = localStorage.getItem('themeSecondary'); - if (storedPrimary || storedSecondary) { - const themes = vuetify.theme.themes.value; - ['PurpleTheme', 'PurpleThemeDark'].forEach((name) => { - const theme = themes[name]; - if (!theme?.colors) return; - if (storedPrimary) theme.colors.primary = storedPrimary; - if (storedSecondary) theme.colors.secondary = storedSecondary; - if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary; - if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary; - }); +} + +async function initApp() { + // 等待配置加载 + const config = await loadAppConfig(); + const configApiUrl = config.apiBaseUrl || ""; + const presets = config.presets || []; + + // 优先使用 localStorage 中的配置,其次是 config.json,最后是空字符串 + const localApiUrl = localStorage.getItem("apiBaseUrl"); + const apiBaseUrl = localApiUrl !== null ? localApiUrl : configApiUrl; + + if (apiBaseUrl) { + console.log( + `API Base URL set to: ${apiBaseUrl} (Local: ${localApiUrl}, Config: ${configApiUrl})`, + ); + } + + // 配置 Axios 全局 Base URL + axios.defaults.baseURL = apiBaseUrl; + + axios.interceptors.request.use((config) => { + const token = localStorage.getItem("token"); + if (token) { + config.headers["Authorization"] = `Bearer ${token}`; } + const locale = localStorage.getItem("astrbot-locale"); + if (locale) { + config.headers["Accept-Language"] = locale; + } + return config; }); -}); + // Keep fetch() calls consistent with axios by automatically attaching the JWT. + // Some parts of the UI use fetch directly; without this, those requests will 401. + // Also handle apiBaseUrl for fetch + const _origFetch = window.fetch.bind(window); + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + let url = input; -axios.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); - if (token) { - config.headers['Authorization'] = `Bearer ${token}`; - } - const locale = localStorage.getItem('astrbot-locale'); - if (locale) { - config.headers['Accept-Language'] = locale; - } - return config; -}); + // 动态获取当前的 Base URL (可能已被 Store 修改) + const currentBaseUrl = axios.defaults.baseURL; -// Keep fetch() calls consistent with axios by automatically attaching the JWT. -// Some parts of the UI use fetch directly; without this, those requests will 401. -const _origFetch = window.fetch.bind(window); -window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { - const token = localStorage.getItem('token'); - if (!token) return _origFetch(input, init); + // 如果是字符串路径且以 /api 开头,并且配置了 Base URL,则拼接 + if ( + typeof input === "string" && + input.startsWith("/api") && + currentBaseUrl + ) { + // 移除 apiBaseUrl 尾部的斜杠 + const cleanBase = currentBaseUrl.replace(/\/+$/, ""); + // 移除 input 开头的斜杠 + const cleanPath = input.replace(/^\/+/, ""); + url = `${cleanBase}/${cleanPath}`; + } - const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined)); - if (!headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${token}`); - } - const locale = localStorage.getItem('astrbot-locale'); - if (locale && !headers.has('Accept-Language')) { - headers.set('Accept-Language', locale); - } - return _origFetch(input, { ...init, headers }); -}; + const token = localStorage.getItem("token"); -loader.config({ - paths: { - vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs', - }, -}) + const headers = new Headers( + init?.headers || + (typeof input !== "string" && "headers" in input + ? (input as Request).headers + : undefined), + ); + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + + const locale = localStorage.getItem("astrbot-locale"); + if (locale && !headers.has("Accept-Language")) { + headers.set("Accept-Language", locale); + } + + return _origFetch(url, { ...init, headers }); + }; + + loader.config({ + paths: { + vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs", + }, + }); + + // 初始化新的i18n系统,等待完成后再挂载应用 + setupI18n() + .then(async () => { + console.log("🌍 新i18n系统初始化完成"); + + const app = createApp(App); + app.use(router); + const pinia = createPinia(); + app.use(pinia); + + // Initialize API Store with presets + const { useApiStore } = await import("@/stores/api"); + const apiStore = useApiStore(pinia); + apiStore.setPresets(presets); + + app.use(print); + app.use(VueApexCharts); + app.use(vuetify); + app.use(confirmPlugin); + + mountApp(app, pinia); + }) + .catch(async (error) => { + console.error("❌ 新i18n系统初始化失败:", error); + + // 即使i18n初始化失败,也要挂载应用(使用回退机制) + const app = createApp(App); + app.use(router); + const pinia = createPinia(); + app.use(pinia); + + // Initialize API Store with presets + const { useApiStore } = await import("@/stores/api"); + const apiStore = useApiStore(pinia); + apiStore.setPresets(presets); + + app.use(print); + app.use(VueApexCharts); + app.use(vuetify); + app.use(confirmPlugin); + + mountApp(app, pinia); + }); +} + +// 启动应用 +initApp(); diff --git a/dashboard/src/stores/api.ts b/dashboard/src/stores/api.ts new file mode 100644 index 000000000..b664c1d95 --- /dev/null +++ b/dashboard/src/stores/api.ts @@ -0,0 +1,70 @@ +import { defineStore } from "pinia"; +import axios from "axios"; + +export type ApiPreset = { + name: string; + url: string; +}; + +export const useApiStore = defineStore({ + id: "api", + state: () => ({ + // 优先从 localStorage 读取用户手动设置的地址 + apiBaseUrl: localStorage.getItem("apiBaseUrl") || "", + configPresets: [] as ApiPreset[], + customPresets: JSON.parse( + localStorage.getItem("customPresets") || "[]", + ) as ApiPreset[], + }), + getters: { + presets: (state): ApiPreset[] => [ + ...state.configPresets, + ...state.customPresets, + ], + }, + actions: { + setPresets(presets: ApiPreset[]) { + this.configPresets = presets; + }, + + addPreset(preset: ApiPreset) { + this.customPresets.push(preset); + localStorage.setItem("customPresets", JSON.stringify(this.customPresets)); + }, + + removePreset(name: string) { + this.customPresets = this.customPresets.filter((p) => p.name !== name); + localStorage.setItem("customPresets", JSON.stringify(this.customPresets)); + }, + + /** + * 设置 API 基础地址 + * @param url 后端地址,例如 http://localhost:6185 + */ + setApiBaseUrl(url: string) { + // 移除尾部斜杠,确保一致性 + const cleanUrl = url ? url.replace(/\/+$/, "") : ""; + + this.apiBaseUrl = cleanUrl; + + if (cleanUrl) { + localStorage.setItem("apiBaseUrl", cleanUrl); + } else { + localStorage.removeItem("apiBaseUrl"); + } + + // 立即更新 axios 配置 + axios.defaults.baseURL = cleanUrl; + }, + + /** + * 初始化 API 配置 + * 通常在应用启动时调用,同步 localStorage 到 axios + */ + init() { + if (this.apiBaseUrl) { + axios.defaults.baseURL = this.apiBaseUrl; + } + }, + }, +}); diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index 8ec447dac..027effc34 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -1,488 +1,750 @@