feat(web): 任务中心全局 Provider / 设置·布局·路由调整 / 视频解析页适配

- tasks.tsx:顶栏任务中心全局 Provider(SSE 任务进度,供各插件页共享)
- 设置页与 AppLayout / client / router 调整,BF2042 插件页路由注册
- web_hub:dashboard / bot_settings 同步更新
- 视频解析页配合后端(缓存 tab / 任务槽进度)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:27 +08:00
1 parent 6af60a7d55
commit 968b3d1cfe
10 files changed
+804 -9

No files matched your search

+58 -1
View File
@@ -41,6 +41,7 @@ from .web_plugin_registry import get_web_plugin_builder, list_web_plugins
from .dashboard import collect_dashboard
# hexi/web_hub 为机器人核心模块(统一 Web 管理台),非 NoneBot 插件
basic_path = Path(__file__).resolve().parent
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
@@ -333,6 +334,10 @@ async def _first_bot():
def build_hub_app() -> FastAPI:
# 延迟导入:hexi.core 是插件侧基础设施,模块级导入会触发它整套初始化
# (见本模块文档"对 core 的引用一律函数内导入"的约定)
from hexi.core.task_slots import TASKS
sync_admin()
app = FastAPI(title="HeXi Web Hub")
@@ -575,6 +580,58 @@ def build_hub_app() -> FastAPI:
return StreamingResponse(gen(), media_type="text/event-stream")
@app.get("/api/tasks")
async def list_tasks(_: dict = Depends(get_current_user)):
"""任务槽的当前快照(新的在前);SSE 首帧同款,作轮询兜底用。"""
return {"ok": True, "tasks": TASKS.snapshot()}
@app.get("/api/tasks/stream")
async def tasks_stream(_: dict = Depends(get_current_user)):
"""任务槽 SSE:连上先发一帧全量快照,之后每次任务变化再发一帧。
变化不频繁(按百分比桶节流),所以直接推送全量快照,不做增量协议。
"""
queue = TASKS.subscribe()
async def gen():
try:
yield f"data: {json.dumps({'ok': True, 'tasks': TASKS.snapshot()}, ensure_ascii=False)}\n\n"
while True:
try:
tasks = await asyncio.wait_for(queue.get(), timeout=15)
except asyncio.TimeoutError:
yield ": ping\n\n" # ~15s 心跳保活
continue
yield f"data: {json.dumps({'ok': True, 'tasks': tasks}, ensure_ascii=False)}\n\n"
finally:
TASKS.unsubscribe(queue)
return StreamingResponse(gen(), media_type="text/event-stream")
@app.post("/api/tasks/clear")
async def clear_tasks(_: dict = Depends(get_current_user)):
"""清掉所有已结束的任务(在跑的不动)。"""
removed = TASKS.clear_finished()
return {"ok": True, "removed": removed}
@app.post("/api/tasks/{task_id}/dismiss")
async def dismiss_task(task_id: str, _: dict = Depends(get_current_user)):
"""从列表里删掉一条已结束的任务(在跑的先取消,删不了)。"""
if not TASKS.dismiss(task_id):
return {"ok": False, "msg": "任务不存在,或还在运行(在跑的只能先取消)"}
return {"ok": True}
@app.post("/api/tasks/{task_id}/cancel")
async def cancel_task(task_id: str, _: dict = Depends(get_current_user)):
"""请求取消一个在跑的任务。
协作式:只置标志,任务体在下一个检查点收尾(见 task_slots 模块文档),
所以接口马上返回不代表任务已经停了 —— 状态变化会从 SSE 推出去。
"""
if not TASKS.cancel(task_id):
return {"ok": False, "msg": "任务不存在或已结束"}
return {"ok": True}
@app.get("/api/logs/stream")
async def logs_stream(
since: int = 0,
@@ -726,4 +783,4 @@ async def _startup_mount_web_plugins() -> None:
@get_driver().on_startup
async def _downgrade_uvicorn_access_logs() -> None:
"""把 uvicorn.access 日志降级为 DEBUG(经 loguru 后按日志等级过滤)。"""
_logging.getLogger("uvicorn.access").addFilter(_uvicorn_access_debug)
_logging.getLogger("uvicorn.access").addFilter(_uvicorn_access_debug)
+37
View File
@@ -32,6 +32,10 @@ _KEY_ENV: dict[str, str] = {
# LOG_LEVEL 可选项(写入时建议大写)
LOG_LEVEL_OPTIONS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# 资源发送方式(写入 .env 的 HEXI_MEDIA_MODE;语义见 hexi/core/outbound_media._mode)
MEDIA_MODE_OPTIONS = ["http", "base64", "file"]
_MEDIA_MODE_ENV = "HEXI_MEDIA_MODE"
# 日志清理大小(MB)默认值(未在 .env 配置时)
_LOG_MAX_MB_DEFAULT = 20
@@ -103,6 +107,22 @@ def _current_log_max_mb() -> int:
return _LOG_MAX_MB_DEFAULT
def _current_media_mode() -> str:
"""当前资源发送方式(HEXI_MEDIA_MODE > 旧的两个开关 > 默认 http)。
推断规则必须与 `hexi.core.outbound_media._mode` 一致(那边运行时读
os.environ,这边多读一层 .env 文件,好在手改 .env 未重启时也能显示正确)。
"""
raw = _env_get(_MEDIA_MODE_ENV).strip().lower()
if raw in MEDIA_MODE_OPTIONS:
return raw
if _env_get("HEXI_INLINE_MEDIA").strip().lower() in ("0", "false", "no", "off"):
return "file"
if _env_get("HEXI_INLINE_MEDIA_URL").strip().lower() in ("0", "false", "no", "off"):
return "base64"
return "http"
def current_values() -> dict[str, Any]:
"""返回当前生效值(读取优先级:driver.config > .env)。"""
try:
@@ -129,6 +149,8 @@ def current_values() -> dict[str, Any]:
"log_level_options": LOG_LEVEL_OPTIONS,
"log_max_mb": _current_log_max_mb(),
"log_max_mb_default": _LOG_MAX_MB_DEFAULT,
"media_mode": _current_media_mode(),
"media_mode_options": MEDIA_MODE_OPTIONS,
"source": "config" if cfg is not None else "env",
}
@@ -190,6 +212,14 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
if log_max_mb <= 0:
raise ValueError("日志清理大小必须是正整数(MB)")
# 资源发送方式(http/base64/file,写入 HEXI_MEDIA_MODE)
media_mode: str | None = None
_media_mode_raw = values.get("media_mode")
if _media_mode_raw is not None and str(_media_mode_raw).strip() != "":
media_mode = str(_media_mode_raw).strip().lower()
if media_mode not in MEDIA_MODE_OPTIONS:
raise ValueError(f"资源发送方式必须是 {'/'.join(MEDIA_MODE_OPTIONS)} 之一")
# 1. 写 os.environ(即时可读,进程内 getenv 立刻生效)
os.environ["SUPERUSERS"] = _serialize_list(superusers)
os.environ["NICKNAME"] = _serialize_list(nickname)
@@ -197,6 +227,9 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
os.environ["LOG_LEVEL"] = log_level
if log_max_mb is not None:
os.environ["HEXI_LOG_MAX_MB"] = str(log_max_mb)
if media_mode is not None:
# outbound_media._mode() 每次发送现读 os.environ → 保存即生效,无需重启
os.environ[_MEDIA_MODE_ENV] = media_mode
# 2. 落盘 .env(重启后依然生效)
_set_env_line("SUPERUSERS", _serialize_list(superusers))
@@ -205,6 +238,8 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
_set_env_line("LOG_LEVEL", log_level)
if log_max_mb is not None:
_set_env_line("HEXI_LOG_MAX_MB", str(log_max_mb))
if media_mode is not None:
_set_env_line(_MEDIA_MODE_ENV, media_mode)
# 3. 热应用到 driver.config
applied: list[str] = []
@@ -232,6 +267,8 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
_apply_log_level(log_level)
if log_max_mb is not None:
applied.append("HEXI_LOG_MAX_MB")
if media_mode is not None:
applied.append(_MEDIA_MODE_ENV)
return {
"ok": True,
+26 -2
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio
import os
import platform
import socket
import time
import psutil
@@ -216,8 +217,26 @@ def _is_private_ip(ip: str) -> bool:
return a == 10 or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31)
def _route_ip() -> str | None:
"""默认路由出口 IP —— 连外网时内核会挑的那张网卡。
VPN / 虚拟机 / WSL / EasyTier 的地址同样是私有段,纯按字符串排序会让
虚拟网卡赢(10.0.0.129 < 192.168.2.186),首页「协议端配对」就会展示一个
协议端连不上的地址(2026-10-08 实测)。逻辑与 hexi.core.outbound_media
的 `_route_ip` 同源,改这里记得同步那边;无网络时返回 None。
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("8.8.8.8", 80))
return str(sock.getsockname()[0])
except OSError:
return None
finally:
sock.close()
def _local_ips() -> list[str]:
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。"""
"""本机可被协议端访问的 IPv4 列表(默认路由网卡最前,其次私有网段优先)。"""
ips: list[str] = []
try:
addrs = psutil.net_if_addrs()
@@ -237,7 +256,12 @@ def _local_ips() -> list[str]:
continue
if ip not in ips:
ips.append(ip)
return sorted(ips, key=lambda ip: (not _is_private_ip(ip), ip))
ips.sort(key=lambda ip: (not _is_private_ip(ip), ip))
route = _route_ip()
if route in ips:
ips.remove(route)
ips.insert(0, route)
return ips
def _ws_path() -> str: