import { ref, reactive, nextTick } from 'vue' import { ElMessage } from 'element-plus' import { marked } from 'marked' import DOMPurify from 'dompurify' import { throttle } from 'lodash' import { sendChatMessage, stopMessagesStream, getFilePathList, type ChatMessageResponse, type TraceData, type TraceFile, type TraceContext } from '@/api/chat' import { useI18n } from 'vue-i18n' interface EChartData { id: string option: any } interface Message { text: string isUser: boolean isLoading?: boolean formattedText?: string sources?: TraceData conversationId?: string messageId?: string echarts?: EChartData[] isOpenRemark?: boolean } export function useMessageHandlers() { const { t } = useI18n() const isLoading = ref(false) const currentTaskId = ref('') const showStopButton = ref(false) const hoveredMessageIndex = ref(-1) const editingMessageIndex = ref(-1) const editingMessageText = ref('') // 解析ECharts代码块 const parseECharts = (text: string): { processedText: string; echarts: EChartData[] } => { const echartsRegex = /```echarts\n([\s\S]*?)\n```/g const echarts: EChartData[] = [] let match let processedText = text while ((match = echartsRegex.exec(text)) !== null) { try { const echartsJson = match[1].trim() const option = JSON.parse(echartsJson) const id = `echart_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` echarts.push({ id, option }) // 将echarts代码块替换为占位符 processedText = processedText.replace(match[0], `
`) } catch (error) { console.error('解析ECharts配置失败:', error) // 如果解析失败,保留原始代码块 } } return { processedText, echarts } } // 格式化答案 const formatAnswer = async (text: string) => { try { // 先解析ECharts代码块 const { processedText, echarts } = parseECharts(text) // 配置marked以更好地支持表格 const html = await marked.parse(processedText, { gfm: true, breaks: true, async: true }) // 为表格添加容器包装和强制样式属性 const wrappedHtml = html.replace( //g, `
展开 / 折叠表格
` ).replace( /<\/table>/g, '
' ).replace( //g, '' ).replace( //g, '' ).replace(/ { const thinkRegex = /(.*?)<\/think>/s const match = text.match(thinkRegex) return { think: match?.[1] || '', answer: match ? text.replace(thinkRegex, '').trim() : text, } } // 生成Excel文件名 (格式: YYYYMMDDHHMM.excel) const generateExcelFileName = (timestamp: number = Date.now()) => { const date = new Date(timestamp) const year = date.getFullYear() const month = (date.getMonth() + 1).toString().padStart(2, '0') const day = date.getDate().toString().padStart(2, '0') const hour = date.getHours().toString().padStart(2, '0') const minute = date.getMinutes().toString().padStart(2, '0') return `${year}${month}${day}${hour}${minute}.excel` } // 发送消息的核心逻辑 const sendMessageCore = async ( userQuery: string, messages: Message[], userId: string, chatType: string, conversationId?: string, onMessageUpdate?: (message: Message) => void, onStreamComplete?: (data: { conversationId: string; messageId: string; content: string }) => void, onStreamUpdate?: () => void ) => { try { isLoading.value = true // 添加用户消息 const userMessage: Message = { text: userQuery, isUser: true, } messages.push(userMessage) onMessageUpdate?.(userMessage) // 创建响应式机器人消息 const botMessage = reactive({ text: '', isUser: false, isLoading: true, formattedText: '' }) messages.push(botMessage) // 发起请求 const response = await sendChatMessage({ content: userQuery, userId: userId, chatType: chatType, conversationId: conversationId }) // 显示中止按钮 showStopButton.value = true if (!response.body) throw new Error('No response body') const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' let newConversationId = '' let newMessageId = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const chunks = buffer.split(/\n\n/) buffer = chunks.pop() || '' for (const chunk of chunks) { const eventData = chunk.trim() if (!eventData) continue const jsonData = eventData.replace(/^data:\s*/, '') try { const data = JSON.parse(jsonData) as ChatMessageResponse if (data.answer) { botMessage.text += data.answer const formatted = await formatAnswer(parseThink(botMessage.text).answer) botMessage.formattedText = formatted.html botMessage.echarts = formatted.echarts // 更新会话ID和消息ID if (data.conversationId) { newConversationId = data.conversationId } if (data.messageId) { newMessageId = data.messageId } // 获取并存储taskId if (data.taskId) { currentTaskId.value = data.taskId } // 触发流式更新回调,用于滚动 onStreamUpdate?.() } } catch (e) { console.error('解析错误:', e) } } } // 处理剩余数据 if (buffer) { const eventData = buffer.trim() if (eventData) { try { const jsonData = eventData.replace(/^data:\s*/, '') const data = JSON.parse(jsonData) if (data.answer) { botMessage.text += data.answer const formatted = await formatAnswer(parseThink(botMessage.text).answer) botMessage.formattedText = formatted.html botMessage.echarts = formatted.echarts if (data.conversationId) { newConversationId = data.conversationId } if (data.messageId) { newMessageId = data.messageId } // 触发流式更新回调,用于滚动 onStreamUpdate?.() } } catch (e) { console.error('最终解析错误:', e) } } } // 获取文件来源数据 if (newConversationId && newMessageId) { try { const { data } = await getFilePathList(newConversationId, newMessageId) // 检查数据是否存在且不为null if (data !== null && data !== undefined && ( (data.resultPdf && data.resultPdf.length > 0) || (data.resultExcel && data.resultExcel.length > 0) || (data.resultMarkdown && data.resultMarkdown.length > 0) || (data.resultWord && data.resultWord.length > 0) || // 保持对旧结构的兼容 (data.tracePdf && data.tracePdf.length > 0) || (data.traceExcel && data.traceExcel.trim()) )) { // 如果是旧结构,转换为新结构 if (data.tracePdf || data.traceExcel) { const convertedData: TraceData = { resultPdf: data.tracePdf || [], resultExcel: data.traceExcel ? [{ fileName: generateExcelFileName(), context: data.traceExcel }] : [], resultMarkdown: [], resultWord: [] } botMessage.sources = convertedData } else { botMessage.sources = data } botMessage.conversationId = newConversationId botMessage.messageId = newMessageId } } catch (error) { console.log('获取文件来源失败:', error) // 不显示错误信息,静默失败 } } // 发射消息接收事件 onMessageUpdate?.(botMessage) // 流式请求完成,发送事件 onStreamComplete?.({ conversationId: newConversationId, messageId: newMessageId, content: userQuery }) return { success: true, conversationId: newConversationId, messageId: newMessageId } } catch (error) { console.error('请求失败:', error) const errorMessage: Message = { text: '请求失败,请稍后重试', isUser: false, } messages.push(errorMessage) onMessageUpdate?.(errorMessage) return { success: false } } finally { isLoading.value = false showStopButton.value = false // 隐藏中止按钮 currentTaskId.value = '' // 清除taskId if (messages[messages.length - 1]?.isLoading) { messages[messages.length - 1].isLoading = false } } } // 中止流式回答 const handleStopStream = async (chatType: string, userId: string) => { if (currentTaskId.value) { try { await stopMessagesStream(Number(chatType), currentTaskId.value, userId) // 隐藏中止按钮 showStopButton.value = false // 停止加载状态 isLoading.value = false // 清除taskId currentTaskId.value = '' ElMessage.success('已停止生成') } catch (error) { console.error('停止流式回答失败:', error) ElMessage.error('停止失败,请重试') } } } // 处理消息悬浮事件 const handleMessageHover = (index: number, isHovered: boolean) => { hoveredMessageIndex.value = isHovered ? index : -1 } // 复制消息内容 const handleCopyMessage = async (message: Message) => { const textToCopy = message.text try { // 优先使用现代的 Clipboard API if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(textToCopy) ElMessage.success('已复制到剪贴板') return } // 回退到传统方法 const textArea = document.createElement('textarea') textArea.value = textToCopy textArea.style.position = 'fixed' textArea.style.left = '-999999px' textArea.style.top = '-999999px' document.body.appendChild(textArea) textArea.focus() textArea.select() const successful = document.execCommand('copy') document.body.removeChild(textArea) if (successful) { ElMessage.success('已复制到剪贴板') } else { throw new Error('execCommand failed') } } catch (error) { console.error('复制失败:', error) ElMessage.error('复制失败') } } // 编辑消息 const handleEditMessage = (message: Message, index: number) => { editingMessageIndex.value = index editingMessageText.value = message.text hoveredMessageIndex.value = -1 // 隐藏操作按钮 } // 取消编辑 const handleCancelEdit = () => { editingMessageIndex.value = -1 editingMessageText.value = '' } // 点赞消息 const handleLikeMessage = (index: number) => { // TODO: 实现点赞逻辑,调用相关API ElMessage.success('感谢您的反馈!') } // 点踩消息 const handleDislikeMessage = (index: number) => { // TODO: 实现点踩逻辑,调用相关API ElMessage.info('感谢您的反馈,我们会继续改进') } // 手动解析markdown表格为HTML const parseMarkdownTable = (markdown: string): string => { const lines = markdown.trim().split('\n') if (lines.length < 3) return markdown // 至少需要表头、分隔线、数据行 const headerLine = lines[0] const separatorLine = lines[1] const dataLines = lines.slice(2) // 检查是否是有效的markdown表格 if (!headerLine.includes('|') || !separatorLine.includes('---')) { return markdown } // 解析表头 const headers = headerLine.split('|').map(h => h.trim()).filter(h => h) // 解析数据行 const rows = dataLines.map(line => line.split('|').map(cell => cell.trim()).filter(cell => cell) ).filter(row => row.length > 0) // 生成HTML表格 let html = `
` headers.forEach(header => { html += `` }) html += ` ` rows.forEach((row, rowIndex) => { html += '' row.forEach((cell, cellIndex) => { const cellStyle = rowIndex % 2 === 0 ? 'border: 1px solid #cbd5e1; padding: 12px; background: white;' : 'border: 1px solid #cbd5e1; padding: 12px; background: #f8fafc;' html += `` }) html += '' }) html += `
${header}
${cell}
` return html } return { // 状态 isLoading, currentTaskId, showStopButton, hoveredMessageIndex, editingMessageIndex, editingMessageText, // 方法 formatAnswer, parseThink, generateExcelFileName, sendMessageCore, handleStopStream, handleMessageHover, handleCopyMessage, handleEditMessage, handleCancelEdit, handleLikeMessage, handleDislikeMessage, parseMarkdownTable } }