This commit is contained in:
Windpicker-owo
2025-11-13 15:10:15 +08:00
17 changed files with 560 additions and 387 deletions

1
.gitignore vendored
View File

@@ -344,3 +344,4 @@ package-lock.json
package.json package.json
src/chat/planner_actions/新建 文本文档.txt src/chat/planner_actions/新建 文本文档.txt
/backup /backup
mofox_bot_statistics.html

View File

@@ -10,7 +10,7 @@ services:
volumes: volumes:
- ./docker-config/core/.env:/app/.env # 持久化env配置文件 - ./docker-config/core/.env:/app/.env # 持久化env配置文件
- ./docker-config/core:/app/config # 持久化bot配置文件 - ./docker-config/core:/app/config # 持久化bot配置文件
- ./data/core/maibot_statistics.html:/app/maibot_statistics.html #统计数据输出 - ./data/core/mofox_bot_statistics.html:/app/mofox_bot_statistics.html #统计数据输出
- ./data/app:/app/data # 共享目录 - ./data/app:/app/data # 共享目录
- ./data/core/plugins:/app/plugins # 插件目录 - ./data/core/plugins:/app/plugins # 插件目录
- ./data/core/logs:/app/logs # 日志目录 - ./data/core/logs:/app/logs # 日志目录

View File

@@ -9,7 +9,7 @@ import re
import time import time
import traceback import traceback
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any, Literal
from src.chat.express.expression_selector import expression_selector from src.chat.express.expression_selector import expression_selector
from src.chat.message_receive.chat_stream import ChatStream from src.chat.message_receive.chat_stream import ChatStream
@@ -129,7 +129,7 @@ def init_prompt():
## 规则 ## 规则
{safety_guidelines_block} {safety_guidelines_block}
注意:在规划回复时,务必确定对方是不是真的在叫自己。聊天时往往有数百甚至数千个用户,请务必认清自己的身份和角色,避免误以为对方在和自己对话而贸然插入回复,导致尴尬局面。 {group_chat_reminder_block}
你的回复应该是一条简短、完整且口语化的回复。 你的回复应该是一条简短、完整且口语化的回复。
-------------------------------- --------------------------------
@@ -211,7 +211,7 @@ If you need to use the search tool, please directly call the function "lpmm_sear
## 规则 ## 规则
{safety_guidelines_block} {safety_guidelines_block}
注意:在规划回复时,务必确定对方是不是真的在叫自己。聊天时往往有数百甚至数千个用户,请务必认清自己的身份和角色,避免误以为对方在和自己对话而贸然插入回复,导致尴尬局面。 {group_chat_reminder_block}
你的回复应该是一条简短、完整且口语化的回复。 你的回复应该是一条简短、完整且口语化的回复。
-------------------------------- --------------------------------
@@ -355,7 +355,7 @@ class DefaultReplyer:
try: try:
# 从available_actions中提取prompt_mode由action_manager传递 # 从available_actions中提取prompt_mode由action_manager传递
# 如果没有指定默认使用s4u模式 # 如果没有指定默认使用s4u模式
prompt_mode_value: str = "s4u" prompt_mode_value: Any = "s4u"
if available_actions and "_prompt_mode" in available_actions: if available_actions and "_prompt_mode" in available_actions:
mode = available_actions.get("_prompt_mode", "s4u") mode = available_actions.get("_prompt_mode", "s4u")
# 确保类型安全 # 确保类型安全
@@ -602,10 +602,16 @@ class DefaultReplyer:
} }
# 使用记忆管理器的智能检索(多查询策略) # 使用记忆管理器的智能检索(多查询策略)
memories = []
if global_config.memory:
memories = []
if global_config.memory:
top_k = global_config.memory.search_top_k
min_importance = global_config.memory.search_min_importance
memories = await manager.search_memories( memories = await manager.search_memories(
query=target, query=target,
top_k=global_config.memory.search_top_k, top_k=top_k,
min_importance=global_config.memory.search_min_importance, min_importance=min_importance,
include_forgotten=False, include_forgotten=False,
use_multi_query=True, use_multi_query=True,
context=query_context, context=query_context,
@@ -1095,7 +1101,7 @@ class DefaultReplyer:
available_actions: dict[str, ActionInfo] | None = None, available_actions: dict[str, ActionInfo] | None = None,
enable_tool: bool = True, enable_tool: bool = True,
reply_message: DatabaseMessages | None = None, reply_message: DatabaseMessages | None = None,
prompt_mode: str = "s4u", # 新增参数s4u 或 normal prompt_mode: Literal["s4u", "normal", "minimal"] = "s4u", # 新增参数s4u 或 normal
) -> str: ) -> str:
""" """
构建回复器上下文 构建回复器上下文
@@ -1506,6 +1512,11 @@ class DefaultReplyer:
auth_role_prompt_block = await self._build_auth_role_prompt() auth_role_prompt_block = await self._build_auth_role_prompt()
# 动态构建群聊提醒
group_chat_reminder_block = ""
if is_group_chat:
group_chat_reminder_block = "注意:在规划回复时,务必确定对方是不是真的在叫自己。聊天时往往有数百甚至数千个用户,请务必认清自己的身份和角色,避免误以为对方在和自己对话而贸然插入回复,导致尴尬局面。"
# 使用新的统一Prompt系统 - 创建PromptParameters # 使用新的统一Prompt系统 - 创建PromptParameters
prompt_parameters = PromptParameters( prompt_parameters = PromptParameters(
chat_scene=chat_scene_prompt, chat_scene=chat_scene_prompt,
@@ -1542,6 +1553,7 @@ class DefaultReplyer:
mood_prompt=mood_prompt, mood_prompt=mood_prompt,
auth_role_prompt_block=auth_role_prompt_block, auth_role_prompt_block=auth_role_prompt_block,
action_descriptions=action_descriptions, action_descriptions=action_descriptions,
group_chat_reminder_block=group_chat_reminder_block,
bot_name=global_config.bot.nickname, bot_name=global_config.bot.nickname,
bot_nickname=",".join(global_config.bot.alias_names) if global_config.bot.alias_names else "", bot_nickname=",".join(global_config.bot.alias_names) if global_config.bot.alias_names else "",
) )

