ai-manus/chat-client/src/views/chatweb/composables/useMessageHandlers.ts

500 lines
15 KiB
TypeScript
Raw Normal View History

2025-09-04 08:23:52 +08:00
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,
onStreamUpdate?: () => void
2025-09-04 08:23:52 +08:00
) => {
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?.()
2025-09-04 08:23:52 +08:00
}
} 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?.()
2025-09-04 08:23:52 +08:00
}
} 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
}
}