feat(datasource): add auto ECharts visualization for SQL query results

This commit is contained in:
matevip 2026-04-06 14:53:22 +08:00
parent 10dfe35d49
commit 3940cfd1ce
9 changed files with 570 additions and 2 deletions

View File

@ -0,0 +1,283 @@
package vip.mate.datasource.service;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* ECharts Option 自动生成器
* <p>
* 根据 SQL 查询结果的列类型和数据特征自动选择图表类型并生成 ECharts option JSON
* 参考 SQLBot 的图表选择规则使用确定性逻辑代替 LLM 推理
* <p>
* 规则
* <ul>
* <li>日期/时间列 + 数值列 折线图 (line)</li>
* <li>分类列 + 数值列15 个类别 柱状图 (bar)</li>
* <li>分类列 + 单个数值列8 个类别 饼图 (pie)</li>
* <li>其他情况单行纯文本列太多 不生成图表</li>
* </ul>
*
* @author MateClaw Team
*/
public class EChartsOptionBuilder {
private static final int MIN_ROWS = 2;
private static final int MAX_ROWS_FOR_CHART = 50;
private static final int MAX_PIE_CATEGORIES = 8;
private static final int MAX_BAR_CATEGORIES = 15;
/**
* 尝试根据查询结果生成 ECharts option JSON 字符串
*
* @param columns 列名列表
* @param rows 数据行每行是字符串列表
* @return ECharts option JSON 字符串如果数据不适合可视化则返回 null
*/
public static String tryBuild(List<String> columns, List<List<String>> rows) {
if (columns == null || rows == null || rows.size() < MIN_ROWS || rows.size() > MAX_ROWS_FOR_CHART) {
return null;
}
if (columns.size() < 2 || columns.size() > 10) {
return null;
}
// 分析每列的类型
List<ColumnInfo> colInfos = analyzeColumns(columns, rows);
// 找出维度列第一个非数值列和数值列
ColumnInfo dimensionCol = null;
List<ColumnInfo> metricCols = new ArrayList<>();
for (ColumnInfo ci : colInfos) {
if (ci.type == ColType.NUMERIC) {
metricCols.add(ci);
} else if (dimensionCol == null) {
dimensionCol = ci;
}
}
// 必须有至少一个维度列和一个数值列
if (dimensionCol == null || metricCols.isEmpty()) {
return null;
}
int categoryCount = rows.size();
// 决定图表类型
if (metricCols.size() == 1 && categoryCount <= MAX_PIE_CATEGORIES && dimensionCol.type == ColType.CATEGORY) {
return buildPie(dimensionCol, metricCols.get(0), rows, columns);
} else if (dimensionCol.type == ColType.DATE) {
return buildLine(dimensionCol, metricCols, rows, columns);
} else if (categoryCount <= MAX_BAR_CATEGORIES) {
return buildBar(dimensionCol, metricCols, rows, columns);
} else {
return buildLine(dimensionCol, metricCols, rows, columns);
}
}
// ==================== 图表构建 ====================
private static String buildLine(ColumnInfo dim, List<ColumnInfo> metrics, List<List<String>> rows, List<String> columns) {
JSONObject option = new JSONObject(new LinkedHashMap<>());
option.set("title", new JSONObject().set("text", buildTitle(dim, metrics, "趋势")).set("left", "center"));
option.set("tooltip", new JSONObject().set("trigger", "axis"));
if (metrics.size() > 1) {
JSONArray legendData = new JSONArray();
metrics.forEach(m -> legendData.add(m.name));
option.set("legend", new JSONObject().set("data", legendData).set("bottom", 0));
}
option.set("grid", defaultGrid());
option.set("xAxis", new JSONObject()
.set("type", "category")
.set("data", extractColumn(rows, columns.indexOf(dim.name))));
option.set("yAxis", new JSONObject().set("type", "value"));
JSONArray series = new JSONArray();
for (ColumnInfo m : metrics) {
series.add(new JSONObject()
.set("name", m.name)
.set("type", "line")
.set("data", extractNumericColumn(rows, columns.indexOf(m.name)))
.set("smooth", true));
}
option.set("series", series);
return option.toString();
}
private static String buildBar(ColumnInfo dim, List<ColumnInfo> metrics, List<List<String>> rows, List<String> columns) {
JSONObject option = new JSONObject(new LinkedHashMap<>());
option.set("title", new JSONObject().set("text", buildTitle(dim, metrics, "对比")).set("left", "center"));
option.set("tooltip", new JSONObject().set("trigger", "axis"));
if (metrics.size() > 1) {
JSONArray legendData = new JSONArray();
metrics.forEach(m -> legendData.add(m.name));
option.set("legend", new JSONObject().set("data", legendData).set("bottom", 0));
}
option.set("grid", defaultGrid());
option.set("xAxis", new JSONObject()
.set("type", "category")
.set("data", extractColumn(rows, columns.indexOf(dim.name))));
option.set("yAxis", new JSONObject().set("type", "value"));
JSONArray series = new JSONArray();
for (ColumnInfo m : metrics) {
series.add(new JSONObject()
.set("name", m.name)
.set("type", "bar")
.set("data", extractNumericColumn(rows, columns.indexOf(m.name))));
}
option.set("series", series);
return option.toString();
}
private static String buildPie(ColumnInfo dim, ColumnInfo metric, List<List<String>> rows, List<String> columns) {
JSONObject option = new JSONObject(new LinkedHashMap<>());
option.set("title", new JSONObject().set("text", buildTitle(dim, List.of(metric), "占比")).set("left", "center"));
option.set("tooltip", new JSONObject().set("trigger", "item"));
int dimIdx = columns.indexOf(dim.name);
int metricIdx = columns.indexOf(metric.name);
JSONArray pieData = new JSONArray();
for (List<String> row : rows) {
JSONObject item = new JSONObject();
item.set("name", row.get(dimIdx));
item.set("value", parseNumber(row.get(metricIdx)));
pieData.add(item);
}
JSONArray series = new JSONArray();
series.add(new JSONObject()
.set("name", metric.name)
.set("type", "pie")
.set("radius", "60%")
.set("data", pieData));
option.set("series", series);
return option.toString();
}
// ==================== 列分析 ====================
private enum ColType { NUMERIC, DATE, CATEGORY }
private static class ColumnInfo {
String name;
ColType type;
ColumnInfo(String name, ColType type) {
this.name = name;
this.type = type;
}
}
private static List<ColumnInfo> analyzeColumns(List<String> columns, List<List<String>> rows) {
List<ColumnInfo> result = new ArrayList<>();
for (int i = 0; i < columns.size(); i++) {
String colName = columns.get(i);
ColType type = detectColumnType(colName, rows, i);
result.add(new ColumnInfo(colName, type));
}
return result;
}
private static ColType detectColumnType(String colName, List<List<String>> rows, int colIdx) {
// 列名启发式判断日期
String lowerName = colName.toLowerCase();
if (lowerName.contains("date") || lowerName.contains("time") || lowerName.contains("day")
|| lowerName.contains("month") || lowerName.contains("year") || lowerName.contains("week")
|| lowerName.contains("日期") || lowerName.contains("时间") || lowerName.contains("月份")) {
return ColType.DATE;
}
// 采样数据判断类型
int numericCount = 0;
int dateCount = 0;
int sampleSize = Math.min(rows.size(), 10);
for (int i = 0; i < sampleSize; i++) {
String val = rows.get(i).get(colIdx);
if (val == null || "NULL".equals(val)) continue;
if (isNumeric(val)) {
numericCount++;
} else if (isDateLike(val)) {
dateCount++;
}
}
if (numericCount >= sampleSize * 0.8) return ColType.NUMERIC;
if (dateCount >= sampleSize * 0.5) return ColType.DATE;
return ColType.CATEGORY;
}
private static boolean isNumeric(String val) {
if (val == null || val.isEmpty()) return false;
try {
new BigDecimal(val.trim());
return true;
} catch (NumberFormatException e) {
return false;
}
}
private static boolean isDateLike(String val) {
if (val == null || val.length() < 6) return false;
// 匹配常见日期格式2024-01-01, 2024/01/01, 2024-01, 01-01, 20240101
return val.matches("\\d{4}[-/]\\d{1,2}([-/]\\d{1,2})?.*")
|| val.matches("\\d{1,2}[-/]\\d{1,2}([-/]\\d{2,4})?");
}
// ==================== 工具方法 ====================
private static JSONArray extractColumn(List<List<String>> rows, int idx) {
JSONArray arr = new JSONArray();
for (List<String> row : rows) {
arr.add(row.get(idx));
}
return arr;
}
private static JSONArray extractNumericColumn(List<List<String>> rows, int idx) {
JSONArray arr = new JSONArray();
for (List<String> row : rows) {
arr.add(parseNumber(row.get(idx)));
}
return arr;
}
private static Object parseNumber(String val) {
if (val == null || "NULL".equals(val)) return 0;
try {
BigDecimal bd = new BigDecimal(val.trim());
// 如果没有小数部分返回整数
if (bd.scale() <= 0 || bd.stripTrailingZeros().scale() <= 0) {
return bd.longValue();
}
return bd.doubleValue();
} catch (NumberFormatException e) {
return 0;
}
}
private static String buildTitle(ColumnInfo dim, List<ColumnInfo> metrics, String suffix) {
if (metrics.size() == 1) {
return metrics.get(0).name + " " + suffix;
}
return dim.name + " " + suffix;
}
private static JSONObject defaultGrid() {
return new JSONObject()
.set("left", "3%")
.set("right", "4%")
.set("bottom", "12%")
.set("containLabel", true);
}
}

