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

500 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}