♻️ refactor(ui): 重构 UI 渲染系统并优化资源管理 (#2094)

* ♻️ refactor(ui): 重构 UI 渲染系统并优化资源管理

- 【重构】重构 `RendererService` 架构,解耦模板引擎、主题管理与截图引擎
- 【重构】重构 `ui` 模块,采用组件注册机制与数据模型驱动,移除旧版 `builders`
- 【功能】新增 UI 热重载模式,支持在不重启的情况下实时预览 HTML/CSS 修改
- 【功能】增强启动项资源检查,支持基于 `resources.spec` 的版本校验与自动更新
- 【优化】统一内置插件的 UI 渲染逻辑,迁移至新的 `ui.table`、`ui.markdown` 等工厂接口
- 【优化】优化日志脱敏工具,支持自动折叠调试输出中冗长的样式标签
- 【优化】引入 `AssetResolutionService`,完善皮肤、组件、主题间的多级资源回退机制
- 【优化】新增组件生命周期钩子 `prepare`,支持渲染前的异步数据预处理

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio
2026-02-06 20:13:40 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bc8e1659ae
commit 5e30694663
77 changed files with 3035 additions and 3258 deletions
+51 -2
View File
@@ -1,9 +1,12 @@
from datetime import datetime
from pathlib import Path
import uuid
import nonebot
from nonebot.adapters import Bot
from nonebot.drivers import Driver
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from tortoise import Tortoise
from tortoise.exceptions import IntegrityError, OperationalError
import ujson as json
@@ -85,8 +88,54 @@ from bag_users t1
@PriorityLifecycle.on_startup(priority=5)
async def _():
if not ZhenxunRepoManager.check_resources_exists():
await ZhenxunRepoManager.resources_update()
try:
should_update = False
resource_path = ZhenxunRepoManager.config.RESOURCE_PATH
default_theme_path = resource_path / "themes" / "default"
version_file = resource_path / "__version__"
if (
not ZhenxunRepoManager.check_resources_exists()
or not default_theme_path.exists()
or not version_file.exists()
):
should_update = True
logger.info(
"检测到资源文件(字体/主题/版本信息)缺失,准备进行初始化下载...",
"资源检查",
)
else:
spec_file = Path("resources.spec")
req_ver_str = ">=0.0.0"
if spec_file.exists():
try:
for line in spec_file.read_text("utf-8").splitlines():
if line.strip().startswith("require_resources_version:"):
req_ver_str = line.split(":", 1)[1].strip().strip("'\"")
break
except Exception:
pass
local_ver_str = "0.0.0"
try:
content = version_file.read_text("utf-8").strip()
local_ver_str = (
content.split(":", 1)[1].strip() if ":" in content else content
)
except Exception:
pass
if not SpecifierSet(req_ver_str).contains(Version(local_ver_str)):
should_update = True
logger.info(
f"资源版本({local_ver_str})不满足要求({req_ver_str}),准备强制更新...",
"资源检查",
)
if should_update:
await ZhenxunRepoManager.resources_update()
except Exception as e:
logger.error(f"资源检查或更新失败: {e}", "资源检查")
"""签到与用户的数据迁移"""
if goods_list := await GoodsInfo.filter(uuid__isnull=True).all():
for goods in goods_list:
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from typing import cast
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import (
@@ -21,7 +22,6 @@ from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.services import avatar_service
from zhenxun.services.log import logger
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
@@ -121,9 +121,12 @@ async def _(
if not show_quit_member:
fetch_count = count.result * 2
if rank_data := await ChatHistory.get_group_msg_rank(
raw_rank_data = await ChatHistory.get_group_msg_rank(
group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope
):
)
if raw_rank_data:
rank_data = cast(list[tuple[str, int]], raw_rank_data)
rows_data = []
platform = "qq"
@@ -174,10 +177,10 @@ async def _(
f"{date_scope[1].replace(microsecond=0)}"
)
builder = TableBuilder(f"消息排行({count.result})", date_str)
builder.set_headers(column_name).add_rows(rows_data)
table = ui.table(f"消息排行({count.result})", date_str)
table.set_headers(column_name).add_rows(rows_data)
image_bytes = await ui.render(builder.build())
image_bytes = await ui.render(table)
logger.info(
f"查看消息排行 数量={count.result}", arparma.header_result, session=session
+17 -16
View File
@@ -17,11 +17,7 @@ from zhenxun.services import (
generate,
)
from zhenxun.services.log import logger
from zhenxun.ui.builders import (
NotebookBuilder,
PluginMenuBuilder,
)
from zhenxun.ui.models import PluginMenuCategory
from zhenxun.ui.models import PluginMenuCategory, PluginMenuData
from zhenxun.utils.common_utils import format_usage_for_markdown
from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.platform import PlatformUtils
@@ -109,18 +105,23 @@ async def create_help_img(
bot_avatar_path = await avatar_service.get_avatar_path(platform, bot_id)
bot_avatar_url = bot_avatar_path.as_uri() if bot_avatar_path else ""
builder = PluginMenuBuilder(
bot_name=BotConfig.self_nickname,
bot_avatar_url=bot_avatar_url,
is_detail=is_detail,
)
categories_objects = []
for category in categories_for_model:
builder.add_category(
categories_objects.append(
PluginMenuCategory(name=category["name"], items=category["items"])
)
return await ui.render(builder.build())
# 直接实例化 Data Model
menu_data = PluginMenuData(
bot_name=BotConfig.self_nickname,
bot_avatar_url=bot_avatar_url,
is_detail=is_detail,
plugin_count=plugin_count,
active_count=active_count,
categories=categories_objects,
)
return await ui.render(menu_data)
async def get_user_allow_help(user_id: str) -> list[PluginType]:
@@ -299,9 +300,9 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes:
threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50)
if len(reply_text) > threshold:
builder = NotebookBuilder()
builder.text(reply_text)
return await ui.render(builder.build())
notebook = ui.notebook()
notebook.text(reply_text)
return await ui.render(notebook)
return reply_text
@@ -1,9 +1,9 @@
from typing import Any
from zhenxun import ui
from zhenxun.services import renderer_service
from zhenxun.services.llm.core import KeyStatus
from zhenxun.services.llm.types import ModelModality
from zhenxun.ui.builders import MarkdownBuilder, TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
@@ -33,10 +33,10 @@ class Presenters:
title = "LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)")
if not models:
builder = TableBuilder(
title=title, tip="当前没有配置任何LLM模型。"
).set_headers(["提供商", "模型名称", "API类型", "状态"])
return await renderer_service.render(builder.build())
table = ui.table(title=title, tip="当前没有配置任何LLM模型。").set_headers(
["提供商", "模型名称", "API类型", "状态"]
)
return await renderer_service.render(table)
column_name = ["提供商", "模型名称", "API类型", "状态"]
rows_data = []
@@ -55,13 +55,13 @@ class Presenters:
]
)
builder = TableBuilder(
table = ui.table(
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
)
builder.set_headers(column_name)
builder.set_column_alignments(["left", "left", "left", "center"])
builder.add_rows(rows_data)
return await renderer_service.render(builder.build(), use_cache=True)
table.set_headers(column_name)
table.set_column_alignments(["left", "left", "left", "center"])
table.add_rows(rows_data)
return await renderer_service.render(table, use_cache=True)
@staticmethod
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
@@ -82,25 +82,25 @@ class Presenters:
if caps.is_embedding_model:
cap_list.append("文本嵌入")
builder = MarkdownBuilder()
builder.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
builder.text("---")
builder.head("提供商信息", 2)
builder.text(f"- **名称**: {provider.name}")
builder.text(f"- **API 类型**: {provider.api_type}")
builder.text(f"- **API Base**: {provider.api_base or '默认'}")
md = ui.markdown("")
md.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
md.text("---")
md.head("提供商信息", 2)
md.text(f"- **名称**: {provider.name}")
md.text(f"- **API 类型**: {provider.api_type}")
md.text(f"- **API Base**: {provider.api_base or '默认'}")
builder.head("模型详情", 2)
md.head("模型详情", 2)
temp_value = model.temperature or provider.temperature or "未设置"
token_value = model.max_tokens or provider.max_tokens or "未设置"
builder.text(f"- **名称**: {model.model_name}")
builder.text(f"- **默认温度**: {temp_value}")
builder.text(f"- **最大Token**: {token_value}")
builder.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
md.text(f"- **名称**: {model.model_name}")
md.text(f"- **默认温度**: {temp_value}")
md.text(f"- **最大Token**: {token_value}")
md.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
return await renderer_service.render(builder.with_style("light").build())
return await renderer_service.render(md.with_style("light"))
@staticmethod
async def format_key_status_as_image(
@@ -167,10 +167,8 @@ class Presenters:
]
)
builder = TableBuilder(
title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态"
)
builder.set_headers(
table = ui.table(title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态")
table.set_headers(
[
"Key (部分)",
"状态",
@@ -181,5 +179,5 @@ class Presenters:
"建议操作",
]
)
builder.add_rows(data_list)
return await renderer_service.render(builder.build(), use_cache=False)
table.add_rows(data_list)
return await renderer_service.render(table, use_cache=False)
@@ -3,7 +3,6 @@ from typing import Any
from zhenxun import ui
from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services import scheduler_manager
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
from zhenxun.utils.pydantic_compat import model_json_schema
@@ -159,14 +158,14 @@ async def format_schedule_list_as_image(
if not data_list:
return "没有找到任何相关的定时任务。"
builder = TableBuilder(
table = ui.table(
title, f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务"
)
builder.set_headers(
table.set_headers(
["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"]
).add_rows(data_list)
return await ui.render(
builder.build(),
table,
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
)
+6 -8
View File
@@ -146,11 +146,10 @@ async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes |
else:
title = "金币全局排行"
tip = f"你的排名在全局第 {index} 位哦!"
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(title, tip)
builder.set_headers(column_name).add_rows(data_list)
return await ui.render(builder.build())
table = ui.table(title, tip)
table.set_headers(column_name).add_rows(data_list)
return await ui.render(table)
class ShopManage:
@@ -551,11 +550,10 @@ class ShopManage:
return None
column_name = ["-", "使用ID", "名称", "数量", "简介"]
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
builder.set_headers(column_name).add_rows(table_rows)
return await ui.render(builder.build())
table = ui.table(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
table.set_headers(column_name).add_rows(table_rows)
return await ui.render(table)
@classmethod
async def my_cost(cls, user_id: str, platform: str | None = None) -> int:
@@ -105,11 +105,10 @@ class SignManage:
else:
title = "好感度全局排行"
tip = f"你的排名在全局第 {index} 位哦!"
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(title, tip)
builder.set_headers(column_name).add_rows(data_list)
return await ui.render(builder.build())
table = ui.table(title, tip)
table.set_headers(column_name).add_rows(data_list)
return await ui.render(table)
@classmethod
async def sign(
@@ -21,12 +21,12 @@ from nonebot_plugin_alconna import (
from nonebot_plugin_session import EventSession
from pydantic import BaseModel, ValidationError
from zhenxun import ui
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services import group_settings_service, renderer_service
from zhenxun.services.log import logger
from zhenxun.services.tags import tag_manager
from zhenxun.ui import builders as ui
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
@@ -254,15 +254,15 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
rows.append(row_data)
builder = ui.TableBuilder(
table = ui.table(
title=f"插件 '{plugin_name_str}' 全群配置",
tip=f"共查询 {len(rows)} 个群组",
)
builder.set_headers(headers).add_rows(rows)
table.set_headers(headers).add_rows(rows)
viewport_width = 300 + len(config_keys) * 280
img = await renderer_service.render(
builder.build(), viewport={"width": viewport_width, "height": 10}
table, viewport={"width": viewport_width, "height": 10}
)
await MessageUtils.build_message(img).finish()
@@ -277,20 +277,20 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
f"插件 '{plugin_name_str}' 没有可配置的全局项。"
).finish()
builder = ui.TableBuilder(
table = ui.table(
title=f"插件 '{plugin_name_str}' 全局可配置项",
tip=(
f"位于 config.yaml, 使用 pconf set <key>=<value> "
f"-p {plugin_name_str} --global 进行设置"
),
)
builder.set_headers(["配置项", "当前值", "类型", "描述"])
table.set_headers(["配置项", "当前值", "类型", "描述"])
for key, config_model in config_group.configs.items():
type_name = getattr(
config_model.type, "__name__", str(config_model.type)
)
builder.add_row(
table.add_row(
[
key,
truncate_text(str(config_model.value), 20),
@@ -299,7 +299,7 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
]
)
img = await renderer_service.render(builder.build())
img = await renderer_service.render(table)
await MessageUtils.build_message(img).finish()
else:
model = await get_plugin_config_model(plugin_name_str)
@@ -309,11 +309,11 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
f"插件 '{plugin_name_str}' 不支持分群配置。"
).finish()
builder = ui.TableBuilder(
table = ui.table(
title=f"插件 '{plugin_name_str}' 可配置项",
tip=f"使用 pconf set <key>=<value> -p {plugin_name_str} 进行设置",
)
builder.set_headers(["配置项", "类型", "描述", "默认值"])
table.set_headers(["配置项", "类型", "描述", "默认值"])
for field in model_fields_list:
type_name = getattr(field.annotation, "__name__", str(field.annotation))
@@ -323,9 +323,9 @@ async def handle_list(arp: Arparma, bot: Bot, event: Event):
if field.field_info.default is not None
else "无"
)
builder.add_row([field.name, type_name, description, default_value])
table.add_row([field.name, type_name, description, default_value])
img = await renderer_service.render(builder.build())
img = await renderer_service.render(table)
await MessageUtils.build_message(img).finish()
else:
@@ -56,6 +56,15 @@ __plugin_meta__ = PluginMetadata(
default_value=False,
type=bool,
),
RegisterConfig(
module="UI",
key="HOT_RELOAD",
value=False,
help="是否开启UI热重载模式 (修改HTML/CSS后立即生效,"
"性能较低,仅建议开发时开启)",
default_value=False,
type=bool,
),
],
).to_dict(),
)