diff --git a/mateclaw-server/src/main/java/vip/mate/datasource/service/EChartsOptionBuilder.java b/mateclaw-server/src/main/java/vip/mate/datasource/service/EChartsOptionBuilder.java new file mode 100644 index 00000000..b5366e17 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/datasource/service/EChartsOptionBuilder.java @@ -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 自动生成器 + *

+ * 根据 SQL 查询结果的列类型和数据特征,自动选择图表类型并生成 ECharts option JSON。 + * 参考 SQLBot 的图表选择规则,使用确定性逻辑代替 LLM 推理。 + *

+ * 规则: + *

+ * + * @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 columns, List> 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 colInfos = analyzeColumns(columns, rows); + + // 找出维度列(第一个非数值列)和数值列 + ColumnInfo dimensionCol = null; + List 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 metrics, List> rows, List 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 metrics, List> rows, List 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> rows, List 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 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 analyzeColumns(List columns, List> rows) { + List 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> 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> rows, int idx) { + JSONArray arr = new JSONArray(); + for (List row : rows) { + arr.add(row.get(idx)); + } + return arr; + } + + private static JSONArray extractNumericColumn(List> rows, int idx) { + JSONArray arr = new JSONArray(); + for (List 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 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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SqlQueryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SqlQueryTool.java index 3c5c836f..c04acdba 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SqlQueryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SqlQueryTool.java @@ -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; *

* 仅允许 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(); } } diff --git a/mateclaw-server/src/main/resources/skills/sql_query/SKILL.md b/mateclaw-server/src/main/resources/skills/sql_query/SKILL.md index 8c1aa88f..58331865 100644 --- a/mateclaw-server/src/main/resources/skills/sql_query/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/sql_query/SKILL.md @@ -51,6 +51,8 @@ dependencies: - 用自然语言总结查询结果的要点 - 如果结果为空,分析可能的原因(表名/列名/条件有误等) - 如果需要,可以调整 SQL 重新查询 +- 如果查询结果包含数值列,系统会自动生成 ECharts 图表,无需你手动生成图表代码 +- **重要:不要使用 write_file 工具生成 HTML 图表文件,系统已内置图表渲染能力** ## 错误处理 diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index 2d0a10d0..9a8c340d 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -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", diff --git a/mateclaw-ui/pnpm-lock.yaml b/mateclaw-ui/pnpm-lock.yaml index d51a85d9..028d18d9 100644 --- a/mateclaw-ui/pnpm-lock.yaml +++ b/mateclaw-ui/pnpm-lock.yaml @@ -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 diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index 644feb81..d5951511 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -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; } diff --git a/mateclaw-ui/src/composables/useEChartsRenderer.ts b/mateclaw-ui/src/composables/useEChartsRenderer.ts new file mode 100644 index 00000000..83200d72 --- /dev/null +++ b/mateclaw-ui/src/composables/useEChartsRenderer.ts @@ -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): 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): Record { + const filtered: Record = {} + 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) { + const themeStore = useThemeStore() + const instanceMap = new WeakMap() // echarts.ECharts + const trackedElements: Set = new Set() + const mountingSet = new Set() // 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 } +} diff --git a/mateclaw-ui/src/composables/useMarkdownRenderer.ts b/mateclaw-ui/src/composables/useMarkdownRenderer.ts index 2df629be..e1092391 100644 --- a/mateclaw-ui/src/composables/useMarkdownRenderer.ts +++ b/mateclaw-ui/src/composables/useMarkdownRenderer.ts @@ -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 `

` + } + 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'], } diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 6743fa83..57ed1508 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -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 | null>(null) const chatInputRef = ref | 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,防止内存泄漏