Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""前端管理页面的构建与静态挂载。
|
||||
|
||||
方案: 不再由 bot 拉起 vite dev server, 而是把构建产物用 FastAPI StaticFiles
|
||||
挂载到 bot 端口 /learning_chat 路径, 与 API 同源, 无孤儿进程、无 CORS 问题。
|
||||
前端开发时手动 `npm run dev` 即可(API 已配置 dev proxy)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from nonebot import logger
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ADMIN_DIR = BASE_DIR / "web" / "learning-chat-admin"
|
||||
DIST_DIR = ADMIN_DIR / "dist"
|
||||
|
||||
# 参与构建新鲜度检查的源文件(不在 src/ 下的)
|
||||
SRC_FILES = ("vite.config.ts", "index.html", "package.json")
|
||||
|
||||
|
||||
def _dist_is_stale() -> bool:
|
||||
"""dist/index.html 不存在, 或比任何源码旧时视为需要重建"""
|
||||
dist_index = DIST_DIR / "index.html"
|
||||
if not dist_index.exists():
|
||||
return True
|
||||
dist_mtime = dist_index.stat().st_mtime
|
||||
for src_file in (ADMIN_DIR / "src").rglob("*"):
|
||||
if src_file.is_file() and src_file.stat().st_mtime > dist_mtime:
|
||||
return True
|
||||
return any(
|
||||
(ADMIN_DIR / name).stat().st_mtime > dist_mtime
|
||||
for name in SRC_FILES
|
||||
if (ADMIN_DIR / name).exists()
|
||||
)
|
||||
|
||||
|
||||
async def build_frontend() -> bool:
|
||||
"""执行 vite 构建, 返回是否成功"""
|
||||
logger.info("群聊学习 | 前端源码已更新, 开始重新构建(约10-40秒)...")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"npx.cmd",
|
||||
"vite",
|
||||
"build",
|
||||
cwd=str(ADMIN_DIR),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
output, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.error(
|
||||
"群聊学习 | 前端构建失败(%s):\n%s",
|
||||
proc.returncode,
|
||||
output.decode(errors="ignore")[-3000:],
|
||||
)
|
||||
return False
|
||||
logger.info("群聊学习 | 前端构建完成")
|
||||
return True
|
||||
|
||||
|
||||
async def ensure_frontend_ready() -> bool:
|
||||
"""确保构建产物是最新的, 返回是否可用(最新或已成功重建)"""
|
||||
if not _dist_is_stale():
|
||||
return True
|
||||
return await build_frontend()
|
||||
|
||||
|
||||
def mount_frontend(app) -> None:
|
||||
"""将构建产物挂载到 bot 端口的 /learning_chat 路径(需在 API 路由注册后调用)。
|
||||
|
||||
history 模式 SPA 需要回退: assets/ 静态挂载 + 真实文件直接返回,
|
||||
其余路径(如 /login /config 等前端路由)一律回退到 index.html。
|
||||
"""
|
||||
if not DIST_DIR.exists():
|
||||
logger.error("群聊学习 | 前端构建产物不存在, 管理页面不可用")
|
||||
return
|
||||
assets_dir = DIST_DIR / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount(
|
||||
"/learning_chat/assets",
|
||||
StaticFiles(directory=str(assets_dir)),
|
||||
name="learning_chat_assets",
|
||||
)
|
||||
dist_resolved = DIST_DIR.resolve()
|
||||
|
||||
@app.get("/learning_chat/{full_path:path}")
|
||||
async def learning_chat_spa(full_path: str):
|
||||
target = (DIST_DIR / full_path).resolve()
|
||||
if full_path and target.is_file() and target.is_relative_to(dist_resolved):
|
||||
return FileResponse(target)
|
||||
# SPA 回退: 前端路由(如 /login /config)全部交给 index.html
|
||||
return FileResponse(DIST_DIR / "index.html")
|
||||
|
||||
@app.get("/learning_chat")
|
||||
async def learning_chat_root():
|
||||
return FileResponse(DIST_DIR / "index.html")
|
||||
|
||||
logger.info("群聊学习 | 管理页面已挂载: http://<host>:<port>/learning_chat/")
|
||||
Reference in New Issue
Block a user