diff --git a/src/chat/utils/report_generator.py b/src/chat/utils/report_generator.py
new file mode 100644
index 000000000..5526781fa
--- /dev/null
+++ b/src/chat/utils/report_generator.py
@@ -0,0 +1,446 @@
+"""
+该模块用于生成HTML格式的统计报告。
+"""
+from datetime import datetime, timedelta
+from typing import Any
+
+import aiofiles
+
+from .statistic_keys import * # noqa: F403
+
+
+def format_online_time(online_seconds: int) -> str:
+ """
+ 格式化在线时间。
+
+ :param online_seconds: 在线时间(秒)。
+ :return: 格式化后的在线时间字符串。
+ """
+ total_online_time = timedelta(seconds=online_seconds)
+ days = total_online_time.days
+ hours = total_online_time.seconds // 3600
+ minutes = (total_online_time.seconds // 60) % 60
+ seconds = total_online_time.seconds % 60
+ if days > 0:
+ return f"{days}天{hours}小时{minutes}分钟{seconds}秒"
+ elif hours > 0:
+ return f"{hours}小时{minutes}分钟{seconds}秒"
+ else:
+ return f"{minutes}分钟{seconds}秒"
+
+
+class HTMLReportGenerator:
+ """生成HTML统计报告"""
+
+ def __init__(
+ self,
+ name_mapping: dict,
+ stat_period: list,
+ deploy_time: datetime,
+ ):
+ """
+ 初始化报告生成器。
+
+ :param name_mapping: 聊天ID到名称的映射。
+ :param stat_period: 统计时间段配置。
+ :param deploy_time: 系统部署时间。
+ """
+ self.name_mapping = name_mapping
+ self.stat_period = stat_period
+ self.deploy_time = deploy_time
+
+ def _format_stat_data_div(self, stat_data: dict[str, Any], div_id: str, start_time: datetime, now: datetime) -> str:
+ """
+ 将单个时间段的统计数据格式化为HTML div块。
+
+ :param stat_data: 统计数据。
+ :param div_id: div的ID。
+ :param start_time: 统计时间段的开始时间。
+ :param now: 当前时间。
+ :return: HTML字符串。
+ """
+ # 按模型分类统计
+ model_rows = "\n".join(
+ [
+ f"
"
+ f"| {model_name} | "
+ f"{count} | "
+ f"{stat_data[AVG_TOK_BY_MODEL].get(model_name, 0)} | "
+ f"{stat_data[TOTAL_TOK_BY_MODEL].get(model_name, 0)} | "
+ f"{stat_data[TPS_BY_MODEL].get(model_name, 0):.2f} | "
+ f"{stat_data[COST_PER_KTOK_BY_MODEL].get(model_name, 0):.4f} ¥ | "
+ f"{stat_data[COST_BY_MODEL].get(model_name, 0):.4f} ¥ | "
+ f"{stat_data[AVG_TIME_COST_BY_MODEL].get(model_name, 0):.3f} 秒 | "
+ f"
"
+ for model_name, count in sorted(stat_data[REQ_CNT_BY_MODEL].items())
+ ]
+ )
+ # 按供应商分类统计
+ provider_rows = "\n".join(
+ [
+ f""
+ f"| {provider_name} | "
+ f"{count} | "
+ f"{stat_data[TOTAL_TOK_BY_PROVIDER].get(provider_name, 0)} | "
+ f"{stat_data[TPS_BY_PROVIDER].get(provider_name, 0):.2f} | "
+ f"{stat_data[COST_PER_KTOK_BY_PROVIDER].get(provider_name, 0):.4f} ¥ | "
+ f"{stat_data[COST_BY_PROVIDER].get(provider_name, 0):.4f} ¥ | "
+ f"
"
+ for provider_name, count in sorted(stat_data[REQ_CNT_BY_PROVIDER].items())
+ ]
+ )
+ # 按请求类型分类统计
+ type_rows = "\n".join(
+ [
+ f""
+ f"| {req_type} | "
+ f"{count} | "
+ f"{stat_data[IN_TOK_BY_TYPE].get(req_type, 0)} | "
+ f"{stat_data[OUT_TOK_BY_TYPE].get(req_type, 0)} | "
+ f"{stat_data[TOTAL_TOK_BY_TYPE].get(req_type, 0)} | "
+ f"{stat_data[COST_BY_TYPE].get(req_type, 0):.4f} ¥ | "
+ f"{stat_data[AVG_TIME_COST_BY_TYPE].get(req_type, 0):.3f} 秒 | "
+ f"{stat_data[STD_TIME_COST_BY_TYPE].get(req_type, 0):.3f} 秒 | "
+ f"
"
+ for req_type, count in sorted(stat_data[REQ_CNT_BY_TYPE].items())
+ ]
+ )
+ # 按模块分类统计
+ module_rows = "\n".join(
+ [
+ f""
+ f"| {module_name} | "
+ f"{count} | "
+ f"{stat_data[IN_TOK_BY_MODULE].get(module_name, 0)} | "
+ f"{stat_data[OUT_TOK_BY_MODULE].get(module_name, 0)} | "
+ f"{stat_data[TOTAL_TOK_BY_MODULE].get(module_name, 0)} | "
+ f"{stat_data[COST_BY_MODULE].get(module_name, 0):.4f} ¥ | "
+ f"{stat_data[AVG_TIME_COST_BY_MODULE].get(module_name, 0):.3f} 秒 | "
+ f"{stat_data[STD_TIME_COST_BY_MODULE].get(module_name, 0):.3f} 秒 | "
+ f"
"
+ for module_name, count in sorted(stat_data[REQ_CNT_BY_MODULE].items())
+ ]
+ )
+ # 聊天消息统计
+ chat_rows = "\n".join(
+ [
+ f"| {self.name_mapping.get(chat_id, ('未知', 0))[0]} | {count} |
"
+ for chat_id, count in sorted(stat_data[MSG_CNT_BY_CHAT].items())
+ ]
+ )
+ summary_cards = f"""
+
+
+
总花费
+
{stat_data.get(TOTAL_COST, 0):.4f} ¥
+
+
+
总请求数
+
{stat_data.get(TOTAL_REQ_CNT, 0)}
+
+
+
总Token数
+
{sum(stat_data.get(TOTAL_TOK_BY_MODEL, {}).values())}
+
+
+
总消息数
+
{stat_data.get(TOTAL_MSG_CNT, 0)}
+
+
+
总在线时间
+
{format_online_time(int(stat_data.get(ONLINE_TIME, 0)))}
+
+
+ """
+
+ # 增加饼图和条形图
+ static_charts = self._generate_static_charts_div(stat_data, div_id) # 该功能尚未实现
+ return f"""
+
+
+ 统计时段:
+ {start_time.strftime("%Y-%m-%d %H:%M:%S")} ~ {now.strftime("%Y-%m-%d %H:%M:%S")}
+
+ {summary_cards}
+ {static_charts}
+
+
按模型分类统计
+
+ | 模型名称 | 调用次数 | 平均Token数 | Token总量 | TPS | 每K Token成本 | 累计花费 | 平均耗时(秒) |
+ {model_rows}
+
+
+
按供应商分类统计
+
+ | 供应商名称 | 调用次数 | Token总量 | TPS | 每K Token成本 | 累计花费 |
+ {provider_rows}
+
+
+
按模块分类统计
+
+
+ | 模块名称 | 调用次数 | 输入Token | 输出Token | Token总量 | 累计花费 | 平均耗时(秒) | 标准差(秒) |
+
+ {module_rows}
+
+
+
按请求类型分类统计
+
+
+ | 请求类型 | 调用次数 | 输入Token | 输出Token | Token总量 | 累计花费 | 平均耗时(秒) | 标准差(秒) |
+
+ {type_rows}
+
+
+
聊天消息统计
+
+
+ | 联系人/群组名称 | 消息数量 |
+
+ {chat_rows}
+
+
+ """
+ def _generate_chart_tab(self, chart_data: dict) -> str:
+ """生成图表选项卡的HTML内容。"""
+ return f"""
+
+
数据图表
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ async def generate_report(self, stat: dict[str, Any], chart_data: dict, now: datetime, output_path: str):
+ """
+ 生成并写入完整的HTML报告文件。
+
+ :param stat: 所有时间段的统计数据。
+ :param chart_data: 用于图表的数据。
+ :param now: 当前时间。
+ :param output_path: 输出文件路径。
+ """
+ tab_list = [
+ f''
+ for period in self.stat_period
+ ]
+ tab_list.append('')
+
+ tab_content_list = [
+ self._format_stat_data_div(stat[period[0]], period[0], now - period[1], now)
+ for period in self.stat_period
+ if period[0] != "all_time"
+ ]
+ tab_content_list.append(
+ self._format_stat_data_div(stat["all_time"], "all_time", self.deploy_time, now)
+ )
+ tab_content_list.append(self._generate_chart_tab(chart_data))
+
+ joined_tab_list = "\n".join(tab_list)
+ joined_tab_content = "\n".join(tab_content_list)
+
+ html_template = f"""
+
+
+
+
+
+ MoFox-Bot运行统计报告
+
+
+
+
+
+
MoFox-Bot运行统计报告
+
统计截止时间: {now.strftime("%Y-%m-%d %H:%M:%S")}
+
{joined_tab_list}
+ {joined_tab_content}
+
+
+
+
+ """
+ async with aiofiles.open(output_path, "w", encoding="utf-8") as f:
+ await f.write(html_template)
+ def _generate_static_charts_div(self, stat_data: dict[str, Any], period_id: str) -> str:
+ """生成静态图表(饼图、条形图)的HTML和JS。"""
+ provider_cost_data = stat_data.get(PIE_CHART_COST_BY_PROVIDER, {})
+ model_cost_data = stat_data.get(BAR_CHART_COST_BY_MODEL, {})
+
+ if not provider_cost_data and not model_cost_data:
+ return "数据总览
当前时段暂无足够数据生成图表。
"
+
+ provider_labels = provider_cost_data.get('labels', [])
+ provider_data = provider_cost_data.get('data', [])
+ model_labels = model_cost_data.get('labels', [])
+ model_data = model_cost_data.get('data', [])
+
+ return f"""
+ 数据总览
+
+
+ """
diff --git a/src/chat/utils/statistic.py b/src/chat/utils/statistic.py
index 8007f8f97..08d2ed8be 100644
--- a/src/chat/utils/statistic.py
+++ b/src/chat/utils/statistic.py
@@ -16,6 +16,7 @@ logger = get_logger("maibot_statistic")
# 彻底异步化:删除原同步包装器 _sync_db_get,所有数据库访问统一使用 await db_get。
+from .report_generator import HTMLReportGenerator, format_online_time
from .statistic_keys import *
@@ -180,6 +181,16 @@ class StatisticOutputTask(AsyncTask):
logger.info("统计数据收集完成")
self._statistic_console_output(stats, now)
+ # 使用新的 HTMLReportGenerator 生成报告
+ chart_data = await self._collect_chart_data(stats)
+ deploy_time = datetime.fromtimestamp(local_storage.get("deploy_time", now.timestamp()))
+ report_generator = HTMLReportGenerator(
+ name_mapping=self.name_mapping,
+ stat_period=self.stat_period,
+ deploy_time=deploy_time,
+ )
+ await report_generator.generate_report(stats, chart_data, now, self.record_file_path)
+ logger.info("统计数据HTML报告输出完成")
except Exception as e:
logger.exception(f"输出统计数据过程中发生异常,错误信息:{e}")
@@ -196,6 +207,18 @@ class StatisticOutputTask(AsyncTask):
logger.info("(后台) 正在收集统计数据(异步)...")
stats = await self._collect_all_statistics(now)
self._statistic_console_output(stats, now)
+
+ # 使用新的 HTMLReportGenerator 生成报告
+ chart_data = await self._collect_chart_data(stats)
+ deploy_time = datetime.fromtimestamp(local_storage.get("deploy_time", now.timestamp()))
+ report_generator = HTMLReportGenerator(
+ name_mapping=self.name_mapping,
+ stat_period=self.stat_period,
+ deploy_time=deploy_time,
+ )
+ await report_generator.generate_report(stats, chart_data, now, self.record_file_path)
+
+ logger.info("统计数据后台输出完成")
except Exception as e:
logger.exception(f"后台统计数据输出过程中发生异常:{e}")