AI问答组件
import {
defineComponent,
reactive,
ref,
onMounted,
onUnmounted,
nextTick
} from '@vue/composition-api'
// import { useSelector } from '@/utils/bhooks'
import { marked } from 'marked'
import request from '@/utils/request'
import { getHistoryMessages, getConversationMessages } from '@/services/newProductPerformance'
import HistoryPopup from './HistoryPopup'
import './index.scss'
import { trackView } from '../../pages/commandCenter/utils/sensors'
// 配置 marked (4.x 兼容移动端)
marked.setOptions({
breaks: true,
gfm: true
})
// 性能日志 - 记录关键性能指标(生产测试环境都显示)
const perfLog = (...args: any[]) => {
console.log(...args)
}
// 数据日志 - 记录分析数据和最终数据(生产测试环境都显示)
const dataLog = (...args: any[]) => {
console.log(...args)
}
interface Message {
id: string
type: 'user' | 'ai'
content: string
timestamp: number
options?: string[] // 选项列表
streaming?: boolean // 是否正在流式输出
thinkingProcess?: string // 思考过程(保留兼容)
thinkingTime?: number // 思考用时(秒)
showThinking?: boolean // 是否展开思考过程
thinkingStartTime?: number // 开始思考的时间戳
technicalData?: string // 技术数据(outputs.result)
finalResult?: string // 最终结果(outputs.answer)
typeWriterTimer?: any // 打字机效果定时器
}
interface HistoryMessage {
id: string
conversation_id: string
query: string
answer: string
created_at: number
status: string
}
interface HistorySession {
id: string
title: string
lastMessage: string
timestamp: number
}
interface AIRequestBody {
inputs: {
user_query: string
}
conversation_id: string
query: string
files: Array<{
transfer_method: string
upload_file_id: string
type: string
url: string
}>
user: string
}
export default defineComponent({
name: 'AIChat',
props: {
visible: {
type: Boolean,
default: false
},
handleClose: {
type: Function,
default: undefined
}
},
setup(props) {
// const { op } = useSelector(({ op }) => ({ op }))
const state = reactive<{
messages: Message[]
inputValue: string
loading: boolean
copiedId: string | null
showHistory: boolean
historySessions: HistorySession[]
conversationId: string
historyMessagesMap: Record<string, HistoryMessage>
showQuickQuestions: boolean
}>({
messages: [],
inputValue: '',
loading: false,
copiedId: null,
showHistory: false,
conversationId: '',
historySessions: [],
historyMessagesMap: {},
showQuickQuestions: true
})
const chatContainer = ref<HTMLElement>()
const abortControllerRef = ref<AbortController | null>(null)
const timeoutAbortFlag = ref(false) // 标记是否是超时中断
const userAbortFlag = ref(false) // 标记是否是用户主动中断(点击新建对话)
const userScrolled = ref(false) // 标记用户是否手动滚动
const lastScrollTop = ref(0) // 记录上次滚动位置
const lastScrollHeight = ref(0) // 记录上次滚动时的内容高度
// 流式渲染优化 - 为每个请求创建独立的节流器
const throttleTimers = new Map<string, any>()
const pendingContents = new Map<string, string>()
const markdownCache = new Map<string, string>() // Markdown解析缓存
const renderCache = new Map<string, any>() // 渲染结果缓存(非响应式)
// 滚动节流(使用节流而不是防抖,减少触发频率)
let scrollTimeout: any = null
let lastScrollTime = 0 // 上次滚动时间
// 复制消息内容
const copyMessage = (content: string, messageId: string) => {
// 方法1:尝试使用 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(content)
.then(() => {
state.copiedId = messageId
setTimeout(() => {
state.copiedId = null
}, 2000)
})
.catch(() => {
// 降级到方法2
fallbackCopy(content, messageId)
})
} else {
// 直接使用降级方案
fallbackCopy(content, messageId)
}
}
// 降级复制方案
const fallbackCopy = (content: string, messageId: string) => {
const textarea = document.createElement('textarea')
textarea.value = content
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
// 检查 document.body 是否存在
if (!document.body) {
return
}
document.body.appendChild(textarea)
textarea.select()
try {
const success = document.execCommand('copy')
if (success) {
state.copiedId = messageId
setTimeout(() => {
state.copiedId = null
}, 2000)
}
} catch (err) {
// 复制失败,静默处理
} finally {
// 确保元素存在再移除
if (document.body.contains(textarea)) {
document.body.removeChild(textarea)
}
}
}
// 刷新消息(重新生成)
const refreshMessage = async (messageId: string) => {
const messageIndex = state.messages.findIndex((m) => m.id === messageId)
if (messageIndex === -1 || messageIndex === 0) return
// 获取用户的问题(前一条消息)
const userMessage = state.messages[messageIndex - 1]
if (!userMessage || userMessage.type !== 'user') return
// 确保不是中断状态,允许正常刷新
userAbortFlag.value = false
// 删除旧消息
state.messages.splice(messageIndex, 1)
state.loading = true
try {
// 确保消息ID唯一性,使用更高精度时间戳
const newMessageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${performance.now().toString().replace('.', '')}`
let messageCreated = false
const thinkingStartTime = Date.now()
// 流式更新函数 - 立即逐字显示,类似DeepSeek的流畅效果
const throttledUpdate = (chunk: string) => {
// 使用 requestAnimationFrame 优化更新,提高流畅度
requestAnimationFrame(() => {
if (!messageCreated) {
// 双重检查:确保消息不存在才创建,避免重复
const existingMessage = state.messages.find((m) => m.id === newMessageId)
if (existingMessage) {
// 消息已存在,直接更新
if (existingMessage.typeWriterTimer) {
clearTimeout(existingMessage.typeWriterTimer)
existingMessage.typeWriterTimer = null
}
existingMessage.content = chunk
existingMessage.finalResult = chunk
messageCreated = true
return
}
const newAiMessage: Message = {
id: newMessageId,
type: 'ai',
content: chunk,
timestamp: Date.now(),
streaming: true,
showThinking: true,
thinkingStartTime,
finalResult: chunk
}
state.messages.splice(messageIndex, 0, newAiMessage)
messageCreated = true
// 创建流式消息后立即隐藏loading,避免重复显示
state.loading = false
scrollToBottom(false, true)
} else {
const message = state.messages.find(
(m) => m.id === newMessageId
)
if (message) {
// 清理定时器
if (message.typeWriterTimer) {
clearTimeout(message.typeWriterTimer)
message.typeWriterTimer = null
}
// 直接更新,立即显示
message.content = chunk
message.finalResult = chunk
}
}
})
}
const result = await callAIApiStream(
userMessage.content,
throttledUpdate,
(errorContent) => {
// 如果是用户主动中断(点击新建对话),不更新错误信息
if (userAbortFlag.value) {
return
}
// 错误处理:立即结束流式状态
const streamingMessage = state.messages.find(m => m.type === 'ai' && m.streaming)
if (streamingMessage) {
streamingMessage.streaming = false
streamingMessage.content = errorContent
}
}
)
// 查找已存在的消息(可能在流式更新时已创建)
let message = state.messages.find((m) => m.id === newMessageId)
const thinkingTime = result?.thinkingTime || 0
// 如果消息已创建(messageCreated为true)或找到了消息,就更新它,不要创建新的
if (messageCreated || message) {
// 如果messageCreated为true但找不到消息,尝试再次查找(可能Vue响应式更新延迟)
if (!message && messageCreated) {
message = state.messages.find((m) => m.id === newMessageId)
}
if (message) {
// 更新已存在的消息
message.streaming = false
message.thinkingTime = thinkingTime
message.technicalData = result?.technicalData || ''
message.finalResult = result?.finalResult || ''
// 清理打字机定时器
if (message.typeWriterTimer) {
clearTimeout(message.typeWriterTimer)
message.typeWriterTimer = null
}
}
// 如果messageCreated为true但找不到消息,不创建新消息,避免重复
} else {
// 只有在既没有创建也没有找到消息时,才创建新消息(超时等情况)
if (result?.content) {
const newAiMessage: Message = {
id: newMessageId,
type: 'ai',
content: result.content,
timestamp: Date.now(),
streaming: false,
thinkingTime,
showThinking: true,
thinkingStartTime,
technicalData: result.technicalData || '',
finalResult: result.finalResult || ''
}
state.messages.splice(messageIndex, 0, newAiMessage)
} else {
// 如果是用户主动中断(点击新建对话),不显示错误信息
if (userAbortFlag.value) {
return
}
const newAiMessage: Message = {
id: newMessageId,
type: 'ai',
content: '抱歉,服务暂时不可用,请稍后再试。',
timestamp: Date.now(),
streaming: false,
showThinking: true,
thinkingStartTime
}
state.messages.splice(messageIndex, 0, newAiMessage)
// 未收到任何响应,显示默认提示
}
}
// refreshMessage 中不记录性能指标(性能指标在 callAIApiStream 中记录)
} catch (error: any) {
// 刷新失败,静默处理
// 如果是用户主动中断(点击新建对话),不显示错误信息
if (userAbortFlag.value) {
return
}
// 根据错误类型提供不同的错误信息
let errorContent = '抱歉,服务暂时不可用,请稍后再试。'
if (error.message?.includes('timeout') || error.code === 'ECONNABORTED') {
errorContent = '抱歉,请求超时,请稍后重试。'
} else if (error.message?.includes('Network Error') || error.code === 'NETWORK_ERROR') {
errorContent = '抱歉,网络连接失败,请检查网络设置后重试。'
} else if (error.response?.status >= 500) {
errorContent = '抱歉,服务器暂时不可用,请稍后重试。'
} else if (error.response?.status === 401 || error.response?.status === 403) {
errorContent = '抱歉,认证失败,请重新登录后重试。'
}
const errorMessage: Message = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${performance.now().toString().replace('.', '')}`,
type: 'ai',
content: errorContent,
timestamp: Date.now()
}
state.messages.splice(messageIndex, 0, errorMessage)
} finally {
state.loading = false
debugLoadingState()
scrollToBottom()
}
}
// 点赞/点踩
// const likeMessage = (messageId: string, isLike: boolean) => {
// const message = state.messages.find((m) => m.id === messageId)
// if (message) {
// // 记录反馈(实际项目中发送到后端)
// console.log(`消息 ${messageId} ${isLike ? '点赞' : '点踩'}`)
// }
// }
// 渲染 Markdown 内容
const renderContent = (content: string, isStreaming?: boolean) => {
if (!content) return null
// 流式时统一使用纯文本显示,保持 DOM 结构一致,避免闪烁
// 流式结束后再解析 Markdown,获得完整样式
if (isStreaming) {
// 使用内容hash作为缓存key(前200字符+长度),避免内存过大
const hashKey = content.length > 200
? `${content.substring(0, 200)}_${content.length}`
: content
const cacheKey = `streaming_${hashKey}`
const cached = renderCache.get(cacheKey)
if (cached) return cached
// 流式时统一使用纯文本,保持 DOM 结构一致
// 使用 pre-wrap 保持格式,但避免频繁解析导致的闪烁
const result = (
<div class="streaming-text">
{content}
</div>
)
renderCache.set(cacheKey, result)
return result
}
try {
// 使用内容hash作为缓存key(简单hash,避免内存过大)
const cacheKey = content.length > 1000 ? content.substring(0, 100) + content.length : content
let html = markdownCache.get(cacheKey)
if (!html) {
html = marked(content) as string
// 为表格添加滚动容器和优化
html = html.replace(/<table>/gi, '<div class="table-wrapper"><table>')
html = html.replace(/<\/table>/gi, '</table></div>')
// 优化表格列宽 - 检测数字列
html = html.replace(/<td([^>]*)>(\d+)<\/td>/gi, '<td$1 data-numeric="true">$2</td>')
html = html.replace(/<th([^>]*)>(\d+)<\/th>/gi, '<th$1 data-numeric="true">$2</th>')
// 缓存结果(限制缓存大小,避免内存泄漏)
if (markdownCache.size > 50) {
const firstKey = markdownCache.keys().next().value
markdownCache.delete(firstKey)
}
markdownCache.set(cacheKey, html)
}
const result = (
<div class="markdown-content" domPropsInnerHTML={html}></div>
)
// 使用非响应式缓存,避免无限更新循环
if (renderCache.size > 100) {
const firstKey = renderCache.keys().next().value
renderCache.delete(firstKey)
}
renderCache.set(cacheKey, result)
return result
} catch (error) {
// Markdown 渲染错误,降级处理
// 降级:使用简单格式化显示
const lines = content.split('\n')
return (
<div class="formatted-content">
{lines.map((line, idx) => (
<div key={idx} style={{ marginBottom: '8px' }}>
{line || <br />}
</div>
))}
</div>
)
}
}
// 切换思考过程显示
const toggleThinking = (messageId: string) => {
const index = state.messages.findIndex((m) => m.id === messageId)
if (index !== -1) {
const message = state.messages[index]
// 使用 Vue 2 响应式更新
state.messages.splice(index, 1, {
...message,
showThinking: !message.showThinking
})
}
}
// 流式调用 AI 接口
const callAIApiStream = async (
question: string,
onChunk: (chunk: string) => void,
onError?: (error: string) => void
) => {
const apiPath = 'ipdHwCockpit/ai/streaming-messages'
// SSE 流式处理状态(提升到 try 外部)
let accumulatedContent = ''
let technicalData = '' // 技术数据(outputs.result)
let finalResult = '' // 最终结果(outputs.answer)
let displayedLength = 0 // 已显示的长度,用于逐字显示
let currentEvent = ''
let buffer = ''
let lastResponseLength = 0
let hasContentNode = false // 是否收到内容节点
let startTime = Date.now() // 请求开始时间
let firstCharTime = 0 // 收到第一个字符的时间
let renderStartTime = 0 // 开始渲染的时间
let timeoutCheckInterval: any = null
let typeWriterTimer: any = null // 打字机效果定时器
try {
// 清理之前的 AbortController
if (abortControllerRef.value) {
abortControllerRef.value.abort()
}
// 创建新的 AbortController
abortControllerRef.value = new AbortController()
// 重置显示状态
displayedLength = 0
accumulatedContent = ''
technicalData = ''
finalResult = ''
lastResponseLength = 0
hasContentNode = false
firstCharTime = 0
renderStartTime = 0
if (typeWriterTimer) {
clearTimeout(typeWriterTimer)
typeWriterTimer = null
}
const userId = localStorage.getItem('usercode') || 'W9065236'
// 构建请求体
const requestBody: AIRequestBody = {
inputs: {
user_query: question
},
conversation_id: state.conversationId,
query: question,
files: [
{
transfer_method: '',
upload_file_id: '',
type: '',
url: ''
}
],
user: userId
}
// 记录请求开始时间(startTime 已在外部定义,用于性能分析)
const processStreamData = (responseText: string) => {
// 只处理新增的部分
const newChunk = responseText.substring(lastResponseLength)
lastResponseLength = responseText.length
buffer += newChunk
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine) continue
if (trimmedLine.startsWith('event:')) {
currentEvent = trimmedLine.substring(6).trim()
continue
}
if (trimmedLine.startsWith('data:')) {
try {
const jsonStr = trimmedLine.substring(5).trim()
if (!jsonStr || jsonStr === '[DONE]') continue
if (jsonStr.startsWith('Error:')) {
const errorMsg = jsonStr.substring(6).trim()
// 如果是用户主动中断(点击新建对话),不显示错误信息
if (userAbortFlag.value) {
return
}
// 根据错误类型提供不同的提示
if (errorMsg.includes('credentials is not initialized')) {
accumulatedContent = '抱歉,AI服务配置异常,请联系管理员检查模型凭据设置。'
} else if (errorMsg.includes('timeout') || errorMsg.includes('超时')) {
accumulatedContent = '抱歉,AI服务响应超时,请稍后重试。'
} else if (errorMsg.includes('network') || errorMsg.includes('网络')) {
accumulatedContent = '抱歉,网络连接异常,请检查网络设置后重试。'
} else if (errorMsg.includes('server') || errorMsg.includes('服务器')) {
accumulatedContent = '抱歉,服务器暂时不可用,请稍后重试。'
} else {
accumulatedContent = `服务异常:${errorMsg}`
}
onChunk(accumulatedContent)
return
}
let data
try {
data = JSON.parse(jsonStr)
} catch (parseError) {
continue
}
if (data.conversation_id && !state.conversationId) {
state.conversationId = data.conversation_id
}
const eventType = currentEvent || data.event || ''
if (eventType === 'message' || eventType === 'agent_message') {
// message 事件包含最终结果,逐字显示
const answer = data.answer || ''
if (answer) {
// 记录收到第一个字符的时间
if (firstCharTime === 0 && answer.length > 0) {
firstCharTime = Date.now()
const firstCharResponseTime = firstCharTime - startTime
perfLog('⏱️ 请求到首字渲染时间:', `${firstCharResponseTime}ms`)
// 记录开始渲染时间
if (renderStartTime === 0) {
renderStartTime = performance.now()
}
}
// 累积到 finalResult
finalResult += answer
accumulatedContent = finalResult
// 如果 finalResult 变长了,继续逐字显示新增部分
if (finalResult.length > displayedLength) {
// 逐字显示:从已显示位置开始,逐字符显示新内容
const getCurrentFinalResult = () => finalResult
const displayNextChunk = () => {
const currentFinalResult = getCurrentFinalResult()
const currentLength = currentFinalResult.length
// 关键:只有在有新数据时才继续显示,避免大块渲染
if (displayedLength < currentLength) {
// 检测是否包含表格,优化渲染策略
const hasTable = currentFinalResult.includes('|') || currentFinalResult.includes('<table')
// 计算未显示的数据量
const pendingLength = currentLength - displayedLength
// 动态调整显示速度:类似DeepSeek的流畅效果
let chunkSize = 1
let delay = 5 // 减少延迟,提高流畅度
if (hasTable) {
// 表格内容:根据未显示数据量动态调整
if (pendingLength > 100) {
chunkSize = 15
delay = 8
} else if (pendingLength > 50) {
chunkSize = 8
delay = 8
} else {
chunkSize = 4
delay = 10
}
} else if (currentLength > 1000) {
// 长文本:根据未显示数据量动态调整
if (pendingLength > 100) {
chunkSize = 12
delay = 5
} else if (pendingLength > 50) {
chunkSize = 6
delay = 5
} else {
chunkSize = 2
delay = 6
}
} else {
// 普通文本:根据未显示数据量动态调整,追求流畅度
if (pendingLength > 50) {
chunkSize = 8
delay = 4
} else if (pendingLength > 20) {
chunkSize = 4
delay = 4
} else {
chunkSize = 1
delay = 5
}
}
// 只显示少量字符,避免大块渲染
const nextLength = Math.min(displayedLength + chunkSize, currentLength)
const chunkToShow = currentFinalResult.substring(0, nextLength)
displayedLength = nextLength
// 使用 requestAnimationFrame 优化Vue更新,类似DeepSeek的流畅效果
requestAnimationFrame(() => {
onChunk(chunkToShow)
// 流式更新时不自动滚动,只在流式结束时滚动一次
// 避免频繁滚动导致屏幕晃动
})
// 重新获取最新的长度(可能已经增长)
const updatedLength = getCurrentFinalResult().length
// 如果还有未显示的内容,继续显示
if (displayedLength < updatedLength) {
// 清除之前的定时器(如果有),确保使用最新延迟
if (typeWriterTimer) {
clearTimeout(typeWriterTimer)
typeWriterTimer = null
}
// 继续逐字显示
typeWriterTimer = setTimeout(() => {
typeWriterTimer = null
try {
displayNextChunk()
} catch (error: any) {
// 静默处理渲染错误
}
}, delay)
} else {
// 当前已接收的数据已全部显示完
// 暂停渲染,等待新数据到达
if (typeWriterTimer) {
clearTimeout(typeWriterTimer)
typeWriterTimer = null
}
// 使用稍长的延迟检查新数据,避免频繁检查
typeWriterTimer = setTimeout(() => {
typeWriterTimer = null
try {
// 再次检查是否有新数据到达
const checkLength = getCurrentFinalResult().length
if (displayedLength < checkLength) {
// 有新数据到达,立即继续逐字显示
displayNextChunk()
} else {
// 确实没有新数据了,完全停止渲染
scrollToBottom(false, true)
}
} catch (error: any) {
// 静默处理检查错误
}
}, delay * 2) // 使用2倍延迟检查新数据
}
} else {
// 没有新数据,完全停止渲染
if (typeWriterTimer) {
clearTimeout(typeWriterTimer)
typeWriterTimer = null
}
}
}
// 如果有定时器在运行,检查它是否在等待新数据
// 如果正在等待新数据,立即清除并开始显示,避免延迟
if (typeWriterTimer) {
// 清除等待中的定时器,立即开始显示新数据
clearTimeout(typeWriterTimer)
typeWriterTimer = null
}
// 立即开始显示,确保新数据到达时能及时响应
displayNextChunk()
}
}
} else if (eventType === 'message_replace') {
accumulatedContent = data.answer || ''
onChunk(accumulatedContent)
// 替换时立即滚动
scrollToBottom(true)
} else if (eventType === 'node_finished') {
if (data.data?.status === 'succeeded' && data.data?.outputs) {
const outputs = data.data.outputs
const nodeType = data.data.node_type
// 标记是否收到内容节点
const contentNodes = [
'llm',
'tool',
'code',
'template-transform',
'http-request'
]
if (contentNodes.includes(nodeType)) {
hasContentNode = true
}
// 分别提取技术数据和最终结果
const currentTechnical = outputs.result || ''
const currentFinal = outputs.answer || ''
// 只累积技术数据,不触发显示
// 因为 message 事件已经在处理显示了,避免重复输出
if (currentTechnical) {
technicalData += currentTechnical
}
// 如果 message 事件不存在,才累积 finalResult(兼容旧接口)
// 但不再触发显示,避免与 message 事件重复
if (currentFinal && !accumulatedContent) {
finalResult += currentFinal
accumulatedContent = finalResult
}
}
} else if (eventType === 'error') {
const errorMsg = data.message || '请求失败,请稍后重试'
// 关键错误,静默处理
// 如果是用户主动中断(点击新建对话),不显示错误信息
if (userAbortFlag.value) {
return
}
// 特殊处理模型凭据未初始化错误
if (errorMsg.includes('credentials is not initialized')) {
accumulatedContent = '抱歉,AI服务配置异常,请联系管理员检查模型凭据设置。'
} else if (errorMsg.includes('Model') && errorMsg.includes('not available')) {
accumulatedContent = '抱歉,AI模型暂时不可用,请稍后重试或联系管理员。'
} else {
accumulatedContent = `抱歉,服务异常:${errorMsg}`
}
onChunk(accumulatedContent)
// 错误时立即滚动到底部
scrollToBottom(false)
// 立即中断请求,防止继续处理
if (abortControllerRef.value) {
abortControllerRef.value.abort()
}
// 标记为错误状态,不再处理后续数据
hasContentNode = true
// 立即结束流式状态,防止继续显示"正在生成回答"
if (onError) {
onError(accumulatedContent)
}
return
} else if (
eventType === 'message_end' ||
eventType === 'workflow_finished'
) {
if (accumulatedContent.length === 0) {
if (!hasContentNode) {
// 后台工作流未执行到内容节点
accumulatedContent =
'抱歉,AI服务响应超时,请稍后重试或联系管理员。'
onChunk(accumulatedContent)
}
}
} else if (eventType === 'ping') {
// 静默处理
} else if (
eventType === 'node_started' ||
eventType === 'workflow_started'
) {
// 静默处理
}
} catch (e) {
// 静默处理解析错误
}
}
}
}
// 启动超时检测(300秒无内容节点则中断)
timeoutAbortFlag.value = false
timeoutCheckInterval = setInterval(() => {
const elapsed = Date.now() - startTime
// 300秒无内容节点超时
if (
elapsed > 300000 &&
!hasContentNode &&
accumulatedContent.length === 0
) {
// 300秒未收到内容节点,超时中断
timeoutAbortFlag.value = true
clearInterval(timeoutCheckInterval)
if (abortControllerRef.value) {
abortControllerRef.value.abort()
}
}
// 300秒总超时(兜底保护)
if (elapsed > 300000) {
// 300秒总超时,兜底保护
timeoutAbortFlag.value = true
clearInterval(timeoutCheckInterval)
if (abortControllerRef.value) {
abortControllerRef.value.abort()
}
}
}, 5000) // 每5秒检查一次
try {
await request.post(apiPath, requestBody, {
timeout: 300000, // AI 流式请求超时 300s
responseType: 'text',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache'
},
signal: abortControllerRef.value.signal,
onDownloadProgress: (progressEvent) => {
const response =
(progressEvent.currentTarget as any)?.response ||
(progressEvent.target as any)?.response
if (response) {
processStreamData(response)
}
}
})
} finally {
clearInterval(timeoutCheckInterval)
// 请求完成后重置 AbortController
abortControllerRef.value = null
// 注意:不清理 typeWriterTimer,让它继续完成逐字显示
}
const thinkingTime = Math.round((Date.now() - startTime) / 1000)
// 计算首字到最终渲染时间
if (firstCharTime > 0) {
const finalRenderTime = Date.now()
const firstCharToFinalTime = finalRenderTime - firstCharTime
perfLog('⏱️ 首字到最终渲染时间:', `${firstCharToFinalTime}ms`)
}
// 记录分析数据和最终数据
if (technicalData || finalResult) {
dataLog('📊 分析数据 (technicalData):', technicalData)
dataLog('📝 最终数据 (finalResult):', finalResult)
}
return {
content: accumulatedContent,
thinkingTime,
technicalData,
finalResult
}
} catch (error: any) {
clearInterval(timeoutCheckInterval) // 清理定时器
const thinkingTime = Math.round((Date.now() - startTime) / 1000)
// 关键错误,静默处理
// 如果是用户主动中断(点击新建对话),不显示错误信息
if (userAbortFlag.value) {
return {
content: '',
thinkingTime,
technicalData: '',
finalResult: ''
}
}
if (error.name === 'AbortError') {
// 检查是否是超时导致的中断
if (timeoutAbortFlag.value) {
return {
content:
'抱歉,AI服务响应超时,请稍后重试或联系管理员。\n\n可能原因:后台工作流未执行到内容节点(tool/llm),请检查 Dify 工作流配置。',
thinkingTime,
technicalData: '',
finalResult: ''
}
}
return {
content: '',
thinkingTime,
technicalData: '',
finalResult: ''
}
}
// axios 超时
if (
error.code === 'ECONNABORTED' ||
error.message?.includes('timeout')
) {
// 请求超时,静默处理
return {
content: '抱歉,AI服务响应超时,请稍后重试。',
thinkingTime,
technicalData: '',
finalResult: ''
}
}
// 处理网络连接错误
if (error.code === 'NETWORK_ERROR' || error.message?.includes('Network Error')) {
// 网络连接失败,静默处理
return {
content: '抱歉,网络连接失败,请检查网络设置后重试。',
thinkingTime,
technicalData: '',
finalResult: ''
}
}
// 处理服务器错误
if (error.response?.status >= 500) {
// 服务器内部错误,静默处理
// 尝试从错误响应中提取更详细的错误信息
let errorMsg = '抱歉,服务器暂时不可用,请稍后重试。'
if (error.response.data) {
if (typeof error.response.data === 'string') {
errorMsg = `服务器错误: ${error.response.data}`
} else if (error.response.data.message) {
errorMsg = `服务器错误: ${error.response.data.message}`
} else if (error.response.data.error) {
errorMsg = `服务器错误: ${error.response.data.error}`
}
}
return {
content: errorMsg,
thinkingTime,
technicalData: '',
finalResult: ''
}
}
// 处理认证错误
if (error.response?.status === 401 || error.response?.status === 403) {
// 认证失败,静默处理
return {
content: '抱歉,认证失败,请重新登录后重试。',
thinkingTime,
technicalData: '',
finalResult: ''
}
}
// 处理模型凭据相关错误
if (error.message?.includes('credentials is not initialized')) {
// 模型凭据未初始化,静默处理
return {
content: '抱歉,AI服务配置异常,请联系管理员检查模型凭据设置。',
thinkingTime,
technicalData: '',
finalResult: ''
}
}
// 处理请求被取消
if (error.name === 'CanceledError' || error.code === 'ERR_CANCELED') {
// 请求被取消,静默处理
return {
content: '',
thinkingTime,
technicalData: '',
finalResult: ''
}
}
// 处理其他未知错误,静默处理
return {
content: '抱歉,服务暂时不可用,请稍后再试。',
thinkingTime,
technicalData: '',
finalResult: ''
}
} finally {
// 确保清理定时器和重置状态
clearInterval(timeoutCheckInterval)
// 注意:不清理 typeWriterTimer,让它继续完成逐字显示
// 只有在用户主动中断或组件卸载时才清理
if (abortControllerRef.value) {
abortControllerRef.value = null
}
// 清理完成
}
}
// 检测用户是否手动滚动
const handleScroll = () => {
const container = chatContainer.value || document.querySelector('.chat-messages') as HTMLElement
if (!container) return
const currentScrollTop = container.scrollTop
const scrollHeight = container.scrollHeight
const clientHeight = container.clientHeight
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight
// 如果用户向上滚动(scrollTop增加且不在底部),标记为用户手动滚动
// 排除内容增长导致的自动滚动到底部的情况
if (currentScrollTop > lastScrollTop.value && currentScrollTop > 0 && distanceFromBottom > 50) {
userScrolled.value = true
}
// 如果用户滚动到底部附近(50px内),恢复自动滚动
if (distanceFromBottom <= 50) {
userScrolled.value = false
}
lastScrollTop.value = currentScrollTop
}
// 滚动到底部 - 智能滚动(使用节流减少触发频率)
const scrollToBottom = (smooth: boolean = true, force: boolean = false) => {
// 检查组件是否可见
if (!props.visible) {
return
}
// 如果用户手动滚动且不是强制滚动,则不自动滚动
if (!force && userScrolled.value) {
return
}
// 使用节流,限制滚动频率(至少间隔500ms才滚动一次)
const now = Date.now()
const throttleDelay = 500 // 节流间隔500ms,大幅减少滚动
if (!force && now - lastScrollTime < throttleDelay) {
// 如果距离上次滚动时间太短,取消本次滚动
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
// 延迟执行,确保最后一次滚动能执行
scrollTimeout = setTimeout(() => {
scrollToBottom(smooth, force)
}, throttleDelay - (now - lastScrollTime))
return
}
// 清除之前的定时器
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
// 使用 requestAnimationFrame 优化滚动性能
requestAnimationFrame(() => {
// 使用DOM查询获取滚动容器
const getScrollContainer = () => {
// 先尝试使用ref
if (chatContainer.value) {
return chatContainer.value
}
// 如果ref不存在,使用DOM查询
const container = document.querySelector('.chat-messages')
if (container) {
return container as HTMLElement
}
return null
}
// 使用更稳定的滚动方式
const container = getScrollContainer()
if (container) {
const scrollHeight = container.scrollHeight
const currentScrollTop = container.scrollTop
const clientHeight = container.clientHeight
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight
// 检测内容高度是否明显增加(至少增加200px才滚动,大幅减少触发)
// 或者距离底部超过300px时才滚动
const heightDiff = scrollHeight - lastScrollHeight.value
if (!force && heightDiff < 200 && distanceFromBottom < 300) {
// 内容增加不明显且距离底部不远,不滚动
return
}
// 强制滚动时,重置用户滚动状态
if (force) {
userScrolled.value = false
} else if (userScrolled.value) {
// 再次检查用户是否手动滚动(双重检查)
// 如果不在底部附近,不自动滚动
if (distanceFromBottom > 50) {
return
}
// 如果在底部附近,恢复自动滚动
userScrolled.value = false
}
// 流式更新时始终使用立即滚动,避免平滑滚动导致的晃动
// 只在非流式且非强制时使用平滑滚动
const isStreaming = state.messages.some(m => m.type === 'ai' && m.streaming)
if (smooth && !force && !isStreaming) {
// 平滑滚动(仅非流式时)
container.scrollTo({
top: scrollHeight,
behavior: 'smooth'
})
} else {
// 立即滚动,使用 scrollTop 避免重排和晃动
container.scrollTop = scrollHeight
}
// 更新滚动位置和高度记录
lastScrollTop.value = container.scrollTop
lastScrollHeight.value = scrollHeight
lastScrollTime = now
}
})
}
// 发送消息
const sendMessage = async () => {
if (!state.inputValue.trim() || state.loading) return
// 确保不是中断状态,允许正常提问
userAbortFlag.value = false
// 重置滚动状态,新消息时允许自动滚动
userScrolled.value = false
// 隐藏快速问题区域
state.showQuickQuestions = false
// 检查网络状态
if (!checkNetworkStatus()) {
const errorMessage: Message = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${performance.now().toString().replace('.', '')}`,
type: 'ai',
content: '抱歉,网络连接已断开,请检查网络设置后重试。',
timestamp: Date.now()
}
state.messages.push(errorMessage)
return
}
const userMessage: Message = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${performance.now().toString().replace('.', '')}`,
type: 'user',
content: state.inputValue.trim(),
timestamp: Date.now()
}
state.messages.push(userMessage)
const question = state.inputValue
state.inputValue = ''
state.loading = true
scrollToBottom(false)
try {
// 确保消息ID唯一性,使用更高精度时间戳
const aiMessageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${performance.now().toString().replace('.', '')}`
let messageCreated = false
const thinkingStartTime = Date.now()
// 流式更新函数 - 立即逐字显示,类似DeepSeek的流畅效果
const throttledUpdate = (chunk: string) => {
// 使用 requestAnimationFrame 优化更新,提高流畅度
requestAnimationFrame(() => {
if (!messageCreated) {
// 双重检查:确保消息不存在才创建,避免重复
const existingMessage = state.messages.find((m) => m.id === aiMessageId)
if (existingMessage) {
// 消息已存在,直接更新
if (existingMessage.typeWriterTimer) {
clearTimeout(existingMessage.typeWriterTimer)
existingMessage.typeWriterTimer = null
}
existingMessage.content = chunk
existingMessage.finalResult = chunk
messageCreated = true
return
}
const aiMessage: Message = {
id: aiMessageId,
type: 'ai',
content: chunk,
timestamp: Date.now(),
streaming: true,
showThinking: true,
thinkingStartTime,
finalResult: chunk
}
state.messages.push(aiMessage)
messageCreated = true
// 创建流式消息后立即隐藏loading,避免重复显示
state.loading = false
scrollToBottom(false, true)
} else {
// 更新消息内容
const message = state.messages.find((m) => m.id === aiMessageId)
if (message) {
// 清理定时器
if (message.typeWriterTimer) {
clearTimeout(message.typeWriterTimer)
message.typeWriterTimer = null
}
// 直接更新,立即显示
message.content = chunk
message.finalResult = chunk
}
}
})
}
const result = await callAIApiStream(question, throttledUpdate, (errorContent) => {
// 如果是用户主动中断(点击新建对话),不更新错误信息
if (userAbortFlag.value) {
return
}
// 错误处理:立即结束流式状态
const streamingMessage = state.messages.find(m => m.type === 'ai' && m.streaming)
if (streamingMessage) {
streamingMessage.streaming = false
streamingMessage.content = errorContent
// 流式错误:已结束流式状态
}
})
// 流式结束,标记完成
const thinkingTime = result?.thinkingTime || 0
// 数据日志已在 callAIApiStream 中记录,此处不重复记录
// 查找已存在的消息(可能在流式更新时已创建)
let message = state.messages.find((m) => m.id === aiMessageId)
// 如果消息已创建(messageCreated为true)或找到了消息,就更新它,不要创建新的
if (messageCreated || message) {
// 如果messageCreated为true但找不到消息,尝试再次查找(可能Vue响应式更新延迟)
if (!message && messageCreated) {
message = state.messages.find((m) => m.id === aiMessageId)
}
if (message) {
// 更新已存在的消息
message.streaming = false
message.thinkingTime = thinkingTime
message.technicalData = result?.technicalData || ''
message.finalResult = result?.finalResult || ''
// 清理打字机定时器
if (message.typeWriterTimer) {
clearTimeout(message.typeWriterTimer)
message.typeWriterTimer = null
}
}
// 如果messageCreated为true但找不到消息,不创建新消息,避免重复
} else {
// 只有在既没有创建也没有找到消息时,才创建新消息(超时等情况)
if (result?.content) {
const aiMessage: Message = {
id: aiMessageId,
type: 'ai',
content: result.content,
timestamp: Date.now(),
streaming: false,
thinkingTime,
showThinking: true,
thinkingStartTime,
technicalData: result.technicalData || '',
finalResult: result.finalResult || ''
}
state.messages.push(aiMessage)
} else {
// 如果是用户主动中断(点击新建对话),不显示错误信息
if (userAbortFlag.value) {
return
}
// 兜底:如果连 result 都没有,显示默认提示
const aiMessage: Message = {
id: aiMessageId,
type: 'ai',
content: '抱歉,服务暂时不可用,请稍后再试。',
timestamp: Date.now(),
streaming: false,
showThinking: true,
thinkingStartTime
}
state.messages.push(aiMessage)
// 未收到任何响应,显示默认提示
}
}
} catch (error: any) {
// 发送失败,静默处理
// 如果是用户主动中断(点击新建对话),不显示错误信息
if (userAbortFlag.value) {
return
}
// 根据错误类型提供不同的错误信息
let errorContent = '抱歉,服务暂时不可用,请稍后再试。'
if (error.message?.includes('timeout') || error.code === 'ECONNABORTED') {
errorContent = '抱歉,请求超时,请稍后重试。'
} else if (error.message?.includes('Network Error') || error.code === 'NETWORK_ERROR') {
errorContent = '抱歉,网络连接失败,请检查网络设置后重试。'
} else if (error.response?.status >= 500) {
errorContent = '抱歉,服务器暂时不可用,请稍后重试。'
} else if (error.response?.status === 401 || error.response?.status === 403) {
errorContent = '抱歉,认证失败,请重新登录后重试。'
} else if (error.message?.includes('credentials is not initialized')) {
errorContent = '抱歉,AI服务配置异常,请联系管理员检查模型凭据设置。'
}
const errorMessage: Message = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${performance.now().toString().replace('.', '')}`,
type: 'ai',
content: errorContent,
timestamp: Date.now()
}
state.messages.push(errorMessage)
} finally {
state.loading = false
debugLoadingState()
scrollToBottom(false)
}
}
// 快捷问题
const quickQuestions = [
'今天吃什么',
'天气怎么样',
'你是不是很帅'
]
const askQuickQuestion = (question: string) => {
state.inputValue = question
sendMessage()
}
// 清空对话
const clearChat = () => {
// 清空对话
// 立即清空消息,避免显示任何错误信息
state.messages = []
// 立即设置用户主动中断标志,避免显示任何错误信息
userAbortFlag.value = true
// 中断正在进行的请求
if (abortControllerRef.value) {
// 中断正在进行的请求
abortControllerRef.value.abort()
abortControllerRef.value = null
}
// 清理定时器
throttleTimers.forEach(timer => {
if (timer) clearTimeout(timer)
})
throttleTimers.clear()
pendingContents.clear()
// 清理滚动防抖定时器
if (scrollTimeout) {
clearTimeout(scrollTimeout)
scrollTimeout = null
}
// 重置状态
state.conversationId = ''
state.loading = false
state.showQuickQuestions = true // 显示快速问题区域
// 重置滚动状态
userScrolled.value = false
lastScrollTop.value = 0
// 延迟重置中断标志,确保所有异步操作都能检测到
setTimeout(() => {
userAbortFlag.value = false
// 对话清空完成
}, 200)
scrollToBottom(false, true) // 强制滚动到底部
}
// 打开历史记录
const openHistory = async () => {
state.showHistory = true
const userId = localStorage.getItem('usercode') || 'W9065236'
// if (!state.conversationId) return
try {
const res = await getHistoryMessages({
user: userId,
limit: 50,
// sort_by:'-updated_at'
})
// 检查数据结构 - 可能是直接返回数组,而不是 {data: []} 格式
let dataArray: any[] | null = null
if (Array.isArray(res?.data)) {
dataArray = res.data
} else if (res?.data?.data && Array.isArray(res.data.data)) {
dataArray = res.data.data
}
if (dataArray && dataArray.length > 0) {
// 存储完整会话数据
state.historyMessagesMap = {}
dataArray.forEach((item: any) => {
state.historyMessagesMap[item.id] = item
})
state.historySessions = dataArray.map((item: any) => ({
id: item.id,
title:
item.name.slice(0, 30) + (item.name.length > 30 ? '...' : ''),
lastMessage: item.introduction || '',
timestamp: item.updated_at > 1000000000000 ? item.updated_at : item.updated_at * 1000
}))
} else {
// 历史记录数据为空
}
} catch (error) {
// 获取历史记录失败,静默处理
}
}
// 关闭历史记录
const closeHistory = () => {
state.showHistory = false
}
// 加载历史会话
const loadHistorySession = async (sessionId: string) => {
const userId = localStorage.getItem('usercode') || 'W9065236'
// 确保不是中断状态,允许正常加载历史
userAbortFlag.value = false
// 隐藏快速问题区域
state.showQuickQuestions = false
try {
// 设置当前会话ID
state.conversationId = sessionId
// 加载历史会话
// 先关闭历史弹窗
closeHistory()
// 清空当前消息
state.messages = []
state.loading = true
// 调用接口获取历史消息列表
const res = await getConversationMessages({
conversation_id: sessionId,
user: userId,
limit: 20
})
// 检查数据结构
let messagesData: any[] = []
if (Array.isArray(res?.data?.data)) {
messagesData = res.data.data
} else if (Array.isArray(res?.data)) {
messagesData = res.data
}
if (messagesData && messagesData.length > 0) {
// 将接口返回的消息转换为 Message 格式
const formattedMessages: Message[] = []
messagesData.forEach((item: any) => {
// 转换时间戳:如果是秒级则转为毫秒,并加8小时转换为本地时间
const timezoneOffset = 8 * 60 * 60 * 1000 // 8小时时区偏移
const timestamp = (item.created_at > 1000000000000
? item.created_at
: item.created_at * 1000) + timezoneOffset
// 用户消息(query)
if (item.query) {
formattedMessages.push({
id: `${item.id}-user`,
type: 'user',
content: item.query,
timestamp
})
}
// AI消息(answer)
if (item.answer) {
formattedMessages.push({
id: `${item.id}-ai`,
type: 'ai',
content: item.answer,
timestamp,
streaming: false,
showThinking: true,
finalResult: item.answer
})
}
})
// 按时间排序,确保消息顺序正确
formattedMessages.sort((a, b) => a.timestamp - b.timestamp)
// 添加到消息列表
state.messages = formattedMessages
// 加载历史消息成功
} else {
// 历史消息数据为空
}
nextTick(() => {
scrollToBottom(false)
})
} catch (error: any) {
// 加载历史会话失败,静默处理
// 如果是用户主动中断(点击新建对话),不显示错误信息
if (userAbortFlag.value) {
return
}
// 根据错误类型提供不同的错误信息
let errorContent = '抱歉,无法加载历史消息,请稍后再试。'
if (error.message?.includes('timeout') || error.code === 'ECONNABORTED') {
errorContent = '抱歉,请求超时,请稍后重试。'
} else if (error.message?.includes('Network Error') || error.code === 'NETWORK_ERROR') {
errorContent = '抱歉,网络连接失败,请检查网络设置后重试。'
} else if (error.response?.status >= 500) {
errorContent = '抱歉,服务器暂时不可用,请稍后重试。'
} else if (error.response?.status === 401 || error.response?.status === 403) {
errorContent = '抱歉,认证失败,请重新登录后重试。'
}
const errorMessage: Message = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${performance.now().toString().replace('.', '')}`,
type: 'ai',
content: errorContent,
timestamp: Date.now()
}
state.messages.push(errorMessage)
} finally {
state.loading = false
debugLoadingState()
}
}
// 处理回车发送
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
sendMessage()
}
}
// 调试函数:检查loading状态(已禁用日志)
const debugLoadingState = () => {
// 静默处理
}
// 重试机制(预留功能)
// const retryWithBackoff = async (
// fn: () => Promise<any>,
// maxRetries: number = 3,
// baseDelay: number = 1000
// ) => {
// for (let attempt = 1; attempt <= maxRetries; attempt++) {
// try {
// return await fn()
// } catch (error: any) {
// console.warn(`⚠️ 第${attempt}次尝试失败:`, error.message)
//
// // 如果是最后一次尝试,抛出错误
// if (attempt === maxRetries) {
// throw error
// }
//
// // 指数退避延迟
// const delay = baseDelay * Math.pow(2, attempt - 1)
// console.log(`⏳ ${delay}ms后重试...`)
// await new Promise(resolve => setTimeout(resolve, delay))
// }
// }
// }
// 清理函数
const cleanup = () => {
// 清理所有定时器
throttleTimers.forEach(timer => {
if (timer) clearTimeout(timer)
})
throttleTimers.clear()
pendingContents.clear()
markdownCache.clear() // 清理Markdown缓存
renderCache.clear() // 清理渲染缓存
// 清理滚动防抖定时器
if (scrollTimeout) {
clearTimeout(scrollTimeout)
scrollTimeout = null
}
// 清理 AbortController
if (abortControllerRef.value) {
abortControllerRef.value.abort()
abortControllerRef.value = null
}
// 强制重置 loading 状态
state.loading = false
}
// 网络状态检测
const checkNetworkStatus = () => {
if (!navigator.onLine) {
// 网络离线
return false
}
return true
}
// 网络状态变化监听
const handleOnline = () => {
// 网络已连接
}
const handleOffline = () => {
// 网络已断开
// 如果正在加载,显示网络错误
if (state.loading) {
state.loading = false
const errorMessage: Message = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${performance.now().toString().replace('.', '')}`,
type: 'ai',
content: '抱歉,网络连接已断开,请检查网络设置后重试。',
timestamp: Date.now()
}
state.messages.push(errorMessage)
}
}
onMounted(() => {
// 延迟滚动,确保组件完全渲染
setTimeout(() => {
scrollToBottom(false, true) // 强制滚动到底部
}, 100)
// 绑定滚动事件监听器
nextTick(() => {
const container = chatContainer.value || document.querySelector('.chat-messages')
if (container) {
container.addEventListener('scroll', handleScroll, { passive: true })
}
})
// 监听网络状态
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
trackView({
level2Directory: 'AI智能报告',
level3Directory: 'AI智能报告'
})
})
onUnmounted(() => {
cleanup()
// 移除滚动事件监听器
const container = chatContainer.value || document.querySelector('.chat-messages')
if (container) {
container.removeEventListener('scroll', handleScroll)
}
// 移除网络状态监听
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
})
const handleClose = () => {
// 中断正在进行的请求(避免资源浪费)
if (abortControllerRef.value) {
abortControllerRef.value.abort()
abortControllerRef.value = null
}
// 清理定时器(避免内存泄漏)
throttleTimers.forEach(timer => {
if (timer) clearTimeout(timer)
})
throttleTimers.clear()
pendingContents.clear()
if (scrollTimeout) {
clearTimeout(scrollTimeout)
scrollTimeout = null
}
// 重置loading状态
state.loading = false
// 关闭弹窗
if (props.handleClose) {
props.handleClose()
}
}
return () => {
if (!props.visible) return null
return (
<div class="ai-chat-fullscreen">
<div class="ai-chat-mask" onClick={handleClose}></div>
<div class="ai-chat-container">
<div class="chat-header">
<div class="header-left">
<div class="header-title">
{/* <span class="ai-icon">你好,</span> */}
<span>你好,我是你的AI质量助手</span>
</div>
{/* <div class="header-subtitle">基于当前数据上下文的智能问答</div> */}
</div>
<div class="header-right">
<button
class="history-btn"
onClick={openHistory}
title="历史消息">
<i class="co-icon-list"></i>
</button>
<button
class="new-chat-btn"
onClick={clearChat}
title="新建对话">
<i class="co-icon-screen-capture"></i>
</button>
<button class="close-btn" onClick={handleClose} title="关闭">
<i class="co-icon-close"></i>
</button>
</div>
</div>
{state.showQuickQuestions ? (
<div class="welcome-section">
<div class="welcome-content">
<div class="welcome-left">
<div class="welcome-title">
<span class="title-black">我是</span>
<span class="title-green">AI质量助手</span>
</div>
<div class="welcome-subtitle">质量最亲密的AI工作搭子,问答无顾虑</div>
<div class="quick-questions-container">
<div class="quick-title">你可以对我说:</div>
<div class="quick-questions-list">
{quickQuestions.map((question: string, index: number) => (
<div
key={index}
class="quick-question-item"
onClick={() => askQuickQuestion(question)}>
<span class="question-icon">💬</span>
{question}
</div>
))}
</div>
</div>
</div>
{/* <div class="welcome-right">
<div class="ai-character">
<div class="character-avatar">🤖</div>
</div>
</div> */}
</div>
</div>
) : (
<div class="chat-messages" ref={chatContainer}>
{state.messages.map((message) => (
<div key={message.id} class={`message ${message.type}`}>
{message.type === 'ai' && (
<div class="message-avatar">🤖AI分析助手</div>
)}
<div class="message-content">
{/* AI 消息统一使用深度思考模式 */}
{message.type === 'ai' ? (
<div class="thinking-section">
{/* 流式时只显示最终结果,不显示思考过程 */}
{message.streaming ? (
<div class="final-result">
{message.finalResult ? (
renderContent(message.finalResult, true)
) : null}
</div>
) : (
<div>
<div
class="thinking-header"
onClick={() => toggleThinking(message.id)}>
<span class="thinking-icon"></span>
<span class="thinking-title">
已深度思考{message.thinkingTime ? `(${message.thinkingTime}秒)` : ''}
</span>
<span class="thinking-toggle">
{message.showThinking ? '▼' : '▶'}
</span>
</div>
{message.showThinking && (
<div class="thinking-content">
{/* 非流式时才显示技术数据 */}
<pre class="thinking-raw-data">
{message.technicalData || message.content}
</pre>
</div>
)}
{/* 最终结果显示 */}
<div class="final-result">
{renderContent(message.finalResult || message.content, false)}
</div>
</div>
)}
</div>
) : (
<div class="message-text">{message.content}</div>
)}
{message.options && message.options.length > 0 && (
<div class="message-options">
{message.options.map((option: string, idx: number) => (
<button
key={idx}
class="option-btn"
onClick={() => askQuickQuestion(option)}>
{option}
</button>
))}
</div>
)}
<div class="message-footer">
<div class="message-time">
{new Date(message.timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})}
</div>
{message.type === 'ai' && !message.streaming && (
<div class="message-actions">
<button
class={`action-btn copy-btn ${
state.copiedId === message.id ? 'copied' : ''
}`}
onClick={() =>
copyMessage(message.finalResult || message.content, message.id)
}
title={
state.copiedId === message.id ? '已复制' : '复制'
}>
{state.copiedId === message.id ? (
<svg viewBox="0 0 24 24" width="14" height="14">
<path
fill="currentColor"
d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
/>
</svg>
) : (
<svg viewBox="0 0 24 24" width="14" height="14">
<path
fill="currentColor"
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"
/>
</svg>
)}
</button>
<button
class="action-btn refresh-btn"
onClick={() => refreshMessage(message.id)}
title="重新生成">
<svg viewBox="0 0 24 24" width="14" height="14">
<path
fill="currentColor"
d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
/>
</svg>
</button>
</div>
)}
</div>
</div>
</div>
))}
{state.loading && !state.messages.some(m => m.type === 'ai' && m.streaming) && (
<div class="message ai">
<div class="message-avatar">🤖质量分析助手</div>
<div class="message-content">
<div class="thinking-section loading">
<div class="thinking-header">
<span class="thinking-icon"></span>
<span class="thinking-title">AI正在思考中</span>
<div class="thinking-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
<div class="chat-input">
<input
value={state.inputValue}
onInput={(e: any) => {
state.inputValue = e.target.value
}}
placeholder="请输入你的问题..."
onKeypress={handleKeyPress}
disabled={state.loading}
/>
<button
class="send-btn"
onClick={sendMessage}
disabled={state.loading || !state.inputValue.trim()}>
发送
</button>
</div>
{/* 历史记录弹窗 */}
<HistoryPopup
show={state.showHistory}
close={closeHistory}
loadSession={loadHistorySession}
historySessions={state.historySessions}
/>
</div>
</div>
)
}
}
})

浙公网安备 33010602011771号