diff --git a/src/chat/utils/report_generator.py b/src/chat/utils/report_generator.py index ec6136f49..f923b6e63 100644 --- a/src/chat/utils/report_generator.py +++ b/src/chat/utils/report_generator.py @@ -319,11 +319,21 @@ class HTMLReportGenerator: "provider_cost_data": stat[period_id].get(PIE_CHART_COST_BY_PROVIDER, {}), "module_cost_data": stat[period_id].get(PIE_CHART_COST_BY_MODULE, {}), "model_cost_data": stat[period_id].get(BAR_CHART_COST_BY_MODEL, {}), + "token_comparison_data": stat[period_id].get(BAR_CHART_TOKEN_COMPARISON, {}), + "response_time_scatter_data": stat[period_id].get(SCATTER_CHART_RESPONSE_TIME, []), + "model_efficiency_radar_data": stat[period_id].get(RADAR_CHART_MODEL_EFFICIENCY, {}), + "provider_requests_data": stat[period_id].get(DOUGHNUT_CHART_PROVIDER_REQUESTS, {}), + "avg_response_time_data": stat[period_id].get(BAR_CHART_AVG_RESPONSE_TIME, {}), } static_chart_data["all_time"] = { "provider_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_PROVIDER, {}), "module_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_MODULE, {}), "model_cost_data": stat["all_time"].get(BAR_CHART_COST_BY_MODEL, {}), + "token_comparison_data": stat["all_time"].get(BAR_CHART_TOKEN_COMPARISON, {}), + "response_time_scatter_data": stat["all_time"].get(SCATTER_CHART_RESPONSE_TIME, []), + "model_efficiency_radar_data": stat["all_time"].get(RADAR_CHART_MODEL_EFFICIENCY, {}), + "provider_requests_data": stat["all_time"].get(DOUGHNUT_CHART_PROVIDER_REQUESTS, {}), + "avg_response_time_data": stat["all_time"].get(BAR_CHART_AVG_RESPONSE_TIME, {}), } # 渲染模板 @@ -332,15 +342,15 @@ class HTMLReportGenerator: report_css = await f.read() async with aiofiles.open(os.path.join(self.jinja_env.loader.searchpath[0], "report.js"), encoding="utf-8") as f: report_js = await f.read() - # 渲染模板 + # 渲染模板(使用紧凑的JSON格式减少文件大小) 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), + all_chart_data=json.dumps(chart_data, separators=(',', ':'), ensure_ascii=False), + static_chart_data=json.dumps(static_chart_data, separators=(',', ':'), ensure_ascii=False), report_css=report_css, report_js=report_js, ) diff --git a/src/chat/utils/statistic.py b/src/chat/utils/statistic.py index c8a93a614..a99f5785d 100644 --- a/src/chat/utils/statistic.py +++ b/src/chat/utils/statistic.py @@ -302,6 +302,13 @@ class StatisticOutputTask(AsyncTask): PIE_CHART_COST_BY_MODULE: {}, BAR_CHART_COST_BY_MODEL: {}, BAR_CHART_REQ_BY_MODEL: {}, + BAR_CHART_TOKEN_COMPARISON: {}, + SCATTER_CHART_RESPONSE_TIME: {}, + RADAR_CHART_MODEL_EFFICIENCY: {}, + HEATMAP_CHAT_ACTIVITY: {}, + DOUGHNUT_CHART_PROVIDER_REQUESTS: {}, + LINE_CHART_COST_TREND: {}, + BAR_CHART_AVG_RESPONSE_TIME: {}, } for period_key, _ in collect_period } @@ -475,6 +482,91 @@ class StatisticOutputTask(AsyncTask): "labels": [item[0] for item in sorted_models], "data": [round(item[1], 4) for item in sorted_models], } + + # 1. Token输入输出对比条形图 + model_names = list(period_stats[REQ_CNT_BY_MODEL].keys()) + if model_names: + period_stats[BAR_CHART_TOKEN_COMPARISON] = { + "labels": model_names, + "input_tokens": [period_stats[IN_TOK_BY_MODEL].get(m, 0) for m in model_names], + "output_tokens": [period_stats[OUT_TOK_BY_MODEL].get(m, 0) for m in model_names], + } + + # 2. 响应时间分布散点图数据(限制数据点以提高加载速度) + scatter_data = [] + max_points_per_model = 50 # 每个模型最多50个点 + for model_name, time_costs in period_stats[TIME_COST_BY_MODEL].items(): + # 如果数据点太多,进行采样 + if len(time_costs) > max_points_per_model: + step = len(time_costs) // max_points_per_model + sampled_costs = time_costs[::step][:max_points_per_model] + else: + sampled_costs = time_costs + + for idx, time_cost in enumerate(sampled_costs): + scatter_data.append({ + "model": model_name, + "x": idx, + "y": round(time_cost, 3), + "tokens": period_stats[TOTAL_TOK_BY_MODEL].get(model_name, 0) // len(time_costs) if time_costs else 0 + }) + period_stats[SCATTER_CHART_RESPONSE_TIME] = scatter_data + + # 3. 模型效率雷达图 + if model_names: + # 取前5个最常用的模型 + top_models = sorted(period_stats[REQ_CNT_BY_MODEL].items(), key=lambda x: x[1], reverse=True)[:5] + radar_data = [] + for model_name, _ in top_models: + # 归一化各项指标到0-100 + req_count = period_stats[REQ_CNT_BY_MODEL].get(model_name, 0) + tps = period_stats[TPS_BY_MODEL].get(model_name, 0) + avg_time = period_stats[AVG_TIME_COST_BY_MODEL].get(model_name, 0) + cost_per_ktok = period_stats[COST_PER_KTOK_BY_MODEL].get(model_name, 0) + avg_tokens = period_stats[AVG_TOK_BY_MODEL].get(model_name, 0) + + # 简单的归一化(反向归一化时间和成本,值越小越好) + max_req = max([period_stats[REQ_CNT_BY_MODEL].get(m[0], 1) for m in top_models]) + max_tps = max([period_stats[TPS_BY_MODEL].get(m[0], 1) for m in top_models]) + max_time = max([period_stats[AVG_TIME_COST_BY_MODEL].get(m[0], 0.1) for m in top_models]) + max_cost = max([period_stats[COST_PER_KTOK_BY_MODEL].get(m[0], 0.001) for m in top_models]) + max_tokens = max([period_stats[AVG_TOK_BY_MODEL].get(m[0], 1) for m in top_models]) + + radar_data.append({ + "model": model_name, + "metrics": [ + round((req_count / max_req) * 100, 2) if max_req > 0 else 0, # 请求量 + round((tps / max_tps) * 100, 2) if max_tps > 0 else 0, # TPS + round((1 - avg_time / max_time) * 100, 2) if max_time > 0 else 100, # 速度(反向) + round((1 - cost_per_ktok / max_cost) * 100, 2) if max_cost > 0 else 100, # 成本效益(反向) + round((avg_tokens / max_tokens) * 100, 2) if max_tokens > 0 else 0, # Token容量 + ] + }) + period_stats[RADAR_CHART_MODEL_EFFICIENCY] = { + "labels": ["请求量", "TPS", "响应速度", "成本效益", "Token容量"], + "datasets": radar_data + } + + # 4. 供应商请求占比环形图 + provider_requests = period_stats[REQ_CNT_BY_PROVIDER] + if provider_requests: + sorted_provider_reqs = sorted(provider_requests.items(), key=lambda item: item[1], reverse=True) + period_stats[DOUGHNUT_CHART_PROVIDER_REQUESTS] = { + "labels": [item[0] for item in sorted_provider_reqs], + "data": [item[1] for item in sorted_provider_reqs], + } + + # 5. 平均响应时间条形图 + if model_names: + sorted_by_time = sorted( + [(m, period_stats[AVG_TIME_COST_BY_MODEL].get(m, 0)) for m in model_names], + key=lambda x: x[1], + reverse=True + ) + period_stats[BAR_CHART_AVG_RESPONSE_TIME] = { + "labels": [item[0] for item in sorted_by_time], + "data": [round(item[1], 3) for item in sorted_by_time], + } return stats @staticmethod diff --git a/src/chat/utils/statistic_keys.py b/src/chat/utils/statistic_keys.py index 1f27ffbe9..3233ae8c8 100644 --- a/src/chat/utils/statistic_keys.py +++ b/src/chat/utils/statistic_keys.py @@ -63,6 +63,15 @@ PIE_CHART_COST_BY_MODULE = "pie_chart_cost_by_module" BAR_CHART_COST_BY_MODEL = "bar_chart_cost_by_model" BAR_CHART_REQ_BY_MODEL = "bar_chart_req_by_model" +# 新增更多图表数据 +BAR_CHART_TOKEN_COMPARISON = "bar_chart_token_comparison" # Token输入输出对比图 +SCATTER_CHART_RESPONSE_TIME = "scatter_chart_response_time" # 响应时间分布散点图 +RADAR_CHART_MODEL_EFFICIENCY = "radar_chart_model_efficiency" # 模型效率雷达图 +HEATMAP_CHAT_ACTIVITY = "heatmap_chat_activity" # 聊天活跃度热力图 +DOUGHNUT_CHART_PROVIDER_REQUESTS = "doughnut_chart_provider_requests" # 供应商请求占比环形图 +LINE_CHART_COST_TREND = "line_chart_cost_trend" # 成本趋势折线图 +BAR_CHART_AVG_RESPONSE_TIME = "bar_chart_avg_response_time" # 平均响应时间条形图 + # 新增消息分析指标 MSG_CNT_BY_USER = "messages_by_user" # 按用户的消息数 ACTIVE_CHATS_CNT = "active_chats_count" # 活跃聊天数 diff --git a/src/chat/utils/templates/report.js b/src/chat/utils/templates/report.js index 16b574ea1..749769a42 100644 --- a/src/chat/utils/templates/report.js +++ b/src/chat/utils/templates/report.js @@ -4,6 +4,9 @@ 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"); +// 跟踪哪些tab的图表已经初始化 +const initializedTabs = new Set(); + function showTab(evt, tabName) { for (i = 0; i < tab_content.length; i++) { tab_content[i].classList.remove("active"); @@ -15,6 +18,12 @@ function showTab(evt, tabName) { document.getElementById(tabName).classList.add("active"); document.getElementById(tabName).style.animation = 'slideIn 0.5s ease-out'; evt.currentTarget.classList.add("active"); + + // 懒加载:只在第一次切换到tab时初始化该tab的图表 + if (!initializedTabs.has(tabName) && tabName !== 'charts') { + initializeStaticChartsForPeriod(tabName); + initializedTabs.add(tabName); + } } document.addEventListener('DOMContentLoaded', function () { @@ -176,7 +185,12 @@ document.addEventListener('DOMContentLoaded', function () { console.error("Problematic static_chart_data string:", static_chart_data_json_string); } - Object.keys(staticChartData).forEach(period_id => { + // 懒加载函数:只初始化指定tab的静态图表 + function initializeStaticChartsForPeriod(period_id) { + if (!staticChartData[period_id]) { + console.warn(`No static chart data for period: ${period_id}`); + return; + } const providerCostData = staticChartData[period_id].provider_cost_data; const moduleCostData = staticChartData[period_id].module_cost_data; const modelCostData = staticChartData[period_id].model_cost_data; @@ -212,22 +226,57 @@ document.addEventListener('DOMContentLoaded', function () { }, options: { responsive: true, - maintainAspectRatio: true, - aspectRatio: 1.3, + maintainAspectRatio: false, plugins: { title: { - display: true, - text: '🏢 按供应商花费分布', - font: { size: 13, weight: '500' }, - color: '#1C1B1F', - padding: { top: 4, bottom: 12 } + display: false }, legend: { - position: 'bottom', + position: 'right', + align: 'center', labels: { usePointStyle: true, - padding: 10, - font: { size: 11 } + padding: 8, + font: { size: 10 }, + boxWidth: 12, + boxHeight: 12, + color: function(context) { + const chart = context.chart; + const meta = chart.getDatasetMeta(0); + const index = context.index; + return meta.data[index] && meta.data[index].hidden ? '#CCCCCC' : '#666666'; + }, + generateLabels: function(chart) { + const data = chart.data; + if (data.labels.length && data.datasets.length) { + const dataset = data.datasets[0]; + const labels = data.labels.slice(0, 10); + return labels.map((label, i) => { + const meta = chart.getDatasetMeta(0); + const style = meta.controller.getStyle(i); + const isHidden = meta.data[i] && meta.data[i].hidden; + return { + text: label.length > 15 ? label.substring(0, 15) + '...' : label, + fillStyle: isHidden ? '#E0E0E0' : style.backgroundColor, + strokeStyle: isHidden ? '#E0E0E0' : style.borderColor, + lineWidth: style.borderWidth, + fontColor: isHidden ? '#CCCCCC' : '#666666', + hidden: isNaN(dataset.data[i]) || isHidden, + index: i + }; + }); + } + return []; + } + }, + onClick: function(e, legendItem, legend) { + const index = legendItem.index; + const chart = legend.chart; + const meta = chart.getDatasetMeta(0); + + // 切换该扇区的可见性 + meta.data[index].hidden = !meta.data[index].hidden; + chart.update(); } }, tooltip: { @@ -279,22 +328,55 @@ document.addEventListener('DOMContentLoaded', function () { }, options: { responsive: true, - maintainAspectRatio: true, - aspectRatio: 1.3, + maintainAspectRatio: false, plugins: { title: { - display: true, - text: '🔧 按模块使用分布', - font: { size: 13, weight: '500' }, - color: '#1C1B1F', - padding: { top: 4, bottom: 12 } + display: false }, legend: { - position: 'bottom', + position: 'right', + align: 'center', labels: { usePointStyle: true, - padding: 10, - font: { size: 11 } + padding: 8, + font: { size: 10 }, + boxWidth: 12, + boxHeight: 12, + color: function(context) { + const chart = context.chart; + const meta = chart.getDatasetMeta(0); + const index = context.index; + return meta.data[index] && meta.data[index].hidden ? '#CCCCCC' : '#666666'; + }, + generateLabels: function(chart) { + const data = chart.data; + if (data.labels.length && data.datasets.length) { + const dataset = data.datasets[0]; + return data.labels.map((label, i) => { + const meta = chart.getDatasetMeta(0); + const style = meta.controller.getStyle(i); + const isHidden = meta.data[i] && meta.data[i].hidden; + return { + text: label.length > 15 ? label.substring(0, 15) + '...' : label, + fillStyle: isHidden ? '#E0E0E0' : style.backgroundColor, + strokeStyle: isHidden ? '#E0E0E0' : style.borderColor, + lineWidth: style.borderWidth, + fontColor: isHidden ? '#CCCCCC' : '#666666', + hidden: isNaN(dataset.data[i]) || isHidden, + index: i + }; + }); + } + return []; + } + }, + onClick: function(e, legendItem, legend) { + const index = legendItem.index; + const chart = legend.chart; + const meta = chart.getDatasetMeta(0); + + meta.data[index].hidden = !meta.data[index].hidden; + chart.update(); } }, tooltip: { @@ -331,33 +413,53 @@ document.addEventListener('DOMContentLoaded', function () { // Model Cost Bar Chart const modelCtx = document.getElementById(`modelCostBarChart_${period_id}`); if (modelCtx && modelCostData && modelCostData.data && modelCostData.data.length > 0) { + // 动态计算高度:每个条目至少25px + const minHeight = Math.max(250, modelCostData.labels.length * 25); + modelCtx.parentElement.style.minHeight = minHeight + 'px'; + + // 为每个柱子创建单独的数据集以支持单独隐藏 + const datasets = modelCostData.labels.map((label, idx) => ({ + label: label, + data: modelCostData.labels.map((_, i) => i === idx ? modelCostData.data[idx] : null), + backgroundColor: colors[idx % colors.length], + borderColor: colors[idx % colors.length], + borderWidth: 2, + borderRadius: 6, + hoverBackgroundColor: colors[idx % colors.length] + 'dd' + })); + new Chart(modelCtx, { type: 'bar', data: { labels: modelCostData.labels, - datasets: [{ - label: '按模型花费', - data: modelCostData.data, - backgroundColor: colors, - borderColor: colors, - borderWidth: 2, - borderRadius: 6, - hoverBackgroundColor: colors.map(c => c + 'dd') - }] + datasets: datasets }, options: { responsive: true, - maintainAspectRatio: true, - aspectRatio: 1.2, + maintainAspectRatio: false, plugins: { title: { - display: true, - text: '🤖 按模型花费排行', - font: { size: 13, weight: '500' }, - color: '#1C1B1F', - padding: { top: 4, bottom: 12 } + display: false + }, + legend: { + display: true, + position: 'top', + align: 'start', + labels: { + usePointStyle: true, + padding: 6, + font: { size: 9 }, + boxWidth: 10, + boxHeight: 10 + }, + onClick: function(e, legendItem, legend) { + const index = legendItem.datasetIndex; + const chart = legend.chart; + const meta = chart.getDatasetMeta(index); + meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; + chart.update(); + } }, - legend: { display: false }, tooltip: { backgroundColor: 'rgba(0, 0, 0, 0.8)', padding: 12, @@ -366,16 +468,39 @@ document.addEventListener('DOMContentLoaded', function () { cornerRadius: 8, callbacks: { label: function(context) { - return context.dataset.label + ': ' + context.parsed.y.toFixed(4) + ' ¥'; + if (context.parsed.y !== null) { + return context.dataset.label + ': ' + context.parsed.y.toFixed(4) + ' ¥'; + } + return ''; } + }, + filter: function(tooltipItem) { + return tooltipItem.parsed.y !== null; } } }, scales: { x: { + stacked: false, grid: { display: false }, ticks: { - font: { size: 10 } + font: { size: 9 }, + maxRotation: 45, + minRotation: 0, + callback: function(value, index, ticks) { + const chart = this.chart; + // 检查该索引位置是否有可见的数据 + let hasVisibleData = false; + for (let i = 0; i < chart.data.datasets.length; i++) { + const meta = chart.getDatasetMeta(i); + if (!meta.hidden && chart.data.datasets[i].data[index] !== null) { + hasVisibleData = true; + break; + } + } + // 只显示有可见数据的标签 + return hasVisibleData ? chart.data.labels[index] : ''; + } } }, y: { @@ -398,5 +523,478 @@ document.addEventListener('DOMContentLoaded', function () { } }); } - }); + + // === 新增图表 === + + // 1. Token使用对比条形图 + const tokenCompData = staticChartData[period_id].token_comparison_data; + const tokenCompCtx = document.getElementById(`tokenComparisonChart_${period_id}`); + if (tokenCompCtx && tokenCompData && tokenCompData.labels && tokenCompData.labels.length > 0) { + // 动态计算高度 + const minHeight = Math.max(270, tokenCompData.labels.length * 30); + tokenCompCtx.parentElement.style.minHeight = minHeight + 'px'; + + new Chart(tokenCompCtx, { + type: 'bar', + data: { + labels: tokenCompData.labels, + datasets: [ + { + label: '输入Token', + data: tokenCompData.input_tokens, + backgroundColor: '#FF9800', + borderColor: '#F57C00', + borderWidth: 2, + borderRadius: 6 + }, + { + label: '输出Token', + data: tokenCompData.output_tokens, + backgroundColor: '#4CAF50', + borderColor: '#388E3C', + borderWidth: 2, + borderRadius: 6 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: false + }, + legend: { + position: 'top', + labels: { + usePointStyle: true, + padding: 10, + font: { size: 11 }, + boxWidth: 12, + boxHeight: 12 + }, + onClick: function(e, legendItem, legend) { + const index = legendItem.datasetIndex; + const chart = legend.chart; + const meta = chart.getDatasetMeta(index); + meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; + chart.update(); + } + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + cornerRadius: 8, + callbacks: { + label: function(context) { + const value = context.parsed.y.toLocaleString(); + const total = context.dataset.data.reduce((a, b) => a + b, 0); + const percentage = ((context.parsed.y / total) * 100).toFixed(1); + return context.dataset.label + ': ' + value + ' tokens (' + percentage + '%)'; + } + } + } + }, + scales: { + x: { + grid: { display: false }, + ticks: { + font: { size: 9 }, + maxRotation: 45, + minRotation: 0 + } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: 'Token数量', + font: { size: 11, weight: 'bold' } + }, + grid: { color: 'rgba(0, 0, 0, 0.05)' }, + ticks: { font: { size: 10 } } + } + }, + animation: { duration: 1000, easing: 'easeInOutQuart' }, + interaction: { + mode: 'index', + intersect: false + } + } + }); + } + + // 2. 供应商请求占比环形图 + const providerReqData = staticChartData[period_id].provider_requests_data; + const providerReqCtx = document.getElementById(`providerRequestsDoughnutChart_${period_id}`); + if (providerReqCtx && providerReqData && providerReqData.data && providerReqData.data.length > 0) { + new Chart(providerReqCtx, { + type: 'doughnut', + data: { + labels: providerReqData.labels, + datasets: [{ + label: '请求数', + data: providerReqData.data, + backgroundColor: ['#9C27B0', '#E91E63', '#F44336', '#FF9800', '#FFC107', '#FFEB3B', '#CDDC39', '#8BC34A', '#4CAF50', '#009688'], + borderColor: '#FFFFFF', + borderWidth: 2, + hoverOffset: 10 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: false + }, + legend: { + position: 'right', + align: 'center', + labels: { + usePointStyle: true, + padding: 8, + font: { size: 10 }, + boxWidth: 12, + boxHeight: 12, + generateLabels: function(chart) { + const data = chart.data; + if (data.labels.length && data.datasets.length) { + const dataset = data.datasets[0]; + return data.labels.map((label, i) => { + const meta = chart.getDatasetMeta(0); + const style = meta.controller.getStyle(i); + return { + text: label.length > 15 ? label.substring(0, 15) + '...' : label, + fillStyle: style.backgroundColor, + strokeStyle: style.borderColor, + lineWidth: style.borderWidth, + hidden: isNaN(dataset.data[i]) || meta.data[i].hidden, + index: i + }; + }); + } + return []; + } + }, + onClick: function(e, legendItem, legend) { + const index = legendItem.index; + const chart = legend.chart; + const meta = chart.getDatasetMeta(0); + + meta.data[index].hidden = !meta.data[index].hidden; + chart.update(); + } + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + cornerRadius: 8, + callbacks: { + label: function(context) { + const total = context.dataset.data.reduce((a, b) => a + b, 0); + const percentage = ((context.parsed / total) * 100).toFixed(2); + return context.label + ': ' + context.parsed + ' 次 (' + percentage + '%)'; + } + } + } + }, + animation: { animateRotate: true, animateScale: true, duration: 1000 } + } + }); + } + + // 3. 平均响应时间条形图 + const avgRespTimeData = staticChartData[period_id].avg_response_time_data; + const avgRespTimeCtx = document.getElementById(`avgResponseTimeChart_${period_id}`); + if (avgRespTimeCtx && avgRespTimeData && avgRespTimeData.data && avgRespTimeData.data.length > 0) { + // 动态计算高度:横向条形图每个条目至少30px + const minHeight = Math.max(270, avgRespTimeData.labels.length * 30); + avgRespTimeCtx.parentElement.style.minHeight = minHeight + 'px'; + + // 为每个柱子创建渐变色 + const barColors = avgRespTimeData.labels.map((_, idx) => { + const colorPalette = ['#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#00BCD4', '#009688', '#4CAF50']; + return colorPalette[idx % colorPalette.length]; + }); + + // 为每个柱子创建单独的数据集 + const datasets = avgRespTimeData.labels.map((label, idx) => ({ + label: label.length > 25 ? label.substring(0, 25) + '...' : label, + data: avgRespTimeData.labels.map((_, i) => i === idx ? avgRespTimeData.data[idx] : null), + backgroundColor: barColors[idx], + borderColor: barColors[idx], + borderWidth: 2, + borderRadius: 6 + })); + + new Chart(avgRespTimeCtx, { + type: 'bar', + data: { + labels: avgRespTimeData.labels.map(label => label.length > 25 ? label.substring(0, 25) + '...' : label), + datasets: datasets + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: false + }, + legend: { + display: true, + position: 'top', + align: 'start', + labels: { + usePointStyle: true, + padding: 6, + font: { size: 9 }, + boxWidth: 10, + boxHeight: 10 + }, + onClick: function(e, legendItem, legend) { + const index = legendItem.datasetIndex; + const chart = legend.chart; + const meta = chart.getDatasetMeta(index); + meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; + chart.update(); + } + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + cornerRadius: 8, + callbacks: { + label: function(context) { + if (context.parsed.x !== null) { + return context.dataset.label + ': ' + context.parsed.x.toFixed(3) + ' 秒'; + } + return ''; + } + }, + filter: function(tooltipItem) { + return tooltipItem.parsed.x !== null; + } + } + }, + scales: { + x: { + beginAtZero: true, + title: { + display: true, + text: '时间 (秒)', + font: { size: 11, weight: 'bold' } + }, + grid: { color: 'rgba(0, 0, 0, 0.05)' }, + ticks: { font: { size: 10 } } + }, + y: { + grid: { display: false }, + ticks: { font: { size: 9 } } + } + }, + animation: { duration: 1000, easing: 'easeInOutQuart' } + } + }); + } + + // 4. 模型效率雷达图 + const radarData = staticChartData[period_id].model_efficiency_radar_data; + const radarCtx = document.getElementById(`modelEfficiencyRadarChart_${period_id}`); + if (radarCtx && radarData && radarData.datasets && radarData.datasets.length > 0) { + const radarColors = ['#00BCD4', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0']; + const datasets = radarData.datasets.map((dataset, idx) => ({ + label: dataset.model.length > 20 ? dataset.model.substring(0, 20) + '...' : dataset.model, + data: dataset.metrics, + backgroundColor: radarColors[idx % radarColors.length] + '40', + borderColor: radarColors[idx % radarColors.length], + borderWidth: 2, + pointBackgroundColor: radarColors[idx % radarColors.length], + pointBorderColor: '#fff', + pointHoverBackgroundColor: '#fff', + pointHoverBorderColor: radarColors[idx % radarColors.length], + pointRadius: 4, + pointHoverRadius: 6 + })); + + new Chart(radarCtx, { + type: 'radar', + data: { + labels: radarData.labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: false + }, + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + padding: 8, + font: { size: 10 }, + boxWidth: 12, + boxHeight: 12 + }, + onClick: function(e, legendItem, legend) { + const index = legendItem.datasetIndex; + const chart = legend.chart; + const meta = chart.getDatasetMeta(index); + meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; + chart.update(); + } + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + cornerRadius: 8, + callbacks: { + label: function(context) { + const label = context.dataset.label || ''; + const metric = context.label || ''; + const value = context.parsed.r.toFixed(1); + return label + ' - ' + metric + ': ' + value + '/100'; + } + } + } + }, + scales: { + r: { + beginAtZero: true, + max: 100, + ticks: { + stepSize: 20, + font: { size: 9 }, + backdropColor: 'transparent' + }, + pointLabels: { + font: { size: 10, weight: 'bold' } + }, + grid: { color: 'rgba(0, 0, 0, 0.1)' }, + angleLines: { color: 'rgba(0, 0, 0, 0.1)' } + } + }, + animation: { duration: 1200, easing: 'easeInOutQuart' }, + interaction: { + mode: 'point', + intersect: true + } + } + }); + } + + // 5. 响应时间分布散点图 + const scatterData = staticChartData[period_id].response_time_scatter_data; + const scatterCtx = document.getElementById(`responseTimeScatterChart_${period_id}`); + if (scatterCtx && scatterData && scatterData.length > 0) { + // 按模型分组数据,限制每个模型最多显示100个点 + const groupedData = {}; + scatterData.forEach(point => { + if (!groupedData[point.model]) { + groupedData[point.model] = []; + } + if (groupedData[point.model].length < 100) { + groupedData[point.model].push({x: point.x, y: point.y}); + } + }); + + const scatterColors = ['#4CAF50', '#2196F3', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4', '#FFC107', '#607D8B']; + const datasets = Object.keys(groupedData).slice(0, 8).map((model, idx) => ({ + label: model.length > 20 ? model.substring(0, 20) + '...' : model, + data: groupedData[model], + backgroundColor: scatterColors[idx % scatterColors.length] + '80', + borderColor: scatterColors[idx % scatterColors.length], + borderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, + pointStyle: 'circle' + })); + + new Chart(scatterCtx, { + type: 'scatter', + data: { datasets: datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: false + }, + legend: { + position: 'top', + labels: { + usePointStyle: true, + padding: 8, + font: { size: 10 }, + boxWidth: 12, + boxHeight: 12 + }, + onClick: function(e, legendItem, legend) { + // 默认行为:切换数据集的可见性 + const index = legendItem.datasetIndex; + const chart = legend.chart; + const meta = chart.getDatasetMeta(index); + + // 切换可见性 + meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; + + // 更新图表 + chart.update(); + } + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + cornerRadius: 8, + callbacks: { + label: function(context) { + return context.dataset.label + ': ' + context.parsed.y.toFixed(3) + ' 秒'; + }, + afterLabel: function(context) { + return '请求 #' + context.parsed.x; + } + } + } + }, + scales: { + x: { + title: { + display: true, + text: '请求序号', + font: { size: 11, weight: 'bold' } + }, + grid: { color: 'rgba(0, 0, 0, 0.05)' }, + ticks: { font: { size: 10 } } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: '响应时间 (秒)', + font: { size: 11, weight: 'bold' } + }, + grid: { color: 'rgba(0, 0, 0, 0.05)' }, + ticks: { font: { size: 10 } } + } + }, + animation: { duration: 1000, easing: 'easeInOutQuart' }, + interaction: { + mode: 'point', + intersect: true + } + } + }); + } + } + + // 初始化第一个tab(默认显示的tab)的图表 + const firstTab = tab_content[0]?.id; + if (firstTab && firstTab !== 'charts') { + initializeStaticChartsForPeriod(firstTab); + initializedTabs.add(firstTab); + } }); \ No newline at end of file diff --git a/src/chat/utils/templates/static_charts.html b/src/chat/utils/templates/static_charts.html index 4b9e7cd07..a31d890cc 100644 --- a/src/chat/utils/templates/static_charts.html +++ b/src/chat/utils/templates/static_charts.html @@ -1,16 +1,65 @@
+ 💡 提示:点击图例可以隐藏/显示对应的数据系列 +