| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="ai-chat-sidebar-wrapper"> |
| | | <!-- æ¬æµ®å¾æ --> |
| | | <div class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible"> |
| | | <el-tooltip content="AI 婿" placement="left"> |
| | | <div class="trigger-icon"> |
| | | <el-icon :size="30" color="#fff"><Cpu /></el-icon> |
| | | </div> |
| | | </el-tooltip> |
| | | </div> |
| | | |
| | | <!-- ä¾§è¾¹æ å¯¹è¯æ¡ --> |
| | | <el-drawer |
| | | v-model="visible" |
| | | :size="drawerSize" |
| | | direction="rtl" |
| | | :with-header="true" |
| | | class="ai-chat-drawer" |
| | | :modal="false" |
| | | :show-close="true" |
| | | :append-to-body="false" |
| | | @close="handleClose" |
| | | > |
| | | <template #header> |
| | | <div class="drawer-header"> |
| | | <div class="header-left"> |
| | | <el-icon :size="20" class="header-icon"><Cpu /></el-icon> |
| | | <span class="title">AI æºè½å©æ</span> |
| | | </div> |
| | | <div class="header-actions"> |
| | | <el-tooltip content="ä¼è¯åå²" placement="bottom"> |
| | | <el-button link @click="toggleHistory"> |
| | | <el-icon :size="18"><Timer /></el-icon> |
| | | </el-button> |
| | | </el-tooltip> |
| | | <el-tooltip content="å¼å¯æ°ä¼è¯" placement="bottom"> |
| | | <el-button link @click="newChat"> |
| | | <el-icon :size="18"><Plus /></el-icon> |
| | | </el-button> |
| | | </el-tooltip> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <div class="chat-container"> |
| | | <!-- åå²ä¼è¯å表 --> |
| | | <div v-if="showHistory" class="history-panel"> |
| | | <div class="history-header"> |
| | | <span>æè¿ä¼è¯</span> |
| | | <el-button link type="primary" @click="showHistory = false">è¿å对è¯</el-button> |
| | | </div> |
| | | <el-skeleton :loading="loadingSessions" animated> |
| | | <template #template> |
| | | <div v-for="i in 5" :key="i" style="padding: 10px"> |
| | | <el-skeleton-item variant="p" style="width: 80%" /> |
| | | </div> |
| | | </template> |
| | | <div class="session-list"> |
| | | <div |
| | | v-for="session in sessions" |
| | | :key="session.memoryId" |
| | | :class="['session-item', { active: uuid === session.memoryId }]" |
| | | @click="selectSession(session)" |
| | | > |
| | | <el-icon><ChatDotSquare /></el-icon> |
| | | <span class="session-name" :title="session.lastMessage || 'æ°ä¼è¯'"> |
| | | {{ session.lastMessage || 'æ°ä¼è¯' }} |
| | | </span> |
| | | <el-button |
| | | link |
| | | type="danger" |
| | | class="delete-btn" |
| | | @click.stop="handleDeleteSession(session.memoryId)" |
| | | > |
| | | <el-icon><Delete /></el-icon> |
| | | </el-button> |
| | | </div> |
| | | <el-empty v-if="sessions.length === 0" description="ææ åå²ä¼è¯" /> |
| | | </div> |
| | | </el-skeleton> |
| | | </div> |
| | | |
| | | <div v-else class="chat-main"> |
| | | <div class="message-list" ref="messageListRef"> |
| | | <div |
| | | v-for="(message, index) in messages" |
| | | :key="index" |
| | | :class="['message-item', message.isUser ? 'user-message' : 'bot-message']" |
| | | > |
| | | <div class="avatar"> |
| | | <el-icon v-if="message.isUser"><User /></el-icon> |
| | | <el-icon v-else><Cpu /></el-icon> |
| | | </div> |
| | | <div class="message-content"> |
| | | <!-- ææ¬å
容 --> |
| | | <div class="text-box" v-html="message.htmlContent"></div> |
| | | |
| | | <!-- å¾è¡¨å
容 --> |
| | | <div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper"> |
| | | <div |
| | | v-for="(option, key) in message.chartOptions" |
| | | :key="key" |
| | | class="chart-item" |
| | | :id="`ai-chart-${index}-${key}`" |
| | | ></div> |
| | | </div> |
| | | |
| | | <!-- è¡¨æ ¼å
容 --> |
| | | <div v-if="message.type === 'todo_list' && message.tableData" class="table-wrapper"> |
| | | <el-table :data="message.tableData.items" border stripe size="small" style="width: 100%"> |
| | | <el-table-column |
| | | v-for="col in message.tableData.columns" |
| | | :key="col" |
| | | :prop="col" |
| | | :label="columnLabelMap[col] || col" |
| | | min-width="100" |
| | | show-overflow-tooltip |
| | | /> |
| | | </el-table> |
| | | </div> |
| | | |
| | | <!-- æåä¸å¨ç» --> |
| | | <div v-if="message.isTyping" class="typing-indicator"> |
| | | <span class="dot"></span> |
| | | <span class="dot"></span> |
| | | <span class="dot"></span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="input-area"> |
| | | <div class="input-actions"> |
| | | <el-button link type="primary" size="small" @click="newChat"> |
| | | <el-icon><Plus /></el-icon>æ°ä¼è¯ |
| | | </el-button> |
| | | <el-button v-if="isSending" link type="danger" size="small" @click="stopGeneration"> |
| | | <el-icon><VideoPause /></el-icon>åæ¢çæ |
| | | </el-button> |
| | | <el-upload |
| | | class="file-upload-trigger" |
| | | action="#" |
| | | :auto-upload="false" |
| | | :show-file-list="false" |
| | | :on-change="handleFileChange" |
| | | :disabled="isSending" |
| | | > |
| | | <el-button link type="primary" size="small" :disabled="isSending"> |
| | | <el-icon><Upload /></el-icon>åææä»¶ |
| | | </el-button> |
| | | </el-upload> |
| | | </div> |
| | | <div class="input-box"> |
| | | <div v-if="selectedFile" class="selected-file-tag"> |
| | | <el-icon><Document /></el-icon> |
| | | <span class="file-name">{{ selectedFile.name }}</span> |
| | | <el-icon class="remove-file" @click="removeSelectedFile"><Close /></el-icon> |
| | | </div> |
| | | <el-input |
| | | v-model="inputMessage" |
| | | type="textarea" |
| | | :rows="selectedFile ? 2 : 3" |
| | | placeholder="请è¾å
¥æ¨çé®é¢... (Enter åé, Shift+Enter æ¢è¡)" |
| | | resize="none" |
| | | @keydown.enter.exact.prevent="sendMessage" |
| | | /> |
| | | <el-button |
| | | type="primary" |
| | | class="send-btn" |
| | | :disabled="isSending || (!inputMessage.trim() && !selectedFile)" |
| | | @click="sendMessage" |
| | | > |
| | | åé |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </el-drawer> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue' |
| | | import request from '@/utils/request' |
| | | import * as echarts from 'echarts' |
| | | import { Cpu, User, Plus, Loading, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close } from '@element-plus/icons-vue' |
| | | import { ElMessage } from 'element-plus' |
| | | |
| | | const visible = ref(false) |
| | | const windowWidth = ref(window.innerWidth) |
| | | const drawerSize = computed(() => { |
| | | if (windowWidth.value < 768) return '100%' |
| | | if (windowWidth.value < 1200) return '500px' |
| | | return '600px' |
| | | }) |
| | | const messageListRef = ref(null) |
| | | const isSending = ref(false) |
| | | const currentAbortController = ref(null) |
| | | const inputMessage = ref('') |
| | | const selectedFile = ref(null) |
| | | const messages = ref([]) |
| | | const uuid = ref('') |
| | | const chartInstances = ref({}) |
| | | const resizeHandlers = ref([]) |
| | | const outputState = ref({}) |
| | | |
| | | // åå²ä¼è¯ç¸å
³ |
| | | const showHistory = ref(false) |
| | | const sessions = ref([]) |
| | | const loadingSessions = ref(false) |
| | | |
| | | const toggleHistory = () => { |
| | | showHistory.value = !showHistory.value |
| | | if (showHistory.value) { |
| | | loadSessions() |
| | | } |
| | | } |
| | | |
| | | const loadSessions = async () => { |
| | | loadingSessions.value = true |
| | | try { |
| | | const res = await request.get('/xiaozhi/history/sessions') |
| | | if (res.code === 200) { |
| | | sessions.value = res.data || [] |
| | | } |
| | | } catch (err) { |
| | | console.error('Failed to load sessions', err) |
| | | } finally { |
| | | loadingSessions.value = false |
| | | } |
| | | } |
| | | |
| | | const selectSession = async (session) => { |
| | | showHistory.value = false |
| | | uuid.value = session.memoryId |
| | | localStorage.setItem('ai_chat_uuid', uuid.value) |
| | | |
| | | // å è½½ä¼è¯æ¶æ¯ |
| | | try { |
| | | const res = await request.get(`/xiaozhi/history/messages/${uuid.value}`) |
| | | if (res.code === 200) { |
| | | disposeCharts() |
| | | messages.value = [] |
| | | const historyMsgs = res.data || [] |
| | | |
| | | // éæ°æé æ¶æ¯å表并解æ |
| | | historyMsgs.forEach((msg, idx) => { |
| | | const isUser = msg.role === 'user' |
| | | const botMsgIndex = messages.value.length |
| | | |
| | | const messageObj = { |
| | | isUser, |
| | | content: msg.content, |
| | | htmlContent: '', |
| | | isTyping: false, |
| | | chartOptions: null, |
| | | chartRenderReady: false, |
| | | type: '', |
| | | tableData: null |
| | | } |
| | | |
| | | messages.value.push(messageObj) |
| | | |
| | | if (!isUser) { |
| | | outputState.value[botMsgIndex] = { |
| | | isPaused: false, |
| | | jsonBlockStartPos: -1, |
| | | jsBlockStartPos: -1, |
| | | blockEndPos: -1, |
| | | hasRenderedChart: false |
| | | } |
| | | |
| | | // è§£æå岿¶æ¯ä¸ç JSON |
| | | const jsonRegex = /\{"success":\s*true,[\s\S]*\}/ |
| | | const jsonMatch = msg.content.match(jsonRegex) |
| | | if (jsonMatch) { |
| | | try { |
| | | const parsedData = JSON.parse(jsonMatch[0]) |
| | | if (parsedData.success) { |
| | | messageObj.type = parsedData.type || '' |
| | | if (messageObj.type === 'todo_list' && parsedData.data) { |
| | | messageObj.tableData = parsedData.data |
| | | } |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | messageObj.chartOptions = parsedData.charts |
| | | messageObj.chartRenderReady = true |
| | | renderCharts(botMsgIndex, messageObj.chartOptions) |
| | | } |
| | | } |
| | | } catch (err) {} |
| | | } |
| | | |
| | | updateOutputState(msg.content, botMsgIndex) |
| | | messageObj.htmlContent = convertStreamOutput(msg.content, botMsgIndex) |
| | | } else { |
| | | messageObj.htmlContent = convertTextToHtml(msg.content) |
| | | } |
| | | }) |
| | | scrollToBottom() |
| | | } |
| | | } catch (err) { |
| | | console.error('Failed to load messages', err) |
| | | } |
| | | } |
| | | |
| | | const handleDeleteSession = async (memoryId) => { |
| | | try { |
| | | const res = await request.delete(`/xiaozhi/history/${memoryId}`) |
| | | if (res.code === 200) { |
| | | loadSessions() |
| | | if (uuid.value === memoryId) { |
| | | newChat() |
| | | } |
| | | } |
| | | } catch (err) { |
| | | console.error('Failed to delete session', err) |
| | | } |
| | | } |
| | | |
| | | const columnLabelMap = { |
| | | approveId: '审æ¹ç¼å·', |
| | | approveType: '审æ¹ç±»å', |
| | | approveUserName: '审æ¹äºº', |
| | | approveUserCurrentName: 'å½åå¤ç人', |
| | | approveReason: '审æ¹åå ', |
| | | approveStatus: '审æ¹ç¶æ', |
| | | createTime: 'å建æ¶é´' |
| | | } |
| | | |
| | | onMounted(() => { |
| | | initUUID() |
| | | // åå§æ¬¢è¿ |
| | | if (messages.value.length === 0) { |
| | | hello() |
| | | } |
| | | window.addEventListener('resize', handleWindowResize) |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | disposeCharts() |
| | | window.removeEventListener('resize', handleWindowResize) |
| | | }) |
| | | |
| | | const handleWindowResize = () => { |
| | | windowWidth.value = window.innerWidth |
| | | } |
| | | |
| | | const toggleSidebar = () => { |
| | | visible.value = !visible.value |
| | | if (visible.value) { |
| | | scrollToBottom() |
| | | } |
| | | } |
| | | |
| | | const handleClose = () => { |
| | | visible.value = false |
| | | } |
| | | |
| | | const initUUID = () => { |
| | | let storedUUID = localStorage.getItem('ai_chat_uuid') |
| | | if (!storedUUID) { |
| | | storedUUID = Math.random().toString(36).substring(2, 10) + Date.now().toString(36).substring(4) |
| | | localStorage.setItem('ai_chat_uuid', storedUUID) |
| | | } |
| | | uuid.value = storedUUID |
| | | } |
| | | |
| | | const hello = () => { |
| | | sendRequest('ä½ å¥½') |
| | | } |
| | | |
| | | const newChat = () => { |
| | | disposeCharts() |
| | | messages.value = [] |
| | | outputState.value = {} |
| | | localStorage.removeItem('ai_chat_uuid') |
| | | initUUID() |
| | | hello() |
| | | } |
| | | |
| | | const disposeCharts = () => { |
| | | Object.values(chartInstances.value).forEach(chart => chart.dispose()) |
| | | resizeHandlers.value.forEach(handler => window.removeEventListener('resize', handler)) |
| | | chartInstances.value = {} |
| | | resizeHandlers.value = [] |
| | | } |
| | | |
| | | const scrollToBottom = () => { |
| | | nextTick(() => { |
| | | if (messageListRef.value) { |
| | | messageListRef.value.scrollTop = messageListRef.value.scrollHeight |
| | | } |
| | | }) |
| | | } |
| | | |
| | | const handleFileChange = (file) => { |
| | | if (!file) return |
| | | const rawFile = file.raw |
| | | if (rawFile) { |
| | | // é嶿件大å°ï¼ä¾å¦ 10MB |
| | | const isLt10M = rawFile.size / 1024 / 1024 < 10 |
| | | if (!isLt10M) { |
| | | ElMessage.error('æä»¶å¤§å°ä¸è½è¶
è¿ 10MB!') |
| | | return |
| | | } |
| | | selectedFile.value = rawFile |
| | | } |
| | | } |
| | | |
| | | const removeSelectedFile = () => { |
| | | selectedFile.value = null |
| | | } |
| | | |
| | | const analyzeFile = async (file, message = '') => { |
| | | if (isSending.value) return |
| | | isSending.value = true |
| | | currentAbortController.value = new AbortController() |
| | | |
| | | const userMsg = message ? `${message}\n[ä¸ä¼ æä»¶åæ] ${file.name}` : `[ä¸ä¼ æä»¶åæ] ${file.name}` |
| | | messages.value.push({ |
| | | isUser: true, |
| | | content: userMsg, |
| | | htmlContent: convertTextToHtml(userMsg), |
| | | isTyping: false |
| | | }) |
| | | |
| | | const botMsgIndex = messages.value.length |
| | | messages.value.push({ |
| | | isUser: false, |
| | | content: '', |
| | | htmlContent: '', |
| | | isTyping: true, |
| | | chartOptions: null, |
| | | chartRenderReady: false, |
| | | type: '', |
| | | tableData: null |
| | | }) |
| | | |
| | | outputState.value[botMsgIndex] = { |
| | | isPaused: false, |
| | | jsonBlockStartPos: -1, |
| | | jsBlockStartPos: -1, |
| | | blockEndPos: -1, |
| | | hasRenderedChart: false |
| | | } |
| | | |
| | | scrollToBottom() |
| | | |
| | | const formData = new FormData() |
| | | formData.append('file', file) |
| | | formData.append('memoryId', uuid.value) |
| | | if (message.trim()) { |
| | | formData.append('message', message.trim()) |
| | | } |
| | | |
| | | request.post('/xiaozhi/analyze-file', formData, { |
| | | headers: { |
| | | 'Content-Type': 'multipart/form-data' |
| | | }, |
| | | signal: currentAbortController.value.signal, |
| | | onDownloadProgress: (e) => { |
| | | const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '') |
| | | if (!fullText) return |
| | | |
| | | const currentMsg = messages.value[botMsgIndex] |
| | | if (!currentMsg) return |
| | | |
| | | currentMsg.content = fullText |
| | | |
| | | // è§£æ JSON æ°æ®ï¼é对åµå
¥å¼ JSONï¼ |
| | | const jsonRegex = /\{"success":\s*true,[\s\S]*\}/ |
| | | const jsonMatch = fullText.match(jsonRegex) |
| | | |
| | | if (jsonMatch) { |
| | | try { |
| | | const parsedData = JSON.parse(jsonMatch[0]) |
| | | if (parsedData.success) { |
| | | currentMsg.type = parsedData.type || '' |
| | | if (currentMsg.type === 'todo_list' && parsedData.data) { |
| | | currentMsg.tableData = parsedData.data |
| | | } |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | currentMsg.chartOptions = parsedData.charts |
| | | currentMsg.chartRenderReady = true |
| | | if (!outputState.value[botMsgIndex].hasRenderedChart) { |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | outputState.value[botMsgIndex].hasRenderedChart = true |
| | | } |
| | | } |
| | | } |
| | | } catch (err) {} |
| | | } |
| | | |
| | | updateOutputState(fullText, botMsgIndex) |
| | | currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex) |
| | | scrollToBottom() |
| | | } |
| | | }).then(() => { |
| | | const currentMsg = messages.value[botMsgIndex] |
| | | currentMsg.isTyping = false |
| | | isSending.value = false |
| | | currentAbortController.value = null |
| | | |
| | | // æç»è§£æç¡®ä¿å¾è¡¨æ¸²æ |
| | | if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) { |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | outputState.value[botMsgIndex].hasRenderedChart = true |
| | | } |
| | | }).catch(err => { |
| | | if (err.name === 'CanceledError' || err.name === 'AbortError') { |
| | | console.log('Analysis aborted by user') |
| | | return |
| | | } |
| | | console.error('File analysis error:', err) |
| | | const errorMsg = 'æ±æï¼æä»¶åæè¿ç¨ä¸éå°äºä¸ç¹é®é¢ï¼è¯·ç¨ååè¯ã' |
| | | if (messages.value[botMsgIndex]) { |
| | | messages.value[botMsgIndex].content = errorMsg |
| | | messages.value[botMsgIndex].htmlContent = convertTextToHtml(errorMsg) |
| | | messages.value[botMsgIndex].isTyping = false |
| | | } |
| | | isSending.value = false |
| | | currentAbortController.value = null |
| | | }) |
| | | } |
| | | |
| | | const sendMessage = () => { |
| | | const msg = inputMessage.value?.trim() || '' |
| | | if ((msg || selectedFile.value) && !isSending.value) { |
| | | if (selectedFile.value) { |
| | | analyzeFile(selectedFile.value, msg) |
| | | selectedFile.value = null |
| | | } else { |
| | | sendRequest(msg) |
| | | } |
| | | inputMessage.value = '' |
| | | } |
| | | } |
| | | |
| | | const stopGeneration = () => { |
| | | if (currentAbortController.value) { |
| | | currentAbortController.value.abort() |
| | | currentAbortController.value = null |
| | | isSending.value = false |
| | | |
| | | // å°æå䏿¡æ¶æ¯æ 记为éæåç¶æ |
| | | const lastMsg = messages.value[messages.value.length - 1] |
| | | if (lastMsg && !lastMsg.isUser) { |
| | | lastMsg.isTyping = false |
| | | } |
| | | } |
| | | } |
| | | |
| | | const sendRequest = (message) => { |
| | | isSending.value = true |
| | | currentAbortController.value = new AbortController() |
| | | |
| | | // ç¨æ·æ¶æ¯ |
| | | if (messages.value.length > 0) { |
| | | messages.value.push({ |
| | | isUser: true, |
| | | content: message, |
| | | htmlContent: convertTextToHtml(message), |
| | | isTyping: false |
| | | }) |
| | | } |
| | | |
| | | // æºå¨äººå ä½ |
| | | const botMsgIndex = messages.value.length |
| | | const botMsg = { |
| | | isUser: false, |
| | | content: '', |
| | | htmlContent: '', |
| | | isTyping: true, |
| | | chartOptions: null, |
| | | chartRenderReady: false, |
| | | type: '', |
| | | tableData: null |
| | | } |
| | | messages.value.push(botMsg) |
| | | |
| | | outputState.value[botMsgIndex] = { |
| | | isPaused: false, |
| | | jsonBlockStartPos: -1, |
| | | jsBlockStartPos: -1, |
| | | blockEndPos: -1, |
| | | hasRenderedChart: false |
| | | } |
| | | |
| | | scrollToBottom() |
| | | |
| | | request.post('/xiaozhi/chat', |
| | | { memoryId: uuid.value, message }, |
| | | { |
| | | signal: currentAbortController.value.signal, |
| | | onDownloadProgress: (e) => { |
| | | // å
¼å®¹ä¸åçæ¬ç axios è·åååºææ¬çæ¹å¼ |
| | | const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '') |
| | | if (!fullText) return |
| | | |
| | | const currentMsg = messages.value[botMsgIndex] |
| | | if (!currentMsg) return |
| | | |
| | | currentMsg.content = fullText |
| | | |
| | | // è§£æ JSON æ°æ®ï¼é对åµå
¥å¼ JSONï¼ |
| | | const jsonRegex = /\{"success":\s*true,[\s\S]*\}/ |
| | | const jsonMatch = fullText.match(jsonRegex) |
| | | |
| | | if (jsonMatch) { |
| | | try { |
| | | const parsedData = JSON.parse(jsonMatch[0]) |
| | | if (parsedData.success) { |
| | | currentMsg.type = parsedData.type || '' |
| | | if (currentMsg.type === 'todo_list' && parsedData.data) { |
| | | currentMsg.tableData = parsedData.data |
| | | } |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | currentMsg.chartOptions = parsedData.charts |
| | | currentMsg.chartRenderReady = true |
| | | if (!outputState.value[botMsgIndex].hasRenderedChart) { |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | outputState.value[botMsgIndex].hasRenderedChart = true |
| | | } |
| | | } |
| | | } |
| | | } catch (err) {} |
| | | } |
| | | |
| | | updateOutputState(fullText, botMsgIndex) |
| | | currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex) |
| | | scrollToBottom() |
| | | } |
| | | } |
| | | ).then(() => { |
| | | const currentMsg = messages.value[botMsgIndex] |
| | | currentMsg.isTyping = false |
| | | isSending.value = false |
| | | currentAbortController.value = null |
| | | |
| | | // æç»è§£æ |
| | | const fullText = currentMsg.content |
| | | const jsonRegex = /\{"success":\s*true,[\s\S]*\}/ |
| | | const jsonMatch = fullText.match(jsonRegex) |
| | | if (jsonMatch) { |
| | | try { |
| | | const parsedData = JSON.parse(jsonMatch[0]) |
| | | if (parsedData.success) { |
| | | currentMsg.type = parsedData.type || '' |
| | | if (currentMsg.type === 'todo_list' && parsedData.data) { |
| | | currentMsg.tableData = parsedData.data |
| | | } |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | currentMsg.chartOptions = parsedData.charts |
| | | currentMsg.chartRenderReady = true |
| | | if (!outputState.value[botMsgIndex].hasRenderedChart) { |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | outputState.value[botMsgIndex].hasRenderedChart = true |
| | | } |
| | | } |
| | | currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex) |
| | | } |
| | | } catch (err) {} |
| | | } |
| | | |
| | | // å
åºæ¸²æ |
| | | if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) { |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | } |
| | | }).catch(err => { |
| | | if (err.name === 'CanceledError' || err.name === 'AbortError') { |
| | | console.log('Request aborted by user') |
| | | return |
| | | } |
| | | console.error('AI Chat Error:', err) |
| | | const errorMsg = 'æ±æï¼æç°å¨éå°äºä¸ç¹é®é¢ï¼è¯·ç¨ååè¯ã' |
| | | if (messages.value[botMsgIndex]) { |
| | | messages.value[botMsgIndex].content = errorMsg |
| | | messages.value[botMsgIndex].htmlContent = convertTextToHtml(errorMsg) |
| | | messages.value[botMsgIndex].isTyping = false |
| | | } |
| | | isSending.value = false |
| | | currentAbortController.value = null |
| | | }) |
| | | } |
| | | |
| | | const updateOutputState = (text, msgIndex) => { |
| | | const state = outputState.value[msgIndex] |
| | | if (state.jsonBlockStartPos === -1) { |
| | | const pos = text.indexOf('```json') |
| | | if (pos !== -1) { state.jsonBlockStartPos = pos; state.isPaused = true } |
| | | } |
| | | if (state.jsBlockStartPos === -1) { |
| | | const pos = text.indexOf('```javascript') !== -1 ? text.indexOf('```javascript') : text.indexOf('```js') |
| | | if (pos !== -1) { state.jsBlockStartPos = pos; state.isPaused = true } |
| | | } |
| | | if ((state.jsonBlockStartPos !== -1 || state.jsBlockStartPos !== -1) && state.blockEndPos === -1) { |
| | | const startCheck = state.jsonBlockStartPos !== -1 ? state.jsonBlockStartPos + 7 : state.jsBlockStartPos + (text.includes('javascript') ? 13 : 5) |
| | | const endPos = text.indexOf('```', startCheck) |
| | | if (endPos !== -1) { state.blockEndPos = endPos + 3; state.isPaused = false } |
| | | } |
| | | } |
| | | |
| | | const convertTextToHtml = (text) => { |
| | | if (!text) return '' |
| | | return text |
| | | .replace(/&/g, '&') |
| | | .replace(/</g, '<') |
| | | .replace(/>/g, '>') |
| | | .replace(/\n/g, '<br>') |
| | | } |
| | | |
| | | const convertStreamOutput = (output, msgIndex) => { |
| | | if (!output) return '' |
| | | const state = outputState.value[msgIndex] |
| | | let display = output |
| | | |
| | | const jsonRegex = /\{"success":\s*true,[\s\S]*\}/ |
| | | const jsonMatch = output.match(jsonRegex) |
| | | if (jsonMatch) { |
| | | try { |
| | | const parsed = JSON.parse(jsonMatch[0]) |
| | | display = output.replace(jsonMatch[0], '').trim() |
| | | if (!display && parsed.description) display = parsed.description |
| | | } catch (e) { |
| | | const start = output.search(/\{"success":\s*true/) |
| | | display = output.substring(0, start) + '... (æ£å¨çææ°æ®å¾è¡¨)' |
| | | } |
| | | } |
| | | |
| | | if (state.jsonBlockStartPos !== -1 && state.blockEndPos === -1) { |
| | | display = display.substring(0, state.jsonBlockStartPos) |
| | | } else if (state.jsBlockStartPos !== -1 && state.blockEndPos === -1) { |
| | | display = display.substring(0, state.jsBlockStartPos) |
| | | } |
| | | |
| | | display = display.replace(/```(javascript|js)([\s\S]*?)```/g, '<pre class="code-block js-code">$2</pre>') |
| | | display = display.replace(/```([\s\S]*?)```/g, '<pre class="code-block">$1</pre>') |
| | | |
| | | return convertTextToHtml(display) |
| | | } |
| | | |
| | | const renderCharts = (msgIndex, chartOptions) => { |
| | | nextTick(() => { |
| | | Object.keys(chartOptions).forEach(key => { |
| | | const id = `ai-chart-${msgIndex}-${key}` |
| | | const tryInit = (count = 0) => { |
| | | const dom = document.getElementById(id) |
| | | if (dom) { |
| | | if (chartInstances.value[id]) chartInstances.value[id].dispose() |
| | | const chart = echarts.init(dom) |
| | | chartInstances.value[id] = chart |
| | | chart.setOption(chartOptions[key]) |
| | | const handler = () => chart.resize() |
| | | resizeHandlers.value.push(handler) |
| | | window.addEventListener('resize', handler) |
| | | } else if (count < 10) { |
| | | setTimeout(() => tryInit(count + 1), 200) |
| | | } |
| | | } |
| | | tryInit() |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | watch(messages, () => scrollToBottom(), { deep: true }) |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .ai-chat-sidebar-wrapper { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 2000; |
| | | pointer-events: none; |
| | | |
| | | :deep(.el-drawer__container) { |
| | | pointer-events: none; |
| | | } |
| | | |
| | | :deep(.el-drawer) { |
| | | pointer-events: auto; |
| | | } |
| | | } |
| | | |
| | | .ai-chat-trigger { |
| | | pointer-events: auto; |
| | | position: fixed; |
| | | right: 20px; |
| | | bottom: 100px; |
| | | width: 60px; |
| | | height: 60px; |
| | | background: linear-gradient(135deg, #409eff 0%, #007aff 100%); |
| | | border-radius: 50%; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | cursor: pointer; |
| | | box-shadow: 0 4px 12px rgba(0, 122, 255, 0.4); |
| | | transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); |
| | | z-index: 2001; |
| | | |
| | | &:hover { |
| | | transform: scale(1.1) translateY(-5px); |
| | | box-shadow: 0 8px 20px rgba(0, 122, 255, 0.5); |
| | | } |
| | | |
| | | .trigger-icon { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | } |
| | | |
| | | .ai-chat-drawer { |
| | | :deep(.el-drawer__body) { |
| | | padding: 0; |
| | | overflow: hidden; |
| | | } |
| | | :deep(.el-drawer__header) { |
| | | margin-bottom: 0; |
| | | padding: 12px 16px; |
| | | background: #fff; |
| | | border-bottom: 1px solid #ebeef5; |
| | | color: #303133; |
| | | } |
| | | } |
| | | |
| | | .drawer-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | width: 100%; |
| | | padding-right: 32px; |
| | | |
| | | .header-left { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | |
| | | .header-icon { |
| | | color: #409eff; |
| | | } |
| | | |
| | | .title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | } |
| | | |
| | | .header-actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | } |
| | | } |
| | | |
| | | .chat-container { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 100%; |
| | | background-color: #f5f7fa; |
| | | position: relative; |
| | | } |
| | | |
| | | .history-panel { |
| | | position: absolute; |
| | | inset: 0; |
| | | background: #fff; |
| | | z-index: 10; |
| | | display: flex; |
| | | flex-direction: column; |
| | | |
| | | .history-header { |
| | | padding: 16px; |
| | | border-bottom: 1px solid #ebeef5; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | font-weight: 600; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .session-list { |
| | | flex: 1; |
| | | overflow-y: auto; |
| | | padding: 8px; |
| | | |
| | | .session-item { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 12px; |
| | | margin-bottom: 4px; |
| | | border-radius: 8px; |
| | | cursor: pointer; |
| | | transition: all 0.2s; |
| | | gap: 10px; |
| | | position: relative; |
| | | border: 1px solid transparent; |
| | | |
| | | &:hover { |
| | | background-color: #f5f7fa; |
| | | .delete-btn { |
| | | opacity: 1; |
| | | } |
| | | } |
| | | |
| | | &.active { |
| | | background-color: #ecf5ff; |
| | | border-color: #d9ecff; |
| | | color: #409eff; |
| | | } |
| | | |
| | | .el-icon { |
| | | font-size: 16px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .session-name { |
| | | flex: 1; |
| | | font-size: 13px; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .delete-btn { |
| | | opacity: 0; |
| | | transition: opacity 0.2s; |
| | | padding: 4px; |
| | | &:hover { |
| | | color: #f56c6c; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .chat-main { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 100%; |
| | | flex: 1; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .message-list { |
| | | flex: 1; |
| | | overflow-y: auto; |
| | | padding: 20px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 20px; |
| | | |
| | | &::-webkit-scrollbar { |
| | | width: 6px; |
| | | } |
| | | &::-webkit-scrollbar-thumb { |
| | | background: #dcdfe6; |
| | | border-radius: 3px; |
| | | } |
| | | } |
| | | |
| | | .message-item { |
| | | display: flex; |
| | | gap: 12px; |
| | | width: 100%; |
| | | |
| | | .avatar { |
| | | width: 36px; |
| | | height: 36px; |
| | | border-radius: 8px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | font-size: 20px; |
| | | } |
| | | |
| | | .message-content { |
| | | flex: 1; |
| | | overflow-x: hidden; // ä¿®æ¹ä¸º hiddenï¼å
é¨å®¹å¨å¤çæ»å¨ |
| | | display: flex; |
| | | flex-direction: column; |
| | | max-width: calc(100% - 48px); // åå»å¤´ååé´è· |
| | | |
| | | .text-box { |
| | | padding: 12px 16px; |
| | | border-radius: 12px; |
| | | font-size: 14px; |
| | | line-height: 1.6; |
| | | word-break: break-word; |
| | | max-width: 100%; |
| | | width: fit-content; |
| | | overflow-x: auto; |
| | | &::-webkit-scrollbar { |
| | | height: 6px; |
| | | } |
| | | &::-webkit-scrollbar-thumb { |
| | | background: #dcdfe6; |
| | | border-radius: 3px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | &.bot-message { |
| | | .message-content { |
| | | align-items: flex-start; |
| | | } |
| | | .avatar { |
| | | background-color: #409eff; |
| | | color: #fff; |
| | | } |
| | | .text-box { |
| | | background-color: #fff; |
| | | color: #303133; |
| | | box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); |
| | | } |
| | | } |
| | | |
| | | &.user-message { |
| | | flex-direction: row-reverse; |
| | | .message-content { |
| | | align-items: flex-end; |
| | | } |
| | | .avatar { |
| | | background-color: #95d475; |
| | | color: #fff; |
| | | } |
| | | .text-box { |
| | | background-color: #409eff; |
| | | color: #fff; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .charts-wrapper { |
| | | margin-top: 10px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | overflow-x: auto; |
| | | width: 100%; |
| | | padding-bottom: 8px; |
| | | &::-webkit-scrollbar { |
| | | height: 6px; |
| | | } |
| | | &::-webkit-scrollbar-thumb { |
| | | background: #dcdfe6; |
| | | border-radius: 3px; |
| | | } |
| | | } |
| | | |
| | | .chart-item { |
| | | width: 100%; |
| | | min-width: 300px; |
| | | height: 300px; |
| | | background: #fff; |
| | | border-radius: 8px; |
| | | padding: 10px; |
| | | } |
| | | |
| | | .table-wrapper { |
| | | margin-top: 10px; |
| | | background: #fff; |
| | | border-radius: 8px; |
| | | overflow: hidden; |
| | | overflow-x: auto; |
| | | width: 100%; |
| | | &::-webkit-scrollbar { |
| | | height: 6px; |
| | | } |
| | | &::-webkit-scrollbar-thumb { |
| | | background: #dcdfe6; |
| | | border-radius: 3px; |
| | | } |
| | | .el-table { |
| | | min-width: 300px; |
| | | } |
| | | } |
| | | |
| | | .input-area { |
| | | padding: 16px; |
| | | background-color: #fff; |
| | | border-top: 1px solid #dcdfe6; |
| | | |
| | | .input-actions { |
| | | display: flex; |
| | | gap: 12px; |
| | | margin-bottom: 8px; |
| | | align-items: center; |
| | | |
| | | .file-upload-trigger { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | } |
| | | } |
| | | |
| | | .input-box { |
| | | padding: 12px; |
| | | position: relative; |
| | | background: #fff; |
| | | border: 1px solid #dcdfe6; |
| | | border-radius: 8px; |
| | | margin: 0 16px 16px; |
| | | transition: border-color 0.2s; |
| | | |
| | | &:focus-within { |
| | | border-color: #409eff; |
| | | } |
| | | |
| | | .selected-file-tag { |
| | | display: flex; |
| | | align-items: center; |
| | | background: #f0f7ff; |
| | | border: 1px solid #d9ecff; |
| | | border-radius: 4px; |
| | | padding: 4px 8px; |
| | | margin-bottom: 8px; |
| | | gap: 6px; |
| | | width: fit-content; |
| | | max-width: 100%; |
| | | |
| | | .el-icon { |
| | | color: #409eff; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .file-name { |
| | | font-size: 12px; |
| | | color: #606266; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .remove-file { |
| | | cursor: pointer; |
| | | color: #909399; |
| | | transition: color 0.2s; |
| | | &:hover { |
| | | color: #f56c6c; |
| | | } |
| | | } |
| | | } |
| | | |
| | | :deep(.el-textarea__inner) { |
| | | padding: 0; |
| | | border: none; |
| | | box-shadow: none; |
| | | background: transparent; |
| | | font-family: inherit; |
| | | font-size: 14px; |
| | | line-height: 1.5; |
| | | color: #303133; |
| | | &::placeholder { |
| | | color: #c0c4cc; |
| | | } |
| | | } |
| | | |
| | | .send-btn { |
| | | position: absolute; |
| | | right: 12px; |
| | | bottom: 12px; |
| | | padding: 8px 16px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .typing-indicator { |
| | | display: flex; |
| | | gap: 4px; |
| | | padding: 8px 12px; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | width: fit-content; |
| | | box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); |
| | | margin-top: 4px; |
| | | .dot { |
| | | width: 6px; |
| | | height: 6px; |
| | | background-color: #909399; |
| | | border-radius: 50%; |
| | | animation: typing 1.4s infinite ease-in-out; |
| | | &:nth-child(2) { animation-delay: 0.2s; } |
| | | &:nth-child(3) { animation-delay: 0.4s; } |
| | | } |
| | | } |
| | | |
| | | @keyframes typing { |
| | | 0%, 80%, 100% { transform: scale(0); } |
| | | 40% { transform: scale(1); } |
| | | } |
| | | |
| | | .code-block { |
| | | background: #2d2d2d; |
| | | color: #ccc; |
| | | padding: 12px; |
| | | border-radius: 6px; |
| | | font-family: monospace; |
| | | margin: 8px 0; |
| | | overflow-x: auto; |
| | | &.js-code { |
| | | color: #f08d49; |
| | | } |
| | | } |
| | | </style> |