| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>智能数据分析师 Agent</title> |
| |
| <script src="https://cdn.tailwindcss.com"></script> |
| |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> |
| |
| <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> |
| |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> |
| |
| <style> |
| body { |
| background-color: #ffffff; |
| color: #000000; |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; |
| } |
| .chat-bubble { |
| max-width: 80%; |
| padding: 1rem; |
| border-radius: 0.5rem; |
| margin-bottom: 0.5rem; |
| line-height: 1.6; |
| } |
| .user-bubble { |
| background-color: #f3f4f6; |
| align-self: flex-end; |
| margin-left: auto; |
| border-top-right-radius: 0; |
| } |
| .agent-bubble { |
| background-color: #ffffff; |
| border: 1px solid #e5e7eb; |
| align-self: flex-start; |
| border-top-left-radius: 0; |
| box-shadow: 0 1px 2px rgba(0,0,0,0.05); |
| } |
| .chart-container { |
| height: 400px; |
| width: 100%; |
| } |
| |
| table { |
| width: 100%; |
| border-collapse: collapse; |
| font-size: 0.875rem; |
| } |
| th, td { |
| border: 1px solid #e5e7eb; |
| padding: 0.5rem; |
| text-align: left; |
| } |
| th { |
| background-color: #f9fafb; |
| font-weight: 600; |
| } |
| |
| ::-webkit-scrollbar { |
| width: 6px; |
| height: 6px; |
| } |
| ::-webkit-scrollbar-track { |
| background: transparent; |
| } |
| ::-webkit-scrollbar-thumb { |
| background: #d1d5db; |
| border-radius: 3px; |
| } |
| ::-webkit-scrollbar-thumb:hover { |
| background: #9ca3af; |
| } |
| </style> |
| </head> |
| <body class="h-screen flex flex-col overflow-hidden bg-gray-50"> |
| <div id="app" class="flex h-full shadow-lg max-w-[1920px] mx-auto bg-white"> |
| |
| <div class="w-72 border-r border-gray-200 bg-gray-50 flex flex-col p-5"> |
| <h1 class="text-xl font-bold mb-8 flex items-center text-gray-800"> |
| <span class="text-2xl mr-2">📊</span> 智能分析师 |
| </h1> |
| |
| <div class="mb-8"> |
| <h2 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">数据源</h2> |
| <div class="space-y-3"> |
| <button @click="loadDemo" :disabled="loading" class="w-full flex items-center justify-center px-4 py-2.5 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 hover:border-gray-400 text-sm font-medium text-gray-700 transition-all shadow-sm"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" /> |
| </svg> |
| 加载演示数据 |
| </button> |
| |
| <div class="relative"> |
| <input type="file" @change="uploadFile" ref="fileInput" class="hidden" accept=".csv,.xlsx"> |
| <button @click="triggerUpload" :disabled="loading" class="w-full flex items-center justify-center px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 hover:shadow-md text-sm font-medium transition-all shadow-sm"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> |
| </svg> |
| 上传文件 (CSV/Excel) |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| <div v-if="summary" class="flex-1 overflow-y-auto pr-1"> |
| <h2 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">数据集概览</h2> |
| <div class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm text-sm"> |
| <div class="grid grid-cols-2 gap-2 mb-4"> |
| <div class="bg-gray-50 p-2 rounded text-center"> |
| <div class="text-xs text-gray-500">行数</div> |
| <div class="font-bold text-gray-800">${ summary.row_count }</div> |
| </div> |
| <div class="bg-gray-50 p-2 rounded text-center"> |
| <div class="text-xs text-gray-500">列数</div> |
| <div class="font-bold text-gray-800">${ summary.columns.length }</div> |
| </div> |
| </div> |
| |
| <div class="mt-4"> |
| <h3 class="font-medium mb-2 text-gray-700 flex items-center"> |
| <span class="w-2 h-2 bg-blue-500 rounded-full mr-2"></span>数值型列 |
| </h3> |
| <ul class="space-y-1"> |
| <li v-for="col in summary.numeric_columns" :key="col" class="text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded truncate border border-gray-100">${ col }</li> |
| </ul> |
| </div> |
| |
| <div class="mt-4"> |
| <h3 class="font-medium mb-2 text-gray-700 flex items-center"> |
| <span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>类别型列 |
| </h3> |
| <ul class="space-y-1"> |
| <li v-for="col in summary.categorical_columns" :key="col" class="text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded truncate border border-gray-100">${ col }</li> |
| </ul> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 flex flex-col min-w-0 bg-white relative"> |
| |
| <div class="h-16 border-b border-gray-200 flex items-center px-8 justify-between bg-white z-10"> |
| <div> |
| <h2 class="font-bold text-lg text-gray-800">分析仪表盘</h2> |
| <p class="text-xs text-gray-500">与您的数据对话,发现洞察</p> |
| </div> |
| <div class="text-xs px-3 py-1 bg-blue-50 text-blue-600 rounded-full border border-blue-100 font-medium"> |
| Powered by Agentic Analytics |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 overflow-y-auto p-8" id="chat-container"> |
| |
| <div v-if="messages.length === 0" class="h-full flex flex-col items-center justify-center text-gray-400 animate-fade-in"> |
| <div class="w-24 h-24 bg-gray-50 rounded-full flex items-center justify-center mb-6"> |
| <span class="text-4xl">👋</span> |
| </div> |
| <h3 class="text-xl font-medium text-gray-700 mb-2">欢迎使用智能分析师</h3> |
| <p class="text-sm text-gray-500 max-w-md text-center"> |
| 数据已准备就绪。您可以尝试上传自己的文件,或者直接开始分析当前的演示数据。 |
| </p> |
| </div> |
|
|
| |
| <div v-else class="flex flex-col space-y-8 pb-4"> |
| <div v-for="(msg, index) in messages" :key="index" :class="['flex flex-col', msg.role === 'user' ? 'items-end' : 'items-start']"> |
| |
| <div :class="['chat-bubble shadow-sm text-sm', msg.role === 'user' ? 'user-bubble text-gray-800' : 'agent-bubble text-gray-700']"> |
| <div v-if="msg.role === 'agent'" class="font-bold text-xs text-blue-600 mb-2 flex items-center"> |
| <span class="w-4 h-4 rounded-full bg-blue-100 flex items-center justify-center mr-1 text-[10px]">AI</span> |
| 智能分析师 |
| </div> |
| <div v-html="renderMarkdown(msg.content)" class="prose prose-sm max-w-none"></div> |
| </div> |
|
|
| |
| <div v-if="msg.chart" :id="'chart-' + index" class="chart-container mt-3 border border-gray-200 rounded-lg p-4 shadow-sm bg-white"></div> |
| </div> |
| |
| |
| <div v-if="loading" class="flex items-center space-x-2 text-gray-500 text-sm p-2 bg-gray-50 rounded-lg self-start"> |
| <div class="flex space-x-1"> |
| <div class="w-2 h-2 bg-blue-400 rounded-full animate-bounce"></div> |
| <div class="w-2 h-2 bg-blue-400 rounded-full animate-bounce" style="animation-delay: 0.1s"></div> |
| <div class="w-2 h-2 bg-blue-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div> |
| </div> |
| <span class="text-xs font-medium">正在分析数据...</span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="p-6 border-t border-gray-200 bg-white"> |
| <div class="relative max-w-4xl mx-auto"> |
| <input |
| v-model="inputMessage" |
| @keyup.enter="sendMessage" |
| :disabled="!summary || loading" |
| type="text" |
| placeholder="输入您的问题,例如:分析销售趋势、展示类别分布..." |
| class="w-full pl-5 pr-14 py-4 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-50 disabled:text-gray-400 shadow-sm text-sm transition-all" |
| > |
| <button |
| @click="sendMessage" |
| :disabled="!summary || loading" |
| class="absolute right-3 top-3 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors shadow-sm" |
| > |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" /> |
| </svg> |
| </button> |
| </div> |
| <div class="text-center mt-3" v-if="summary"> |
| <div class="text-xs text-gray-400 flex justify-center space-x-4"> |
| <button class="hover:text-blue-600 hover:underline transition-colors" @click="setQuery('展示销售趋势')">📊 销售趋势</button> |
| <button class="hover:text-blue-600 hover:underline transition-colors" @click="setQuery('分析各分类的分布情况')">🥧 分类分布</button> |
| <button class="hover:text-blue-600 hover:underline transition-colors" @click="setQuery('显示相关性矩阵')">🔥 相关性分析</button> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-if="toast.show" class="fixed top-5 right-5 z-50 transform transition-all duration-300 ease-in-out" :class="toast.type === 'error' ? 'text-red-800 bg-red-50 border-red-200' : 'text-green-800 bg-green-50 border-green-200'"> |
| <div class="flex items-center p-4 mb-4 rounded-lg shadow border" role="alert"> |
| <svg v-if="toast.type === 'success'" class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg> |
| <svg v-else class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg> |
| <div class="text-sm font-medium">${ toast.message }</div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const { createApp, ref, nextTick, onMounted } = Vue; |
| |
| createApp({ |
| delimiters: ['${', '}'], |
| setup() { |
| const messages = ref([]); |
| const inputMessage = ref(''); |
| const loading = ref(false); |
| const summary = ref(null); |
| const fileInput = ref(null); |
| const toast = ref({ show: false, message: '', type: 'info' }); |
| |
| const showToast = (msg, type = 'info') => { |
| toast.value = { show: true, message: msg, type }; |
| setTimeout(() => { |
| toast.value.show = false; |
| }, 3000); |
| }; |
| |
| const renderMarkdown = (text) => { |
| return marked.parse(text); |
| }; |
| |
| const scrollToBottom = () => { |
| nextTick(() => { |
| const container = document.getElementById('chat-container'); |
| if (container) container.scrollTop = container.scrollHeight; |
| }); |
| }; |
| |
| const renderChart = (index, chartData) => { |
| nextTick(() => { |
| const chartDom = document.getElementById('chart-' + index); |
| if (!chartDom) return; |
| |
| const myChart = echarts.init(chartDom); |
| let option = {}; |
| |
| if (chartData.type === 'line') { |
| option = { |
| title: { text: chartData.title }, |
| tooltip: { trigger: 'axis' }, |
| xAxis: { type: 'category', data: chartData.x }, |
| yAxis: { type: 'value' }, |
| grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, |
| series: [{ |
| data: chartData.y, |
| type: 'line', |
| smooth: true, |
| areaStyle: { opacity: 0.1 }, |
| itemStyle: { color: '#3b82f6' }, |
| lineStyle: { color: '#3b82f6', width: 3 } |
| }] |
| }; |
| } else if (chartData.type === 'bar') { |
| option = { |
| title: { text: chartData.title }, |
| tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, |
| xAxis: { type: 'category', data: chartData.x, axisLabel: { interval: 0, rotate: 30 } }, |
| yAxis: { type: 'value' }, |
| grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true }, |
| series: [{ |
| data: chartData.y, |
| type: 'bar', |
| itemStyle: { |
| borderRadius: [4, 4, 0, 0], |
| color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ |
| { offset: 0, color: '#60a5fa' }, |
| { offset: 1, color: '#2563eb' } |
| ]) |
| } |
| }] |
| }; |
| } else if (chartData.type === 'heatmap') { |
| option = { |
| title: { text: chartData.title, left: 'center' }, |
| tooltip: { position: 'top' }, |
| grid: { height: '70%', top: '15%' }, |
| xAxis: { type: 'category', data: chartData.x, splitArea: { show: true } }, |
| yAxis: { type: 'category', data: chartData.y, splitArea: { show: true } }, |
| visualMap: { |
| min: -1, max: 1, |
| calculable: true, |
| orient: 'horizontal', |
| left: 'center', |
| bottom: '0%', |
| inRange: { color: ['#ef4444', '#f3f4f6', '#3b82f6'] } |
| }, |
| series: [{ |
| type: 'heatmap', |
| data: chartData.z.map((row, i) => row.map((val, j) => [j, i, val || '-'])).flat(), |
| label: { show: true, formatter: (p) => p.value[2].toFixed(2) }, |
| emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } } |
| }] |
| }; |
| } |
| |
| myChart.setOption(option); |
| window.addEventListener('resize', () => myChart.resize()); |
| }); |
| }; |
| |
| const addMessage = (role, content, chart = null) => { |
| messages.value.push({ role, content, chart }); |
| scrollToBottom(); |
| if (chart) { |
| renderChart(messages.value.length - 1, chart); |
| } |
| }; |
| |
| const loadDemo = async () => { |
| loading.value = true; |
| try { |
| const res = await fetch('/api/load_demo', { method: 'POST' }); |
| const data = await res.json(); |
| if (data.status === 'success') { |
| summary.value = data.summary; |
| showToast('演示数据加载成功', 'success'); |
| if (messages.value.length === 0) { |
| addMessage('agent', '已为您加载演示零售数据集。包含日期、分类、销售额等信息。'); |
| addMessage('agent', '您可以尝试问我:"展示销售趋势" 或 "分析各分类的利润"。'); |
| } |
| } |
| } catch (e) { |
| console.error(e); |
| showToast('加载演示数据失败', 'error'); |
| } finally { |
| loading.value = false; |
| } |
| }; |
| |
| const triggerUpload = () => { |
| if (fileInput.value) { |
| fileInput.value.click(); |
| } else { |
| console.error("File input not found"); |
| } |
| }; |
| |
| const uploadFile = async (event) => { |
| const file = event.target.files[0]; |
| if (!file) return; |
| |
| const formData = new FormData(); |
| formData.append('file', file); |
| |
| loading.value = true; |
| try { |
| const res = await fetch('/api/upload', { method: 'POST', body: formData }); |
| const data = await res.json(); |
| if (data.status === 'success') { |
| summary.value = data.summary; |
| showToast('文件上传成功', 'success'); |
| addMessage('agent', `文件上传成功!共发现 ${data.summary.row_count} 行和 ${data.summary.columns.length} 列数据。`); |
| } else { |
| showToast(data.error || '上传失败', 'error'); |
| } |
| } catch (e) { |
| console.error(e); |
| showToast('上传失败,请检查网络或文件大小', 'error'); |
| } finally { |
| loading.value = false; |
| |
| event.target.value = ''; |
| } |
| }; |
| |
| const sendMessage = async () => { |
| if (!inputMessage.value.trim() || loading.value) return; |
| |
| const msg = inputMessage.value; |
| addMessage('user', msg); |
| inputMessage.value = ''; |
| loading.value = true; |
| |
| try { |
| const res = await fetch('/api/chat', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ message: msg }) |
| }); |
| const data = await res.json(); |
| addMessage('agent', data.text, data.chart); |
| } catch (e) { |
| addMessage('agent', '抱歉,分析您的请求时出现错误。'); |
| } finally { |
| loading.value = false; |
| } |
| }; |
| |
| const setQuery = (q) => { |
| inputMessage.value = q; |
| sendMessage(); |
| } |
| |
| |
| onMounted(() => { |
| loadDemo(); |
| }); |
| |
| return { |
| messages, |
| inputMessage, |
| loading, |
| summary, |
| fileInput, |
| toast, |
| renderMarkdown, |
| loadDemo, |
| uploadFile, |
| triggerUpload, |
| sendMessage, |
| setQuery |
| }; |
| } |
| }).mount('#app'); |
| </script> |
| </body> |
| </html> |