| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896 |
- <script setup>
- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
- import { ArrowLeft, ArrowRight, ArrowUp, DocumentCopy, Paperclip, Plus, RefreshRight, VideoPause } from '@element-plus/icons-vue'
- import { useAudioPlayer } from '@/api/tts/useAudioPlayer.js'
- import VoiceInputDoubao from '@/components/ai/voice/VoiceInputDoubao.vue'
- import MarkdownView from '@/components/MarkdownView/index.vue'
- import { Message } from '@/utils/message/Message.js'
- const props = defineProps({
- config: { type: Object, required: true },
- createDialogueApi: { type: Function, required: true },
- sendChatStreamApi: { type: Function, required: true }
- })
- const DEFAULT_AI_QA_CONFIG = Object.freeze({
- roleId: null,
- maxFileSizeMB: 20,
- maxFiles: Infinity,
- maxTotalFileSizeMB: Infinity,
- invalidFileNamePattern: null,
- invalidFileNameMessage: '文件名包含非法字符,请修改后重试',
- enableReferenceScript: false,
- referenceScriptMaxLength: 2000,
- maxInputLength: Infinity,
- acceptedFileExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx'],
- uploadHint: '',
- inputHint: '发消息,或上传附件...',
- enableFileUpload: false,
- enableContext: true,
- enableVoicePlayback: true,
- typewriterDelay: 28,
- fastTypewriterDelay: 8,
- maxInputHeight: 180,
- voiceLanguage: 'zh-CN',
- voiceMaxDuration: 30,
- suggestions: [
- '开始陪练',
- '你都知道什么',
- '我需要知识点....'
- ]
- })
- const VBP_CITATION_RESOURCE = Object.freeze({
- documentTitle: 'VBP执行期的生存法则逐字稿',
- documentUrl: `/qsfl/${encodeURIComponent('《VBP执行期的生存法则》逐字稿.pdf')}`,
- videoTitle: 'VBP执行期的生存法则相关视频',
- videoUrl: `/qsfl/${encodeURIComponent('VBP执行期的生存法则相关视频.mp4')}`
- })
- const AI_QA_CONFIG = Object.freeze({ ...DEFAULT_AI_QA_CONFIG, ...props.config })
- const uploadEnabled = computed(() => AI_QA_CONFIG.enableFileUpload === true)
- const hasInputLengthLimit = computed(() => Number.isFinite(AI_QA_CONFIG.maxInputLength) && AI_QA_CONFIG.maxInputLength >= 0)
- const referenceScriptEnabled = computed(() => AI_QA_CONFIG.enableReferenceScript === true)
- const conversationStorageKey = `ai-qa-conversation-id:${AI_QA_CONFIG.roleId}`
- const conversationHistoryStorageKey = `ai-qa-conversation-history:${AI_QA_CONFIG.roleId}`
- const input = ref('')
- const referenceScript = ref('')
- const referenceScriptVisible = ref(false)
- const currentCitation = ref(null)
- const citationDialogVisible = ref(false)
- const currentCitationDocument = ref(null)
- const citationDocumentDialogVisible = ref(false)
- const currentCitationVideo = ref(null)
- const citationVideoDialogVisible = ref(false)
- const textareaRef = ref(null)
- const digitalHumanVideoRef = ref(null)
- const messages = ref([])
- const attachments = ref([])
- const messagesRef = ref(null)
- const fileInputRef = ref(null)
- const conversationId = ref(null)
- const conversations = ref([])
- const historyDrawerOpen = ref(true)
- const isCreatingConversation = ref(false)
- const initialConversationType = ref('qa')
- const isSending = ref(false)
- const isVoiceRecording = ref(false)
- const isTyping = ref(false)
- const isAudioSpeaking = ref(false)
- const voicePlaybackEnabled = ref(AI_QA_CONFIG.enableVoicePlayback)
- const abortController = ref(null)
- const userHasScrolled = ref(false)
- const preserveMessageScroll = ref(false)
- const { playAudioChunk, stopPlayback, setOnPlaybackComplete, getIsPlaying } = useAudioPlayer()
- let typewriterTimer = null
- let typewriterMessage = null
- let typewriterFullText = ''
- let typewriterIndex = 0
- let activeVoiceMessage = null
- let persistTimer = null
- const hasConversation = computed(() => Boolean(conversationId.value))
- const hasMessages = computed(() => messages.value.length > 0)
- const canSend = computed(() => hasConversation.value && (input.value.trim() || (uploadEnabled.value && attachments.value.length)) && !isSending.value && !isTyping.value)
- const welcomeTitle = computed(() => initialConversationType.value === 'practice' ? '您要去拜访谁呢?' : '有什么可以帮您?')
- const getCachedConversationId = () => {
- try {
- return localStorage.getItem(conversationStorageKey)
- } catch (error) {
- console.warn('读取 AI 会话缓存失败', error)
- return null
- }
- }
- const cacheConversationId = (id) => {
- try {
- localStorage.setItem(conversationStorageKey, id)
- } catch (error) {
- console.warn('保存 AI 会话缓存失败', error)
- }
- }
- const clearCachedConversationId = () => {
- try {
- localStorage.removeItem(conversationStorageKey)
- } catch (error) {
- console.warn('清除 AI 会话缓存失败', error)
- }
- }
- const serializeMessages = () => messages.value.map((message) => ({
- id: message.id,
- type: message.type,
- content: message.content || '',
- segmentIds: message.segmentIds || [],
- segments: message.segments || [],
- pending: false,
- showVoiceStop: false,
- hasAudio: Boolean(message.hasAudio),
- audioStreamEnded: Boolean(message.audioStreamEnded),
- referenceScript: message.referenceScript || '',
- attachments: message.attachments?.map(({ id, name, size, type, url }) => ({ id, name, size, type, url })) || []
- }))
- const saveConversationList = () => {
- try {
- localStorage.setItem(conversationHistoryStorageKey, JSON.stringify(conversations.value))
- } catch (error) {
- console.warn('保存 AI 历史记录失败', error)
- }
- }
- const loadConversationList = () => {
- try {
- const stored = JSON.parse(localStorage.getItem(conversationHistoryStorageKey) || '[]')
- conversations.value = Array.isArray(stored)
- ? stored.filter((item) => item?.id).map((item) => ({
- id: item.id,
- title: item.title || '未命名对话',
- updatedAt: Number(item.updatedAt) || Date.now(),
- messages: Array.isArray(item.messages) ? item.messages : []
- })).sort((a, b) => b.updatedAt - a.updatedAt)
- : []
- } catch (error) {
- console.warn('读取 AI 历史记录失败', error)
- conversations.value = []
- }
- }
- const getConversationTitle = () => {
- const firstQuestion = messages.value.find((message) => message.type === 'user')
- if (!firstQuestion) return ''
- return firstQuestion.content?.trim() || firstQuestion.attachments?.[0]?.name || '附件问答'
- }
- const persistConversation = () => {
- if (!conversationId.value) return
- const title = getConversationTitle()
- if (!title) return
- const record = {
- id: conversationId.value,
- title: title.slice(0, 42),
- updatedAt: Date.now(),
- messages: serializeMessages()
- }
- const index = conversations.value.findIndex((item) => item.id === record.id)
- if (index >= 0) conversations.value.splice(index, 1)
- conversations.value.unshift(record)
- saveConversationList()
- }
- const schedulePersistConversation = () => {
- if (persistTimer) window.clearTimeout(persistTimer)
- persistTimer = window.setTimeout(() => {
- persistTimer = null
- persistConversation()
- }, 180)
- }
- const formatHistoryTime = (value) => {
- const date = new Date(value)
- const today = new Date()
- if (date.toDateString() === today.toDateString()) {
- return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
- }
- return `${date.getMonth() + 1}/${date.getDate()}`
- }
- const makeConversation = async () => {
- if (conversationId.value || isCreatingConversation.value) return conversationId.value
- isCreatingConversation.value = true
- try {
- if (!AI_QA_CONFIG.roleId) {
- Message().warning('请先选择数字人角色!', true)
- return null
- }
- const result = await props.createDialogueApi({ roleId: AI_QA_CONFIG.roleId })
- conversationId.value = result?.data
- if (!conversationId.value) throw new Error('未获取到会话标识')
- cacheConversationId(conversationId.value)
- return conversationId.value
- } catch (error) {
- console.error('创建 AI 会话失败', error)
- Message().error('AI 服务暂时不可用,请稍后重试', true)
- return null
- } finally {
- isCreatingConversation.value = false
- }
- }
- const createInitialConversation = (type) => {
- initialConversationType.value = type
- return makeConversation()
- }
- const restoreActiveConversation = () => {
- const cachedId = getCachedConversationId()
- const target = conversations.value.find((item) => item.id === cachedId) || conversations.value[0]
- if (target) selectConversation(target, false)
- }
- const selectConversation = async (conversation, stopCurrent = true) => {
- if (!conversation?.id || conversation.id === conversationId.value) return
- if (stopCurrent) stopResponse()
- conversationId.value = conversation.id
- cacheConversationId(conversation.id)
- messages.value = conversation.messages.map((message) => ({
- ...message,
- segments: mergeSegments([], message.segments),
- pending: false,
- showVoiceStop: false,
- hasAudio: false,
- audioStreamEnded: true
- }))
- input.value = ''
- attachments.value = []
- referenceScript.value = ''
- referenceScriptVisible.value = false
- userHasScrolled.value = false
- await scrollToBottom(true)
- }
- const chooseSuggestion = (suggestion) => {
- input.value = hasInputLengthLimit.value ? suggestion.slice(0, AI_QA_CONFIG.maxInputLength) : suggestion
- nextTick(() => textareaRef.value?.focus())
- }
- const scrollToBottom = async (force = false) => {
- if (userHasScrolled.value && !force) return
- await nextTick()
- if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight
- }
- const handleScroll = () => {
- const node = messagesRef.value
- if (!node) return
- userHasScrolled.value = node.scrollHeight - node.scrollTop - node.clientHeight > 72
- }
- const resizeTextarea = async () => {
- await nextTick()
- const textarea = textareaRef.value
- if (!textarea) return
- textarea.style.height = 'auto'
- const height = Math.min(textarea.scrollHeight, AI_QA_CONFIG.maxInputHeight)
- textarea.style.height = `${height}px`
- textarea.style.overflowY = textarea.scrollHeight > AI_QA_CONFIG.maxInputHeight ? 'auto' : 'hidden'
- }
- const renderNextCharacter = () => {
- if (!typewriterMessage) return
- if (typewriterIndex >= typewriterFullText.length) {
- typewriterTimer = null
- isTyping.value = false
- return
- }
- typewriterMessage.content += typewriterFullText.charAt(typewriterIndex)
- typewriterMessage.pending = false
- typewriterIndex += 1
- const backlog = typewriterFullText.length - typewriterIndex
- const delay = backlog > 24 ? AI_QA_CONFIG.fastTypewriterDelay : AI_QA_CONFIG.typewriterDelay
- scrollToBottom()
- typewriterTimer = window.setTimeout(renderNextCharacter, delay)
- }
- const appendStreamText = (message, text) => {
- typewriterMessage = message
- typewriterFullText += text
- message.pending = false
- message.showVoiceStop = true
- if (!isTyping.value) {
- isTyping.value = true
- renderNextCharacter()
- }
- }
- const isReferenceBlock = (content = '') => /^引用资料[::]/.test(content.trimStart())
- const mergeSegments = (oldList = [], newList = []) => {
- const segmentMap = new Map()
- for (const item of [...oldList, ...newList]) {
- if (item?.id != null) segmentMap.set(item.id, item)
- }
- return Array.from(segmentMap.values())
- }
- const mergeSegmentIds = (oldList = [], newList = []) => {
- return Array.from(new Set([...oldList, ...newList].filter((id) => id != null)))
- }
- const collapseText = (content = '', maxLength = 160) => {
- return content.length > maxLength ? `${content.slice(0, maxLength)}……` : content
- }
- const getAnswerBody = (message) => {
- const content = message.content || ''
- if (!message.segments?.length) return content
- const referenceIndex = content.lastIndexOf('\n引用资料:')
- return referenceIndex >= 0 ? content.slice(0, referenceIndex) : content
- }
- const normalizeCitationName = (value = '') => String(value)
- .replace(/[《》\s]/g, '')
- .replace(/\.(doc|docx|wps)$/i, '')
- const getCitationResource = (segment) => {
- const documentName = normalizeCitationName(segment?.documentName)
- return documentName.includes('VBP执行期的生存法则') ? VBP_CITATION_RESOURCE : null
- }
- const currentCitationResource = computed(() => getCitationResource(currentCitation.value))
- const currentCitationDocumentPreviewUrl = computed(() => {
- return currentCitationDocument.value?.documentUrl || ''
- })
- const isCitationListExpanded = (message) => message.citationExpanded === true
- const toggleCitationList = async (message) => {
- const messageList = messagesRef.value
- const scrollTop = messageList?.scrollTop ?? 0
- preserveMessageScroll.value = true
- message.citationExpanded = !isCitationListExpanded(message)
- await nextTick()
- if (messageList) messageList.scrollTop = scrollTop
- preserveMessageScroll.value = false
- }
- const getCitationSummaryAction = (message) => {
- if (isCitationListExpanded(message)) return '收起引用'
- return message.segments.length > 1 ? `展开引用(${message.segments.length}条)` : '展开引用'
- }
- const showCitation = (segment) => {
- currentCitation.value = segment
- citationDialogVisible.value = true
- }
- const openCitationDocument = (resource) => {
- if (!resource?.documentUrl) return
- currentCitationDocument.value = resource
- citationDocumentDialogVisible.value = true
- }
- const downloadCitationDocument = (resource) => {
- if (!resource?.documentUrl) return
- window.open(resource.documentUrl, '_blank', 'noopener,noreferrer')
- }
- const clearCitationDocument = () => {
- citationDocumentDialogVisible.value = false
- currentCitationDocument.value = null
- }
- const playCitationVideo = (resource) => {
- if (!resource?.videoUrl) return
- currentCitationVideo.value = resource
- citationVideoDialogVisible.value = true
- }
- const clearCitationVideo = () => {
- citationVideoDialogVisible.value = false
- currentCitationVideo.value = null
- }
- const openCitationById = (message, segmentId) => {
- const segment = message.segments?.find((item) => item.id === segmentId)
- if (segment) showCitation(segment)
- }
- const stopTypewriter = (showAll = false) => {
- if (typewriterTimer) window.clearTimeout(typewriterTimer)
- if (showAll && typewriterMessage) {
- typewriterMessage.content = typewriterFullText
- typewriterMessage.pending = false
- }
- typewriterTimer = null
- typewriterMessage = null
- typewriterFullText = ''
- typewriterIndex = 0
- isTyping.value = false
- }
- setOnPlaybackComplete(() => {
- if (activeVoiceMessage?.audioStreamEnded) {
- isAudioSpeaking.value = false
- activeVoiceMessage.showVoiceStop = false
- activeVoiceMessage = null
- }
- })
- const resetDigitalHumanVideo = () => {
- const video = digitalHumanVideoRef.value
- if (!video) return
- video.pause()
- if (video.readyState >= HTMLMediaElement.HAVE_METADATA) video.currentTime = 0
- }
- watch(isAudioSpeaking, async (speaking) => {
- await nextTick()
- const video = digitalHumanVideoRef.value
- if (!video) return
- if (speaking) {
- video.currentTime = 0
- video.play().catch((error) => console.warn('数字人视频播放失败', error))
- } else {
- resetDigitalHumanVideo()
- }
- })
- const handleKeydown = (event) => {
- if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
- event.preventDefault()
- sendMessage()
- }
- }
- const onVoiceRecognized = ({ processedText }) => {
- const value = processedText || input.value
- input.value = hasInputLengthLimit.value ? value.slice(0, AI_QA_CONFIG.maxInputLength) : value
- }
- const getRichCopyHtml = (trigger) => {
- const markdownNode = trigger?.closest('.message-card')?.querySelector('.markdown-view')
- if (!markdownNode) return null
- const copyNode = markdownNode.cloneNode(true)
- copyNode.querySelectorAll('table').forEach((table) => Object.assign(table.style, { width: '100%', borderCollapse: 'collapse', border: '1px solid #B7C3D4' }))
- copyNode.querySelectorAll('th').forEach((cell) => Object.assign(cell.style, { padding: '8px 10px', border: '1px solid #B7C3D4', backgroundColor: '#E8F0FC', fontWeight: 'bold', textAlign: 'left' }))
- copyNode.querySelectorAll('td').forEach((cell) => Object.assign(cell.style, { padding: '8px 10px', border: '1px solid #B7C3D4', textAlign: 'left', verticalAlign: 'top' }))
- return copyNode.innerHTML
- }
- const copyMessageContent = async (content, event) => {
- if (!content) return
- try {
- const markdownNode = event.currentTarget.closest('.message-card')?.querySelector('.markdown-view')
- const richHtml = getRichCopyHtml(event.currentTarget)
- const plainText = markdownNode?.innerText || content
- if (richHtml && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
- await navigator.clipboard.write([new ClipboardItem({ 'text/plain': new Blob([plainText], { type: 'text/plain' }), 'text/html': new Blob([richHtml], { type: 'text/html' }) })])
- } else if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(content)
- }
- Message().success('已复制 AI 回复', true)
- } catch (error) {
- console.error('复制 AI 回复失败', error)
- Message().error('复制失败,请手动选择内容复制', true)
- }
- }
- const getRequestContent = (content, script) => script ? `评分参考话术:\n${script}\n\n用户问题:\n${content}` : content
- const selectFile = () => fileInputRef.value?.click()
- const formatSize = (size) => size < 1024 * 1024 ? `${Math.max(1, Math.round(size / 1024))} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`
- const getFileExtension = (fileName) => fileName.split('.').pop()?.toLowerCase() || ''
- const isSupportedFile = (file) => file.type?.startsWith('image/') || AI_QA_CONFIG.acceptedFileExtensions.includes(getFileExtension(file.name))
- const hasInvalidFileName = (fileName) => AI_QA_CONFIG.invalidFileNamePattern?.test(fileName)
- const getAttachmentSize = () => attachments.value.reduce((total, file) => total + file.size, 0)
- const handleFileChange = (event) => {
- const file = event.target.files?.[0]
- event.target.value = ''
- if (!file || !uploadEnabled.value) return
- if (hasInvalidFileName(file.name)) return Message().warning(AI_QA_CONFIG.invalidFileNameMessage, true)
- if (!isSupportedFile(file)) return Message().warning('仅支持图片、PDF、Word、Excel 文件', true)
- if (attachments.value.length >= AI_QA_CONFIG.maxFiles) return Message().warning(`单次最多上传 ${AI_QA_CONFIG.maxFiles} 个附件`, true)
- if (file.size > AI_QA_CONFIG.maxFileSizeMB * 1024 * 1024) return Message().warning(`附件大小不能超过 ${AI_QA_CONFIG.maxFileSizeMB} MB`, true)
- if (getAttachmentSize() + file.size > AI_QA_CONFIG.maxTotalFileSizeMB * 1024 * 1024) return Message().warning(`附件总大小不能超过 ${AI_QA_CONFIG.maxTotalFileSizeMB} MB`, true)
- attachments.value.push({ id: `${Date.now()}-${Math.random()}`, name: file.name, size: file.size, type: file.type, file })
- }
- const removeAttachment = (index) => attachments.value.splice(index, 1)
- const sendMessage = async () => {
- if (!canSend.value) return
- if (hasInputLengthLimit.value && input.value.length > AI_QA_CONFIG.maxInputLength) return Message().warning(`单次提问最多输入 ${AI_QA_CONFIG.maxInputLength.toLocaleString()} 个字符`, true)
- stopPlayback()
- stopTypewriter()
- const content = input.value.trim()
- const script = referenceScript.value.trim()
- const files = [...attachments.value]
- const id = await makeConversation()
- if (!id) return
- input.value = ''
- attachments.value = []
- userHasScrolled.value = false
- messages.value.push({ type: 'user', content: content || '请查看我上传的附件。', attachments: files, referenceScript: script })
- messages.value.push({
- id: null,
- type: 'assistant',
- content: '',
- segmentIds: [],
- segments: [],
- referenceBlockStarted: false,
- pending: true,
- showVoiceStop: false,
- hasAudio: false,
- audioStreamEnded: false
- })
- const assistantMessage = messages.value[messages.value.length - 1]
- isSending.value = true
- abortController.value = new AbortController()
- await scrollToBottom(true)
- typewriterMessage = assistantMessage
- typewriterFullText = ''
- typewriterIndex = 0
- try {
- await props.sendChatStreamApi(id, getRequestContent(content, script), null, abortController.value, AI_QA_CONFIG.enableContext, async (event) => {
- try {
- const result = JSON.parse(event.data)
- if (result.code !== 0) throw new Error(result.msg || '对话服务返回异常')
- const payload = result.data
- if (payload?.eventType === 'TEXT' && payload.receive) {
- const receive = payload.receive
- if (assistantMessage.id == null && receive.id != null) {
- assistantMessage.id = receive.id
- }
- if (receive.segments?.length) {
- assistantMessage.segments = mergeSegments(
- assistantMessage.segments,
- receive.segments
- )
- assistantMessage.segmentIds = mergeSegmentIds(
- assistantMessage.segmentIds,
- receive.segments.map((item) => item.id)
- )
- assistantMessage.pending = false
- }
- if (receive.segmentIds?.length) {
- assistantMessage.segmentIds = mergeSegmentIds(
- assistantMessage.segmentIds,
- receive.segmentIds
- )
- }
- if (isReferenceBlock(receive.content)) {
- assistantMessage.referenceBlockStarted = true
- return
- }
- if (!assistantMessage.referenceBlockStarted && receive.content) {
- appendStreamText(assistantMessage, receive.content)
- }
- } else if (payload?.eventType === 'AUDIO' && payload.audioData && voicePlaybackEnabled.value) {
- assistantMessage.hasAudio = true
- assistantMessage.showVoiceStop = true
- activeVoiceMessage = assistantMessage
- isAudioSpeaking.value = true
- await playAudioChunk(payload.audioData)
- }
- } catch (error) {
- console.error('解析 AI 返回内容失败', error)
- }
- }, (error) => {
- if (abortController.value?.signal.aborted) return
- assistantMessage.content ||= '抱歉,服务连接出现问题,请稍后再试。'
- assistantMessage.pending = false
- throw error
- }, () => {
- assistantMessage.content ||= '暂未收到有效回复,请换一种方式提问。'
- assistantMessage.pending = false
- assistantMessage.audioStreamEnded = true
- if (!assistantMessage.hasAudio || !getIsPlaying()) {
- assistantMessage.showVoiceStop = false
- if (activeVoiceMessage === assistantMessage) activeVoiceMessage = null
- isAudioSpeaking.value = false
- }
- }, voicePlaybackEnabled.value, files)
- } catch (error) {
- if (!abortController.value?.signal.aborted) {
- assistantMessage.content ||= '抱歉,服务连接出现问题,请稍后再试。'
- assistantMessage.pending = false
- }
- } finally {
- isSending.value = false
- abortController.value = null
- persistConversation()
- scrollToBottom()
- }
- }
- const stopResponse = () => {
- abortController.value?.abort()
- stopPlayback()
- isAudioSpeaking.value = false
- if (activeVoiceMessage) activeVoiceMessage.showVoiceStop = false
- activeVoiceMessage = null
- stopTypewriter(true)
- isSending.value = false
- const last = messages.value[messages.value.length - 1]
- if (last?.type === 'assistant' && last.pending) {
- last.pending = false
- last.content ||= '已停止生成。'
- }
- }
- const stopVoicePlayback = (message) => {
- stopPlayback()
- isAudioSpeaking.value = false
- message.showVoiceStop = false
- if (activeVoiceMessage === message) activeVoiceMessage = null
- }
- const toggleVoicePlayback = () => {
- voicePlaybackEnabled.value = !voicePlaybackEnabled.value
- if (!voicePlaybackEnabled.value) stopVoicePlayback(activeVoiceMessage || { showVoiceStop: false })
- }
- const startNewConversation = () => {
- persistConversation()
- stopResponse()
- messages.value = []
- attachments.value = []
- input.value = ''
- referenceScript.value = ''
- referenceScriptVisible.value = false
- initialConversationType.value = 'qa'
- conversationId.value = null
- clearCachedConversationId()
- userHasScrolled.value = false
- nextTick(() => textareaRef.value?.focus())
- }
- watch(messages, () => {
- if (!preserveMessageScroll.value) scrollToBottom()
- schedulePersistConversation()
- }, { deep: true })
- watch(input, resizeTextarea)
- onMounted(() => {
- loadConversationList()
- restoreActiveConversation()
- resizeTextarea()
- })
- onUnmounted(() => {
- if (persistTimer) window.clearTimeout(persistTimer)
- persistConversation()
- stopResponse()
- stopPlayback()
- })
- </script>
- <template>
- <main class="ai-qa-page">
- <header class="page-header">
- <div class="brand">
- <span class="brand-mark"><img src="@/assets/images/ai-qa/lighthouse-icon-transparent.png" alt="" /></span>
- <span>非临 AI 助教</span>
- </div>
- <div class="header-actions">
- <button class="header-toggle" :class="{ active: voicePlaybackEnabled }" type="button" @click="toggleVoicePlayback">
- <span class="speaker-dot"></span>{{ voicePlaybackEnabled ? '语音播报已开启' : '语音播报已关闭' }}
- </button>
- <button class="new-conversation" type="button" @click="startNewConversation">
- <el-icon><RefreshRight /></el-icon><span>新建对话</span>
- </button>
- </div>
- </header>
- <section class="qa-workspace" :class="{ 'history-collapsed': !historyDrawerOpen }">
- <aside class="conversation-history">
- <div class="history-heading">
- <div><span class="history-overline">HISTORY</span><h2>历史问答</h2></div>
- <button class="history-drawer-toggle" type="button" :title="historyDrawerOpen ? '收起历史记录' : '展开历史记录'" @click="historyDrawerOpen = !historyDrawerOpen"><el-icon><ArrowLeft v-if="historyDrawerOpen" /><ArrowRight v-else /></el-icon></button>
- </div>
- <button class="history-new" type="button" @click="startNewConversation"><el-icon><Plus /></el-icon>发起新的问答</button>
- <div v-if="conversations.length" class="history-list">
- <button v-for="conversation in conversations" :key="conversation.id" class="history-item" :class="{ active: conversation.id === conversationId }" type="button" @click="selectConversation(conversation)">
- <span class="history-item-title">{{ conversation.title }}</span>
- <span class="history-item-time">{{ formatHistoryTime(conversation.updatedAt) }}</span>
- </button>
- </div>
- <div v-else class="history-empty"><span>暂无历史记录</span><small>你的问答将在此处保存</small></div>
- </aside>
- <section class="right-workspace">
- <aside class="digital-human-panel" :class="{ speaking: isAudioSpeaking }">
- <div class="digital-human-copy"><span>AI ASSISTANT</span><h2>非临 AI 助教</h2><p>随时为您解答健康与服务问题</p></div>
- <div class="digital-human-stage">
- <span class="stage-orbit orbit-one"></span><span class="stage-orbit orbit-two"></span>
- <video
- ref="digitalHumanVideoRef"
- class="digital-human-video"
- src="@/assets/images/ai-qa/ai-qa.mp4"
- poster="@/assets/images/ai-qa/ai-qa-first-frame.png"
- muted
- playsinline
- preload="auto"
- @loadedmetadata="resetDigitalHumanVideo"
- @ended="resetDigitalHumanVideo"
- ></video>
- </div>
- <div class="digital-human-status"><i></i>{{ isAudioSpeaking ? '正在为您播报' : '在线为您服务' }}</div>
- </aside>
- <section class="chat-panel">
- <div v-if="!hasConversation" class="welcome empty-conversation">
- <p class="eyebrow">AI ASSISTANT</p><h1>开始一次新的问答</h1>
- <p class="welcome-copy">向非临 AI 助教描述您的问题,获得及时的智能解答。</p>
- <div class="initial-conversation-actions">
- <button class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="createInitialConversation('qa')"><el-icon><Plus /></el-icon>智能问答</button>
- <button class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="createInitialConversation('practice')"><el-icon><Plus /></el-icon>智能陪练</button>
- </div>
- </div>
- <div v-else-if="!hasMessages" class="welcome">
- <p class="eyebrow">AI ASSISTANT</p><h1>{{ welcomeTitle }}</h1>
- <p class="welcome-copy">请输入问题,您的本次对话将自动保存到左侧历史记录。</p>
- <div class="suggestion-list"><button v-for="suggestion in AI_QA_CONFIG.suggestions" :key="suggestion" type="button" @click="chooseSuggestion(suggestion)"><span>{{ suggestion }}</span><el-icon><ArrowUp /></el-icon></button></div>
- </div>
- <div v-else ref="messagesRef" class="messages" @scroll="handleScroll">
- <article v-for="(message, index) in messages" :key="index" class="message-row" :class="message.type">
- <div v-if="message.type === 'assistant'" class="assistant-badge"><img src="@/assets/images/ai-qa/lighthouse-icon-transparent.png" alt="AI" /></div>
- <div class="message-card">
- <div v-if="message.type === 'assistant' && message.pending" class="thinking"><i></i><i></i><i></i><span>正在思考</span></div>
- <template v-else-if="message.type === 'assistant'">
- <MarkdownView
- :content="getAnswerBody(message)"
- theme="dark"
- :citation-ids="message.segments?.map((item) => item.id) || []"
- @citation-click="openCitationById(message, $event)"
- />
- <div v-if="message.segments?.length" class="citation-list">
- <button class="citation-summary" type="button" @click="toggleCitationList(message)">
- <span class="citation-summary-name">《{{ message.segments[0]?.documentName || '知识库文档' }}》</span>
- <span class="citation-summary-toggle">{{ getCitationSummaryAction(message) }}</span>
- </button>
- <div v-if="isCitationListExpanded(message)" class="citation-list-content">
- <div class="citation-title">引用资料</div>
- <div v-for="segment in message.segments" :key="segment.id" class="citation-item">
- <button class="citation-main" type="button" @click="showCitation(segment)">
- <div class="citation-document">[S{{ segment.id }}] 《{{ segment.documentName || '知识库文档' }}》</div>
- <div class="citation-content">{{ collapseText(segment.content) }}</div>
- </button>
- <div v-if="getCitationResource(segment)" class="citation-resource-actions">
- <button class="citation-resource-button" type="button" @click="openCitationDocument(getCitationResource(segment))">在线查看文档</button>
- <button class="citation-resource-button" type="button" @click="playCitationVideo(getCitationResource(segment))">在线观看视频</button>
- </div>
- </div>
- </div>
- </div>
- </template>
- <p v-else class="user-content">{{ message.content }}</p>
- <div v-if="message.attachments?.length" class="attachment-list"><div v-for="file in message.attachments" :key="file.id" class="attachment-card"><el-icon><Paperclip /></el-icon><span class="attachment-name">{{ file.name }}</span><small>{{ formatSize(file.size) }}</small></div></div>
- <div v-if="message.referenceScript" class="reference-script-summary"><span>评分参考话术</span><p>{{ message.referenceScript }}</p></div>
- <button v-if="voicePlaybackEnabled && message.type === 'assistant' && message.showVoiceStop" class="stop-voice-button" type="button" @click="stopVoicePlayback(message)"><el-icon><VideoPause /></el-icon>停止播报</button>
- <div v-if="message.type === 'assistant' && !message.pending && getAnswerBody(message)" class="assistant-message-actions"><button class="copy-message-button" type="button" title="复制回复" @click="copyMessageContent(getAnswerBody(message), $event)"><el-icon><DocumentCopy /></el-icon></button></div>
- </div>
- </article>
- </div>
- <section v-if="hasConversation" class="composer-wrap">
- <p v-if="uploadEnabled && AI_QA_CONFIG.uploadHint" class="composer-limit-hint">{{ AI_QA_CONFIG.uploadHint }}</p>
- <div v-if="uploadEnabled && attachments.length" class="pending-files"><div v-for="(file, index) in attachments" :key="file.id" class="pending-file"><el-icon><Paperclip /></el-icon><span>{{ file.name }}</span><button type="button" aria-label="移除附件" @click="removeAttachment(index)">×</button></div></div>
- <div class="composer" :class="{ recording: isVoiceRecording, 'reference-script-open': referenceScriptEnabled && referenceScriptVisible }">
- <label v-if="referenceScriptEnabled && referenceScriptVisible" class="reference-script-field"><span class="reference-script-label">评分参考话术 <em>选填</em></span><textarea v-model="referenceScript" class="reference-script-textarea" rows="4" :maxlength="AI_QA_CONFIG.referenceScriptMaxLength" :disabled="isSending" placeholder="可填写评分规则中指定项的参考话术,AI 将据此辅助评分"></textarea><small>{{ referenceScript.length }}/{{ AI_QA_CONFIG.referenceScriptMaxLength }}</small></label>
- <textarea ref="textareaRef" v-model="input" class="ai-qa-textarea" :maxlength="hasInputLengthLimit ? AI_QA_CONFIG.maxInputLength : undefined" :placeholder="AI_QA_CONFIG.inputHint" :disabled="isSending" @keydown="handleKeydown"></textarea>
- <div class="composer-toolbar"><div class="composer-tools"><input ref="fileInputRef" class="file-input" type="file" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" @change="handleFileChange" /><button v-if="uploadEnabled" class="add-button" type="button" title="上传附件" :disabled="isSending" @click="selectFile"><el-icon><Plus /></el-icon></button><VoiceInputDoubao input-selector=".ai-qa-textarea" :lang="AI_QA_CONFIG.voiceLanguage" :max-duration="AI_QA_CONFIG.voiceMaxDuration" :disabled="isSending" @voice-recognized="onVoiceRecognized" @recording-status-changed="isVoiceRecording = $event" /><button v-if="referenceScriptEnabled" class="reference-script-toggle" :class="{ active: referenceScriptVisible }" type="button" :disabled="isSending" @click="referenceScriptVisible = !referenceScriptVisible">评分话术</button></div><button v-if="isSending || isTyping" class="send-button stop-button" type="button" @click="stopResponse"><el-icon><VideoPause /></el-icon></button><button v-else class="send-button" type="button" :disabled="!canSend" @click="sendMessage"><el-icon><ArrowUp /></el-icon></button></div>
- </div>
- </section>
- </section>
- </section>
- </section>
- <el-dialog v-model="citationDialogVisible" title="引用资料" width="min(680px, 92vw)">
- <template v-if="currentCitation">
- <p class="citation-dialog-document">[S{{ currentCitation.id }}] 《{{ currentCitation.documentName || '知识库文档' }}》</p>
- <p class="citation-dialog-content">{{ currentCitation.content }}</p>
- <div v-if="currentCitationResource" class="citation-dialog-actions">
- <button class="citation-resource-button" type="button" @click="openCitationDocument(currentCitationResource)">在线查看《{{ currentCitationResource.documentTitle }}》</button>
- <button class="citation-resource-button" type="button" @click="playCitationVideo(currentCitationResource)">播放相关视频</button>
- </div>
- </template>
- </el-dialog>
- <el-dialog v-model="citationDocumentDialogVisible" :title="currentCitationDocument?.documentTitle || '在线文档'" width="min(1100px, 96vw)" @closed="clearCitationDocument">
- <iframe v-if="currentCitationDocumentPreviewUrl" class="citation-document-preview" :src="currentCitationDocumentPreviewUrl" title="在线文档预览"></iframe>
- <div class="citation-document-fallback">
- 如无法加载在线预览,可<a href="#" @click.prevent="downloadCitationDocument(currentCitationDocument)">在新窗口中打开 PDF 文档</a>。
- </div>
- </el-dialog>
- <el-dialog v-model="citationVideoDialogVisible" :title="currentCitationVideo?.videoTitle || '相关视频'" width="min(860px, 94vw)" @closed="clearCitationVideo">
- <video v-if="currentCitationVideo" class="citation-video-player" :src="currentCitationVideo.videoUrl" controls autoplay playsinline>
- 当前浏览器不支持视频播放。
- </video>
- </el-dialog>
- </main>
- </template>
- <style scoped lang="scss">
- :global(html), :global(body), :global(#app) { width: 100%; height: 100%; margin: 0; }
- /* This page is an application workspace: do not inherit the marketing-page
- max-width and padding applied to the global #app container. */
- :global(#app) { box-sizing: border-box; max-width: none; padding: 0; text-align: initial; }
- .ai-qa-page { --line: rgba(194, 219, 255, .16); position: relative; min-height: 100vh; min-height: 100dvh; overflow: hidden; color: #edf5ff; background: linear-gradient(180deg, rgba(2, 11, 34, .38), rgba(2, 9, 28, .7)), url('@/assets/images/ai-qa/starry-sky.png') center / cover no-repeat; font-family: Inter, "Microsoft YaHei", sans-serif; }
- .ai-qa-page * { box-sizing: border-box; }
- .page-header { position: relative; z-index: 2; display: flex; height: 72px; align-items: center; justify-content: space-between; padding: 0 30px; border-bottom: 1px solid var(--line); background: rgba(3, 15, 42, .48); backdrop-filter: blur(14px); }
- .brand, .header-actions, .new-conversation, .header-toggle { display: flex; align-items: center; }.brand { gap: 10px; font-size: 18px; font-weight: 650; letter-spacing: .04em; }.brand-mark, .assistant-badge { display: grid; width: 32px; height: 32px; place-items: center; overflow: hidden; border: 1px solid rgba(211, 230, 255, .42); border-radius: 9px; background: rgba(8, 29, 69, .62); box-shadow: 0 6px 22px rgba(36, 99, 190, .26); }.brand-mark img, .assistant-badge img { width: 88%; height: 88%; object-fit: contain; }.header-actions { gap: 9px; }.header-toggle, .new-conversation { gap: 7px; min-height: 36px; padding: 0 12px; border: 1px solid rgba(210, 228, 255, .2); border-radius: 9px; color: #dceaff; background: rgba(255, 255, 255, .05); font: inherit; font-size: 12px; cursor: pointer; transition: .2s ease; }.header-toggle.active { border-color: rgba(121, 182, 255, .42); background: rgba(76, 139, 235, .16); }.new-conversation { color: #fff; background: linear-gradient(135deg, #3d83df, #3766bd); }.new-conversation:hover, .history-new:hover, .initial-conversation-button:hover { transform: translateY(-1px); filter: brightness(1.1); }.speaker-dot { width: 7px; height: 7px; border-radius: 50%; background: #7ce8c5; box-shadow: 0 0 9px #7ce8c5; }.header-toggle:not(.active) .speaker-dot { background: #8997ad; box-shadow: none; }
- .brand-mark { position: relative; isolation: isolate; border-color: rgba(220, 237, 255, .72); background: linear-gradient(145deg, rgba(112, 163, 238, .58), rgba(24, 69, 144, .86) 48%, rgba(5, 25, 73, .95)); box-shadow: inset 1px 1px 1px rgba(255, 255, 255, .42), inset -3px -4px 7px rgba(0, 14, 54, .56), 0 3px 0 rgba(7, 35, 93, .9), 0 9px 17px rgba(0, 5, 27, .48), 0 0 15px rgba(99, 171, 255, .28); transform: perspective(120px) rotateX(4deg) rotateY(-5deg); }
- .brand-mark::before { position: absolute; z-index: 1; inset: 1px 2px auto; height: 44%; border-radius: 7px 7px 45% 45%; background: linear-gradient(180deg, rgba(255, 255, 255, .38), rgba(255, 255, 255, 0)); content: ''; pointer-events: none; }
- .brand-mark img { position: relative; z-index: 2; width: 82%; height: 82%; filter: drop-shadow(0 2px 2px rgba(0, 10, 40, .7)) drop-shadow(0 0 4px rgba(210, 235, 255, .28)); transform: translateY(-.5px); }
- .qa-workspace { position: relative; z-index: 1; display: flex; height: calc(100vh - 72px); height: calc(100dvh - 72px); min-height: 560px; }.conversation-history { display: flex; width: clamp(230px, 19vw, 292px); flex: 0 0 auto; flex-direction: column; padding: 24px 15px; border-right: 1px solid var(--line); background: rgba(3, 15, 42, .54); backdrop-filter: blur(14px); transition: width .28s ease, padding .28s ease; }.history-collapsed .conversation-history { width: 48px; padding: 15px 8px; }.history-collapsed .history-heading { justify-content: center; padding: 0; }.history-collapsed .history-heading > div, .history-collapsed .history-new, .history-collapsed .history-list, .history-collapsed .history-empty { display: none; }.history-heading { display: flex; align-items: flex-start; justify-content: space-between; padding: 0 8px 17px; }.history-heading h2 { margin: 4px 0 0; font-size: 18px; }.history-overline { color: #8eaeef; font-size: 10px; letter-spacing: .16em; }.history-heading button { display: grid; width: 28px; height: 28px; place-items: center; padding: 0; border: 1px solid rgba(177, 210, 255, .26); border-radius: 8px; color: #cfe4ff; background: rgba(94, 147, 230, .12); cursor: pointer; }.history-drawer-toggle:hover { border-color: rgba(172, 211, 255, .54); background: rgba(94, 147, 230, .28); }.history-new { display: flex; align-items: center; justify-content: center; gap: 7px; height: 40px; margin: 0 3px 16px; border: 1px solid rgba(161, 201, 255, .35); border-radius: 10px; color: #eff6ff; background: rgba(89, 146, 237, .2); font: inherit; font-size: 13px; cursor: pointer; transition: .2s ease; }.history-list { display: flex; flex: 1; flex-direction: column; gap: 5px; overflow-y: auto; padding: 0 3px; }.history-item { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 11px 9px; border: 1px solid transparent; border-radius: 9px; color: rgba(223, 237, 255, .7); background: transparent; font: inherit; text-align: left; cursor: pointer; transition: .18s ease; }.history-item:hover { color: #fff; background: rgba(108, 154, 232, .12); }.history-item.active { border-color: rgba(133, 184, 255, .3); color: #fff; background: linear-gradient(100deg, rgba(74, 137, 231, .35), rgba(73, 83, 167, .16)); }.history-item-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }.history-item-time { align-self: center; color: rgba(194, 216, 248, .54); font-size: 10px; }.history-empty { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 5px; color: rgba(205, 222, 248, .48); font-size: 13px; text-align: center; }.history-empty small { font-size: 11px; }
- .right-workspace { display: flex; min-width: 0; flex: 1; padding: clamp(14px, 2vw, 28px); gap: clamp(14px, 2vw, 28px); }.digital-human-panel { display: flex; width: 30%; min-width: 240px; max-width: 390px; flex: 0 0 30%; flex-direction: column; justify-content: space-between; overflow: hidden; padding: clamp(20px, 2vw, 30px); border: 1px solid rgba(190, 220, 255, .2); border-radius: 22px; background: linear-gradient(155deg, rgba(14, 48, 105, .67), rgba(17, 34, 85, .62)); box-shadow: inset 0 1px rgba(255, 255, 255, .1), 0 22px 54px rgba(0, 0, 0, .18); backdrop-filter: blur(14px); }.digital-human-copy { position: relative; z-index: 1; }.digital-human-copy span, .eyebrow { color: #9cc4ff; font-size: 11px; font-weight: 650; letter-spacing: .18em; }.digital-human-copy h2 { margin: 8px 0; font-size: clamp(19px, 2vw, 28px); }.digital-human-copy p { margin: 0; color: rgba(224, 238, 255, .7); font-size: 13px; line-height: 1.6; }.digital-human-stage { position: relative; display: grid; min-height: 0; flex: 1; place-items: center; }.digital-human-image { position: relative; z-index: 2; width: min(100%, 330px); max-height: 430px; object-fit: contain; filter: drop-shadow(0 22px 22px rgba(0, 0, 0, .28)); transform-origin: center bottom; }.stage-orbit { position: absolute; width: 78%; aspect-ratio: 1; border: 1px solid rgba(132, 194, 255, .35); border-radius: 50%; box-shadow: inset 0 0 40px rgba(87, 149, 255, .13), 0 0 32px rgba(65, 134, 255, .12); }.orbit-two { width: 94%; border-style: dashed; opacity: .6; animation: orbit 17s linear infinite; }.mouth-sync { position: absolute; z-index: 3; top: 39.5%; left: 49.5%; width: 18%; height: 1%; border-radius: 50%; background: #742437; opacity: 0; transform: translateX(-50%); }.digital-human-panel.speaking .digital-human-image { animation: person-talk .44s ease-in-out infinite alternate; }.digital-human-panel.speaking .mouth-sync { opacity: .85; animation: mouth-talk .36s ease-in-out infinite alternate; }.digital-human-status { display: flex; align-items: center; justify-content: center; gap: 7px; color: #c8dfff; font-size: 12px; }.digital-human-status i { width: 7px; height: 7px; border-radius: 50%; background: #6de1bc; box-shadow: 0 0 10px #6de1bc; }.chat-panel { display: flex; min-width: 0; flex: 1; flex-direction: column; overflow: hidden; border: 1px solid rgba(190, 220, 255, .18); border-radius: 22px; background: rgba(5, 20, 54, .53); box-shadow: inset 0 1px rgba(255, 255, 255, .08), 0 22px 54px rgba(0, 0, 0, .16); backdrop-filter: blur(16px); }.welcome { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 36px; text-align: center; }.welcome h1 { margin: 9px 0 0; font-size: clamp(28px, 3vw, 46px); letter-spacing: -.04em; }.welcome-copy { max-width: 490px; margin: 15px 0 28px; color: rgba(226, 239, 255, .72); font-size: 14px; line-height: 1.7; }.initial-conversation-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; }.initial-conversation-button { display: inline-flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 1px solid rgba(191, 221, 255, .55); border-radius: 22px; color: #fff; background: linear-gradient(135deg, #5b98ed, #4a72ca); font: inherit; cursor: pointer; transition: .2s ease; }.suggestion-list { display: grid; width: min(100%, 680px); grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }.suggestion-list button { display: flex; min-height: 78px; align-items: flex-start; justify-content: space-between; gap: 6px; padding: 13px; border: 1px solid rgba(201, 220, 255, .18); border-radius: 12px; color: #edf3ff; background: rgba(38, 80, 150, .22); font: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: .2s ease; }.suggestion-list button:hover { transform: translateY(-2px); border-color: rgba(126, 174, 255, .7); background: rgba(39, 87, 166, .42); }.messages { flex: 1; min-height: 0; overflow-y: auto; padding: 28px clamp(18px, 3vw, 48px); scrollbar-color: rgba(159, 190, 255, .45) transparent; }.message-row { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 20px; }.message-row.user { justify-content: flex-end; }.assistant-badge { flex: 0 0 auto; width: 30px; height: 30px; margin-top: 3px; }.message-card { max-width: min(82%, 700px); padding: 12px 15px; border: 1px solid rgba(211, 226, 255, .16); border-radius: 5px 16px 16px 16px; color: #e9f1ff; background: rgba(8, 32, 78, .72); box-shadow: 0 10px 25px rgba(0, 0, 0, .1); line-height: 1.7; }.user .message-card { border: 0; border-radius: 16px 5px 16px 16px; background: linear-gradient(135deg, #3e82db, #325daf); }.user-content { margin: 0; white-space: pre-wrap; }.thinking { display: flex; align-items: center; gap: 5px; min-width: 92px; height: 26px; color: #b9cffd; font-size: 13px; }.thinking i { width: 5px; height: 5px; border-radius: 50%; background: #91b9ff; animation: pulse 1.1s infinite; }.thinking i:nth-child(2) { animation-delay: .16s; }.thinking i:nth-child(3) { animation-delay: .32s; }.attachment-list, .pending-files { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }.attachment-card, .pending-file { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 7px; width: 210px; padding: 7px 9px; border: 1px solid rgba(255, 255, 255, .2); border-radius: 8px; background: rgba(255, 255, 255, .1); font-size: 12px; }.attachment-card .el-icon { grid-row: span 2; margin-top: 3px; }.attachment-name, .pending-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attachment-card small { opacity: .7; font-size: 10px; }.assistant-message-actions { display: flex; justify-content: flex-end; margin-top: 8px; }.copy-message-button, .stop-voice-button { border: 1px solid rgba(179, 207, 255, .25); color: rgba(211, 228, 255, .82); background: rgba(130, 170, 235, .1); cursor: pointer; }.copy-message-button { display: grid; width: 27px; height: 27px; place-items: center; border-radius: 7px; }.stop-voice-button { display: inline-flex; align-items: center; gap: 5px; margin-top: 10px; padding: 5px 8px; border-radius: 7px; font: inherit; font-size: 12px; }.reference-script-summary { margin-top: 10px; padding: 8px 10px; border-left: 3px solid rgba(181, 212, 255, .72); border-radius: 4px 8px 8px 4px; background: rgba(255, 255, 255, .11); }.reference-script-summary span { font-size: 11px; font-weight: 600; }.reference-script-summary p { margin: 3px 0 0; white-space: pre-wrap; font-size: 12px; }
- .assistant-message-actions { margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(188, 216, 255, .14); }
- .copy-message-button { display: inline-grid; width: 30px; height: 30px; place-items: center; flex: 0 0 30px; padding: 0; border-radius: 8px; line-height: 1; transition: border-color .2s ease, color .2s ease, background .2s ease, transform .2s ease; }
- .copy-message-button .el-icon { display: grid; width: 16px; height: 16px; place-items: center; font-size: 16px; }
- .copy-message-button:hover { border-color: rgba(169, 207, 255, .74); color: #fff; background: rgba(103, 163, 246, .3); transform: translateY(-1px); }
- .copy-message-button:active { transform: translateY(0); }
- .citation-list { margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(188, 216, 255, .14); }
- .citation-summary { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 0; border: 0; color: #b9d7ff; background: transparent; font: inherit; text-align: left; cursor: pointer; }
- .citation-summary-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
- .citation-summary-toggle { flex: 0 0 auto; color: #8fc2ff; font-size: 11px; }
- .citation-list-content { padding-top: 4px; }
- .citation-title { margin-bottom: 8px; color: #aecaef; font-size: 12px; font-weight: 600; }
- .citation-item { width: 100%; margin-top: 8px; overflow: hidden; border: 1px solid rgba(147, 190, 255, .28); border-radius: 9px; color: #e9f2ff; background: rgba(92, 145, 230, .12); transition: .2s ease; }
- .citation-item:hover { border-color: rgba(151, 202, 255, .7); background: rgba(92, 145, 230, .22); }
- .citation-main { display: block; width: 100%; padding: 10px; border: 0; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; }
- .citation-document { margin: 0; color: #b9d7ff; font-size: 12px; font-weight: 600; }
- .citation-dialog-document { margin: 0; color: #2864a3; font-size: 12px; font-weight: 600; }
- .citation-content { margin: 5px 0 0; color: rgba(233, 241, 255, .8); font-size: 12px; line-height: 1.65; white-space: pre-wrap; }
- .citation-dialog-content { margin: 5px 0 0; color: #3f536d; font-size: 12px; line-height: 1.65; white-space: pre-wrap; }
- .citation-resource-actions, .citation-dialog-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 9px 10px; border-top: 1px solid rgba(188, 216, 255, .14); }
- .citation-dialog-actions { margin-top: 16px; padding: 12px 0 0; border-top-color: #e4eaf2; }
- .citation-resource-button { padding: 5px 9px; border: 1px solid rgba(145, 196, 255, .45); border-radius: 7px; color: #dbeeff; background: rgba(92, 157, 244, .18); font: inherit; font-size: 12px; cursor: pointer; transition: .2s ease; }
- .citation-resource-button:hover { border-color: rgba(169, 211, 255, .82); color: #fff; background: rgba(92, 157, 244, .36); }
- .citation-dialog-actions .citation-resource-button { border-color: #9fc3ed; color: #1f5f9f; background: #edf6ff; }
- .citation-dialog-actions .citation-resource-button:hover { border-color: #5292d1; color: #164d83; background: #dceeff; }
- .citation-document-preview { display: block; width: 100%; height: min(70vh, 760px); border: 0; border-radius: 8px; background: #fff; }
- .citation-document-fallback { margin-top: 10px; color: rgba(233, 241, 255, .72); font-size: 12px; }
- .citation-document-fallback a { color: #8fc2ff; }
- .citation-video-player { display: block; width: 100%; max-height: min(70vh, 620px); border-radius: 8px; background: #000; }
- :deep(.citation-mark) { display: inline; padding: 0 3px; border: 0; border-radius: 4px; color: #8fc2ff; background: rgba(111, 176, 255, .15); font: inherit; font-weight: 600; cursor: pointer; }
- :deep(.citation-mark:hover) { color: #fff; background: rgba(111, 176, 255, .35); }
- .composer-wrap { width: min(100%, 880px); margin: 0 auto; padding: 13px clamp(15px, 3vw, 28px) 18px; }.composer-limit-hint { margin: 0 10px 8px; color: rgba(215, 228, 255, .68); font-size: 11px; }.pending-files { padding: 0 7px; }.pending-file { display: flex; width: auto; max-width: 240px; align-items: center; }.pending-file button { margin-left: auto; padding: 0; border: 0; color: #dfeaff; background: transparent; font-size: 18px; cursor: pointer; }.composer { position: relative; overflow: hidden; border: 1px solid rgba(203, 224, 255, .34); border-radius: 18px; background: rgba(4, 18, 48, .82); box-shadow: inset 0 1px rgba(255, 255, 255, .08); }.composer.reference-script-open { overflow: visible; }.composer:focus-within, .composer.recording { border-color: #86aff8; box-shadow: 0 0 0 3px rgba(108, 167, 255, .13); }.ai-qa-textarea { display: block; width: 100%; min-height: 80px; padding: 15px 17px 7px; overflow-y: hidden; resize: none; border: 0; outline: 0; color: #f7faff; background: transparent; font: inherit; line-height: 1.5; }.ai-qa-textarea::placeholder { color: rgba(215, 228, 255, .5); }.composer-toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 4px 9px 9px 12px; }.composer-tools { display: flex; align-items: center; gap: 7px; }.file-input { display: none; }.add-button, .send-button { display: grid; width: 38px; height: 38px; place-items: center; padding: 0; border: 0; border-radius: 50%; color: #c4d8ff; background: rgba(147, 181, 242, .13); cursor: pointer; }.send-button { color: #fff; background: linear-gradient(145deg, #6ea5ff, #3978d9); box-shadow: 0 5px 15px rgba(58, 122, 221, .28); }.send-button:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }.stop-button { background: linear-gradient(145deg, #e84c59, #bc2845); }.reference-script-toggle { height: 30px; padding: 0 10px; border: 1px solid rgba(161, 199, 255, .28); border-radius: 15px; color: #cfe1ff; background: rgba(127, 171, 242, .1); font: inherit; font-size: 12px; cursor: pointer; }.reference-script-toggle.active { border-color: rgba(145, 190, 255, .7); background: rgba(104, 159, 244, .25); font: inherit; font-size: 12px; cursor: pointer; }.reference-script-field { position: absolute; z-index: 8; right: 0; bottom: calc(100% + 10px); left: 0; display: block; padding: 10px 12px; border: 1px solid rgba(142, 183, 255, .46); border-radius: 13px; background: rgba(15, 47, 97, .98); box-shadow: 0 16px 38px rgba(0, 0, 0, .34); }.reference-script-label { display: flex; gap: 7px; margin-bottom: 7px; font-size: 13px; font-weight: 600; }.reference-script-label em { color: #aecaef; font-size: 11px; font-style: normal; font-weight: 400; }.reference-script-textarea { display: block; width: 100%; min-height: 100px; padding: 8px 10px; overflow-y: auto; resize: none; border: 1px solid rgba(205, 224, 255, .2); border-radius: 9px; outline: 0; color: #edf5ff; background: rgba(3, 18, 49, .68); font: inherit; font-size: 13px; }.reference-script-field small { display: block; margin-top: 4px; color: rgba(201, 221, 252, .62); font-size: 11px; text-align: right; }
- .history-list, .reference-script-textarea { scrollbar-width: thin; scrollbar-color: rgba(138, 188, 255, .7) rgba(7, 27, 70, .35); scrollbar-gutter: stable; }
- .history-list::-webkit-scrollbar, .reference-script-textarea::-webkit-scrollbar { width: 8px; }
- .history-list::-webkit-scrollbar-track, .reference-script-textarea::-webkit-scrollbar-track { border-radius: 999px; background: rgba(7, 27, 70, .35); }
- .history-list::-webkit-scrollbar-thumb, .reference-script-textarea::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: rgba(138, 188, 255, .7); background-clip: padding-box; }
- .history-list::-webkit-scrollbar-thumb:hover, .reference-script-textarea::-webkit-scrollbar-thumb:hover { background-color: rgba(180, 216, 255, .92); }
- .mouth-sync { top: 34.2%; left: 49.5%; width: 12.5%; height: 1.15%; min-height: 5px; border-radius: 0 0 999px 999px; background: linear-gradient(180deg, #632b37, #963d4d); box-shadow: 0 1px 1px rgba(255, 209, 207, .22) inset; transform: translateX(-50%) scale(.72, .58); transform-origin: center top; }
- .digital-human-video { position: relative; z-index: 2; display: block; width: min(100%, 330px); max-height: 430px; object-fit: contain; filter: drop-shadow(0 22px 22px rgba(0, 0, 0, .28)); }
- .digital-human-panel { --digital-human-panel-padding: clamp(20px, 2vw, 30px); background: linear-gradient(180deg, #103261, #0a2148); backdrop-filter: none; }
- .digital-human-stage { margin-inline: calc(0px - var(--digital-human-panel-padding)); overflow: hidden; background: #0e2e59; }
- .digital-human-video { position: absolute; inset: 0; width: 100%; height: 100%; max-height: none; object-fit: cover; filter: none; }
- .digital-human-stage .stage-orbit { display: none; }
- .digital-human-panel.speaking .digital-human-image { animation: none; }
- .digital-human-panel.speaking .mouth-sync { opacity: .78; animation: medical-mouth-talk .3s ease-in-out infinite alternate; }
- .digital-human-panel.speaking .stage-orbit { border-color: rgba(139, 204, 255, .72); box-shadow: inset 0 0 44px rgba(84, 158, 255, .28), 0 0 38px rgba(71, 151, 255, .4); }
- .digital-human-panel.speaking .orbit-one { animation: halo-pulse 1.1s ease-in-out infinite alternate; }
- .digital-human-panel.speaking .orbit-two { animation: orbit 5s linear infinite, halo-glow 1.15s ease-in-out infinite alternate; }
- :deep(.markdown-view p:last-child) { margin-bottom: 0; }
- @keyframes orbit { to { transform: rotate(360deg); } } @keyframes person-talk { to { transform: translateY(-3px) rotate(-.5deg); } } @keyframes mouth-talk { to { height: 4.2%; width: 9%; } } @keyframes medical-mouth-talk { from { transform: translateX(-50%) scale(.72, .58); } to { transform: translateX(-50%) scale(1, 1.7); } } @keyframes halo-pulse { to { transform: scale(1.1); opacity: .72; } } @keyframes halo-glow { to { opacity: .98; filter: brightness(1.35); } } @keyframes pulse { 50% { transform: translateY(-3px); opacity: .45; } }
- @media (max-width: 1100px) { .digital-human-panel { min-width: 205px; padding: 18px; }.digital-human-copy h2 { font-size: 20px; }.suggestion-list { grid-template-columns: 1fr; width: min(100%, 380px); }.suggestion-list button { min-height: 54px; }.header-toggle { display: none; } }
- @media (max-width: 900px) { .conversation-history { width: 200px; padding-right: 10px; padding-left: 10px; }.right-workspace { padding: 12px; gap: 12px; }.digital-human-panel { min-width: 185px; padding: 14px; }.digital-human-copy p { font-size: 12px; }.messages { padding-right: 20px; padding-left: 20px; } }
- @media (max-width: 760px) { .page-header { height: 62px; padding: 0 15px; }.brand { font-size: 16px; }.new-conversation span { display: none; }.new-conversation { width: 36px; justify-content: center; padding: 0; }.qa-workspace { height: calc(100dvh - 62px); min-height: 0; }.conversation-history { width: 72px; padding: 15px 8px; }.history-heading { justify-content: center; padding: 0 0 14px; }.history-heading > div, .history-new { display: none; }.history-list { padding: 0; }.history-item { display: block; padding: 10px 4px; text-align: center; }.history-item-title { display: block; font-size: 0; }.history-item-title::before { color: #b7d4ff; content: '问答'; font-size: 11px; }.history-item-time { display: none; }.right-workspace { padding: 9px; gap: 0; }.digital-human-panel { display: none; }.chat-panel { border-radius: 14px; }.welcome { padding: 20px; }.welcome h1 { font-size: 28px; }.messages { padding: 20px 15px; }.message-card { max-width: 86%; }.composer-wrap { padding: 9px 10px 12px; } }
- @media (max-width: 1100px) { .digital-human-panel { --digital-human-panel-padding: 18px; } }
- @media (max-width: 900px) { .digital-human-panel { --digital-human-panel-padding: 14px; } }
- </style>
|