chore: sync current WechatHookBot workspace
This commit is contained in:
563
utils/webui.py
Normal file
563
utils/webui.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""
|
||||
WebUI 多功能面板
|
||||
|
||||
功能:实时日志查看 + 配置编辑器 + 插件管理
|
||||
前端静态文件位于 utils/webui_static/ 目录。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import collections
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from aiohttp import web, WSMsgType
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import tomli_w
|
||||
except ImportError:
|
||||
tomli_w = None
|
||||
|
||||
|
||||
class LogBuffer:
|
||||
"""环形日志缓冲区"""
|
||||
|
||||
def __init__(self, maxlen: int = 500):
|
||||
self._buffer = collections.deque(maxlen=maxlen)
|
||||
self._clients: set[web.WebSocketResponse] = set()
|
||||
|
||||
def append(self, line: str):
|
||||
self._buffer.append(line)
|
||||
dead = set()
|
||||
for ws in self._clients:
|
||||
if ws.closed:
|
||||
dead.add(ws)
|
||||
continue
|
||||
task = asyncio.get_event_loop().create_task(ws.send_str(line))
|
||||
task.add_done_callback(lambda t, w=ws: self._clients.discard(w) if t.exception() else None)
|
||||
self._clients -= dead
|
||||
|
||||
def get_history(self) -> list[str]:
|
||||
return list(self._buffer)
|
||||
|
||||
def add_client(self, ws: web.WebSocketResponse):
|
||||
self._clients.add(ws)
|
||||
|
||||
def remove_client(self, ws: web.WebSocketResponse):
|
||||
self._clients.discard(ws)
|
||||
|
||||
|
||||
_log_buffer = LogBuffer()
|
||||
|
||||
|
||||
def get_log_buffer() -> LogBuffer:
|
||||
return _log_buffer
|
||||
|
||||
|
||||
def loguru_sink(message):
|
||||
"""loguru sink"""
|
||||
text = str(message).rstrip("\n")
|
||||
_log_buffer.append(text)
|
||||
|
||||
|
||||
# 主配置 section 中文名映射
|
||||
SECTION_LABELS = {
|
||||
"HttpHook": "Hook 连接",
|
||||
"Bot": "机器人",
|
||||
"Database": "数据库",
|
||||
"Performance": "性能",
|
||||
"Queue": "消息队列",
|
||||
"Concurrency": "并发控制",
|
||||
"Scheduler": "定时任务",
|
||||
"WebUI": "WebUI",
|
||||
}
|
||||
|
||||
# 插件配置 section 中文名映射
|
||||
PLUGIN_CONFIG_LABELS = {
|
||||
"AIChat": {
|
||||
"plugin": "插件基本信息",
|
||||
"api": "AI API 配置",
|
||||
"proxy": "代理配置",
|
||||
"prompt": "人设配置",
|
||||
"output": "输出后处理",
|
||||
"behavior": "触发行为",
|
||||
"memory": "对话记忆",
|
||||
"history": "群组历史记录",
|
||||
"image_description": "图片描述",
|
||||
"video_recognition": "视频识别",
|
||||
"rate_limit": "限流配置",
|
||||
"redis": "Redis 存储",
|
||||
"tools": "LLM 工具",
|
||||
"tools.timeout": "工具超时",
|
||||
"tools.concurrency": "工具并发",
|
||||
"vector_memory": "向量长期记忆",
|
||||
},
|
||||
}
|
||||
|
||||
# 静态文件目录
|
||||
_STATIC_DIR = Path(__file__).parent / "webui_static"
|
||||
|
||||
SESSION_COOKIE_NAME = "whb_webui_session"
|
||||
PBKDF2_ROUNDS = 240000
|
||||
DEFAULT_WEBUI_USERNAME = "admin"
|
||||
DEFAULT_WEBUI_PASSWORD = "admin123456"
|
||||
DEFAULT_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60
|
||||
AUTH_MANAGED_KEYS = {"auth_username", "auth_password_hash"}
|
||||
|
||||
|
||||
def hash_password(password: str, *, salt: str | None = None) -> str:
|
||||
"""生成 PBKDF2 密码哈希。"""
|
||||
if not isinstance(password, str) or password == "":
|
||||
raise ValueError("密码不能为空")
|
||||
if salt is None:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("utf-8"),
|
||||
PBKDF2_ROUNDS,
|
||||
)
|
||||
encoded = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
return f"pbkdf2_sha256${PBKDF2_ROUNDS}${salt}${encoded}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored_hash: str) -> bool:
|
||||
"""校验明文密码是否匹配哈希。"""
|
||||
if not isinstance(password, str) or not isinstance(stored_hash, str):
|
||||
return False
|
||||
try:
|
||||
algo, rounds, salt, digest = stored_hash.split("$", 3)
|
||||
rounds_int = int(rounds)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if algo != "pbkdf2_sha256" or rounds_int <= 0:
|
||||
return False
|
||||
calculated = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("utf-8"),
|
||||
rounds_int,
|
||||
)
|
||||
calculated_encoded = base64.urlsafe_b64encode(calculated).decode("ascii").rstrip("=")
|
||||
return hmac.compare_digest(calculated_encoded, digest)
|
||||
|
||||
|
||||
class WebUIServer:
|
||||
"""WebUI HTTP 服务器"""
|
||||
|
||||
def __init__(self, host: str = "0.0.0.0", port: int = 5001, config_path: str = "main_config.toml"):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.config_path = Path(config_path)
|
||||
self.app = web.Application(middlewares=[self._auth_middleware])
|
||||
self.runner = None
|
||||
self._sessions: dict[str, dict[str, float | str]] = {}
|
||||
self._auth_update_lock = asyncio.Lock()
|
||||
self._setup_routes()
|
||||
self._ensure_auth_initialized()
|
||||
|
||||
def _setup_routes(self):
|
||||
self.app.router.add_get("/", self._handle_index)
|
||||
self.app.router.add_get("/api/auth/status", self._handle_auth_status)
|
||||
self.app.router.add_post("/api/auth/login", self._handle_auth_login)
|
||||
self.app.router.add_post("/api/auth/logout", self._handle_auth_logout)
|
||||
self.app.router.add_post("/api/auth/change-credentials", self._handle_change_credentials)
|
||||
self.app.router.add_get("/ws", self._handle_ws)
|
||||
self.app.router.add_get("/api/config", self._handle_get_config)
|
||||
self.app.router.add_post("/api/config", self._handle_save_config)
|
||||
self.app.router.add_get("/api/plugins", self._handle_get_plugins)
|
||||
self.app.router.add_post("/api/plugins/toggle", self._handle_toggle_plugin)
|
||||
self.app.router.add_get("/api/plugins/{name}/config", self._handle_get_plugin_config)
|
||||
self.app.router.add_post("/api/plugins/{name}/config", self._handle_save_plugin_config)
|
||||
self.app.router.add_static("/static/", path=_STATIC_DIR, name="static")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_session_timeout(value) -> int:
|
||||
try:
|
||||
timeout = int(value)
|
||||
except Exception:
|
||||
return DEFAULT_SESSION_TIMEOUT_SECONDS
|
||||
return max(300, timeout)
|
||||
|
||||
def _load_main_config(self) -> dict:
|
||||
with open(self.config_path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
def _save_main_config(self, data: dict):
|
||||
if tomli_w is None:
|
||||
raise RuntimeError("tomli_w 未安装,无法写入 main_config.toml")
|
||||
with open(self.config_path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
def _ensure_auth_initialized(self):
|
||||
try:
|
||||
data = self._load_main_config()
|
||||
except Exception as e:
|
||||
logger.error(f"读取配置失败,无法初始化 WebUI 认证: {e}")
|
||||
return
|
||||
|
||||
webui_cfg = data.setdefault("WebUI", {})
|
||||
changed = False
|
||||
|
||||
username = str(webui_cfg.get("auth_username", "")).strip()
|
||||
if not username:
|
||||
webui_cfg["auth_username"] = DEFAULT_WEBUI_USERNAME
|
||||
changed = True
|
||||
|
||||
pwd_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
|
||||
if not pwd_hash:
|
||||
webui_cfg["auth_password_hash"] = hash_password(DEFAULT_WEBUI_PASSWORD)
|
||||
changed = True
|
||||
logger.warning(
|
||||
"WebUI 未配置管理员账号密码,已初始化默认账号: "
|
||||
f"{DEFAULT_WEBUI_USERNAME} / {DEFAULT_WEBUI_PASSWORD},请尽快在 WebUI 的安全页面修改。"
|
||||
)
|
||||
|
||||
normalized_timeout = self._normalize_session_timeout(
|
||||
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
|
||||
)
|
||||
if webui_cfg.get("session_timeout_seconds") != normalized_timeout:
|
||||
webui_cfg["session_timeout_seconds"] = normalized_timeout
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
try:
|
||||
self._save_main_config(data)
|
||||
except Exception as e:
|
||||
logger.error(f"写入 WebUI 认证初始化配置失败: {e}")
|
||||
|
||||
def _get_auth_settings(self) -> tuple[str, str, int]:
|
||||
try:
|
||||
data = self._load_main_config()
|
||||
webui_cfg = data.get("WebUI", {})
|
||||
username = str(webui_cfg.get("auth_username", DEFAULT_WEBUI_USERNAME)).strip() or DEFAULT_WEBUI_USERNAME
|
||||
pwd_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
|
||||
timeout = self._normalize_session_timeout(
|
||||
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
|
||||
)
|
||||
if not pwd_hash:
|
||||
# 配置异常时兜底,避免直接失去登录能力
|
||||
pwd_hash = hash_password(DEFAULT_WEBUI_PASSWORD)
|
||||
return username, pwd_hash, timeout
|
||||
except Exception as e:
|
||||
logger.error(f"读取 WebUI 认证配置失败,使用默认兜底: {e}")
|
||||
return DEFAULT_WEBUI_USERNAME, hash_password(DEFAULT_WEBUI_PASSWORD), DEFAULT_SESSION_TIMEOUT_SECONDS
|
||||
|
||||
def _cleanup_sessions(self):
|
||||
now = time.time()
|
||||
expired_tokens = [
|
||||
token
|
||||
for token, session in self._sessions.items()
|
||||
if float(session.get("expires_at", 0)) <= now
|
||||
]
|
||||
for token in expired_tokens:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def _create_session(self, username: str, timeout_seconds: int) -> str:
|
||||
self._cleanup_sessions()
|
||||
token = secrets.token_urlsafe(32)
|
||||
self._sessions[token] = {
|
||||
"username": username,
|
||||
"expires_at": time.time() + timeout_seconds,
|
||||
}
|
||||
return token
|
||||
|
||||
def _invalidate_session(self, token: str):
|
||||
if token:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def _set_session_cookie(self, response: web.Response, request: web.Request, token: str, timeout_seconds: int):
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
token,
|
||||
max_age=timeout_seconds,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=request.secure,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
def _get_session_username(self, request: web.Request, *, refresh: bool = True) -> str | None:
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME, "")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
self._cleanup_sessions()
|
||||
session = self._sessions.get(token)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
expires_at = float(session.get("expires_at", 0))
|
||||
if expires_at <= now:
|
||||
self._sessions.pop(token, None)
|
||||
return None
|
||||
|
||||
username = str(session.get("username", "")).strip()
|
||||
if not username:
|
||||
self._sessions.pop(token, None)
|
||||
return None
|
||||
|
||||
if refresh:
|
||||
_, _, timeout = self._get_auth_settings()
|
||||
session["expires_at"] = now + timeout
|
||||
return username
|
||||
|
||||
@staticmethod
|
||||
def _is_public_path(path: str) -> bool:
|
||||
return (
|
||||
path == "/"
|
||||
or path.startswith("/static/")
|
||||
or path == "/favicon.ico"
|
||||
or path == "/api/auth/status"
|
||||
or path == "/api/auth/login"
|
||||
)
|
||||
|
||||
@web.middleware
|
||||
async def _auth_middleware(self, request: web.Request, handler):
|
||||
path = request.path
|
||||
if self._is_public_path(path):
|
||||
return await handler(request)
|
||||
|
||||
username = self._get_session_username(request)
|
||||
if not username:
|
||||
if path.startswith("/api/") or path == "/ws":
|
||||
return web.json_response({"ok": False, "error": "未登录或会话已过期"}, status=401)
|
||||
raise web.HTTPFound("/")
|
||||
|
||||
request["webui_user"] = username
|
||||
return await handler(request)
|
||||
|
||||
async def _handle_auth_status(self, request: web.Request) -> web.Response:
|
||||
configured_username, _, _ = self._get_auth_settings()
|
||||
current_username = self._get_session_username(request, refresh=False)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"authenticated": bool(current_username),
|
||||
"username": current_username or configured_username,
|
||||
})
|
||||
|
||||
async def _handle_auth_login(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
username = str(body.get("username", "")).strip()
|
||||
password = str(body.get("password", ""))
|
||||
configured_username, configured_hash, timeout = self._get_auth_settings()
|
||||
|
||||
if username != configured_username or not verify_password(password, configured_hash):
|
||||
return web.json_response({"ok": False, "error": "用户名或密码错误"}, status=401)
|
||||
|
||||
token = self._create_session(configured_username, timeout)
|
||||
response = web.json_response({"ok": True, "username": configured_username})
|
||||
self._set_session_cookie(response, request, token, timeout)
|
||||
return response
|
||||
|
||||
async def _handle_auth_logout(self, request: web.Request) -> web.Response:
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME, "")
|
||||
if token:
|
||||
self._invalidate_session(token)
|
||||
response = web.json_response({"ok": True})
|
||||
response.del_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
return response
|
||||
|
||||
async def _handle_change_credentials(self, request: web.Request) -> web.Response:
|
||||
if tomli_w is None:
|
||||
return web.json_response({"ok": False, "error": "tomli_w 未安装,无法保存认证配置"})
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
current_password = str(body.get("current_password", ""))
|
||||
new_username = str(body.get("new_username", "")).strip()
|
||||
new_password = str(body.get("new_password", ""))
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "请求参数格式错误"})
|
||||
|
||||
if not current_password:
|
||||
return web.json_response({"ok": False, "error": "请输入当前密码"})
|
||||
if not new_username:
|
||||
return web.json_response({"ok": False, "error": "账号不能为空"})
|
||||
if len(new_password) < 8:
|
||||
return web.json_response({"ok": False, "error": "新密码长度至少 8 位"})
|
||||
|
||||
async with self._auth_update_lock:
|
||||
try:
|
||||
data = self._load_main_config()
|
||||
webui_cfg = data.setdefault("WebUI", {})
|
||||
stored_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
|
||||
if not stored_hash:
|
||||
stored_hash = hash_password(DEFAULT_WEBUI_PASSWORD)
|
||||
|
||||
if not verify_password(current_password, stored_hash):
|
||||
return web.json_response({"ok": False, "error": "当前密码错误"})
|
||||
|
||||
webui_cfg["auth_username"] = new_username
|
||||
webui_cfg["auth_password_hash"] = hash_password(new_password)
|
||||
webui_cfg["session_timeout_seconds"] = self._normalize_session_timeout(
|
||||
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
|
||||
)
|
||||
self._save_main_config(data)
|
||||
|
||||
timeout = self._normalize_session_timeout(webui_cfg["session_timeout_seconds"])
|
||||
self._sessions.clear()
|
||||
new_token = self._create_session(new_username, timeout)
|
||||
|
||||
response = web.json_response({"ok": True, "username": new_username})
|
||||
self._set_session_cookie(response, request, new_token, timeout)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_index(self, request: web.Request) -> web.Response:
|
||||
index_file = _STATIC_DIR / "index.html"
|
||||
return web.FileResponse(index_file)
|
||||
|
||||
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
_log_buffer.add_client(ws)
|
||||
for line in _log_buffer.get_history():
|
||||
await ws.send_str(line)
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.ERROR:
|
||||
break
|
||||
finally:
|
||||
_log_buffer.remove_client(ws)
|
||||
return ws
|
||||
|
||||
async def _handle_get_config(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = self._load_main_config()
|
||||
safe_data = copy.deepcopy(data)
|
||||
webui_cfg = safe_data.get("WebUI")
|
||||
if isinstance(webui_cfg, dict):
|
||||
for key in AUTH_MANAGED_KEYS:
|
||||
webui_cfg.pop(key, None)
|
||||
return web.json_response({"ok": True, "data": safe_data, "labels": SECTION_LABELS})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_save_config(self, request: web.Request) -> web.Response:
|
||||
if tomli_w is None:
|
||||
return web.json_response({"ok": False, "error": "tomli_w 未安装"})
|
||||
try:
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
# 避免配置编辑器覆盖认证字段(认证字段专用接口维护)
|
||||
current = self._load_main_config()
|
||||
current_webui = current.get("WebUI", {})
|
||||
new_webui = data.setdefault("WebUI", {})
|
||||
for key in AUTH_MANAGED_KEYS:
|
||||
if key in current_webui and key not in new_webui:
|
||||
new_webui[key] = current_webui[key]
|
||||
|
||||
self._save_main_config(data)
|
||||
self._ensure_auth_initialized()
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_get_plugins(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
from utils.plugin_manager import PluginManager
|
||||
pm = PluginManager()
|
||||
plugins = []
|
||||
for name, info in pm.plugin_info.items():
|
||||
directory = info.get("directory", name)
|
||||
cfg_path = Path("plugins") / directory / "config.toml"
|
||||
plugins.append({
|
||||
"name": name,
|
||||
"description": info.get("description", ""),
|
||||
"author": info.get("author", ""),
|
||||
"version": info.get("version", ""),
|
||||
"directory": directory,
|
||||
"enabled": info.get("enabled", False),
|
||||
"has_config": cfg_path.exists(),
|
||||
})
|
||||
plugins.sort(key=lambda p: p["name"])
|
||||
return web.json_response({"ok": True, "plugins": plugins})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_toggle_plugin(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
from utils.plugin_manager import PluginManager
|
||||
body = await request.json()
|
||||
name = body.get("name", "")
|
||||
enable = body.get("enable", False)
|
||||
if name == "ManagePlugin":
|
||||
return web.json_response({"ok": False, "error": "ManagePlugin 不可禁用"})
|
||||
pm = PluginManager()
|
||||
if enable:
|
||||
ok = await pm.load_plugin(name)
|
||||
if ok:
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"ok": False, "error": f"启用 {name} 失败"})
|
||||
else:
|
||||
ok = await pm.unload_plugin(name)
|
||||
if ok:
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"ok": False, "error": f"禁用 {name} 失败"})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_get_plugin_config(self, request: web.Request) -> web.Response:
|
||||
name = request.match_info["name"]
|
||||
try:
|
||||
from utils.plugin_manager import PluginManager
|
||||
pm = PluginManager()
|
||||
info = pm.plugin_info.get(name)
|
||||
if not info:
|
||||
return web.json_response({"ok": False, "error": f"插件 {name} 不存在"})
|
||||
directory = info.get("directory", name)
|
||||
cfg_path = Path("plugins") / directory / "config.toml"
|
||||
if not cfg_path.exists():
|
||||
return web.json_response({"ok": False, "error": "该插件无配置文件"})
|
||||
with open(cfg_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
labels = PLUGIN_CONFIG_LABELS.get(name, {})
|
||||
return web.json_response({"ok": True, "data": data, "labels": labels})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_save_plugin_config(self, request: web.Request) -> web.Response:
|
||||
if tomli_w is None:
|
||||
return web.json_response({"ok": False, "error": "tomli_w 未安装"})
|
||||
name = request.match_info["name"]
|
||||
try:
|
||||
from utils.plugin_manager import PluginManager
|
||||
pm = PluginManager()
|
||||
info = pm.plugin_info.get(name)
|
||||
if not info:
|
||||
return web.json_response({"ok": False, "error": f"插件 {name} 不存在"})
|
||||
directory = info.get("directory", name)
|
||||
cfg_path = Path("plugins") / directory / "config.toml"
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
with open(cfg_path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def start(self):
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
site = web.TCPSite(self.runner, self.host, self.port)
|
||||
await site.start()
|
||||
logger.success(f"WebUI 已启动: http://{self.host}:{self.port}")
|
||||
|
||||
async def stop(self):
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("WebUI 已停止")
|
||||
Reference in New Issue
Block a user