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,防止内存泄漏