500 lines
15 KiB
TypeScript
500 lines
15 KiB
TypeScript
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], `<div class="echart-placeholder" data-chart-id="${id}"></div>`)
|
||
} 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(
|
||
/<table>/g,
|
||
`<details class="table-wrapper" open>
|
||
<summary>
|
||
展开 / 折叠表格
|
||
</summary>
|
||
<div class="table-container"><table class="excel-table" border="1" width="auto" cellspacing="0" cellpadding="0">`
|
||
).replace(
|
||
/<\/table>/g,
|
||
'</table></div></details>'
|
||
|
||
).replace(
|
||
/<th>/g,
|
||
'<th style="border: 1px solid #cbd5e1; padding: 12px; background: #f1f5f9;">'
|
||
).replace(
|
||
/<td>/g,
|
||
'<td style="border: 1px solid #cbd5e1; padding: 12px;">'
|
||
).replace(/<img /g, '<img loading="lazy" style="max-width: 100%; display: block; margin: 1rem auto;" ')
|
||
|
||
const sanitizedHtml = DOMPurify.sanitize(wrappedHtml, {
|
||
ALLOWED_TAGS: [
|
||
'strong', 'em', 'code', 'pre', 'a', 'br', 'p',
|
||
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'span',
|
||
'blockquote', 'hr', 'table', 'thead', 'tbody',
|
||
'tr', 'th', 'td', 'div', 'details', 'summary', 'img'
|
||
],
|
||
ALLOWED_ATTR: ['href', 'target', 'class', 'style', 'data-chart-id', 'border', 'cellspacing', 'cellpadding', 'open','src', 'alt', 'loading', 'style']
|
||
}) as string
|
||
|
||
console.log('Markdown输入:', processedText)
|
||
console.log('生成的HTML:', sanitizedHtml)
|
||
|
||
return { html: sanitizedHtml, echarts }
|
||
} catch (error) {
|
||
console.error('Error formatting answer:', error)
|
||
return { html: text, echarts: [] }
|
||
}
|
||
}
|
||
|
||
// 解析think标签
|
||
const parseThink = (text: string) => {
|
||
const thinkRegex = /<think>(.*?)<\/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<Message>({
|
||
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 = `
|
||
<div class="table-container">
|
||
<table class="excel-table" border="1" cellspacing="0" cellpadding="0" style="border-collapse: collapse; width: auto; border: 2px solid #cbd5e1;">
|
||
<thead>
|
||
<tr>
|
||
`
|
||
|
||
headers.forEach(header => {
|
||
html += `<th style="border: 1px solid #cbd5e1; padding: 12px; background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); font-weight: 600; text-align: left; position: sticky; top: 0; z-index: 2; white-space: nowrap;">${header}</th>`
|
||
})
|
||
|
||
html += `
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`
|
||
|
||
rows.forEach((row, rowIndex) => {
|
||
html += '<tr>'
|
||
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 += `<td style="${cellStyle}">${cell}</td>`
|
||
})
|
||
html += '</tr>'
|
||
})
|
||
|
||
html += `
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`
|
||
|
||
return html
|
||
}
|
||
|
||
return {
|
||
// 状态
|
||
isLoading,
|
||
currentTaskId,
|
||
showStopButton,
|
||
hoveredMessageIndex,
|
||
editingMessageIndex,
|
||
editingMessageText,
|
||
|
||
// 方法
|
||
formatAnswer,
|
||
parseThink,
|
||
generateExcelFileName,
|
||
sendMessageCore,
|
||
handleStopStream,
|
||
handleMessageHover,
|
||
handleCopyMessage,
|
||
handleEditMessage,
|
||
handleCancelEdit,
|
||
handleLikeMessage,
|
||
handleDislikeMessage,
|
||
parseMarkdownTable
|
||
}
|
||
}
|
||
|