View File

@ -2,7 +2,6 @@ package vip.mate.tool.builtin;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
@ -11,6 +10,7 @@ import org.springframework.stereotype.Component;
import vip.mate.datasource.model.DatasourceEntity;
import vip.mate.datasource.service.DatasourceConnectionManager;
import vip.mate.datasource.service.DatasourceService;
import vip.mate.datasource.service.EChartsOptionBuilder;
import vip.mate.datasource.service.SqlValidationService;
import java.sql.*;
@ -22,6 +22,7 @@ import java.util.List;
* <p>
* 仅允许 SELECT 语句自动注入 LIMIT 保护
* 查询超时 30 结果格式化为 Markdown 表格或 JSON
* 自动分析数据特征并生成 ECharts 图表配置
*
* @author MateClaw Team
*/
@ -43,6 +44,7 @@ public class SqlQueryTool {
仅允许 SELECT 语句禁止 INSERT/UPDATE/DELETE/DROP 等写操作
如果 SQL 没有 LIMIT 子句会自动添加 LIMIT 500
返回查询结果Markdown 表格或 JSON 格式以及行数和执行耗时
如果数据适合可视化会自动附带一个 echarts 图表配置代码块前端会自动渲染为交互式图表
""")
public String execute_sql(
@ToolParam(description = "目标数据源 ID") Long datasourceId,
@ -137,6 +139,12 @@ public class SqlQueryTool {
sb.append("\n\n> 结果已截断至 ").append(MAX_ROWS).append(" 行,实际数据可能更多。");
}
// 自动生成 ECharts 图表配置
String chartOption = EChartsOptionBuilder.tryBuild(columns, rows);
if (chartOption != null) {
sb.append("\n\n```echarts\n").append(chartOption).append("\n```");
}
return sb.toString();
}
}

View File

@ -51,6 +51,8 @@ dependencies:
- 用自然语言总结查询结果的要点
- 如果结果为空,分析可能的原因(表名/列名/条件有误等)
- 如果需要,可以调整 SQL 重新查询
- 如果查询结果包含数值列,系统会自动生成 ECharts 图表,无需你手动生成图表代码
- **重要:不要使用 write_file 工具生成 HTML 图表文件,系统已内置图表渲染能力**
## 错误处理

View File

@ -15,6 +15,7 @@
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"dompurify": "^3.3.3",
"echarts": "^6.0.0",
"element-plus": "^2.9.1",
"highlight.js": "^11.11.1",
"marked": "^15.0.6",

View File

@ -20,6 +20,9 @@ importers:
dompurify:
specifier: ^3.3.3
version: 3.3.3
echarts:
specifier: ^6.0.0
version: 6.0.0
element-plus:
specifier: ^2.9.1
version: 2.13.6(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3))
@ -823,6 +826,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
electron-to-chromium@1.5.331:
resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==}
@ -1361,6 +1367,9 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -1480,6 +1489,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots:
'@babel/helper-string-parser@7.27.1': {}
@ -2089,6 +2101,11 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.331: {}
element-plus@2.13.6(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3)):
@ -2625,6 +2642,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tslib@2.3.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -2712,3 +2731,7 @@ snapshots:
xml-name-validator@4.0.0: {}
yocto-queue@0.1.0: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0

View File

@ -302,6 +302,24 @@ body {
border-radius: 0;
}
/* ECharts chart block */
.markdown-body .echarts-block {
margin: 14px 0;
border-radius: 12px;
overflow: hidden;
min-height: 350px;
width: 100%;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
}
.markdown-body .echarts-block.echarts-error {
min-height: auto;
padding: 16px;
text-align: center;
color: var(--mc-text-tertiary);
font-size: 13px;
}
/* highlight.js token colors (One Dark inspired — works on dark bg) */
.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c678dd; }
.hljs-string, .hljs-attr { color: #98c379; }

View File

@ -0,0 +1,219 @@
import { type Ref, watch, nextTick } from 'vue'
import { useThemeStore } from '@/stores/useThemeStore'
// Lazy-load echarts to keep initial bundle small (~1MB saved)
let echartsModule: typeof import('echarts') | null = null
async function getECharts() {
if (!echartsModule) {
echartsModule = await import('echarts')
}
return echartsModule
}
/** Top-level keys allowed in ECharts option (security whitelist) */
const ALLOWED_KEYS = new Set([
'title', 'tooltip', 'legend', 'xAxis', 'yAxis', 'series',
'grid', 'color', 'dataset', 'graphic', 'radar', 'polar',
'angleAxis', 'radiusAxis', 'visualMap',
])
const MAX_OPTION_SIZE = 100 * 1024 // 100KB
/**
* Recursively strip function-like values from an ECharts option object
* to prevent XSS via ECharts formatter evaluation.
*/
function sanitizeOption(obj: Record<string, any>): void {
for (const key of Object.keys(obj)) {
const val = obj[key]
if (typeof val === 'string' && val.trimStart().startsWith('function')) {
delete obj[key]
} else if (typeof val === 'function') {
delete obj[key]
} else if (val && typeof val === 'object') {
if (Array.isArray(val)) {
val.forEach((item: any) => {
if (item && typeof item === 'object') sanitizeOption(item)
})
} else {
sanitizeOption(val)
}
}
}
}
function filterTopLevelKeys(option: Record<string, any>): Record<string, any> {
const filtered: Record<string, any> = {}
for (const key of Object.keys(option)) {
if (ALLOWED_KEYS.has(key)) {
filtered[key] = option[key]
}
}
return filtered
}
/**
* Composable that observes a container for `.echarts-block` placeholder divs
* and mounts ECharts instances on them.
*/
export function useEChartsRenderer(containerRef: Ref<HTMLElement | null>) {
const themeStore = useThemeStore()
const instanceMap = new WeakMap<HTMLElement, any>() // echarts.ECharts
const trackedElements: Set<HTMLElement> = new Set()
const mountingSet = new Set<HTMLElement>() // guard against concurrent mounts
let observer: MutationObserver | null = null
let resizeObserver: ResizeObserver | null = null
async function mountChart(el: HTMLElement) {
if (instanceMap.has(el) || mountingSet.has(el)) return
mountingSet.add(el)
const encoded = el.getAttribute('data-echarts-option')
if (!encoded) {
mountingSet.delete(el)
return
}
// Size guard
if (encoded.length > MAX_OPTION_SIZE) {
el.textContent = 'Chart option too large'
mountingSet.delete(el)
return
}
try {
const raw = decodeURIComponent(encoded)
let option = JSON.parse(raw)
// Must be an object with series
if (!option || typeof option !== 'object' || !option.series) {
el.textContent = 'Invalid chart option'
mountingSet.delete(el)
return
}
// Security: filter keys and strip functions
option = filterTopLevelKeys(option)
sanitizeOption(option)
// Ensure the element has explicit dimensions
if (!el.style.height) {
el.style.height = '350px'
}
if (!el.style.width) {
el.style.width = '100%'
}
const echarts = await getECharts()
const theme = themeStore.isDark ? 'dark' : undefined
const chart = echarts.init(el, theme)
chart.setOption(option)
instanceMap.set(el, chart)
trackedElements.add(el)
} catch (e) {
console.error('[EChartsRenderer] mount error:', e)
el.textContent = 'Chart render error'
el.classList.add('echarts-error')
} finally {
mountingSet.delete(el)
}
}
function scanAndMount() {
const container = containerRef.value
if (!container) return
const blocks = container.querySelectorAll('.echarts-block:not(.echarts-error)')
blocks.forEach((el) => {
if (!instanceMap.has(el as HTMLElement) && !mountingSet.has(el as HTMLElement)) {
mountChart(el as HTMLElement)
}
})
}
function rebuildAll() {
trackedElements.forEach((el) => {
const chart = instanceMap.get(el)
if (chart) {
chart.dispose()
instanceMap.delete(el)
}
})
trackedElements.clear()
scanAndMount()
}
function resizeAll() {
trackedElements.forEach((el) => {
const chart = instanceMap.get(el)
if (chart && !chart.isDisposed()) {
chart.resize()
}
})
}
function attachObserver(container: HTMLElement) {
// Clean up previous observers
observer?.disconnect()
resizeObserver?.disconnect()
// MutationObserver to detect new echarts blocks in the DOM
observer = new MutationObserver(() => {
// Use nextTick to ensure DOM is settled after Vue updates
nextTick(() => scanAndMount())
})
observer.observe(container, { childList: true, subtree: true })
// ResizeObserver for container width changes
resizeObserver = new ResizeObserver(() => {
resizeAll()
})
resizeObserver.observe(container)
// Initial scan
scanAndMount()
}
function startObserving() {
const container = containerRef.value
if (container) {
attachObserver(container)
}
}
// Watch containerRef — if it's null at mount time, attach when it becomes available
const stopContainerWatch = watch(
() => containerRef.value,
(newContainer) => {
if (newContainer && !observer) {
attachObserver(newContainer)
}
},
{ immediate: false },
)
// Theme reactivity
const stopThemeWatch = watch(
() => themeStore.isDark,
() => {
rebuildAll()
},
)
function dispose() {
stopContainerWatch()
stopThemeWatch()
observer?.disconnect()
observer = null
resizeObserver?.disconnect()
resizeObserver = null
trackedElements.forEach((el) => {
const chart = instanceMap.get(el)
if (chart && !chart.isDisposed()) {
chart.dispose()
}
})
trackedElements.clear()
}
return { startObserving, dispose, scanAndMount }
}

View File

@ -42,6 +42,13 @@ const customRenderer = {
code({ text, lang }: { type: string; raw: string; text: string; lang?: string }): string {
const rawCode = text || ''
const infoStr = (lang || '').split(/\s/)[0]
// ECharts chart block: render as a placeholder div
if (infoStr === 'echarts') {
const encodedOption = encodeURIComponent(rawCode)
return `<div class="echarts-block" data-echarts-option="${encodedOption}"></div>`
}
const detectedLang = extractLang(infoStr)
const hasLanguage = detectedLang && hljs.getLanguage(detectedLang)
@ -81,7 +88,7 @@ const markedInstance = new Marked({
// 配置 DOMPurify — 允许 Markdown + 代码块复制按钮的标签和属性
const purifyConfig = {
ADD_ATTR: ['target', 'rel', 'class', 'data-code', 'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'points'],
ADD_ATTR: ['target', 'rel', 'class', 'data-code', 'data-echarts-option', 'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'points'],
ADD_TAGS: ['input', 'button', 'svg', 'path', 'rect', 'polyline', 'circle', 'line', 'span'],
}

View File

@ -205,6 +205,7 @@ import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo,
import MessageList from '@/components/chat/MessageList.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
// ============ ============
const isMobile = ref(false)
@ -353,6 +354,10 @@ async function collectFilesFromEntries(dirEntries: FileSystemDirectoryEntry[]):
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null)
// ECharts: extract DOM element from MessageList component ref
const echartsContainerRef = computed(() => messageListRef.value?.$el as HTMLElement | null)
const { startObserving: startECharts, dispose: disposeECharts } = useEChartsRenderer(echartsContainerRef)
// 使 useChat composable
const {
messages,
@ -463,6 +468,7 @@ const eligibleModels = computed(() => {
// ============ ============
onMounted(async () => {
document.addEventListener('click', handleCodeCopy)
startECharts()
mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery)
mobileQuery.addEventListener('change', handleMobileChange)
@ -472,6 +478,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
document.removeEventListener('click', handleCodeCopy)
disposeECharts()
mobileQuery?.removeEventListener('change', handleMobileChange)
stopChatGeneration()
// ObjectURL