2475 lines
65 KiB
Vue
2475 lines
65 KiB
Vue
<template>
|
||
<div class="chat-popup">
|
||
<!-- 导航栏 -->
|
||
<div class="navbar">
|
||
<div class="nav-left">
|
||
<!-- 数字人头像 -->
|
||
<div class="digital-avatar" :class="{ speaking: isAISpeaking, thinking: isAIThinking }">
|
||
<div class="avatar-face">
|
||
<div class="avatar-eyes">
|
||
<div class="eye left-eye" :class="{ blink: isBlinking }"></div>
|
||
<div class="eye right-eye" :class="{ blink: isBlinking }"></div>
|
||
</div>
|
||
<div class="avatar-mouth" :class="{ talking: isAISpeaking }"></div>
|
||
</div>
|
||
<div class="avatar-glow"></div>
|
||
</div>
|
||
<div class="nav-title">智水AI辅导员</div>
|
||
</div>
|
||
<div class="nav-close" @click="closeChat">×</div>
|
||
</div>
|
||
|
||
<!-- 消息列表 -->
|
||
<div class="message-list" ref="messageList" @scroll="onScroll">
|
||
<!-- 加载历史记录提示 -->
|
||
<div v-if="isLoadingHistory" class="loading-history">
|
||
<div class="loading-text">加载历史记录中...</div>
|
||
</div>
|
||
|
||
<!-- 没有更多历史记录提示 -->
|
||
<div v-if="!hasMoreHistory && messages.length > 1" class="no-more-history">
|
||
<div class="no-more-text">没有更多历史记录了</div>
|
||
</div>
|
||
|
||
<!-- 初始化失败重试按钮 -->
|
||
<div v-if="initFailed" class="init-failed">
|
||
<div class="failed-text">聊天初始化失败</div>
|
||
<button class="retry-button" @click="retryInit">重试</button>
|
||
</div>
|
||
|
||
<!-- 消息过多提示 -->
|
||
<div v-if="messages.length > maxVisibleMessages" class="message-limit-notice">
|
||
<div class="notice-text">为了保证页面性能,仅显示最新的 {{ maxVisibleMessages }} 条消息</div>
|
||
<div class="notice-subtext">共 {{ messages.length }} 条消息</div>
|
||
</div>
|
||
|
||
<!-- 消息项 -->
|
||
<div v-for="(message, index) in visibleMessages" :key="index" class="message-item">
|
||
<!-- 用户消息 -->
|
||
<div v-if="message.sender === 'user'" class="user-message">
|
||
<div class="message-content">
|
||
<div class="content-text">{{ message.content }}</div>
|
||
</div>
|
||
<div class="avatar">
|
||
<img v-if="message.avatar" :src="message.avatar" alt="用户头像" @error="handleImageError($event, 'user')" />
|
||
<div v-else class="avatar-text user-avatar">U</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI消息 -->
|
||
<div v-else class="ai-message">
|
||
<div class="avatar">
|
||
<img v-if="message.avatar" :src="message.avatar" alt="AI头像" @error="handleImageError($event, 'ai')" />
|
||
<div v-else class="avatar-text ai-avatar">AI</div>
|
||
</div>
|
||
<div class="message-content">
|
||
<!-- AI提示 -->
|
||
<div class="ai-prompt">智水AI辅导员</div>
|
||
|
||
<!-- 消息内容 -->
|
||
<div class="content-text" v-html="renderMarkdown(message.content)"></div>
|
||
|
||
<!-- 引用来源 -->
|
||
<div v-if="message.retrieverResources && message.retrieverResources.length > 0" class="reference-sources">
|
||
<div class="reference-title">引用来源:</div>
|
||
<div
|
||
v-for="(group, groupIndex) in (message.groupedReferences || getGroupedReferences(message.retrieverResources))"
|
||
:key="groupIndex" class="reference-group">
|
||
<div class="reference-doc"
|
||
:class="{ expanded: showSingleReference[`${message.messageId}-${group.docName}`] }"
|
||
@click="toggleSingleReference(message.messageId, group.docName)">
|
||
{{ group.docName }} ({{ group.items.length }})
|
||
</div>
|
||
<div v-if="showSingleReference[`${message.messageId}-${group.docName}`]" class="reference-details">
|
||
<div v-for="(item, itemIndex) in group.items" :key="itemIndex" class="reference-segment">
|
||
<div class="segment-content">{{ item.content }}</div>
|
||
<div v-if="itemIndex < group.items.length - 1" class="segment-divider"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI操作区域 -->
|
||
<div v-if="message.content && message.content !== '正在思考...' && message.streamCompleted" class="ai-actions">
|
||
<div class="ai-text">AI回答也可能会犯错。请核查重要信息。</div>
|
||
<div class="action-icons">
|
||
<div class="action-icon"
|
||
:class="{ active: (message.feedback && message.feedback.rating === 'like') || message.feedback === 'like' }"
|
||
@click="handleThumbUp(message)">
|
||
<svg class="action-svg" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path
|
||
d="M7 22H4C3.46957 22 2.96086 21.7893 2.58579 21.4142C2.21071 21.0391 2 20.5304 2 20V13C2 12.4696 2.21071 11.9609 2.58579 11.5858C2.96086 11.2107 3.46957 11 4 11H7M14 9V5C14 4.20435 13.6839 3.44129 13.1213 2.87868C12.5587 2.31607 11.7956 2 11 2L7 11V22H18.28C18.7623 22.0055 19.2304 21.8364 19.5979 21.524C19.9654 21.2116 20.2077 20.7769 20.28 20.3L21.66 11.3C21.7035 11.0134 21.6842 10.7207 21.6033 10.4423C21.5225 10.1638 21.3821 9.90629 21.1919 9.68751C21.0016 9.46873 20.7661 9.29393 20.5016 9.17522C20.2371 9.0565 19.9499 8.99672 19.66 9H14Z"
|
||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
</div>
|
||
<div class="action-icon"
|
||
:class="{ active: (message.feedback && message.feedback.rating === 'dislike') || message.feedback === 'dislike' }"
|
||
@click="handleThumbDown(message)">
|
||
<svg class="action-svg" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path
|
||
d="M17 2H20C20.5304 2 21.0391 2.21071 21.4142 2.58579C21.7893 2.96086 22 3.46957 22 4V11C22 11.5304 21.7893 12.0391 21.4142 12.4142C21.0391 12.7893 20.5304 13 20 13H17M10 15V19C10 19.7956 10.3161 20.5587 10.8787 21.1213C11.4413 21.6839 12.2044 22 13 22L17 13V2H5.72C5.23767 1.99448 4.76962 2.16361 4.40213 2.47596C4.03464 2.78831 3.79227 3.22305 3.72 3.7L2.34 12.7C2.29649 12.9866 2.31583 13.2793 2.39672 13.5577C2.47761 13.8362 2.61793 14.0937 2.80817 14.3125C2.9984 14.5313 3.23394 14.7061 3.49843 14.8248C3.76291 14.9435 4.05011 15.0033 4.34 15H10Z"
|
||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 输入框区域 -->
|
||
<div class="input-area">
|
||
<div class="input-container">
|
||
<input v-model="inputMessage" type="text" placeholder="请输入您的问题..." class="message-input"
|
||
@keyup.enter="sendMessage" :disabled="sending" />
|
||
<button class="send-button" @click="sendMessage" :disabled="!inputMessage.trim() || sending">
|
||
{{ sending ? '发送中...' : '发送' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import MarkdownIt from 'markdown-it'
|
||
import DOMPurify from 'dompurify'
|
||
import { createChatStream } from '@/utils/ai_stream'
|
||
import { sendFeedback, getHistory } from '@/api/aiChat/ai_index'
|
||
import { mapGetters } from 'vuex'
|
||
|
||
// 配置 Markdown 渲染器
|
||
const md = new MarkdownIt({
|
||
html: true,
|
||
linkify: true,
|
||
typographer: true
|
||
})
|
||
|
||
export default {
|
||
name: 'ChatPopup',
|
||
data() {
|
||
return {
|
||
// 基础数据
|
||
inputMessage: '',
|
||
messages: [],
|
||
sending: false,
|
||
initFailed: false, // 初始化失败标志
|
||
|
||
// 用户信息
|
||
user: 'default_user',
|
||
userStuNo: null,
|
||
|
||
// 对话相关
|
||
conversation_id: localStorage.getItem('conversation_id') || '',
|
||
currentCancel: null,
|
||
|
||
// 分页加载相关
|
||
isLoadingHistory: false,
|
||
hasMoreHistory: true,
|
||
earliestMessageId: null,
|
||
loadThreshold: 200, // 增加阈值到200px,与移动端保持一致
|
||
lastScrollTop: 0,
|
||
loadDebounceTimer: null,
|
||
scrollWithAnimation: true, // 添加滚动动画控制
|
||
|
||
// 引用信息展示控制
|
||
showSingleReference: {},
|
||
|
||
// Markdown 渲染器
|
||
md: md,
|
||
|
||
// 组件销毁标志
|
||
isDestroyed: false,
|
||
|
||
// 性能优化相关
|
||
scrollThrottleTimer: null, // 滚动节流定时器
|
||
contentUpdateTimer: null, // 内容更新节流定时器
|
||
isUserAtBottom: true, // 用户是否在底部
|
||
lastContentLength: 0, // 上次内容长度
|
||
scrollPending: false, // 滚动待处理标志
|
||
|
||
// 性能优化和错误边界
|
||
requestQueue: [],
|
||
maxRetries: 2,
|
||
retryCount: 0,
|
||
performanceMonitor: null,
|
||
memoryCheckInterval: null,
|
||
lastMemoryUsage: 0,
|
||
|
||
// 请求监控
|
||
activeRequests: new Map(), // 存储活跃请求的信息
|
||
|
||
// 数字人相关状态
|
||
isAISpeaking: false, // AI是否在说话
|
||
isAIThinking: false, // AI是否在思考
|
||
isBlinking: false, // 是否在眨眼
|
||
blinkTimer: null, // 眨眼定时器
|
||
speakingTimer: null, // 说话动画定时器
|
||
requestMonitorInterval: null,
|
||
maxRequestDuration: 60000, // 最大请求持续时间60秒
|
||
|
||
// Markdown渲染缓存
|
||
markdownCache: new Map(), // 缓存已渲染的Markdown内容
|
||
maxCacheSize: 100, // 最大缓存条目数
|
||
|
||
// 引用资源分组缓存
|
||
referencesCache: new Map(), // 缓存引用资源分组结果
|
||
maxReferencesCacheSize: 50, // 最大引用缓存条目数
|
||
|
||
// 消息显示优化
|
||
maxVisibleMessages: 50, // 最大可见消息数量
|
||
messageRenderBatch: 20, // 每批渲染的消息数量
|
||
}
|
||
},
|
||
computed: {
|
||
...mapGetters([
|
||
'name',
|
||
'avatar',
|
||
'roles',
|
||
'userInfo'
|
||
]),
|
||
// 获取用户ID
|
||
userId() {
|
||
return this.$store.state.user.id || localStorage.getItem('userId') || ''
|
||
},
|
||
// 获取用户名
|
||
userName() {
|
||
return this.name || localStorage.getItem('userName') || '用户'
|
||
},
|
||
// 获取可见消息列表(性能优化)
|
||
visibleMessages() {
|
||
if (this.messages.length <= this.maxVisibleMessages) {
|
||
return this.messages
|
||
}
|
||
// 只显示最新的消息,保持对话连续性
|
||
return this.messages.slice(-this.maxVisibleMessages)
|
||
}
|
||
},
|
||
watch: {
|
||
// 监听聊天框显示状态变化
|
||
'$parent.showAI'(newVal, oldVal) {
|
||
if (newVal && !oldVal && !this.isDestroyed) {
|
||
// 只在从关闭状态变为打开状态时,且组件未销毁时,确保滚动到底部
|
||
this.$nextTick(() => {
|
||
if (!this.isDestroyed) {
|
||
setTimeout(() => {
|
||
if (!this.isDestroyed) {
|
||
this.scrollToBottom(false)
|
||
}
|
||
}, 100)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
},
|
||
mounted() {
|
||
console.log(this.userInfo)
|
||
// 使用store中的userInfo.userName作为学号
|
||
if (this.userInfo && this.userInfo.userName) {
|
||
this.user = this.userInfo.userName
|
||
this.userStuNo = this.userInfo.userName
|
||
}
|
||
console.log('当前用户学号:', this.user)
|
||
|
||
// 添加全局错误处理
|
||
this.setupErrorHandling()
|
||
|
||
// 启动性能监控
|
||
this.startPerformanceMonitoring()
|
||
|
||
// 启动请求监控
|
||
this.startRequestMonitoring()
|
||
|
||
// 启动数字人自动眨眼
|
||
this.startAutoBlinking()
|
||
|
||
// 确保DOM完全渲染后再初始化聊天
|
||
this.$nextTick(() => {
|
||
setTimeout(async () => {
|
||
await this.initChat()
|
||
// 初始化完成后立即滚动到底部
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom(false)
|
||
})
|
||
}, 50)
|
||
})
|
||
},
|
||
beforeDestroy() {
|
||
// 设置销毁标志
|
||
this.isDestroyed = true
|
||
|
||
// 移除全局错误处理器
|
||
window.removeEventListener('unhandledrejection', this.handleUnhandledRejection)
|
||
window.removeEventListener('error', this.handleGlobalError)
|
||
|
||
// 停止性能监控
|
||
this.stopPerformanceMonitoring()
|
||
|
||
// 停止请求监控
|
||
this.stopRequestMonitoring()
|
||
|
||
// 清理定时器
|
||
if (this.loadDebounceTimer) {
|
||
clearTimeout(this.loadDebounceTimer)
|
||
this.loadDebounceTimer = null
|
||
}
|
||
if (this.scrollThrottleTimer) {
|
||
clearTimeout(this.scrollThrottleTimer)
|
||
this.scrollThrottleTimer = null
|
||
}
|
||
if (this.contentUpdateTimer) {
|
||
clearTimeout(this.contentUpdateTimer)
|
||
this.contentUpdateTimer = null
|
||
}
|
||
|
||
// 清理数字人相关定时器
|
||
this.stopAutoBlinking()
|
||
this.stopAIAnimation()
|
||
|
||
// 取消正在进行的请求
|
||
if (this.currentCancel) {
|
||
this.currentCancel()
|
||
this.currentCancel = null
|
||
}
|
||
|
||
// 清理请求队列
|
||
this.requestQueue = []
|
||
|
||
// 清理消息数据
|
||
this.messages = []
|
||
|
||
// 清理引用展示状态
|
||
this.showSingleReference = {}
|
||
|
||
// 清理markdown缓存
|
||
this.markdownCache.clear()
|
||
|
||
// 清理引用资源分组缓存
|
||
this.referencesCache.clear()
|
||
|
||
// 重置所有状态
|
||
this.conversation_id = ''
|
||
this.earliestMessageId = null
|
||
this.inputMessage = ''
|
||
this.sending = false
|
||
this.isLoadingHistory = false
|
||
this.hasMoreHistory = false
|
||
this.initFailed = false
|
||
|
||
// 强制垃圾回收(如果浏览器支持)
|
||
if (window.gc) {
|
||
try {
|
||
window.gc()
|
||
} catch (e) {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
},
|
||
methods: {
|
||
/**
|
||
* 初始化聊天 - 获取历史记录
|
||
*/
|
||
async initChat() {
|
||
// 防止重复初始化
|
||
if (this.isLoadingHistory || this.isDestroyed) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
this.isLoadingHistory = true
|
||
this.retryCount = 0
|
||
|
||
// 添加到请求队列
|
||
const requestId = Date.now()
|
||
this.requestQueue.push(requestId)
|
||
|
||
// 如果队列中有太多请求,清理旧请求
|
||
if (this.requestQueue.length > 3) {
|
||
this.requestQueue = this.requestQueue.slice(-3)
|
||
}
|
||
|
||
// 添加超时控制,避免卡死
|
||
const timeoutPromise = new Promise((_, reject) => {
|
||
setTimeout(() => reject(new Error('请求超时')), 8000) // 减少到8秒
|
||
})
|
||
|
||
// 创建取消Promise
|
||
const cancelPromise = new Promise((_, reject) => {
|
||
const checkCancel = () => {
|
||
if (this.isDestroyed || !this.requestQueue.includes(requestId)) {
|
||
reject(new Error('请求已取消'))
|
||
} else {
|
||
setTimeout(checkCancel, 100)
|
||
}
|
||
}
|
||
checkCancel()
|
||
})
|
||
|
||
const historyPromise = getHistory({
|
||
user: this.user,
|
||
conversationId: this.conversation_id || '',
|
||
limit: 10
|
||
})
|
||
|
||
// 使用Promise.race来实现超时和取消控制
|
||
const res = await Promise.race([historyPromise, timeoutPromise, cancelPromise])
|
||
|
||
// 从队列中移除当前请求
|
||
this.requestQueue = this.requestQueue.filter(id => id !== requestId)
|
||
// console.log('历史记录响应:', res);
|
||
if (this.isDestroyed) {
|
||
return
|
||
}
|
||
|
||
if (res.code === 200 && res.data && Array.isArray(res.data.data)) {
|
||
const newMessages = []
|
||
// console.log('历史记录:', res.data.data)
|
||
|
||
// 批量处理历史消息,减少DOM操作
|
||
const messagesBatch = []
|
||
res.data.data.forEach(msg => {
|
||
// 用户消息
|
||
if (msg.query) {
|
||
messagesBatch.push({
|
||
sender: 'user',
|
||
avatar: require('@/assets/ai/yonghu.png'),
|
||
content: msg.query,
|
||
messageId: msg.id,
|
||
conversationId: msg.conversation_id,
|
||
created_at: msg.created_at
|
||
})
|
||
}
|
||
|
||
// AI消息 - 延迟计算groupedReferences
|
||
if (msg.answer) {
|
||
const retrieverResources = msg.retriever_resources || []
|
||
const aiMessage = {
|
||
sender: 'ai',
|
||
avatar: require('@/assets/ai/AI.png'),
|
||
content: msg.answer,
|
||
messageId: 'ai-' + msg.id,
|
||
conversationId: msg.conversation_id,
|
||
created_at: msg.created_at,
|
||
feedback: msg.feedback || null,
|
||
retrieverResources: retrieverResources,
|
||
streamCompleted: true // 历史消息的流输出已完成
|
||
}
|
||
|
||
// 只有在有引用资源时才预计算分组
|
||
if (retrieverResources && retrieverResources.length > 0) {
|
||
// 使用异步方式计算,避免阻塞主线程
|
||
this.$nextTick(() => {
|
||
aiMessage.groupedReferences = this.getGroupedReferences(retrieverResources)
|
||
})
|
||
}
|
||
|
||
messagesBatch.push(aiMessage)
|
||
}
|
||
})
|
||
|
||
// 按时间排序(从旧到新)
|
||
messagesBatch.sort((a, b) => {
|
||
return a.created_at - b.created_at
|
||
})
|
||
|
||
newMessages.push(...messagesBatch)
|
||
// console.log('处理后的历史记录:', newMessages)
|
||
|
||
// 分批渲染消息,避免一次性渲染过多消息导致卡死
|
||
this.renderMessagesInBatches(newMessages)
|
||
|
||
if (newMessages.length > 0) {
|
||
this.conversation_id = newMessages[0].conversationId
|
||
localStorage.setItem('conversation_id', this.conversation_id)
|
||
|
||
// 设置最早的用户消息ID用于分页
|
||
const userMessages = newMessages.filter(msg => msg.sender === 'user')
|
||
if (userMessages.length > 0) {
|
||
this.earliestMessageId = userMessages[0].messageId
|
||
} else {
|
||
this.earliestMessageId = newMessages[0].messageId.replace('ai-', '')
|
||
}
|
||
}
|
||
// console.log('历史记录:', newMessages)
|
||
this.hasMoreHistory = res.data.has_more || false
|
||
} else {
|
||
// console.log('没有历史记录')
|
||
// 没有历史记录,显示欢迎消息
|
||
const welcomeMessages = [{
|
||
sender: 'ai',
|
||
avatar: require('@/assets/ai/AI.png'),
|
||
content: '你好!我是智水AI辅导员,有什么可以帮助你的吗?',
|
||
messageId: 'welcome-' + Date.now()
|
||
}]
|
||
this.renderMessagesInBatches(welcomeMessages)
|
||
this.hasMoreHistory = false
|
||
}
|
||
|
||
this.initFailed = false
|
||
} catch (error) {
|
||
console.error('初始化聊天失败:', error)
|
||
|
||
// 如果是取消错误,直接返回
|
||
if (error.message === '请求已取消' || this.isDestroyed) {
|
||
return
|
||
}
|
||
|
||
// 根据错误类型显示不同的提示信息
|
||
let errorMessage = '加载历史记录失败'
|
||
if (error.message === '请求超时') {
|
||
errorMessage = '网络连接超时,请检查网络后重试'
|
||
} else if (error.message && error.message.includes('Network Error')) {
|
||
errorMessage = '网络连接异常,请检查网络设置'
|
||
} else if (error.response && error.response.status === 401) {
|
||
errorMessage = '登录已过期,请重新登录'
|
||
} else if (error.response && error.response.status >= 500) {
|
||
errorMessage = '服务器暂时不可用,请稍后重试'
|
||
}
|
||
|
||
this.showToast(errorMessage)
|
||
|
||
// 显示欢迎消息作为降级方案
|
||
const fallbackMessages = [{
|
||
sender: 'ai',
|
||
avatar: require('@/assets/ai/AI.png'),
|
||
content: '你好!我是智水AI辅导员,有什么可以帮助你的吗?\n\n如果遇到网络问题,请稍后重试或联系管理员。',
|
||
messageId: 'welcome-' + Date.now()
|
||
}]
|
||
this.renderMessagesInBatches(fallbackMessages)
|
||
|
||
// 重置相关状态
|
||
this.hasMoreHistory = false
|
||
this.conversation_id = ''
|
||
this.earliestMessageId = null
|
||
this.initFailed = true // 标记初始化失败
|
||
} finally {
|
||
this.isLoadingHistory = false
|
||
|
||
// 清理请求队列
|
||
this.requestQueue = []
|
||
|
||
// 滚动到底部
|
||
if (!this.isDestroyed) {
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
if (!this.isDestroyed) {
|
||
this.scrollToBottom(false) // 初始化时直接定位到底部,不使用平滑滚动
|
||
}
|
||
}, 100) // 减少延迟时间
|
||
})
|
||
}
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 加载更多历史记录
|
||
*/
|
||
async loadMoreHistory() {
|
||
if (this.isLoadingHistory || !this.hasMoreHistory || !this.conversation_id || !this.earliestMessageId) {
|
||
return
|
||
}
|
||
|
||
this.isLoadingHistory = true
|
||
|
||
try {
|
||
// 记录当前滚动位置和第一个消息作为锚点
|
||
const currentScrollTop = this.$refs.messageList.scrollTop
|
||
const currentScrollHeight = this.$refs.messageList.scrollHeight
|
||
const firstMessageElement = this.$refs.messageList.querySelector('.message-item')
|
||
const anchorOffset = firstMessageElement ? firstMessageElement.offsetTop : 0
|
||
|
||
// 添加超时控制
|
||
const timeoutPromise = new Promise((_, reject) => {
|
||
setTimeout(() => reject(new Error('加载历史记录超时')), 8000) // 8秒超时
|
||
})
|
||
|
||
const historyPromise = getHistory({
|
||
user: this.user,
|
||
conversationId: this.conversation_id,
|
||
limit: 10,
|
||
beforeId: this.earliestMessageId
|
||
})
|
||
|
||
const res = await Promise.race([historyPromise, timeoutPromise])
|
||
|
||
if (res.code === 200 && res.data && Array.isArray(res.data.data) && res.data.data.length > 0) {
|
||
const newMessages = []
|
||
|
||
// 处理新获取的消息
|
||
res.data.data.forEach(msg => {
|
||
// 用户消息
|
||
if (msg.query) {
|
||
newMessages.push({
|
||
sender: 'user',
|
||
avatar: require('@/assets/ai/yonghu.png'),
|
||
content: msg.query,
|
||
messageId: msg.id,
|
||
conversationId: msg.conversation_id,
|
||
created_at: msg.created_at
|
||
})
|
||
}
|
||
|
||
// AI消息
|
||
if (msg.answer) {
|
||
const retrieverResources = msg.retriever_resources || []
|
||
newMessages.push({
|
||
sender: 'ai',
|
||
avatar: require('@/assets/ai/AI.png'),
|
||
content: msg.answer,
|
||
messageId: 'ai-' + msg.id,
|
||
conversationId: msg.conversation_id,
|
||
created_at: msg.created_at,
|
||
feedback: msg.feedback || null,
|
||
retrieverResources: retrieverResources,
|
||
groupedReferences: this.getGroupedReferences(retrieverResources) // 预计算分组引用
|
||
})
|
||
}
|
||
})
|
||
|
||
// 按时间排序(从旧到新)
|
||
newMessages.sort((a, b) => {
|
||
return a.created_at - b.created_at
|
||
})
|
||
|
||
// 插入到现有消息前面
|
||
this.messages = [...newMessages, ...this.messages]
|
||
|
||
// 更新最早消息ID
|
||
if (newMessages.length > 0) {
|
||
const userMessages = newMessages.filter(msg => msg.sender === 'user')
|
||
if (userMessages.length > 0) {
|
||
this.earliestMessageId = userMessages[0].messageId
|
||
} else {
|
||
this.earliestMessageId = newMessages[0].messageId.replace('ai-', '')
|
||
}
|
||
}
|
||
|
||
this.hasMoreHistory = res.data.has_more || false
|
||
|
||
// 保持滚动位置,避免跳变
|
||
this.$nextTick(() => {
|
||
// 等待DOM更新完成后再计算位置
|
||
setTimeout(() => {
|
||
const newScrollHeight = this.$refs.messageList.scrollHeight
|
||
const heightDiff = newScrollHeight - currentScrollHeight
|
||
|
||
// 计算目标滚动位置,确保用户看到的内容不变
|
||
const targetScrollTop = Math.max(0, currentScrollTop + heightDiff)
|
||
|
||
// 直接设置scrollTop,避免scrollTo的动画效果
|
||
this.$refs.messageList.scrollTop = targetScrollTop
|
||
|
||
// 更新lastScrollTop以避免触发新的加载
|
||
this.lastScrollTop = targetScrollTop
|
||
|
||
// 确保滚动位置稳定
|
||
this.$nextTick(() => {
|
||
if (Math.abs(this.$refs.messageList.scrollTop - targetScrollTop) > 5) {
|
||
this.$refs.messageList.scrollTop = targetScrollTop
|
||
}
|
||
})
|
||
}, 50) // 给DOM更新留出时间
|
||
})
|
||
} else {
|
||
this.hasMoreHistory = false
|
||
}
|
||
} catch (error) {
|
||
console.error('加载历史记录失败:', error)
|
||
|
||
// 根据错误类型显示不同的提示信息
|
||
let errorMessage = '加载历史记录失败'
|
||
if (error.message === '加载历史记录超时') {
|
||
errorMessage = '加载超时,请检查网络连接'
|
||
} else if (error.message && error.message.includes('Network Error')) {
|
||
errorMessage = '网络连接异常'
|
||
} else if (error.response && error.response.status >= 500) {
|
||
errorMessage = '服务器繁忙,请稍后重试'
|
||
}
|
||
|
||
this.showToast(errorMessage)
|
||
|
||
// 如果是超时或网络错误,不改变hasMoreHistory状态,允许用户重试
|
||
if (!error.message || (!error.message.includes('超时') && !error.message.includes('Network'))) {
|
||
this.hasMoreHistory = false
|
||
}
|
||
} finally {
|
||
this.isLoadingHistory = false
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 重试初始化
|
||
*/
|
||
async retryInit() {
|
||
// 防止重复重试
|
||
if (this.isLoadingHistory || this.isDestroyed) {
|
||
return
|
||
}
|
||
|
||
// 检查重试次数
|
||
if (this.retryCount >= this.maxRetries) {
|
||
this.showToast('重试次数过多,请刷新页面')
|
||
return
|
||
}
|
||
|
||
this.initFailed = false
|
||
this.retryCount++
|
||
|
||
// 清理之前的状态
|
||
this.messages = []
|
||
this.requestQueue = []
|
||
|
||
try {
|
||
await this.initChat()
|
||
} catch (error) {
|
||
console.error('重试初始化失败:', error)
|
||
this.initFailed = true
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 发送消息
|
||
*/
|
||
async sendMessage() {
|
||
if (!this.inputMessage.trim() || this.sending || this.isDestroyed) {
|
||
return
|
||
}
|
||
|
||
// 防止频繁发送
|
||
if (this.requestQueue.length > 2) {
|
||
this.showToast('请等待当前消息处理完成')
|
||
return
|
||
}
|
||
|
||
const userMessage = this.inputMessage.trim()
|
||
this.inputMessage = ''
|
||
this.sending = true
|
||
|
||
// 添加到请求队列
|
||
const requestId = Date.now()
|
||
this.requestQueue.push(requestId)
|
||
|
||
// 添加用户消息
|
||
const userMsg = {
|
||
sender: 'user',
|
||
avatar: require('@/assets/ai/yonghu.png'),
|
||
content: userMessage,
|
||
messageId: 'user-' + Date.now()
|
||
}
|
||
this.messages.push(userMsg)
|
||
|
||
// 添加AI消息占位符
|
||
const aiMsg = {
|
||
sender: 'ai',
|
||
avatar: require('@/assets/ai/AI.png'),
|
||
content: '正在思考...',
|
||
messageId: 'ai-' + Date.now(),
|
||
feedback: null,
|
||
retrieverResources: [],
|
||
streamCompleted: false // 流输出完成标志
|
||
}
|
||
this.messages.push(aiMsg)
|
||
|
||
// 启动AI思考动画
|
||
this.startAIThinking()
|
||
|
||
this.scrollToBottom(true) // 添加消息后使用平滑滚动
|
||
|
||
try {
|
||
// 创建流式聊天
|
||
const { stream, cancel, requestId } = createChatStream({
|
||
conversationId: this.conversation_id || '',
|
||
prompt: userMessage,
|
||
user: this.user,
|
||
userId: this.userId,
|
||
userName: this.userName
|
||
})
|
||
|
||
this.currentCancel = cancel
|
||
|
||
// 注册请求到监控系统
|
||
this.activeRequests.set(requestId, {
|
||
startTime: Date.now(),
|
||
type: 'sendMessage',
|
||
cancel: cancel,
|
||
userMessage: userMessage
|
||
})
|
||
|
||
// 添加额外的超时保护
|
||
const streamTimeout = setTimeout(() => {
|
||
if (cancel) {
|
||
cancel('流式响应超时')
|
||
}
|
||
aiMsg.content = '响应超时,请重新发送消息'
|
||
aiMsg.streamCompleted = true
|
||
this.sending = false
|
||
}, 45000) // 45秒超时保护
|
||
|
||
const response = await stream
|
||
clearTimeout(streamTimeout) // 清除超时保护
|
||
|
||
const { reader, decoder } = response
|
||
let buffer = ''
|
||
let lastUpdateTime = Date.now()
|
||
let noDataTimeout = null
|
||
|
||
while (true) {
|
||
// 设置无数据超时检测
|
||
if (noDataTimeout) {
|
||
clearTimeout(noDataTimeout)
|
||
}
|
||
noDataTimeout = setTimeout(() => {
|
||
if (cancel) {
|
||
cancel('数据流中断')
|
||
}
|
||
aiMsg.content = aiMsg.content === '正在思考...' ? '服务器响应中断,请重新发送' : aiMsg.content + '\n\n[响应中断]'
|
||
aiMsg.streamCompleted = true
|
||
}, 15000) // 15秒无数据则认为中断
|
||
|
||
const { done, value } = await reader.read()
|
||
|
||
// 清除无数据超时
|
||
if (noDataTimeout) {
|
||
clearTimeout(noDataTimeout)
|
||
noDataTimeout = null
|
||
}
|
||
|
||
if (done) break
|
||
|
||
// 更新最后接收数据时间
|
||
lastUpdateTime = Date.now()
|
||
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const lines = buffer.split('\n')
|
||
buffer = lines.pop() || ''
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue
|
||
|
||
try {
|
||
let jsonStr = line.replace(/^data:/, '').trim()
|
||
const data = JSON.parse(jsonStr)
|
||
|
||
// 更新消息内容
|
||
if (data.event === 'message' && data.answer) {
|
||
const currentContent = aiMsg.content
|
||
const newContent = (currentContent === '正在思考...' ? '' : currentContent) + data.answer
|
||
|
||
// 如果是第一次收到回复,启动说话动画
|
||
if (currentContent === '正在思考...') {
|
||
this.startAISpeaking()
|
||
}
|
||
|
||
// 使用节流更新方法,减少频繁的DOM更新和滚动
|
||
this.throttledContentUpdate(aiMsg, newContent)
|
||
}
|
||
|
||
// 处理结束消息
|
||
if (data.event === 'message_end') {
|
||
if (data.conversation_id) {
|
||
this.conversation_id = data.conversation_id
|
||
localStorage.setItem('conversation_id', this.conversation_id)
|
||
userMsg.conversationId = this.conversation_id
|
||
aiMsg.conversationId = this.conversation_id
|
||
}
|
||
if (data.message_id) {
|
||
userMsg.messageId = data.message_id
|
||
aiMsg.messageId = 'ai-' + data.message_id
|
||
}
|
||
if (data.metadata && data.metadata.retriever_resources) {
|
||
// 使用Vue的响应式方法确保引用信息正确更新
|
||
this.$set(aiMsg, 'retrieverResources', data.metadata.retriever_resources)
|
||
this.$set(aiMsg, 'groupedReferences', this.getGroupedReferences(data.metadata.retriever_resources))
|
||
}
|
||
// 标记流输出完成并触发最终滚动
|
||
aiMsg.streamCompleted = true
|
||
// 重置内容长度计数器
|
||
this.lastContentLength = 0
|
||
// 停止AI动画
|
||
this.stopAIAnimation()
|
||
// 流式响应结束后强制滚动到底部
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom(true, true) // 使用平滑滚动并强制执行
|
||
})
|
||
}
|
||
|
||
// 处理错误事件
|
||
if (data.event === 'error') {
|
||
aiMsg.content = data.message || '服务器处理出错,请重新发送'
|
||
aiMsg.streamCompleted = true
|
||
break
|
||
}
|
||
} catch (e) {
|
||
console.warn('JSON解析失败:', line, e)
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error)
|
||
|
||
// 清理所有定时器
|
||
if (streamTimeout) clearTimeout(streamTimeout)
|
||
if (noDataTimeout) clearTimeout(noDataTimeout)
|
||
|
||
// 根据错误类型显示不同的错误信息
|
||
let errorMessage = '发送消息失败'
|
||
if (error.message.includes('超时')) {
|
||
aiMsg.content = '网络连接超时,请检查网络后重试'
|
||
errorMessage = '网络连接超时'
|
||
} else if (error.message.includes('取消')) {
|
||
aiMsg.content = '请求已取消'
|
||
errorMessage = '请求已取消'
|
||
} else if (error.message.includes('网络')) {
|
||
aiMsg.content = '网络连接异常,请检查网络设置'
|
||
errorMessage = '网络连接异常'
|
||
} else if (error.message.includes('登录')) {
|
||
aiMsg.content = '登录已过期,请重新登录'
|
||
errorMessage = '登录已过期'
|
||
} else {
|
||
aiMsg.content = '抱歉,发送消息时出现错误,请稍后重试'
|
||
}
|
||
|
||
aiMsg.streamCompleted = true // 即使出错也标记为完成,显示操作区域
|
||
this.showToast(errorMessage, 'error')
|
||
} finally {
|
||
this.sending = false
|
||
this.currentCancel = null
|
||
|
||
// 清理所有定时器
|
||
if (streamTimeout) clearTimeout(streamTimeout)
|
||
if (noDataTimeout) clearTimeout(noDataTimeout)
|
||
|
||
// 从请求队列中移除当前请求
|
||
this.requestQueue = this.requestQueue.filter(id => id !== requestId)
|
||
|
||
// 从监控系统中移除请求
|
||
this.activeRequests.delete(requestId)
|
||
|
||
this.scrollToBottom(true) // 发送完成后使用平滑滚动
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 分批渲染消息,避免一次性渲染过多消息导致卡死
|
||
*/
|
||
renderMessagesInBatches(newMessages) {
|
||
if (!newMessages || newMessages.length === 0) {
|
||
this.messages = []
|
||
return
|
||
}
|
||
|
||
// 如果消息数量较少,直接渲染
|
||
if (newMessages.length <= this.messageRenderBatch) {
|
||
this.messages = newMessages
|
||
return
|
||
}
|
||
|
||
// 分批渲染
|
||
this.messages = []
|
||
let currentIndex = 0
|
||
|
||
const renderNextBatch = () => {
|
||
if (this.isDestroyed || currentIndex >= newMessages.length) {
|
||
return
|
||
}
|
||
|
||
const endIndex = Math.min(currentIndex + this.messageRenderBatch, newMessages.length)
|
||
const batch = newMessages.slice(currentIndex, endIndex)
|
||
|
||
// 添加当前批次的消息
|
||
this.messages.push(...batch)
|
||
currentIndex = endIndex
|
||
|
||
// 如果还有更多消息,继续渲染下一批
|
||
if (currentIndex < newMessages.length) {
|
||
this.$nextTick(() => {
|
||
setTimeout(renderNextBatch, 10) // 10ms延迟,让浏览器有时间处理当前批次
|
||
})
|
||
}
|
||
}
|
||
|
||
// 开始渲染第一批
|
||
renderNextBatch()
|
||
},
|
||
|
||
/**
|
||
* 滚动到底部(优化版本)
|
||
*/
|
||
scrollToBottom(smooth = false, force = false) {
|
||
// 如果用户不在底部且不是强制滚动,则跳过
|
||
if (!force && !this.isUserAtBottom) {
|
||
return
|
||
}
|
||
|
||
// 节流处理,避免频繁滚动
|
||
if (this.scrollThrottleTimer) {
|
||
clearTimeout(this.scrollThrottleTimer)
|
||
}
|
||
|
||
this.scrollThrottleTimer = setTimeout(() => {
|
||
this.$nextTick(() => {
|
||
if (this.$refs.messageList && !this.isDestroyed) {
|
||
const messageList = this.$refs.messageList
|
||
const scrollHeight = messageList.scrollHeight
|
||
|
||
if (smooth && messageList.scrollTo && !this.isLoadingHistory) {
|
||
// 只在非加载状态时使用平滑滚动
|
||
messageList.scrollTo({
|
||
top: scrollHeight,
|
||
behavior: 'smooth'
|
||
})
|
||
} else {
|
||
// 立即滚动到底部
|
||
messageList.scrollTop = scrollHeight
|
||
}
|
||
|
||
// 更新用户位置状态
|
||
this.isUserAtBottom = true
|
||
|
||
// 延迟更新lastScrollTop
|
||
setTimeout(() => {
|
||
if (!this.isDestroyed) {
|
||
this.lastScrollTop = scrollHeight
|
||
}
|
||
}, smooth ? 300 : 50)
|
||
}
|
||
})
|
||
}, smooth ? 0 : 16) // 非平滑滚动时使用16ms节流(约60fps)
|
||
},
|
||
|
||
/**
|
||
* 监听滚动事件
|
||
*/
|
||
onScroll(e) {
|
||
const scrollTop = e.target.scrollTop
|
||
const scrollHeight = e.target.scrollHeight
|
||
const clientHeight = e.target.clientHeight
|
||
|
||
// 检测用户是否在底部(允许10px的误差)
|
||
this.isUserAtBottom = (scrollTop + clientHeight >= scrollHeight - 10)
|
||
|
||
// 检查是否需要加载历史记录
|
||
// 当滚动到距离顶部阈值范围内且向上滚动时触发加载
|
||
if (scrollTop <= this.loadThreshold &&
|
||
scrollTop < this.lastScrollTop &&
|
||
this.hasMoreHistory &&
|
||
!this.isLoadingHistory) {
|
||
|
||
// 清除之前的防抖定时器
|
||
if (this.loadDebounceTimer) {
|
||
clearTimeout(this.loadDebounceTimer)
|
||
}
|
||
|
||
// 设置防抖延迟加载
|
||
this.loadDebounceTimer = setTimeout(() => {
|
||
// 再次检查条件,确保状态没有改变
|
||
if (this.hasMoreHistory &&
|
||
!this.isLoadingHistory &&
|
||
this.$refs.messageList.scrollTop <= this.loadThreshold) {
|
||
this.loadMoreHistory()
|
||
}
|
||
}, 300) // 增加防抖时间,避免频繁触发
|
||
}
|
||
|
||
// 更新上次滚动位置(延迟更新,避免在位置调整时干扰)
|
||
setTimeout(() => {
|
||
this.lastScrollTop = scrollTop
|
||
}, 100)
|
||
},
|
||
|
||
/**
|
||
* 渲染Markdown内容(带缓存优化)
|
||
*/
|
||
renderMarkdown(text) {
|
||
if (!text) return ''
|
||
|
||
// 检查缓存
|
||
if (this.markdownCache.has(text)) {
|
||
return this.markdownCache.get(text)
|
||
}
|
||
|
||
// 渲染markdown
|
||
const html = this.md.render(text)
|
||
const sanitizedHtml = DOMPurify.sanitize(html)
|
||
|
||
// 缓存管理:如果缓存超过最大大小,删除最旧的条目
|
||
if (this.markdownCache.size >= this.maxCacheSize) {
|
||
const firstKey = this.markdownCache.keys().next().value
|
||
this.markdownCache.delete(firstKey)
|
||
}
|
||
|
||
// 添加到缓存
|
||
this.markdownCache.set(text, sanitizedHtml)
|
||
|
||
return sanitizedHtml
|
||
},
|
||
|
||
/**
|
||
* 节流更新内容
|
||
*/
|
||
throttledContentUpdate(aiMsg, newContent) {
|
||
// 检查内容是否真的有变化
|
||
if (aiMsg.content === newContent) {
|
||
return
|
||
}
|
||
|
||
// 立即更新内容(保证响应性)
|
||
aiMsg.content = newContent
|
||
|
||
// 只有当内容长度显著增加时才滚动
|
||
const contentLengthDiff = newContent.length - this.lastContentLength
|
||
if (contentLengthDiff > 20) { // 内容增加超过20个字符才滚动
|
||
this.lastContentLength = newContent.length
|
||
|
||
// 清除之前的定时器
|
||
if (this.contentUpdateTimer) {
|
||
clearTimeout(this.contentUpdateTimer)
|
||
}
|
||
|
||
// 节流滚动更新
|
||
this.contentUpdateTimer = setTimeout(() => {
|
||
if (this.isUserAtBottom && !this.isDestroyed) {
|
||
this.scrollToBottom(false)
|
||
}
|
||
}, 100) // 100ms节流,减少滚动频率
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 数字人控制方法
|
||
*/
|
||
// 开始AI思考动画
|
||
startAIThinking() {
|
||
this.isAIThinking = true
|
||
this.isAISpeaking = false
|
||
},
|
||
|
||
// 开始AI说话动画
|
||
startAISpeaking() {
|
||
this.isAIThinking = false
|
||
this.isAISpeaking = true
|
||
|
||
// 清除之前的说话定时器
|
||
if (this.speakingTimer) {
|
||
clearTimeout(this.speakingTimer)
|
||
}
|
||
},
|
||
|
||
// 停止AI动画
|
||
stopAIAnimation() {
|
||
this.isAIThinking = false
|
||
this.isAISpeaking = false
|
||
|
||
if (this.speakingTimer) {
|
||
clearTimeout(this.speakingTimer)
|
||
this.speakingTimer = null
|
||
}
|
||
},
|
||
|
||
// 眨眼动画
|
||
triggerBlink() {
|
||
if (this.isBlinking) return
|
||
|
||
this.isBlinking = true
|
||
setTimeout(() => {
|
||
this.isBlinking = false
|
||
}, 150)
|
||
},
|
||
|
||
// 启动自动眨眼
|
||
startAutoBlinking() {
|
||
if (this.blinkTimer) {
|
||
clearInterval(this.blinkTimer)
|
||
}
|
||
|
||
this.blinkTimer = setInterval(() => {
|
||
if (!this.isDestroyed) {
|
||
this.triggerBlink()
|
||
}
|
||
}, 3000 + Math.random() * 2000) // 3-5秒随机眨眼
|
||
},
|
||
|
||
// 停止自动眨眼
|
||
stopAutoBlinking() {
|
||
if (this.blinkTimer) {
|
||
clearInterval(this.blinkTimer)
|
||
this.blinkTimer = null
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 处理点赞
|
||
*/
|
||
async handleThumbUp(message) {
|
||
try {
|
||
const messageId = message.messageId.replace('ai-', '')
|
||
const currentFeedback = (message.feedback && message.feedback.rating) || message.feedback
|
||
|
||
let action, newFeedback, toastMessage
|
||
|
||
if (currentFeedback === 'like') {
|
||
// 如果已经点赞,则取消点赞
|
||
action = -1 // 假设-1表示取消反馈
|
||
newFeedback = null
|
||
toastMessage = '已取消点赞'
|
||
} else {
|
||
// 如果没有点赞或者是点踩状态,则设置为点赞
|
||
action = 1
|
||
newFeedback = 'like'
|
||
toastMessage = '感谢您的反馈!'
|
||
}
|
||
|
||
const res = await sendFeedback({
|
||
messageId: messageId,
|
||
action: action,
|
||
user: this.user
|
||
})
|
||
|
||
if (res.code === 200) {
|
||
message.feedback = newFeedback
|
||
this.showToast(toastMessage)
|
||
} else {
|
||
this.showToast('反馈失败,请稍后重试')
|
||
}
|
||
} catch (error) {
|
||
console.error('点赞操作失败:', error)
|
||
this.showToast('反馈失败,请稍后重试')
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 处理点踩
|
||
*/
|
||
async handleThumbDown(message) {
|
||
try {
|
||
const messageId = message.messageId.replace('ai-', '')
|
||
const currentFeedback = (message.feedback && message.feedback.rating) || message.feedback
|
||
|
||
let action, newFeedback, toastMessage
|
||
|
||
if (currentFeedback === 'dislike') {
|
||
// 如果已经点踩,则取消点踩
|
||
action = -1 // 假设-1表示取消反馈
|
||
newFeedback = null
|
||
toastMessage = '已取消点踩'
|
||
} else {
|
||
// 如果没有点踩或者是点赞状态,则设置为点踩
|
||
action = 0
|
||
newFeedback = 'dislike'
|
||
toastMessage = '感谢您的反馈!'
|
||
}
|
||
|
||
const res = await sendFeedback({
|
||
messageId: messageId,
|
||
action: action,
|
||
user: this.user
|
||
})
|
||
|
||
if (res.code === 200) {
|
||
message.feedback = newFeedback
|
||
this.showToast(toastMessage)
|
||
} else {
|
||
this.showToast('反馈失败,请稍后重试')
|
||
}
|
||
} catch (error) {
|
||
console.error('点踩操作失败:', error)
|
||
this.showToast('反馈失败,请稍后重试')
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 获取分组的引用来源(带缓存优化)
|
||
*/
|
||
getGroupedReferences(resources) {
|
||
if (!resources || !Array.isArray(resources)) {
|
||
return []
|
||
}
|
||
|
||
// 生成缓存键
|
||
const cacheKey = JSON.stringify(resources.map(r => ({
|
||
document_name: r.document_name,
|
||
id: r.id || r.chunk_id
|
||
})))
|
||
|
||
// 检查缓存
|
||
if (this.referencesCache.has(cacheKey)) {
|
||
return this.referencesCache.get(cacheKey)
|
||
}
|
||
|
||
// 计算分组
|
||
const grouped = {}
|
||
resources.forEach(resource => {
|
||
const docName = resource.document_name || '未知文档'
|
||
if (!grouped[docName]) {
|
||
grouped[docName] = []
|
||
}
|
||
grouped[docName].push(resource)
|
||
})
|
||
|
||
const result = Object.entries(grouped).map(([docName, items]) => ({
|
||
docName,
|
||
items
|
||
}))
|
||
|
||
// 缓存结果
|
||
if (this.referencesCache.size >= this.maxReferencesCacheSize) {
|
||
// 删除最旧的缓存条目
|
||
const firstKey = this.referencesCache.keys().next().value
|
||
this.referencesCache.delete(firstKey)
|
||
}
|
||
this.referencesCache.set(cacheKey, result)
|
||
|
||
return result
|
||
},
|
||
|
||
/**
|
||
* 切换单个引用的显示状态
|
||
*/
|
||
toggleSingleReference(messageId, docName) {
|
||
const key = `${messageId}-${docName}`
|
||
this.showSingleReference[key] = !this.showSingleReference[key]
|
||
this.$forceUpdate()
|
||
},
|
||
|
||
/**
|
||
* 显示提示信息
|
||
*/
|
||
showToast(title, type = 'info') {
|
||
// 这里可以集成实际的toast组件
|
||
console.log(`[${type}] ${title}`)
|
||
// 如果有Element UI的Message组件,可以使用:
|
||
// this.$message({ message: title, type: type === 'info' ? 'info' : type })
|
||
},
|
||
|
||
/**
|
||
* 关闭聊天窗口
|
||
*/
|
||
closeChat() {
|
||
this.$emit('close')
|
||
},
|
||
|
||
/**
|
||
* 处理图片加载错误
|
||
*/
|
||
handleImageError(event, type) {
|
||
const img = event.target
|
||
const avatar = img.parentElement
|
||
|
||
// 隐藏失败的图片
|
||
img.style.display = 'none'
|
||
|
||
// 创建文字头像
|
||
const textAvatar = document.createElement('div')
|
||
textAvatar.className = `avatar-text ${type === 'user' ? 'user-avatar' : 'ai-avatar'}`
|
||
textAvatar.textContent = type === 'user' ? 'U' : 'AI'
|
||
|
||
// 添加到头像容器
|
||
avatar.appendChild(textAvatar)
|
||
},
|
||
|
||
/**
|
||
* 启动性能监控
|
||
*/
|
||
startPerformanceMonitoring() {
|
||
// 监控内存使用情况
|
||
this.memoryCheckInterval = setInterval(() => {
|
||
if (performance.memory) {
|
||
const memoryUsage = performance.memory.usedJSHeapSize / 1024 / 1024 // MB
|
||
|
||
// 如果内存使用超过100MB,进行清理
|
||
if (memoryUsage > 100) {
|
||
this.performMemoryCleanup()
|
||
}
|
||
|
||
this.lastMemoryUsage = memoryUsage
|
||
}
|
||
}, 30000) // 每30秒检查一次
|
||
|
||
// 监控DOM节点数量
|
||
this.performanceMonitor = setInterval(() => {
|
||
const messageCount = this.messages.length
|
||
|
||
// 如果消息数量过多,清理旧消息
|
||
if (messageCount > 200) {
|
||
this.cleanupOldMessages()
|
||
}
|
||
}, 60000) // 每分钟检查一次
|
||
},
|
||
|
||
/**
|
||
* 停止性能监控
|
||
*/
|
||
stopPerformanceMonitoring() {
|
||
if (this.memoryCheckInterval) {
|
||
clearInterval(this.memoryCheckInterval)
|
||
this.memoryCheckInterval = null
|
||
}
|
||
|
||
if (this.performanceMonitor) {
|
||
clearInterval(this.performanceMonitor)
|
||
this.performanceMonitor = null
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 启动请求监控
|
||
*/
|
||
startRequestMonitoring() {
|
||
if (this.requestMonitorInterval) {
|
||
clearInterval(this.requestMonitorInterval)
|
||
}
|
||
|
||
this.requestMonitorInterval = setInterval(() => {
|
||
this.checkActiveRequests()
|
||
}, 5000) // 每5秒检查一次
|
||
},
|
||
|
||
/**
|
||
* 停止请求监控
|
||
*/
|
||
stopRequestMonitoring() {
|
||
if (this.requestMonitorInterval) {
|
||
clearInterval(this.requestMonitorInterval)
|
||
this.requestMonitorInterval = null
|
||
}
|
||
|
||
// 取消所有活跃请求
|
||
this.activeRequests.forEach((request, requestId) => {
|
||
if (request.cancel) {
|
||
request.cancel('组件销毁')
|
||
}
|
||
})
|
||
|
||
this.activeRequests.clear()
|
||
},
|
||
|
||
/**
|
||
* 检查活跃请求
|
||
*/
|
||
checkActiveRequests() {
|
||
const now = Date.now()
|
||
const expiredRequests = []
|
||
|
||
this.activeRequests.forEach((request, requestId) => {
|
||
const duration = now - request.startTime
|
||
|
||
// 如果请求超过最大持续时间,标记为过期
|
||
if (duration > this.maxRequestDuration) {
|
||
expiredRequests.push({ requestId, request })
|
||
}
|
||
})
|
||
|
||
// 取消过期请求
|
||
expiredRequests.forEach(({ requestId, request }) => {
|
||
console.warn(`强制取消超时请求: ${request.type}, 持续时间: ${(Date.now() - request.startTime) / 1000}秒`)
|
||
|
||
if (request.cancel) {
|
||
request.cancel('请求超时被强制取消')
|
||
}
|
||
|
||
this.activeRequests.delete(requestId)
|
||
|
||
// 显示超时提示
|
||
this.showToast('请求超时已自动取消,请重试', 'warning')
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 执行内存清理
|
||
*/
|
||
performMemoryCleanup() {
|
||
try {
|
||
// 清理引用展示状态
|
||
this.showSingleReference = {}
|
||
|
||
// 强制垃圾回收(如果浏览器支持)
|
||
if (window.gc) {
|
||
window.gc()
|
||
}
|
||
|
||
console.log('执行内存清理')
|
||
} catch (error) {
|
||
console.warn('内存清理失败:', error)
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 清理旧消息
|
||
*/
|
||
cleanupOldMessages() {
|
||
try {
|
||
// 保留最近的100条消息
|
||
if (this.messages.length > 100) {
|
||
const keepCount = 100
|
||
const removedCount = this.messages.length - keepCount
|
||
|
||
this.messages = this.messages.slice(-keepCount)
|
||
|
||
// 更新最早消息ID
|
||
const userMessages = this.messages.filter(msg => msg.sender === 'user')
|
||
if (userMessages.length > 0) {
|
||
this.earliestMessageId = userMessages[0].messageId
|
||
}
|
||
|
||
console.log(`清理了 ${removedCount} 条旧消息`)
|
||
}
|
||
} catch (error) {
|
||
console.warn('清理旧消息失败:', error)
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 设置全局错误处理
|
||
*/
|
||
setupErrorHandling() {
|
||
// 捕获未处理的Promise错误
|
||
window.addEventListener('unhandledrejection', this.handleUnhandledRejection)
|
||
|
||
// 捕获全局JavaScript错误
|
||
window.addEventListener('error', this.handleGlobalError)
|
||
|
||
// Vue错误处理
|
||
this.$options.errorCaptured = this.handleVueError
|
||
},
|
||
|
||
/**
|
||
* 处理未处理的Promise拒绝
|
||
*/
|
||
handleUnhandledRejection(event) {
|
||
console.error('未处理的Promise错误:', event.reason)
|
||
|
||
// 防止错误冒泡导致页面崩溃
|
||
event.preventDefault()
|
||
|
||
// 如果是网络错误,显示友好提示
|
||
if (event.reason && (event.reason.message || '').includes('Network')) {
|
||
this.showToast('网络连接异常,请检查网络设置', 'error')
|
||
} else {
|
||
this.showToast('操作失败,请稍后重试', 'error')
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 处理全局JavaScript错误
|
||
*/
|
||
handleGlobalError(event) {
|
||
console.error('全局JavaScript错误:', event.error)
|
||
|
||
// 防止错误导致页面白屏
|
||
event.preventDefault()
|
||
|
||
// 如果是内存相关错误,执行清理
|
||
if (event.error && event.error.message &&
|
||
(event.error.message.includes('memory') || event.error.message.includes('Maximum call stack'))) {
|
||
this.performMemoryCleanup()
|
||
this.showToast('系统资源不足,已自动清理', 'warning')
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 处理Vue组件错误
|
||
*/
|
||
handleVueError(err, instance, info) {
|
||
console.error('Vue组件错误:', err, info)
|
||
|
||
// 如果是渲染错误,尝试重置状态
|
||
if (info && info.includes('render')) {
|
||
this.$nextTick(() => {
|
||
this.$forceUpdate()
|
||
})
|
||
}
|
||
|
||
return false // 阻止错误继续传播
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 聊天弹窗容器 */
|
||
.chat-popup {
|
||
position: fixed;
|
||
bottom: 45px;
|
||
right: 60px;
|
||
width: 400px;
|
||
height: 600px;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 8px 32px rgba(0, 0, 0, 0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 1000;
|
||
overflow: hidden;
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
transform-origin: bottom right;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
/* 导航栏 */
|
||
.navbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 15px 20px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border-radius: 16px 16px 0 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.navbar::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||
animation: shimmer 3s infinite;
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% {
|
||
left: -100%;
|
||
}
|
||
|
||
100% {
|
||
left: 100%;
|
||
}
|
||
}
|
||
|
||
/* 导航栏左侧容器 */
|
||
.nav-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.nav-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 数字人头像容器 */
|
||
.digital-avatar {
|
||
position: relative;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
transition: all 0.3s ease;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.digital-avatar:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.digital-avatar.thinking {
|
||
animation: thinking-pulse 2s infinite;
|
||
}
|
||
|
||
.digital-avatar.speaking {
|
||
animation: speaking-glow 0.5s infinite alternate;
|
||
}
|
||
|
||
/* 头像发光效果 */
|
||
.avatar-glow {
|
||
position: absolute;
|
||
top: -2px;
|
||
left: -2px;
|
||
right: -2px;
|
||
bottom: -2px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||
opacity: 0;
|
||
z-index: -1;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.digital-avatar.speaking .avatar-glow {
|
||
opacity: 0.6;
|
||
animation: glow-pulse 1s infinite;
|
||
}
|
||
|
||
/* 头像面部 */
|
||
.avatar-face {
|
||
position: relative;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 眼睛容器 */
|
||
.avatar-eyes {
|
||
display: flex;
|
||
gap: 6px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
/* 眼睛 */
|
||
.eye {
|
||
width: 4px;
|
||
height: 4px;
|
||
background: #333;
|
||
border-radius: 50%;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.eye.blink {
|
||
height: 1px;
|
||
background: #666;
|
||
}
|
||
|
||
/* 嘴巴 */
|
||
.avatar-mouth {
|
||
width: 8px;
|
||
height: 3px;
|
||
background: #333;
|
||
border-radius: 0 0 8px 8px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.avatar-mouth.talking {
|
||
animation: mouth-talk 0.3s infinite alternate;
|
||
}
|
||
|
||
/* 动画定义 */
|
||
@keyframes thinking-pulse {
|
||
0%, 100% {
|
||
box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.4);
|
||
}
|
||
50% {
|
||
box-shadow: 0 0 0 8px rgba(79, 172, 254, 0);
|
||
}
|
||
}
|
||
|
||
@keyframes speaking-glow {
|
||
0% {
|
||
box-shadow: 0 0 10px rgba(79, 172, 254, 0.5);
|
||
}
|
||
100% {
|
||
box-shadow: 0 0 20px rgba(79, 172, 254, 0.8);
|
||
}
|
||
}
|
||
|
||
@keyframes glow-pulse {
|
||
0%, 100% {
|
||
transform: scale(1);
|
||
opacity: 0.6;
|
||
}
|
||
50% {
|
||
transform: scale(1.1);
|
||
opacity: 0.8;
|
||
}
|
||
}
|
||
|
||
@keyframes mouth-talk {
|
||
0% {
|
||
height: 3px;
|
||
width: 8px;
|
||
}
|
||
100% {
|
||
height: 6px;
|
||
width: 10px;
|
||
}
|
||
}
|
||
|
||
.nav-close {
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
transition: all 0.2s ease;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
.nav-close:hover {
|
||
background-color: rgba(255, 255, 255, 0.2);
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.nav-close:active {
|
||
transform: scale(0.95);
|
||
background-color: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
/* 消息列表 */
|
||
.message-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
background: #f8f9fa;
|
||
/* 强制禁用平滑滚动,确保立即定位 */
|
||
scroll-behavior: auto !important;
|
||
/* 优化滚动条样式 */
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #c1c1c1 transparent;
|
||
}
|
||
|
||
.message-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.message-list::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.message-list::-webkit-scrollbar-thumb {
|
||
background-color: #c1c1c1;
|
||
border-radius: 3px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.message-list::-webkit-scrollbar-thumb:hover {
|
||
background-color: #a1a1a1;
|
||
}
|
||
|
||
/* 加载提示 */
|
||
.loading-history {
|
||
text-align: center;
|
||
padding: 15px 10px;
|
||
color: #666;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
border-radius: 8px;
|
||
margin: 10px 0;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
animation: fadeInOut 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
.loading-text {
|
||
font-size: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.loading-text::before {
|
||
content: '';
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid #667eea;
|
||
border-top: 2px solid transparent;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
/* 初始化失败样式 */
|
||
.init-failed {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 20px;
|
||
background: rgba(255, 249, 249, 0.95);
|
||
border-radius: 8px;
|
||
margin: 16px;
|
||
border: 1px solid #ffccc7;
|
||
}
|
||
|
||
.failed-text {
|
||
color: #ff4d4f;
|
||
font-size: 14px;
|
||
margin-bottom: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
.retry-button {
|
||
background: #1890ff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
padding: 8px 16px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.retry-button:hover {
|
||
background: #40a9ff;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.retry-button:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes fadeInOut {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
50% {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.no-more-history {
|
||
text-align: center;
|
||
padding: 10px;
|
||
color: #999;
|
||
}
|
||
|
||
.no-more-text {
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 消息项 */
|
||
.message-item {
|
||
margin-bottom: 20px;
|
||
animation: messageSlideIn 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||
transform-origin: left center;
|
||
}
|
||
|
||
@keyframes messageSlideIn {
|
||
0% {
|
||
opacity: 0;
|
||
transform: translateY(20px) scale(0.95);
|
||
}
|
||
|
||
60% {
|
||
opacity: 0.8;
|
||
transform: translateY(-2px) scale(1.01);
|
||
}
|
||
|
||
100% {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
/* 用户消息 */
|
||
.user-message {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
}
|
||
|
||
.user-message .message-content {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 12px 16px;
|
||
border-radius: 18px 18px 4px 18px;
|
||
max-width: 70%;
|
||
word-wrap: break-word;
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.user-message .message-content::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||
animation: messageShimmer 2s infinite;
|
||
}
|
||
|
||
@keyframes messageShimmer {
|
||
0% {
|
||
left: -100%;
|
||
}
|
||
|
||
100% {
|
||
left: 100%;
|
||
}
|
||
}
|
||
|
||
/* AI消息 */
|
||
.ai-message {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
}
|
||
|
||
.ai-message .message-content {
|
||
background: linear-gradient(145deg, #ffffff 0%, #f8f9fa 100%);
|
||
border: 1px solid #e1e5e9;
|
||
padding: 12px 16px;
|
||
border-radius: 18px 18px 18px 4px;
|
||
max-width: 70%;
|
||
word-wrap: break-word;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.ai-message .message-content:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
/* 头像 */
|
||
.avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.avatar img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
/* 文字头像 */
|
||
.avatar-text {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
color: white;
|
||
text-align: center;
|
||
line-height: 1;
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.user-avatar {
|
||
background: linear-gradient(135deg, #409eff 0%, #1890ff 100%);
|
||
}
|
||
|
||
.ai-avatar {
|
||
background: linear-gradient(135deg, #67c23a 0%, #52c41a 100%);
|
||
}
|
||
|
||
/* AI提示 */
|
||
.ai-prompt {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-bottom: 8px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 消息内容 */
|
||
.content-text {
|
||
line-height: 1.5;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 引用来源 */
|
||
.reference-sources {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.reference-title {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-bottom: 8px;
|
||
font-weight: 500;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.reference-title::before {
|
||
content: "📄";
|
||
font-size: 14px;
|
||
}
|
||
|
||
.reference-group {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.reference-doc {
|
||
font-size: 12px;
|
||
color: #1890ff;
|
||
cursor: pointer;
|
||
padding: 6px 10px;
|
||
background: #f0f8ff;
|
||
border-radius: 6px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-bottom: 6px;
|
||
transition: all 0.2s;
|
||
border: 1px solid #e6f4ff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.reference-doc:hover {
|
||
background: #e6f4ff;
|
||
border-color: #bae0ff;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.1);
|
||
}
|
||
|
||
.reference-doc::after {
|
||
content: "▼";
|
||
font-size: 10px;
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.reference-doc.expanded::after {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.reference-details {
|
||
background: #fafafa;
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
margin-top: 6px;
|
||
border: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.reference-segment {
|
||
font-size: 12px;
|
||
color: #666;
|
||
line-height: 1.5;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.segment-content {
|
||
margin-bottom: 6px;
|
||
background: white;
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
border-left: 3px solid #1890ff;
|
||
}
|
||
|
||
.segment-divider {
|
||
height: 1px;
|
||
background: #e8e8e8;
|
||
margin: 8px 0;
|
||
}
|
||
|
||
/* AI操作区域 */
|
||
.ai-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.ai-text {
|
||
color: #999;
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
flex: 1;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.action-icons {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.action-icon {
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
transition: all 0.2s;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.action-icon:hover {
|
||
opacity: 1;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.action-icon.active {
|
||
opacity: 1;
|
||
background: #e6f4ff;
|
||
border: 1px solid #1890ff;
|
||
color: #1890ff;
|
||
}
|
||
|
||
.action-svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
transition: all 0.2s;
|
||
opacity: 0.6;
|
||
stroke: currentColor;
|
||
}
|
||
|
||
.action-icon.active .action-svg {
|
||
opacity: 1;
|
||
stroke: #1890ff;
|
||
fill: rgba(24, 144, 255, 0.1);
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* 输入框区域 */
|
||
.input-area {
|
||
padding: 20px;
|
||
background: linear-gradient(145deg, #ffffff 0%, #f8f9fa 100%);
|
||
border-top: 1px solid #e1e5e9;
|
||
backdrop-filter: blur(10px);
|
||
position: relative;
|
||
}
|
||
|
||
.input-area::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(102, 126, 234, 0.3), transparent);
|
||
}
|
||
|
||
.input-container {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.message-input {
|
||
flex: 1;
|
||
padding: 12px 16px;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 20px;
|
||
outline: none;
|
||
font-size: 14px;
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
.message-input:focus {
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.message-input:disabled {
|
||
background: #f5f5f5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.send-button {
|
||
padding: 12px 20px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 20px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.send-button::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||
transition: left 0.5s;
|
||
}
|
||
|
||
.send-button:hover:not(:disabled) {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.send-button:hover:not(:disabled)::before {
|
||
left: 100%;
|
||
}
|
||
|
||
.send-button:active:not(:disabled) {
|
||
transform: translateY(0);
|
||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||
}
|
||
|
||
.send-button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* Markdown 内容样式 */
|
||
.content-text h1,
|
||
.content-text h2,
|
||
.content-text h3,
|
||
.content-text h4,
|
||
.content-text h5,
|
||
.content-text h6 {
|
||
margin: 16px 0 8px 0;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.content-text h1 {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.content-text h2 {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.content-text h3 {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.content-text h4 {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.content-text h5 {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.content-text h6 {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.content-text p {
|
||
margin: 8px 0;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.content-text code {
|
||
background: #f5f5f5;
|
||
padding: 2px 4px;
|
||
border-radius: 3px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.content-text pre {
|
||
background: #f5f5f5;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
.content-text pre code {
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.content-text ul,
|
||
.content-text ol {
|
||
margin: 8px 0;
|
||
padding-left: 20px;
|
||
}
|
||
|
||
.content-text li {
|
||
margin: 4px 0;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.content-text>>>a {
|
||
color: #1890ff !important;
|
||
text-decoration: none;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.content-text>>>a:hover {
|
||
color: #40a9ff !important;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.content-text>>>a:visited {
|
||
color: #722ed1 !important;
|
||
}
|
||
|
||
/* 消息限制提示样式 */
|
||
.message-limit-notice {
|
||
background: #f6f8fa;
|
||
border: 1px solid #e1e4e8;
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
margin: 16px 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.notice-text {
|
||
color: #586069;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.notice-subtext {
|
||
color: #959da5;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 768px) {
|
||
.chat-popup {
|
||
width: 90vw;
|
||
height: 80vh;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.navbar {
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.message-list {
|
||
padding: 16px;
|
||
}
|
||
|
||
.input-area {
|
||
padding: 16px;
|
||
}
|
||
|
||
.user-message .message-content,
|
||
.ai-message .message-content {
|
||
max-width: 85%;
|
||
}
|
||
|
||
.avatar {
|
||
width: 32px;
|
||
height: 32px;
|
||
}
|
||
|
||
.action-icon {
|
||
width: 20px;
|
||
height: 20px;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
</style>
|