feat: 添加 AstrBot Pages 原生管理台#82
Conversation
There was a problem hiding this comment.
Sorry @Justice-ocr, your pull request is larger than the review limit of 150000 diff characters
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive web administration interface and dashboard for the proactive chat plugin, adding frontend assets (HTML, CSS, JS, and fonts) and registering corresponding backend API endpoints in core/web_admin_server.py. It also adds unanswered message limit checks in core/task_scheduler.py. Feedback focuses on improving robustness, including tracking asynchronous tasks to prevent premature garbage collection, handling potential AttributeError exceptions when session data is null, removing redundant payload unwrapping, and implementing a loading lock in the frontend to prevent concurrent snapshot requests.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| self.plugin.manual_trigger_sessions.add(normalized) | ||
| asyncio.create_task(self.plugin.check_and_chat(normalized)) |
There was a problem hiding this comment.
此处直接调用 asyncio.create_task 且未保留强引用,可能会导致任务在执行中途被垃圾回收。建议使用插件主体提供的 self.plugin._track_task 方法来包裹该任务,以确保其生命周期得到正确管理。
| self.plugin.manual_trigger_sessions.add(normalized) | |
| asyncio.create_task(self.plugin.check_and_chat(normalized)) | |
| self.plugin.manual_trigger_sessions.add(normalized) | |
| self.plugin._track_task(asyncio.create_task(self.plugin.check_and_chat(normalized))) |
There was a problem hiding this comment.
已在 e707217 中修复:立即触发任务现在通过 _track_background_task(asyncio.create_task(...)) 管理;该 helper 会优先调用插件主体的 _track_task,如果不可用则由 WebAdminServer 内部集合保留强引用并在任务完成后自动移除。
| if unanswered_count is None: | ||
| try: | ||
| unanswered_count = int( | ||
| self.session_data.get(session_id, {}).get("unanswered_count", 0) | ||
| or 0 | ||
| ) | ||
| except (TypeError, ValueError): | ||
| unanswered_count = 0 |
There was a problem hiding this comment.
如果 self.session_data 中 session_id 对应的值为 None(例如数据损坏或初始化异常),self.session_data.get(session_id, {}) 将返回 None。随后调用 .get("unanswered_count", 0) 会抛出 AttributeError,而该异常并未被 try-except (TypeError, ValueError) 捕获,从而导致程序崩溃。建议使用更安全的防御性代码。
| if unanswered_count is None: | |
| try: | |
| unanswered_count = int( | |
| self.session_data.get(session_id, {}).get("unanswered_count", 0) | |
| or 0 | |
| ) | |
| except (TypeError, ValueError): | |
| unanswered_count = 0 | |
| if unanswered_count is None: | |
| session_info = self.session_data.get(session_id) | |
| if isinstance(session_info, dict): | |
| try: | |
| unanswered_count = int(session_info.get("unanswered_count", 0) or 0) | |
| except (TypeError, ValueError): | |
| unanswered_count = 0 | |
| else: | |
| unanswered_count = 0 |
There was a problem hiding this comment.
已在 e707217 中修复:_is_unanswered_limit_reached 现在会先确认 session_info 是 dict,再读取 unanswered_count;如果数据损坏为 None 或其他类型,会安全回退为 0,避免 AttributeError。
| updated = update_config(self.config) | ||
| if inspect.isawaitable(updated): | ||
| asyncio.create_task(updated) |
There was a problem hiding this comment.
在 _save_plugin_config 中,asyncio.create_task(updated) 创建的异步任务没有保留强引用,可能会在执行中途被垃圾回收器(GC)意外销毁,导致配置更新未完全应用。建议使用 self.plugin._track_task 来包裹该任务以确保其安全执行。
| updated = update_config(self.config) | |
| if inspect.isawaitable(updated): | |
| asyncio.create_task(updated) | |
| updated = update_config(self.config) | |
| if inspect.isawaitable(updated): | |
| self.plugin._track_task(asyncio.create_task(updated)) |
There was a problem hiding this comment.
已在 e707217 中修复:context.update_config(...) 返回 awaitable 时现在也通过 _track_background_task 管理,避免裸 asyncio.create_task 缺少强引用的问题。
| async def _apply_config_payload(self, payload: dict[str, Any]) -> dict[str, Any]: | ||
| payload = self._unwrap_page_payload(payload) |
There was a problem hiding this comment.
_apply_config_payload 中再次调用了 self._unwrap_page_payload(payload)。然而,传入的 payload 已经在 _read_astrbot_page_json(第 254 行)中被解包过一次。这种重复解包是多余的,并且在极端情况下(例如 payload 恰好只包含一个名为 data、body 或 payload 的合法配置键时)会导致非预期的二次解包,从而破坏数据结构。建议移除此处的重复调用。
| async def _apply_config_payload(self, payload: dict[str, Any]) -> dict[str, Any]: | |
| payload = self._unwrap_page_payload(payload) | |
| async def _apply_config_payload(self, payload: dict[str, Any]) -> dict[str, Any]: |
There was a problem hiding this comment.
已在 e707217 中修复:移除了 _apply_config_payload 中重复的 _unwrap_page_payload 调用。Pages 请求体只在 _read_astrbot_page_json 阶段解包一次,避免极端 payload 结构被二次解包破坏。
| function loadRealtimeSnapshot() { | ||
| if (!state.bridgeReady || !state.bridge) return; | ||
| if (document.visibilityState === "hidden") return; | ||
| if (state.busy.configSave || state.busy.sessionSave) return; | ||
| if (state.view === "config") return; | ||
| apiGet(route("dashboard")).then(function (data) { | ||
| applyDashboardPayload(data); | ||
| if (state.view === "status" || state.view === "tasks") render(); | ||
| }).catch(function () { | ||
| Promise.all([apiGet(route("status")), apiGet(route("jobs")), apiGet(route("session-config/sessions"))]).then(function (parts) { | ||
| state.status = parts[0] || state.status || {}; | ||
| state.jobs = asArray(parts[1].jobs || parts[1]); | ||
| state.sessions = asArray(parts[2].sessions || parts[2]); | ||
| state.lastRealtimeAt = Date.now(); | ||
| if (state.view === "status" || state.view === "tasks") render(); | ||
| }).catch(function () {}); | ||
| }); |
There was a problem hiding this comment.
当前使用 setInterval 每秒无条件调用 loadRealtimeSnapshot。如果网络出现延迟或后端响应变慢,可能会导致多个并发的 dashboard 请求堆积,造成不必要的服务器负载和潜在的竞态条件(更新顺序错乱)。建议引入一个 state.loadingSnapshot 状态锁,在请求未完成时拒绝发起新的轮询请求,并在 Promise 链结束(无论成功或失败)后释放该锁。
function loadRealtimeSnapshot() {
if (!state.bridgeReady || !state.bridge) return;
if (state.loadingSnapshot) return;
if (document.visibilityState === "hidden") return;
if (state.busy.configSave || state.busy.sessionSave) return;
if (state.view === "config") return;
state.loadingSnapshot = true;
apiGet(route("dashboard")).then(function (data) {
applyDashboardPayload(data);
if (state.view === "status" || state.view === "tasks") render();
}).catch(function () {
return Promise.all([apiGet(route("status")), apiGet(route("jobs")), apiGet(route("session-config/sessions"))]).then(function (parts) {
state.status = parts[0] || state.status || {};
state.jobs = asArray(parts[1].jobs || parts[1]);
state.sessions = asArray(parts[2].sessions || parts[2]);
state.lastRealtimeAt = Date.now();
if (state.view === "status" || state.view === "tasks") render();
}).catch(function () {});
}).finally(function () {
state.loadingSnapshot = false;
});
}There was a problem hiding this comment.
已在 e707217 中修复:前端增加 state.loadingSnapshot 轮询锁;loadRealtimeSnapshot 在上一次 dashboard/fallback 请求完成前不会发起新请求,并且 fallback 的 Promise.all 会被 return,确保 finally 在完整链路结束后才释放锁。
|
已根据 review 推送
验证: 另外,Sourcery 提示 PR 超过 review diff 限制。这个问题我理解并认同:Pages 管理台天然会带入一批静态资源,当前已经移除了 React/vendor 残留、目录快捷入口、通知中心和文档浏览等非必要内容,尽量让 #74 聚焦在状态、任务和配置管理三块。若维护者希望继续压缩体量,我可以后续再单独拆分/瘦身静态样式资源。 |
📝 描述 / Description
Fixes #74。
这个 PR 为插件新增 AstrBot Pages 原生管理台,目标是让插件可以在 AstrBot WebUI 的插件页面内直接完成状态查看、任务管理与配置管理,而不是只提供跳转到独立 Web 管理端的新窗口入口。
改动由 AI 协助生成,整体体量仍然偏大,UI 与实现也有继续打磨空间。提交前已经按上游反馈将 #26 的语境调度改动拆到独立 PR,本 PR 只聚焦 #74 的 Pages 管理界面适配。
🛠️ 改动点 / Modifications
这不是一个破坏性变更 / This is NOT a breaking change.
在
core/plugin_lifecycle.py中启动 Web 管理端后注册 AstrBot Pages API。在
core/web_admin_server.py中新增 Pages 专用接口,覆盖运行状态、全局配置、会话差异配置、任务列表、立即触发、重新调度、取消任务等能力。在
core/web_admin_server.py中加入 FastAPI / Starlette Router 启动参数兼容补丁,避免Router.__init__() got an unexpected keyword argument 'on_startup'导致 Web 管理端初始化失败。在
core/task_scheduler.py中补充未回复上限判断 helper,供 Pages 任务视图过滤已达上限的任务状态使用。新增
pages/proactive-chat/原生 Pages 页面,包含运行状态、任务管理、配置管理、会话计时器可视化等界面。配置保存路径增加后端接收确认与回读校验,避免只更新前端状态但未真正写入后端。
按反馈移除了 Pages 中不必要的“打开目录”快捷入口、“通知中心”和“文档浏览”,让页面更聚焦主动消息管理本身。
📸 运行截图或测试结果 / Screenshots or Test Results
✅ 检查清单 / Checklist
requirements.txt文件中。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added torequirements.txt.❤️ CONTRIBUTING