493 lines
15 KiB
TypeScript
493 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>
|
|||
|
|
Expand / Collapse table
|
|||
|
|
</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
|
|||
|
|
) => {
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} 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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} 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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|