View File

@@ -13,7 +13,7 @@
import hashlib import hashlib
import random import random
import re import re
from typing import Any, Literal from typing import Any, ClassVar, Literal
from src.common.logger import get_logger from src.common.logger import get_logger
from src.config.config import global_config from src.config.config import global_config
@@ -26,7 +26,7 @@ class AttentionOptimizer:
# 可交换的block组定义组内block可以随机排序 # 可交换的block组定义组内block可以随机排序
# 每个组是一个列表包含可以互换位置的block名称 # 每个组是一个列表包含可以互换位置的block名称
SWAPPABLE_BLOCK_GROUPS = [ SWAPPABLE_BLOCK_GROUPS:ClassVar = [
# 用户相关信息组(记忆、关系、表达习惯) # 用户相关信息组(记忆、关系、表达习惯)
["memory_block", "relation_info_block", "expression_habits_block"], ["memory_block", "relation_info_block", "expression_habits_block"],
# 上下文增强组(工具、知识、跨群) # 上下文增强组(工具、知识、跨群)
@@ -37,7 +37,7 @@ class AttentionOptimizer:
# 语义等价的文本替换模板 # 语义等价的文本替换模板
# 格式: {原始文本: [替换选项1, 替换选项2, ...]} # 格式: {原始文本: [替换选项1, 替换选项2, ...]}
SEMANTIC_VARIANTS = { SEMANTIC_VARIANTS:ClassVar = {
"当前时间": ["当前时间", "现在是", "此时此刻", "时间"], "当前时间": ["当前时间", "现在是", "此时此刻", "时间"],
"最近的系统通知": ["最近的系统通知", "系统通知", "通知消息", "最新通知"], "最近的系统通知": ["最近的系统通知", "系统通知", "通知消息", "最新通知"],
"聊天历史": ["聊天历史", "对话记录", "历史消息", "之前的对话"], "聊天历史": ["聊天历史", "对话记录", "历史消息", "之前的对话"],
@@ -125,7 +125,7 @@ class AttentionOptimizer:
for group in self.SWAPPABLE_BLOCK_GROUPS: for group in self.SWAPPABLE_BLOCK_GROUPS:
# 过滤出实际存在且非空的block # 过滤出实际存在且非空的block
existing_blocks = [ existing_blocks = [
block for block in group if block in context_data and context_data[block] block for block in group if context_data.get(block)
] ]
if len(existing_blocks) > 1: if len(existing_blocks) > 1:

View File

@@ -910,6 +910,8 @@ class Prompt:
or context_data.get("auth_role_prompt_block", ""), or context_data.get("auth_role_prompt_block", ""),
"chat_scene": self.parameters.chat_scene "chat_scene": self.parameters.chat_scene
or "你正在一个QQ群里聊天你需要理解整个群的聊天动态和话题走向并做出自然的回应。", or "你正在一个QQ群里聊天你需要理解整个群的聊天动态和话题走向并做出自然的回应。",
"group_chat_reminder_block": self.parameters.group_chat_reminder_block
or context_data.get("group_chat_reminder_block", ""),
} }
def _prepare_normal_params(self, context_data: dict[str, Any]) -> dict[str, Any]: def _prepare_normal_params(self, context_data: dict[str, Any]) -> dict[str, Any]:
@@ -952,6 +954,8 @@ class Prompt:
or "你正在一个QQ群里聊天你需要理解整个群的聊天动态和话题走向并做出自然的回应。", or "你正在一个QQ群里聊天你需要理解整个群的聊天动态和话题走向并做出自然的回应。",
"bot_name": self.parameters.bot_name, "bot_name": self.parameters.bot_name,
"bot_nickname": self.parameters.bot_nickname, "bot_nickname": self.parameters.bot_nickname,
"group_chat_reminder_block": self.parameters.group_chat_reminder_block
or context_data.get("group_chat_reminder_block", ""),
} }
def _prepare_default_params(self, context_data: dict[str, Any]) -> dict[str, Any]: def _prepare_default_params(self, context_data: dict[str, Any]) -> dict[str, Any]:

View File

@@ -63,6 +63,7 @@ class PromptParameters:
mood_prompt: str = "" mood_prompt: str = ""
action_descriptions: str = "" action_descriptions: str = ""
notice_block: str = "" notice_block: str = ""
group_chat_reminder_block: str = ""
# 可用动作信息 # 可用动作信息
available_actions: dict[str, Any] | None = None available_actions: dict[str, Any] | None = None

View File

