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

View File

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

View File

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

View File

@ -20,6 +20,9 @@ importers:
dompurify: dompurify:
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.3.3 version: 3.3.3
echarts:
specifier: ^6.0.0
version: 6.0.0
element-plus: element-plus:
specifier: ^2.9.1 specifier: ^2.9.1
version: 2.13.6(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3)) 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==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
electron-to-chromium@1.5.331: electron-to-chromium@1.5.331:
resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==}
@ -1361,6 +1367,9 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -1480,6 +1489,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots: snapshots:
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.27.1': {}
@ -2089,6 +2101,11 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.331: {} electron-to-chromium@1.5.331: {}
element-plus@2.13.6(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3)): 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) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tslib@2.3.0: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@ -2712,3 +2731,7 @@ snapshots:
xml-name-validator@4.0.0: {} xml-name-validator@4.0.0: {}
yocto-queue@0.1.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; 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) */ /* highlight.js token colors (One Dark inspired — works on dark bg) */
.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c678dd; } .hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c678dd; }
.hljs-string, .hljs-attr { color: #98c379; } .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 { code({ text, lang }: { type: string; raw: string; text: string; lang?: string }): string {
const rawCode = text || '' const rawCode = text || ''
const infoStr = (lang || '').split(/\s/)[0] 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 detectedLang = extractLang(infoStr)
const hasLanguage = detectedLang && hljs.getLanguage(detectedLang) const hasLanguage = detectedLang && hljs.getLanguage(detectedLang)
@ -81,7 +88,7 @@ const markedInstance = new Marked({
// 配置 DOMPurify — 允许 Markdown + 代码块复制按钮的标签和属性 // 配置 DOMPurify — 允许 Markdown + 代码块复制按钮的标签和属性
const purifyConfig = { 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'], 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 MessageList from '@/components/chat/MessageList.vue'
import ChatInput from '@/components/chat/ChatInput.vue' import ChatInput from '@/components/chat/ChatInput.vue'
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue' import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
// ============ ============ // ============ ============
const isMobile = ref(false) const isMobile = ref(false)
@ -353,6 +354,10 @@ async function collectFilesFromEntries(dirEntries: FileSystemDirectoryEntry[]):
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null) const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
const chatInputRef = ref<InstanceType<typeof ChatInput> | 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 // 使 useChat composable
const { const {
messages, messages,
@ -463,6 +468,7 @@ const eligibleModels = computed(() => {
// ============ ============ // ============ ============
onMounted(async () => { onMounted(async () => {
document.addEventListener('click', handleCodeCopy) document.addEventListener('click', handleCodeCopy)
startECharts()
mobileQuery = window.matchMedia('(max-width: 768px)') mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery) handleMobileChange(mobileQuery)
mobileQuery.addEventListener('change', handleMobileChange) mobileQuery.addEventListener('change', handleMobileChange)
@ -472,6 +478,7 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleCodeCopy) document.removeEventListener('click', handleCodeCopy)
disposeECharts()
mobileQuery?.removeEventListener('change', handleMobileChange) mobileQuery?.removeEventListener('change', handleMobileChange)
stopChatGeneration() stopChatGeneration()
// ObjectURL // ObjectURL