@@ -1,10 +1,14 @@
""" """
该模块用于生成HTML格式的统计报告。 该模块用于生成HTML格式的统计报告。
""" """
import json
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
import aiofiles import aiofiles
from jinja2 import Environment, FileSystemLoader
from .statistic_keys import * # noqa: F403 from .statistic_keys import * # noqa: F403
@@ -48,6 +52,9 @@ class HTMLReportGenerator:
self.name_mapping = name_mapping self.name_mapping = name_mapping
self.stat_period = stat_period self.stat_period = stat_period
self.deploy_time = deploy_time self.deploy_time = deploy_time
# 初始化Jinja2环境
template_dir = os.path.join(os.path.dirname(__file__), "templates")
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
def _format_stat_data_div(self, stat_data: dict[str, Any], div_id: str, start_time: datetime, now: datetime) -> str: def _format_stat_data_div(self, stat_data: dict[str, Any], div_id: str, start_time: datetime, now: datetime) -> str:
""" """
@@ -152,135 +159,37 @@ class HTMLReportGenerator:
</div> </div>
</div> </div>
""" """
# 增加饼图和条形图 # 增加饼图和条形图
# static_charts = self._generate_static_charts_div(stat_data, div_id) # 该功能尚未实现 static_charts = self._generate_static_charts_div(stat_data, div_id)
static_charts = "" template = self.jinja_env.get_template("tab_content.html")
return f""" return template.render(
<div id="{div_id}" class="tab-content"> div_id=div_id,
<p class="info-item"> start_time=start_time.strftime("%Y-%m-%d %H:%M:%S"),
<strong>统计时段: </strong> end_time=now.strftime("%Y-%m-%d %H:%M:%S"),
{start_time.strftime("%Y-%m-%d %H:%M:%S")} ~ {now.strftime("%Y-%m-%d %H:%M:%S")} summary_cards=summary_cards,
</p> static_charts=static_charts,
{summary_cards} model_rows=model_rows,
{static_charts} provider_rows=provider_rows,
module_rows=module_rows,
type_rows=type_rows,
chat_rows=chat_rows,
)
<h2>按模型分类统计</h2>
<table>
<tr><th>模型名称</th><th>调用次数</th><th>平均Token数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th><th>平均耗时(秒)</th></tr>
<tbody>{model_rows}</tbody>
</table>
<h2>按供应商分类统计</h2>
<table>
<tr><th>供应商名称</th><th>调用次数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th></tr>
<tbody>{provider_rows}</tbody>
</table>
<h2>按模块分类统计</h2>
<table>
<thead>
<tr><th>模块名称</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{module_rows}</tbody>
</table>
<h2>按请求类型分类统计</h2>
<table>
<thead>
<tr><th>请求类型</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{type_rows}</tbody>
</table>
<h2>聊天消息统计</h2>
<table>
<thead>
<tr><th>联系人/群组名称</th><th>消息数量</th></tr>
</thead>
<tbody>{chat_rows}</tbody>
</table>
</div>
"""
def _generate_chart_tab(self, chart_data: dict) -> str: def _generate_chart_tab(self, chart_data: dict) -> str:
"""生成图表选项卡的HTML内容。""" """生成图表选项卡的HTML内容。"""
return f""" template = self.jinja_env.get_template("charts_tab.html")
<div id="charts" class="tab-content"> return template.render()
<h2>数据图表</h2>
<div style="margin: 20px 0; text-align: center;"> def _generate_static_charts_div(self, stat_data: dict[str, Any], div_id: str)-> str:
<label style="margin-right: 10px; font-weight: bold;">时间范围:</label>
<button class="time-range-btn" onclick="switchTimeRange('6h')">6小时</button>
<button class="time-range-btn" onclick="switchTimeRange('12h')">12小时</button>
<button class="time-range-btn active" onclick="switchTimeRange('24h')">24小时</button>
<button class="time-range-btn" onclick="switchTimeRange('48h')">48小时</button>
</div>
<div style="margin-top: 20px;">
<div style="margin-bottom: 40px;"><canvas id="totalCostChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModuleChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModelChart" width="800" height="400"></canvas></div>
<div><canvas id="messageByChatChart" width="800" height="400"></canvas></div>
</div>
<style>
.time-range-btn {{
background-color: #ecf0f1; border: 1px solid #bdc3c7; color: #2c3e50;
padding: 8px 16px; margin: 0 5px; border-radius: 4px; cursor: pointer;
font-size: 14px; transition: all 0.3s ease;
}}
.time-range-btn:hover {{ background-color: #d5dbdb; }}
.time-range-btn.active {{ background-color: #3498db; color: white; border-color: #2980b9; }}
</style>
<script>
const allChartData = {chart_data};
let currentCharts = {{}};
const chartConfigs = {{
totalCost: {{ id: 'totalCostChart', title: '总花费', yAxisLabel: '花费 (¥)', dataKey: 'total_cost_data', fill: true }},
costByModule: {{ id: 'costByModuleChart', title: '各模块花费', yAxisLabel: '花费 (¥)', dataKey: 'cost_by_module', fill: false }},
costByModel: {{ id: 'costByModelChart', title: '各模型花费', yAxisLabel: '花费 (¥)', dataKey: 'cost_by_model', fill: false }},
messageByChat: {{ id: 'messageByChatChart', title: '各聊天流消息数', yAxisLabel: '消息数', dataKey: 'message_by_chat', fill: false }}
}};
function switchTimeRange(timeRange) {{
document.querySelectorAll('.time-range-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
updateAllCharts(allChartData[timeRange], timeRange);
}}
function updateAllCharts(data, timeRange) {{
Object.values(currentCharts).forEach(chart => chart && chart.destroy());
currentCharts = {{}};
Object.keys(chartConfigs).forEach(type => createChart(type, data, timeRange));
}}
function createChart(chartType, data, timeRange) {{
const config = chartConfigs[chartType];
if (!data || !data[config.dataKey]) return;
const colors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e67e22', '#95a5a6', '#f1c40f'];
let datasets = [];
if (chartType === 'totalCost') {{
datasets = [{{ label: config.title, data: data[config.dataKey], borderColor: colors[0], backgroundColor: 'rgba(52, 152, 219, 0.1)', tension: 0.4, fill: config.fill }}];
}} else {{
let i = 0;
Object.entries(data[config.dataKey]).forEach(([name, chartData]) => {{
datasets.push({{ label: name, data: chartData, borderColor: colors[i % colors.length], backgroundColor: colors[i % colors.length] + '20', tension: 0.4, fill: config.fill }});
i++;
}});
}}
currentCharts[chartType] = new Chart(document.getElementById(config.id), {{
type: 'line',
data: {{ labels: data.time_labels, datasets: datasets }},
options: {{
responsive: true,
plugins: {{ title: {{ display: true, text: `${{timeRange}}内${{config.title}}趋势`, font: {{ size: 16 }} }}, legend: {{ display: chartType !== 'totalCost', position: 'top' }} }},
scales: {{ x: {{ title: {{ display: true, text: '时间' }}, ticks: {{ maxTicksLimit: 12 }} }}, y: {{ title: {{ display: true, text: config.yAxisLabel }}, beginAtZero: true }} }},
interaction: {{ intersect: false, mode: 'index' }}
}}
}});
}}
document.addEventListener('DOMContentLoaded', function() {{
if (allChartData['24h']) {{
updateAllCharts(allChartData['24h'], '24h');
}}
}});
</script>
</div>
""" """
生成静态图表的HTML div。
:param stat_data: 统计数据。
:param div_id: The ID for the period, used to uniquely identify chart canvases.
:return: 渲染后的HTML字符串。
"""
template = self.jinja_env.get_template("static_charts.html")
return template.render(period_id=div_id)
async def generate_report(self, stat: dict[str, Any], chart_data: dict, now: datetime, output_path: str): async def generate_report(self, stat: dict[str, Any], chart_data: dict, now: datetime, output_path: str):
""" """
@@ -291,77 +200,50 @@ class HTMLReportGenerator:
:param now: 当前时间。 :param now: 当前时间。
:param output_path: 输出文件路径。 :param output_path: 输出文件路径。
""" """
tab_list = [ tab_list_html = [
f'<button class="tab-link" onclick="showTab(event, \'{period[0]}\')">{period[2]}</button>' f'<button class="tab-link" onclick="showTab(event, \'{period[0]}\')">{period[2]}</button>'
for period in self.stat_period for period in self.stat_period
] ]
tab_list.append('<button class="tab-link" onclick="showTab(event, \'charts\')">数据图表</button>') tab_list_html.append('<button class="tab-link" onclick="showTab(event, \'charts\')">数据图表</button>')
tab_content_list = [ tab_content_html_list = [
self._format_stat_data_div(stat[period[0]], period[0], now - period[1], now) self._format_stat_data_div(stat[period[0]], period[0], now - period[1], now)
for period in self.stat_period for period in self.stat_period
if period[0] != "all_time" if period[0] != "all_time"
] ]
tab_content_list.append( tab_content_html_list.append(self._format_stat_data_div(stat["all_time"], "all_time", self.deploy_time, now))
self._format_stat_data_div(stat["all_time"], "all_time", self.deploy_time, now) tab_content_html_list.append(self._generate_chart_tab(chart_data))
static_chart_data = {}
for period in self.stat_period:
period_id = period[0]
static_chart_data[period_id] = {
"provider_cost_data": stat[period_id].get(PIE_CHART_COST_BY_PROVIDER, {}),
"model_cost_data": stat[period_id].get(BAR_CHART_COST_BY_MODEL, {}),
}
static_chart_data["all_time"] = {
"provider_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_PROVIDER, {}),
"model_cost_data": stat["all_time"].get(BAR_CHART_COST_BY_MODEL, {}),
}
# 渲染模板
# 读取CSS和JS文件内容
async with aiofiles.open(os.path.join(self.jinja_env.loader.searchpath[0], "report.css"), "r", encoding="utf-8") as f:
report_css = await f.read()
async with aiofiles.open(os.path.join(self.jinja_env.loader.searchpath[0], "report.js"), "r", encoding="utf-8") as f:
report_js = await f.read()
# 渲染模板
template = self.jinja_env.get_template("report.html")
rendered_html = template.render(
report_title="MoFox-Bot运行统计报告",
generation_time=now.strftime("%Y-%m-%d %H:%M:%S"),
tab_list="\n".join(tab_list_html),
tab_content="\n".join(tab_content_html_list),
all_chart_data=json.dumps(chart_data),
static_chart_data=json.dumps(static_chart_data),
report_css=report_css,
report_js=report_js,
) )
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"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MoFox-Bot运行统计报告</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 20px; background-color: #f4f7f6; color: #333; line-height: 1.6; }}
.container {{ max-width: 900px; margin: 20px auto; background-color: #fff; padding: 25px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
h1, h2 {{ color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; margin-top: 0; }}
h1 {{ text-align: center; font-size: 2em; }}
h2 {{ font-size: 1.5em; margin-top: 30px; }}
p {{ margin-bottom: 10px; }}
.info-item {{ background-color: #ecf0f1; padding: 8px 12px; border-radius: 4px; margin-bottom: 8px; font-size: 0.95em; }}
.info-item strong {{ color: #2980b9; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 15px; font-size: 0.9em; }}
th, td {{ border: 1px solid #ddd; padding: 10px; text-align: left; }}
th {{ background-color: #3498db; color: white; font-weight: bold; }}
tr:nth-child(even) {{ background-color: #f9f9f9; }}
.footer {{ text-align: center; margin-top: 30px; font-size: 0.8em; color: #7f8c8d; }}
.tabs {{ overflow: hidden; background: #ecf0f1; display: flex; }}
.tabs button {{ background: inherit; border: none; outline: none; padding: 14px 16px; cursor: pointer; transition: 0.3s; font-size: 16px; }}
.tabs button:hover {{ background-color: #d4dbdc; }}
.tabs button.active {{ background-color: #b3bbbd; }}
.tab-content {{ display: none; padding: 20px; background-color: #fff; border: 1px solid #ccc; }}
.tab-content.active {{ display: block; }}
</style>
</head>
<body>
<div class="container">
<h1>MoFox-Bot运行统计报告</h1>
<p class="info-item"><strong>统计截止时间:</strong> {now.strftime("%Y-%m-%d %H:%M:%S")}</p>
<div class="tabs">{joined_tab_list}</div>
{joined_tab_content}
</div>
<script>
let i, tab_content, tab_links;
tab_content = document.getElementsByClassName("tab-content");
tab_links = document.getElementsByClassName("tab-link");
if(tab_content.length > 0) tab_content[0].classList.add("active");
if(tab_links.length > 0) tab_links[0].classList.add("active");
function showTab(evt, tabName) {{
for (i = 0; i < tab_content.length; i++) tab_content[i].classList.remove("active");
for (i = 0; i < tab_links.length; i++) tab_links[i].classList.remove("active");
document.getElementById(tabName).classList.add("active");
evt.currentTarget.classList.add("active");
}}
</script>
</body>
</html>
"""
async with aiofiles.open(output_path, "w", encoding="utf-8") as f: async with aiofiles.open(output_path, "w", encoding="utf-8") as f:
await f.write(html_template) await f.write(rendered_html)

View File

@@ -115,7 +115,7 @@ class StatisticOutputTask(AsyncTask):
SEP_LINE = "-" * 84 SEP_LINE = "-" * 84
def __init__(self, record_file_path: str = "maibot_statistics.html"): def __init__(self, record_file_path: str = "mofox_bot_statistics.html"):
# 延迟300秒启动运行间隔300秒 # 延迟300秒启动运行间隔300秒
super().__init__(task_name="Statistics Data Output Task", wait_before_start=0, run_interval=300) super().__init__(task_name="Statistics Data Output Task", wait_before_start=0, run_interval=300)
@@ -412,9 +412,9 @@ class StatisticOutputTask(AsyncTask):
(REQ_CNT_BY_MODEL, "model"), (REQ_CNT_BY_MODEL, "model"),
(REQ_CNT_BY_MODULE, "module"), (REQ_CNT_BY_MODULE, "module"),
]: ]:
time_cost_key = f"time_costs_by_{items}" time_cost_key = f"TIME_COST_BY_{items.upper()}"
avg_key = f"avg_time_costs_by_{items}" avg_key = f"AVG_TIME_COST_BY_{items.upper()}"
std_key = f"std_time_costs_by_{items}" std_key = f"STD_TIME_COST_BY_{items.upper()}"
for item_name in period_stats[category_key]: for item_name in period_stats[category_key]:
time_costs = period_stats[time_cost_key].get(item_name, []) time_costs = period_stats[time_cost_key].get(item_name, [])

View File

@@ -0,0 +1,16 @@
<div id="charts" class="tab-content">
<h2>数据图表</h2>
<div style="margin: 20px 0; text-align: center;">
<label style="margin-right: 10px; font-weight: bold;">时间范围:</label>
<button class="time-range-btn" onclick="switchTimeRange('6h')">6小时</button>
<button class="time-range-btn" onclick="switchTimeRange('12h')">12小时</button>
<button class="time-range-btn" onclick="switchTimeRange('24h')">24小时</button>
<button class="time-range-btn" onclick="switchTimeRange('48h')">48小时</button>
</div>
<div style="margin-top: 20px;">
<div style="margin-bottom: 40px;"><canvas id="totalCostChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModuleChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModelChart" width="800" height="400"></canvas></div>
<div><canvas id="messageByChatChart" width="800" height="400"></canvas></div>
</div>
</div>

View File

@@ -0,0 +1,199 @@
/* General Body Styles */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f0f4f8; /* Light blue-gray background */
color: #333; /* Darker text for better contrast */
line-height: 1.6;
}
/* Main Container */
.container {
max-width: 95%; /* Make container almost full-width */
margin: 20px auto;
background-color: #FFFFFF; /* Pure white background */
padding: 30px;
border-radius: 12px; /* Slightly more rounded corners */
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.07); /* Softer, deeper shadow */
}
/* Dashboard Layout */
.dashboard-layout {
display: flex;
gap: 30px;
}
.main-content {
flex: 65%;
min-width: 0;
}
.sidebar-content {
flex: 35%;
min-width: 0;
}
/* Responsive Design for Mobile */
@media (max-width: 992px) {
.dashboard-layout {
flex-direction: column;
}
.main-content, .sidebar-content {
flex: 1;
}
}
/* Typography */
h1, h2 {
color: #212529;
padding-bottom: 10px;
margin-top: 0;
}
h1 {
text-align: center;
font-size: 2.2em;
margin-bottom: 20px;
color: #2A6CB5; /* A deeper, more professional blue */
}
h2 {
font-size: 1.5em;
margin-top: 40px;
margin-bottom: 15px;
border-bottom: 2px solid #DDE6ED; /* Lighter border color */
}
/* Info Banners */
.info-item {
background-color: #E9F2FA; /* Light blue background */
padding: 12px 18px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 0.95em;
border: 1px solid #D1E3F4; /* Light blue border */
}
.info-item strong {
color: #2A6CB5; /* Deeper blue for emphasis */
}
/* Tabs */
.tabs {
border-bottom: 2px solid #DEE2E6;
display: flex;
margin-bottom: 20px;
}
.tabs button {
background: none;
border: none;
outline: none;
padding: 14px 20px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 16px;
color: #6C757D;
border-bottom: 3px solid transparent;
margin-bottom: -2px; /* Align with container border */
}
.tabs button:hover {
color: #2A6CB5;
background-color: #f0f4f8; /* Subtle hover background */
}
.tabs button.active {
color: #2A6CB5; /* Active tab color */
border-bottom-color: #2A6CB5; /* Active tab border color */
}
.tab-content {
display: none;
padding-top: 10px;
}
.tab-content.active {
display: block;
}
/* Summary Cards */
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
margin: 20px 0;
}
.card {
background-color: #FFFFFF;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #DDE6ED; /* Lighter border */
transition: all 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 15px rgba(0,0,0,0.08);
}
.card h3 {
margin: 0 0 10px;
font-size: 1em;
color: #6C757D;
}
.card p {
margin: 0;
font-size: 1.8em;
font-weight: bold;
color: #212529;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
font-size: 0.9em;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #EAEAEA;
}
th {
background-color: #4A90E2; /* Main theme blue */
color: white;
font-weight: bold;
font-size: 0.95em;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tr:nth-child(even) {
background-color: #F7FAFC; /* Very light blue for alternate rows */
}
tr:hover {
background-color: #E9F2FA; /* Light blue for hover */
}
/* Chart Container in Sidebar */
.chart-container {
position: relative;
height: 300px; /* Adjust height as needed */
width: 100%;
margin-bottom: 20px;
}
/* Footer */
.footer {
text-align: center;
margin-top: 40px;
font-size: 0.85em;
color: #6C757D;
}

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ report_title }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>{{ report_css }}</style>
</head>
<body>
<div class="container">
<h1>{{ report_title }}</h1>
<p class="info-item"><strong>统计截止时间:</strong> {{ generation_time }}</p>
<div class="tabs">{{ tab_list }}</div>
{{ tab_content }}
</div>
<script>
const all_chart_data_json_string = `{{ all_chart_data|safe }}`;
const static_chart_data_json_string = `{{ static_chart_data|safe }}`;
</script>
<script>{{ report_js }}</script>
</body>
</html>

View File

@@ -0,0 +1,146 @@
let i, tab_content, tab_links;
tab_content = document.getElementsByClassName("tab-content");
tab_links = document.getElementsByClassName("tab-link");
if (tab_content.length > 0) tab_content[0].classList.add("active");
if (tab_links.length > 0) tab_links[0].classList.add("active");
function showTab(evt, tabName) {
for (i = 0; i < tab_content.length; i++) tab_content[i].classList.remove("active");
for (i = 0; i < tab_links.length; i++) tab_links[i].classList.remove("active");
document.getElementById(tabName).classList.add("active");
evt.currentTarget.classList.add("active");
}
document.addEventListener('DOMContentLoaded', function () {
// Chart data is injected by python via the HTML template.
let allChartData = {};
try {
allChartData = JSON.parse(all_chart_data_json_string);
} catch (e) {
console.error("Failed to parse all_chart_data:", e);
console.error("Problematic all_chart_data string:", all_chart_data_json_string);
}
let currentCharts = {};
const chartConfigs = {
totalCost: { id: 'totalCostChart', title: '总花费', yAxisLabel: '花费 (¥)', dataKey: 'total_cost_data', fill: true },
costByModule: { id: 'costByModuleChart', title: '各模块花费', yAxisLabel: '花费 (¥)', dataKey: 'cost_by_module', fill: false },
costByModel: { id: 'costByModelChart', title: '各模型花费', yAxisLabel: '花费 (¥)', dataKey: 'cost_by_model', fill: false },
messageByChat: { id: 'messageByChatChart', title: '各聊天流消息数', yAxisLabel: '消息数', dataKey: 'message_by_chat', fill: false }
};
window.switchTimeRange = function(timeRange) {
document.querySelectorAll('.time-range-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
updateAllCharts(allChartData[timeRange], timeRange);
}
function updateAllCharts(data, timeRange) {
Object.values(currentCharts).forEach(chart => chart && chart.destroy());
currentCharts = {};
Object.keys(chartConfigs).forEach(type => createChart(type, data, timeRange));
}
function createChart(chartType, data, timeRange) {
const config = chartConfigs[chartType];
if (!data || !data[config.dataKey]) return;
const colors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e67e22', '#95a5a6', '#f1c40f'];
let datasets = [];
if (chartType === 'totalCost') {
datasets = [{ label: config.title, data: data[config.dataKey], borderColor: colors[0], backgroundColor: 'rgba(52, 152, 219, 0.1)', tension: 0.4, fill: config.fill }];
} else {
let i = 0;
Object.entries(data[config.dataKey]).forEach(([name, chartData]) => {
datasets.push({ label: name, data: chartData, borderColor: colors[i % colors.length], backgroundColor: colors[i % colors.length] + '20', tension: 0.4, fill: config.fill });
i++;
});
}
currentCharts[chartType] = new Chart(document.getElementById(config.id), {
type: 'line',
data: { labels: data.time_labels, datasets: datasets },
options: {
responsive: true,
plugins: { title: { display: true, text: `${timeRange}${config.title}趋势`, font: { size: 16 } }, legend: { display: chartType !== 'totalCost', position: 'top' } },
scales: { x: { title: { display: true, text: '时间' }, ticks: { maxTicksLimit: 12 } }, y: { title: { display: true, text: config.yAxisLabel }, beginAtZero: true } },
interaction: { intersect: false, mode: 'index' }
}
});
}
if (allChartData['24h']) {
updateAllCharts(allChartData['24h'], '24h');
// Activate the 24h button by default
document.querySelectorAll('.time-range-btn').forEach(btn => {
if (btn.textContent.includes('24小时')) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// Static charts
let staticChartData = {};
try {
staticChartData = JSON.parse(static_chart_data_json_string);
} catch (e) {
console.error("Failed to parse static_chart_data:", e);
console.error("Problematic static_chart_data string:", static_chart_data_json_string);
}
Object.keys(staticChartData).forEach(period_id => {
const providerCostData = staticChartData[period_id].provider_cost_data;
const modelCostData = staticChartData[period_id].model_cost_data;
const colors = ['#3498db', '#2ecc71', '#f1c40f', '#e74c3c', '#9b59b6', '#1abc9c', '#34495e', '#e67e22'];
// Provider Cost Pie Chart
const providerCtx = document.getElementById(`providerCostPieChart_${period_id}`);
if (providerCtx && providerCostData && providerCostData.data && providerCostData.data.length > 0) {
new Chart(providerCtx, {
type: 'pie',
data: {
labels: providerCostData.labels,
datasets: [{
label: '按供应商花费',
data: providerCostData.data,
backgroundColor: colors,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: { display: true, text: '按供应商花费分布', font: { size: 16 } },
legend: { position: 'top' }
}
}
});
}
// Model Cost Bar Chart
const modelCtx = document.getElementById(`modelCostBarChart_${period_id}`);
if (modelCtx && modelCostData && modelCostData.data && modelCostData.data.length > 0) {
new Chart(modelCtx, {
type: 'bar',
data: {
labels: modelCostData.labels,
datasets: [{
label: '按模型花费',
data: modelCostData.data,
backgroundColor: colors,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: { display: true, text: '按模型花费排行', font: { size: 16 } },
legend: { display: false }
},
scales: {
y: { beginAtZero: true, title: { display: true, text: '花费 (¥)' } }
}
}
});
}
});
});

View File

@@ -0,0 +1,9 @@
<h2>数据总览</h2>
<div class="chart-grid">
<div class="chart-container">
<canvas id="providerCostPieChart_{{ period_id }}"></canvas>
</div>
<div class="chart-container">
<canvas id="modelCostBarChart_{{ period_id }}"></canvas>
</div>
</div>

View File

@@ -0,0 +1,50 @@
<div id="{{ div_id }}" class="tab-content">
<div class="dashboard-layout">
<div class="main-content">
<p class="info-item">
<strong>统计时段: </strong>
{{ start_time }} ~ {{ end_time }}
</p>
{{ summary_cards }}
<h2>按模型分类统计</h2>
<table>
<tr><th>模型名称</th><th>调用次数</th><th>平均Token数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th><th>平均耗时(秒)</th></tr>
<tbody>{{ model_rows }}</tbody>
</table>
<h2>按供应商分类统计</h2>
<table>
<tr><th>供应商名称</th><th>调用次数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th></tr>
<tbody>{{ provider_rows }}</tbody>
</table>
<h2>按模块分类统计</h2>
<table>
<thead>
<tr><th>模块名称</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{{ module_rows }}</tbody>
</table>
<h2>按请求类型分类统计</h2>
<table>
<thead>
<tr><th>请求类型</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{{ type_rows }}</tbody>
</table>
<h2>聊天消息统计</h2>
<table>
<thead>
<tr><th>联系人/群组名称</th><th>消息数量</th></tr>
</thead>
<tbody>{{ chat_rows }}</tbody>
</table>
</div>
<div class="sidebar-content">
{{ static_charts }}
</div>
</div>
</div>

View File

@@ -930,12 +930,11 @@ def filter_system_format_content(content: str | None) -> str:
过滤系统格式化内容,移除回复、@、图片、表情包等系统生成的格式文本 过滤系统格式化内容,移除回复、@、图片、表情包等系统生成的格式文本
此方法过滤以下类型的系统格式化内容: 此方法过滤以下类型的系统格式化内容:
1. 回复格式:[回复xxx]xxx 1. 回复格式:[回复xxx]xxx (包括深度嵌套)
2. 表情包格式:[表情包xxx] 2. 表情包格式:[表情包xxx]
3. 图片格式:[图片:xxx] 3. 图片格式:[图片:xxx]
4. @格式:@<xxx> 4. @格式:@<xxx>
5. 错误格式:[表情包(...)]、[图片(...)] 5. 错误格式:[表情包(...)]、[图片(...)]
6. [回复开头的格式
Args: Args:
content: 原始内容 content: 原始内容
@@ -949,29 +948,20 @@ def filter_system_format_content(content: str | None) -> str:
original_content = content original_content = content
cleaned_content = content.strip() cleaned_content = content.strip()
# 1. 移除回复格式:[回复xxx]xxx各种变体 # 核心逻辑:优先处理最复杂的[回复...]格式,特别是嵌套格式。
# 匹配所有包含"],说:"格式的回复 # 这种方法最稳健:如果以[回复开头,就找到最后一个],然后切掉之前的所有内容。
cleaned_content = re.sub(r"\[回复[^\]]*\],说:\s*", "", cleaned_content)
# 匹配 [回复<xxx:数字>]xxx 格式
cleaned_content = re.sub(r"\[回复<[^>]*>\],说:\s*", "", cleaned_content)
# 2. 处理原有的[回复开头格式(保持向后兼容)
# 注意:这步要在上面处理完成后再执行,避免冲突
if cleaned_content.startswith("[回复"): if cleaned_content.startswith("[回复"):
last_bracket_index = cleaned_content.rfind("]") last_bracket_index = cleaned_content.rfind("]")
if last_bracket_index != -1: if last_bracket_index != -1:
cleaned_content = cleaned_content[last_bracket_index + 1 :].strip() cleaned_content = cleaned_content[last_bracket_index + 1 :].strip()
# 3. 移除表情包格式:[表情包xxx] # 在处理完回复格式后,再清理其他简单的格式
# 移除表情包格式:[表情包xxx]
cleaned_content = re.sub(r"\[表情包:[^\]]*\]", "", cleaned_content) cleaned_content = re.sub(r"\[表情包:[^\]]*\]", "", cleaned_content)
# 移除图片格式:[图片:xxx]
# 4. 移除图片格式:[图片:xxx]
cleaned_content = re.sub(r"\[图片:[^\]]*\]", "", cleaned_content) cleaned_content = re.sub(r"\[图片:[^\]]*\]", "", cleaned_content)
# 移除@格式:@<xxx>
# 5. 移除@格式:@<xxx>
cleaned_content = re.sub(r"@<[^>]*>", "", cleaned_content) cleaned_content = re.sub(r"@<[^>]*>", "", cleaned_content)
# 6. 移除其他可能的系统格式
# [表情包(描述生成失败)] 等错误格式 # [表情包(描述生成失败)] 等错误格式
cleaned_content = re.sub(r"\[表情包\([^)]*\)\]", "", cleaned_content) cleaned_content = re.sub(r"\[表情包\([^)]*\)\]", "", cleaned_content)
# [图片(描述生成失败)] 等错误格式 # [图片(描述生成失败)] 等错误格式

View File

@@ -1,11 +0,0 @@
"""数据库配置层
职责:
- 数据库配置现已集成到全局配置中
- 通过 src.config.config.global_config.database 访问
- 优化参数配置
注意:此模块已废弃,配置已迁移到 global_config
"""
__all__ = []

View File

@@ -1,149 +0,0 @@
"""数据库配置管理
统一管理数据库连接配置
"""
import os
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote_plus
from src.common.logger import get_logger
logger = get_logger("database_config")
@dataclass
class DatabaseConfig:
"""数据库配置"""
# 基础配置
db_type: str # "sqlite" 或 "mysql"
url: str # 数据库连接URL
# 引擎配置
engine_kwargs: dict[str, Any]
# SQLite特定配置
sqlite_path: str | None = None
# MySQL特定配置
mysql_host: str | None = None
mysql_port: int | None = None
mysql_user: str | None = None
mysql_password: str | None = None
mysql_database: str | None = None
mysql_charset: str = "utf8mb4"
mysql_unix_socket: str | None = None
_database_config: DatabaseConfig | None = None
def get_database_config() -> DatabaseConfig:
"""获取数据库配置
从全局配置中读取数据库设置并构建配置对象
"""
global _database_config
if _database_config is not None:
return _database_config
from src.config.config import global_config
config = global_config.database
# 构建数据库URL
if config.database_type == "mysql":
# MySQL配置
encoded_user = quote_plus(config.mysql_user)
encoded_password = quote_plus(config.mysql_password)
if config.mysql_unix_socket:
# Unix socket连接
encoded_socket = quote_plus(config.mysql_unix_socket)
url = (
f"mysql+aiomysql://{encoded_user}:{encoded_password}"
f"@/{config.mysql_database}"
f"?unix_socket={encoded_socket}&charset={config.mysql_charset}"
)
else:
# TCP连接
url = (
f"mysql+aiomysql://{encoded_user}:{encoded_password}"
f"@{config.mysql_host}:{config.mysql_port}/{config.mysql_database}"
f"?charset={config.mysql_charset}"
)
engine_kwargs = {
"echo": False,
"future": True,
"pool_size": config.connection_pool_size,
"max_overflow": config.connection_pool_size * 2,
"pool_timeout": config.connection_timeout,
"pool_recycle": 3600,
"pool_pre_ping": True,
"connect_args": {
"autocommit": config.mysql_autocommit,
"charset": config.mysql_charset,
"connect_timeout": config.connection_timeout,
},
}
_database_config = DatabaseConfig(
db_type="mysql",
url=url,
engine_kwargs=engine_kwargs,
mysql_host=config.mysql_host,
mysql_port=config.mysql_port,
mysql_user=config.mysql_user,
mysql_password=config.mysql_password,
mysql_database=config.mysql_database,
mysql_charset=config.mysql_charset,
mysql_unix_socket=config.mysql_unix_socket,
)
logger.info(
f"MySQL配置已加载: "
f"{config.mysql_user}@{config.mysql_host}:{config.mysql_port}/{config.mysql_database}"
)
else:
# SQLite配置
if not os.path.isabs(config.sqlite_path):
ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
db_path = os.path.join(ROOT_PATH, config.sqlite_path)
else:
db_path = config.sqlite_path
# 确保数据库目录存在
os.makedirs(os.path.dirname(db_path), exist_ok=True)
url = f"sqlite+aiosqlite:///{db_path}"
engine_kwargs = {
"echo": False,
"future": True,
"connect_args": {
"check_same_thread": False,
"timeout": 60,
},
}
_database_config = DatabaseConfig(
db_type="sqlite",
url=url,
engine_kwargs=engine_kwargs,
sqlite_path=db_path,
)
logger.info(f"SQLite配置已加载: {db_path}")
return _database_config
def reset_database_config():
"""重置数据库配置(用于测试)"""
global _database_config
_database_config = None