AiQAChat.vue 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896
  1. <script setup>
  2. import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
  3. import { ArrowLeft, ArrowRight, ArrowUp, DocumentCopy, Paperclip, Plus, RefreshRight, VideoPause } from '@element-plus/icons-vue'
  4. import { useAudioPlayer } from '@/api/tts/useAudioPlayer.js'
  5. import VoiceInputDoubao from '@/components/ai/voice/VoiceInputDoubao.vue'
  6. import MarkdownView from '@/components/MarkdownView/index.vue'
  7. import { Message } from '@/utils/message/Message.js'
  8. const props = defineProps({
  9. config: { type: Object, required: true },
  10. createDialogueApi: { type: Function, required: true },
  11. sendChatStreamApi: { type: Function, required: true }
  12. })
  13. const DEFAULT_AI_QA_CONFIG = Object.freeze({
  14. roleId: null,
  15. maxFileSizeMB: 20,
  16. maxFiles: Infinity,
  17. maxTotalFileSizeMB: Infinity,
  18. invalidFileNamePattern: null,
  19. invalidFileNameMessage: '文件名包含非法字符,请修改后重试',
  20. enableReferenceScript: false,
  21. referenceScriptMaxLength: 2000,
  22. maxInputLength: Infinity,
  23. acceptedFileExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx'],
  24. uploadHint: '',
  25. inputHint: '发消息,或上传附件...',
  26. enableFileUpload: false,
  27. enableContext: true,
  28. enableVoicePlayback: true,
  29. typewriterDelay: 28,
  30. fastTypewriterDelay: 8,
  31. maxInputHeight: 180,
  32. voiceLanguage: 'zh-CN',
  33. voiceMaxDuration: 30,
  34. suggestions: [
  35. '开始陪练',
  36. '你都知道什么',
  37. '我需要知识点....'
  38. ]
  39. })
  40. const VBP_CITATION_RESOURCE = Object.freeze({
  41. documentTitle: 'VBP执行期的生存法则逐字稿',
  42. documentUrl: `/qsfl/${encodeURIComponent('《VBP执行期的生存法则》逐字稿.pdf')}`,
  43. videoTitle: 'VBP执行期的生存法则相关视频',
  44. videoUrl: `/qsfl/${encodeURIComponent('VBP执行期的生存法则相关视频.mp4')}`
  45. })
  46. const AI_QA_CONFIG = Object.freeze({ ...DEFAULT_AI_QA_CONFIG, ...props.config })
  47. const uploadEnabled = computed(() => AI_QA_CONFIG.enableFileUpload === true)
  48. const hasInputLengthLimit = computed(() => Number.isFinite(AI_QA_CONFIG.maxInputLength) && AI_QA_CONFIG.maxInputLength >= 0)
  49. const referenceScriptEnabled = computed(() => AI_QA_CONFIG.enableReferenceScript === true)
  50. const conversationStorageKey = `ai-qa-conversation-id:${AI_QA_CONFIG.roleId}`
  51. const conversationHistoryStorageKey = `ai-qa-conversation-history:${AI_QA_CONFIG.roleId}`
  52. const input = ref('')
  53. const referenceScript = ref('')
  54. const referenceScriptVisible = ref(false)
  55. const currentCitation = ref(null)
  56. const citationDialogVisible = ref(false)
  57. const currentCitationDocument = ref(null)
  58. const citationDocumentDialogVisible = ref(false)
  59. const currentCitationVideo = ref(null)
  60. const citationVideoDialogVisible = ref(false)
  61. const textareaRef = ref(null)
  62. const digitalHumanVideoRef = ref(null)
  63. const messages = ref([])
  64. const attachments = ref([])
  65. const messagesRef = ref(null)
  66. const fileInputRef = ref(null)
  67. const conversationId = ref(null)
  68. const conversations = ref([])
  69. const historyDrawerOpen = ref(true)
  70. const isCreatingConversation = ref(false)
  71. const initialConversationType = ref('qa')
  72. const isSending = ref(false)
  73. const isVoiceRecording = ref(false)
  74. const isTyping = ref(false)
  75. const isAudioSpeaking = ref(false)
  76. const voicePlaybackEnabled = ref(AI_QA_CONFIG.enableVoicePlayback)
  77. const abortController = ref(null)
  78. const userHasScrolled = ref(false)
  79. const preserveMessageScroll = ref(false)
  80. const { playAudioChunk, stopPlayback, setOnPlaybackComplete, getIsPlaying } = useAudioPlayer()
  81. let typewriterTimer = null
  82. let typewriterMessage = null
  83. let typewriterFullText = ''
  84. let typewriterIndex = 0
  85. let activeVoiceMessage = null
  86. let persistTimer = null
  87. const hasConversation = computed(() => Boolean(conversationId.value))
  88. const hasMessages = computed(() => messages.value.length > 0)
  89. const canSend = computed(() => hasConversation.value && (input.value.trim() || (uploadEnabled.value && attachments.value.length)) && !isSending.value && !isTyping.value)
  90. const welcomeTitle = computed(() => initialConversationType.value === 'practice' ? '您要去拜访谁呢?' : '有什么可以帮您?')
  91. const getCachedConversationId = () => {
  92. try {
  93. return localStorage.getItem(conversationStorageKey)
  94. } catch (error) {
  95. console.warn('读取 AI 会话缓存失败', error)
  96. return null
  97. }
  98. }
  99. const cacheConversationId = (id) => {
  100. try {
  101. localStorage.setItem(conversationStorageKey, id)
  102. } catch (error) {
  103. console.warn('保存 AI 会话缓存失败', error)
  104. }
  105. }
  106. const clearCachedConversationId = () => {
  107. try {
  108. localStorage.removeItem(conversationStorageKey)
  109. } catch (error) {
  110. console.warn('清除 AI 会话缓存失败', error)
  111. }
  112. }
  113. const serializeMessages = () => messages.value.map((message) => ({
  114. id: message.id,
  115. type: message.type,
  116. content: message.content || '',
  117. segmentIds: message.segmentIds || [],
  118. segments: message.segments || [],
  119. pending: false,
  120. showVoiceStop: false,
  121. hasAudio: Boolean(message.hasAudio),
  122. audioStreamEnded: Boolean(message.audioStreamEnded),
  123. referenceScript: message.referenceScript || '',
  124. attachments: message.attachments?.map(({ id, name, size, type, url }) => ({ id, name, size, type, url })) || []
  125. }))
  126. const saveConversationList = () => {
  127. try {
  128. localStorage.setItem(conversationHistoryStorageKey, JSON.stringify(conversations.value))
  129. } catch (error) {
  130. console.warn('保存 AI 历史记录失败', error)
  131. }
  132. }
  133. const loadConversationList = () => {
  134. try {
  135. const stored = JSON.parse(localStorage.getItem(conversationHistoryStorageKey) || '[]')
  136. conversations.value = Array.isArray(stored)
  137. ? stored.filter((item) => item?.id).map((item) => ({
  138. id: item.id,
  139. title: item.title || '未命名对话',
  140. updatedAt: Number(item.updatedAt) || Date.now(),
  141. messages: Array.isArray(item.messages) ? item.messages : []
  142. })).sort((a, b) => b.updatedAt - a.updatedAt)
  143. : []
  144. } catch (error) {
  145. console.warn('读取 AI 历史记录失败', error)
  146. conversations.value = []
  147. }
  148. }
  149. const getConversationTitle = () => {
  150. const firstQuestion = messages.value.find((message) => message.type === 'user')
  151. if (!firstQuestion) return ''
  152. return firstQuestion.content?.trim() || firstQuestion.attachments?.[0]?.name || '附件问答'
  153. }
  154. const persistConversation = () => {
  155. if (!conversationId.value) return
  156. const title = getConversationTitle()
  157. if (!title) return
  158. const record = {
  159. id: conversationId.value,
  160. title: title.slice(0, 42),
  161. updatedAt: Date.now(),
  162. messages: serializeMessages()
  163. }
  164. const index = conversations.value.findIndex((item) => item.id === record.id)
  165. if (index >= 0) conversations.value.splice(index, 1)
  166. conversations.value.unshift(record)
  167. saveConversationList()
  168. }
  169. const schedulePersistConversation = () => {
  170. if (persistTimer) window.clearTimeout(persistTimer)
  171. persistTimer = window.setTimeout(() => {
  172. persistTimer = null
  173. persistConversation()
  174. }, 180)
  175. }
  176. const formatHistoryTime = (value) => {
  177. const date = new Date(value)
  178. const today = new Date()
  179. if (date.toDateString() === today.toDateString()) {
  180. return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  181. }
  182. return `${date.getMonth() + 1}/${date.getDate()}`
  183. }
  184. const makeConversation = async () => {
  185. if (conversationId.value || isCreatingConversation.value) return conversationId.value
  186. isCreatingConversation.value = true
  187. try {
  188. if (!AI_QA_CONFIG.roleId) {
  189. Message().warning('请先选择数字人角色!', true)
  190. return null
  191. }
  192. const result = await props.createDialogueApi({ roleId: AI_QA_CONFIG.roleId })
  193. conversationId.value = result?.data
  194. if (!conversationId.value) throw new Error('未获取到会话标识')
  195. cacheConversationId(conversationId.value)
  196. return conversationId.value
  197. } catch (error) {
  198. console.error('创建 AI 会话失败', error)
  199. Message().error('AI 服务暂时不可用,请稍后重试', true)
  200. return null
  201. } finally {
  202. isCreatingConversation.value = false
  203. }
  204. }
  205. const createInitialConversation = (type) => {
  206. initialConversationType.value = type
  207. return makeConversation()
  208. }
  209. const restoreActiveConversation = () => {
  210. const cachedId = getCachedConversationId()
  211. const target = conversations.value.find((item) => item.id === cachedId) || conversations.value[0]
  212. if (target) selectConversation(target, false)
  213. }
  214. const selectConversation = async (conversation, stopCurrent = true) => {
  215. if (!conversation?.id || conversation.id === conversationId.value) return
  216. if (stopCurrent) stopResponse()
  217. conversationId.value = conversation.id
  218. cacheConversationId(conversation.id)
  219. messages.value = conversation.messages.map((message) => ({
  220. ...message,
  221. segments: mergeSegments([], message.segments),
  222. pending: false,
  223. showVoiceStop: false,
  224. hasAudio: false,
  225. audioStreamEnded: true
  226. }))
  227. input.value = ''
  228. attachments.value = []
  229. referenceScript.value = ''
  230. referenceScriptVisible.value = false
  231. userHasScrolled.value = false
  232. await scrollToBottom(true)
  233. }
  234. const chooseSuggestion = (suggestion) => {
  235. input.value = hasInputLengthLimit.value ? suggestion.slice(0, AI_QA_CONFIG.maxInputLength) : suggestion
  236. nextTick(() => textareaRef.value?.focus())
  237. }
  238. const scrollToBottom = async (force = false) => {
  239. if (userHasScrolled.value && !force) return
  240. await nextTick()
  241. if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight
  242. }
  243. const handleScroll = () => {
  244. const node = messagesRef.value
  245. if (!node) return
  246. userHasScrolled.value = node.scrollHeight - node.scrollTop - node.clientHeight > 72
  247. }
  248. const resizeTextarea = async () => {
  249. await nextTick()
  250. const textarea = textareaRef.value
  251. if (!textarea) return
  252. textarea.style.height = 'auto'
  253. const height = Math.min(textarea.scrollHeight, AI_QA_CONFIG.maxInputHeight)
  254. textarea.style.height = `${height}px`
  255. textarea.style.overflowY = textarea.scrollHeight > AI_QA_CONFIG.maxInputHeight ? 'auto' : 'hidden'
  256. }
  257. const renderNextCharacter = () => {
  258. if (!typewriterMessage) return
  259. if (typewriterIndex >= typewriterFullText.length) {
  260. typewriterTimer = null
  261. isTyping.value = false
  262. return
  263. }
  264. typewriterMessage.content += typewriterFullText.charAt(typewriterIndex)
  265. typewriterMessage.pending = false
  266. typewriterIndex += 1
  267. const backlog = typewriterFullText.length - typewriterIndex
  268. const delay = backlog > 24 ? AI_QA_CONFIG.fastTypewriterDelay : AI_QA_CONFIG.typewriterDelay
  269. scrollToBottom()
  270. typewriterTimer = window.setTimeout(renderNextCharacter, delay)
  271. }
  272. const appendStreamText = (message, text) => {
  273. typewriterMessage = message
  274. typewriterFullText += text
  275. message.pending = false
  276. message.showVoiceStop = true
  277. if (!isTyping.value) {
  278. isTyping.value = true
  279. renderNextCharacter()
  280. }
  281. }
  282. const isReferenceBlock = (content = '') => /^引用资料[::]/.test(content.trimStart())
  283. const mergeSegments = (oldList = [], newList = []) => {
  284. const segmentMap = new Map()
  285. for (const item of [...oldList, ...newList]) {
  286. if (item?.id != null) segmentMap.set(item.id, item)
  287. }
  288. return Array.from(segmentMap.values())
  289. }
  290. const mergeSegmentIds = (oldList = [], newList = []) => {
  291. return Array.from(new Set([...oldList, ...newList].filter((id) => id != null)))
  292. }
  293. const collapseText = (content = '', maxLength = 160) => {
  294. return content.length > maxLength ? `${content.slice(0, maxLength)}……` : content
  295. }
  296. const getAnswerBody = (message) => {
  297. const content = message.content || ''
  298. if (!message.segments?.length) return content
  299. const referenceIndex = content.lastIndexOf('\n引用资料:')
  300. return referenceIndex >= 0 ? content.slice(0, referenceIndex) : content
  301. }
  302. const normalizeCitationName = (value = '') => String(value)
  303. .replace(/[《》\s]/g, '')
  304. .replace(/\.(doc|docx|wps)$/i, '')
  305. const getCitationResource = (segment) => {
  306. const documentName = normalizeCitationName(segment?.documentName)
  307. return documentName.includes('VBP执行期的生存法则') ? VBP_CITATION_RESOURCE : null
  308. }
  309. const currentCitationResource = computed(() => getCitationResource(currentCitation.value))
  310. const currentCitationDocumentPreviewUrl = computed(() => {
  311. return currentCitationDocument.value?.documentUrl || ''
  312. })
  313. const isCitationListExpanded = (message) => message.citationExpanded === true
  314. const toggleCitationList = async (message) => {
  315. const messageList = messagesRef.value
  316. const scrollTop = messageList?.scrollTop ?? 0
  317. preserveMessageScroll.value = true
  318. message.citationExpanded = !isCitationListExpanded(message)
  319. await nextTick()
  320. if (messageList) messageList.scrollTop = scrollTop
  321. preserveMessageScroll.value = false
  322. }
  323. const getCitationSummaryAction = (message) => {
  324. if (isCitationListExpanded(message)) return '收起引用'
  325. return message.segments.length > 1 ? `展开引用(${message.segments.length}条)` : '展开引用'
  326. }
  327. const showCitation = (segment) => {
  328. currentCitation.value = segment
  329. citationDialogVisible.value = true
  330. }
  331. const openCitationDocument = (resource) => {
  332. if (!resource?.documentUrl) return
  333. currentCitationDocument.value = resource
  334. citationDocumentDialogVisible.value = true
  335. }
  336. const downloadCitationDocument = (resource) => {
  337. if (!resource?.documentUrl) return
  338. window.open(resource.documentUrl, '_blank', 'noopener,noreferrer')
  339. }
  340. const clearCitationDocument = () => {
  341. citationDocumentDialogVisible.value = false
  342. currentCitationDocument.value = null
  343. }
  344. const playCitationVideo = (resource) => {
  345. if (!resource?.videoUrl) return
  346. currentCitationVideo.value = resource
  347. citationVideoDialogVisible.value = true
  348. }
  349. const clearCitationVideo = () => {
  350. citationVideoDialogVisible.value = false
  351. currentCitationVideo.value = null
  352. }
  353. const openCitationById = (message, segmentId) => {
  354. const segment = message.segments?.find((item) => item.id === segmentId)
  355. if (segment) showCitation(segment)
  356. }
  357. const stopTypewriter = (showAll = false) => {
  358. if (typewriterTimer) window.clearTimeout(typewriterTimer)
  359. if (showAll && typewriterMessage) {
  360. typewriterMessage.content = typewriterFullText
  361. typewriterMessage.pending = false
  362. }
  363. typewriterTimer = null
  364. typewriterMessage = null
  365. typewriterFullText = ''
  366. typewriterIndex = 0
  367. isTyping.value = false
  368. }
  369. setOnPlaybackComplete(() => {
  370. if (activeVoiceMessage?.audioStreamEnded) {
  371. isAudioSpeaking.value = false
  372. activeVoiceMessage.showVoiceStop = false
  373. activeVoiceMessage = null
  374. }
  375. })
  376. const resetDigitalHumanVideo = () => {
  377. const video = digitalHumanVideoRef.value
  378. if (!video) return
  379. video.pause()
  380. if (video.readyState >= HTMLMediaElement.HAVE_METADATA) video.currentTime = 0
  381. }
  382. watch(isAudioSpeaking, async (speaking) => {
  383. await nextTick()
  384. const video = digitalHumanVideoRef.value
  385. if (!video) return
  386. if (speaking) {
  387. video.currentTime = 0
  388. video.play().catch((error) => console.warn('数字人视频播放失败', error))
  389. } else {
  390. resetDigitalHumanVideo()
  391. }
  392. })
  393. const handleKeydown = (event) => {
  394. if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
  395. event.preventDefault()
  396. sendMessage()
  397. }
  398. }
  399. const onVoiceRecognized = ({ processedText }) => {
  400. const value = processedText || input.value
  401. input.value = hasInputLengthLimit.value ? value.slice(0, AI_QA_CONFIG.maxInputLength) : value
  402. }
  403. const getRichCopyHtml = (trigger) => {
  404. const markdownNode = trigger?.closest('.message-card')?.querySelector('.markdown-view')
  405. if (!markdownNode) return null
  406. const copyNode = markdownNode.cloneNode(true)
  407. copyNode.querySelectorAll('table').forEach((table) => Object.assign(table.style, { width: '100%', borderCollapse: 'collapse', border: '1px solid #B7C3D4' }))
  408. copyNode.querySelectorAll('th').forEach((cell) => Object.assign(cell.style, { padding: '8px 10px', border: '1px solid #B7C3D4', backgroundColor: '#E8F0FC', fontWeight: 'bold', textAlign: 'left' }))
  409. copyNode.querySelectorAll('td').forEach((cell) => Object.assign(cell.style, { padding: '8px 10px', border: '1px solid #B7C3D4', textAlign: 'left', verticalAlign: 'top' }))
  410. return copyNode.innerHTML
  411. }
  412. const copyMessageContent = async (content, event) => {
  413. if (!content) return
  414. try {
  415. const markdownNode = event.currentTarget.closest('.message-card')?.querySelector('.markdown-view')
  416. const richHtml = getRichCopyHtml(event.currentTarget)
  417. const plainText = markdownNode?.innerText || content
  418. if (richHtml && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
  419. await navigator.clipboard.write([new ClipboardItem({ 'text/plain': new Blob([plainText], { type: 'text/plain' }), 'text/html': new Blob([richHtml], { type: 'text/html' }) })])
  420. } else if (navigator.clipboard?.writeText) {
  421. await navigator.clipboard.writeText(content)
  422. }
  423. Message().success('已复制 AI 回复', true)
  424. } catch (error) {
  425. console.error('复制 AI 回复失败', error)
  426. Message().error('复制失败,请手动选择内容复制', true)
  427. }
  428. }
  429. const getRequestContent = (content, script) => script ? `评分参考话术:\n${script}\n\n用户问题:\n${content}` : content
  430. const selectFile = () => fileInputRef.value?.click()
  431. const formatSize = (size) => size < 1024 * 1024 ? `${Math.max(1, Math.round(size / 1024))} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`
  432. const getFileExtension = (fileName) => fileName.split('.').pop()?.toLowerCase() || ''
  433. const isSupportedFile = (file) => file.type?.startsWith('image/') || AI_QA_CONFIG.acceptedFileExtensions.includes(getFileExtension(file.name))
  434. const hasInvalidFileName = (fileName) => AI_QA_CONFIG.invalidFileNamePattern?.test(fileName)
  435. const getAttachmentSize = () => attachments.value.reduce((total, file) => total + file.size, 0)
  436. const handleFileChange = (event) => {
  437. const file = event.target.files?.[0]
  438. event.target.value = ''
  439. if (!file || !uploadEnabled.value) return
  440. if (hasInvalidFileName(file.name)) return Message().warning(AI_QA_CONFIG.invalidFileNameMessage, true)
  441. if (!isSupportedFile(file)) return Message().warning('仅支持图片、PDF、Word、Excel 文件', true)
  442. if (attachments.value.length >= AI_QA_CONFIG.maxFiles) return Message().warning(`单次最多上传 ${AI_QA_CONFIG.maxFiles} 个附件`, true)
  443. if (file.size > AI_QA_CONFIG.maxFileSizeMB * 1024 * 1024) return Message().warning(`附件大小不能超过 ${AI_QA_CONFIG.maxFileSizeMB} MB`, true)
  444. if (getAttachmentSize() + file.size > AI_QA_CONFIG.maxTotalFileSizeMB * 1024 * 1024) return Message().warning(`附件总大小不能超过 ${AI_QA_CONFIG.maxTotalFileSizeMB} MB`, true)
  445. attachments.value.push({ id: `${Date.now()}-${Math.random()}`, name: file.name, size: file.size, type: file.type, file })
  446. }
  447. const removeAttachment = (index) => attachments.value.splice(index, 1)
  448. const sendMessage = async () => {
  449. if (!canSend.value) return
  450. if (hasInputLengthLimit.value && input.value.length > AI_QA_CONFIG.maxInputLength) return Message().warning(`单次提问最多输入 ${AI_QA_CONFIG.maxInputLength.toLocaleString()} 个字符`, true)
  451. stopPlayback()
  452. stopTypewriter()
  453. const content = input.value.trim()
  454. const script = referenceScript.value.trim()
  455. const files = [...attachments.value]
  456. const id = await makeConversation()
  457. if (!id) return
  458. input.value = ''
  459. attachments.value = []
  460. userHasScrolled.value = false
  461. messages.value.push({ type: 'user', content: content || '请查看我上传的附件。', attachments: files, referenceScript: script })
  462. messages.value.push({
  463. id: null,
  464. type: 'assistant',
  465. content: '',
  466. segmentIds: [],
  467. segments: [],
  468. referenceBlockStarted: false,
  469. pending: true,
  470. showVoiceStop: false,
  471. hasAudio: false,
  472. audioStreamEnded: false
  473. })
  474. const assistantMessage = messages.value[messages.value.length - 1]
  475. isSending.value = true
  476. abortController.value = new AbortController()
  477. await scrollToBottom(true)
  478. typewriterMessage = assistantMessage
  479. typewriterFullText = ''
  480. typewriterIndex = 0
  481. try {
  482. await props.sendChatStreamApi(id, getRequestContent(content, script), null, abortController.value, AI_QA_CONFIG.enableContext, async (event) => {
  483. try {
  484. const result = JSON.parse(event.data)
  485. if (result.code !== 0) throw new Error(result.msg || '对话服务返回异常')
  486. const payload = result.data
  487. if (payload?.eventType === 'TEXT' && payload.receive) {
  488. const receive = payload.receive
  489. if (assistantMessage.id == null && receive.id != null) {
  490. assistantMessage.id = receive.id
  491. }
  492. if (receive.segments?.length) {
  493. assistantMessage.segments = mergeSegments(
  494. assistantMessage.segments,
  495. receive.segments
  496. )
  497. assistantMessage.segmentIds = mergeSegmentIds(
  498. assistantMessage.segmentIds,
  499. receive.segments.map((item) => item.id)
  500. )
  501. assistantMessage.pending = false
  502. }
  503. if (receive.segmentIds?.length) {
  504. assistantMessage.segmentIds = mergeSegmentIds(
  505. assistantMessage.segmentIds,
  506. receive.segmentIds
  507. )
  508. }
  509. if (isReferenceBlock(receive.content)) {
  510. assistantMessage.referenceBlockStarted = true
  511. return
  512. }
  513. if (!assistantMessage.referenceBlockStarted && receive.content) {
  514. appendStreamText(assistantMessage, receive.content)
  515. }
  516. } else if (payload?.eventType === 'AUDIO' && payload.audioData && voicePlaybackEnabled.value) {
  517. assistantMessage.hasAudio = true
  518. assistantMessage.showVoiceStop = true
  519. activeVoiceMessage = assistantMessage
  520. isAudioSpeaking.value = true
  521. await playAudioChunk(payload.audioData)
  522. }
  523. } catch (error) {
  524. console.error('解析 AI 返回内容失败', error)
  525. }
  526. }, (error) => {
  527. if (abortController.value?.signal.aborted) return
  528. assistantMessage.content ||= '抱歉,服务连接出现问题,请稍后再试。'
  529. assistantMessage.pending = false
  530. throw error
  531. }, () => {
  532. assistantMessage.content ||= '暂未收到有效回复,请换一种方式提问。'
  533. assistantMessage.pending = false
  534. assistantMessage.audioStreamEnded = true
  535. if (!assistantMessage.hasAudio || !getIsPlaying()) {
  536. assistantMessage.showVoiceStop = false
  537. if (activeVoiceMessage === assistantMessage) activeVoiceMessage = null
  538. isAudioSpeaking.value = false
  539. }
  540. }, voicePlaybackEnabled.value, files)
  541. } catch (error) {
  542. if (!abortController.value?.signal.aborted) {
  543. assistantMessage.content ||= '抱歉,服务连接出现问题,请稍后再试。'
  544. assistantMessage.pending = false
  545. }
  546. } finally {
  547. isSending.value = false
  548. abortController.value = null
  549. persistConversation()
  550. scrollToBottom()
  551. }
  552. }
  553. const stopResponse = () => {
  554. abortController.value?.abort()
  555. stopPlayback()
  556. isAudioSpeaking.value = false
  557. if (activeVoiceMessage) activeVoiceMessage.showVoiceStop = false
  558. activeVoiceMessage = null
  559. stopTypewriter(true)
  560. isSending.value = false
  561. const last = messages.value[messages.value.length - 1]
  562. if (last?.type === 'assistant' && last.pending) {
  563. last.pending = false
  564. last.content ||= '已停止生成。'
  565. }
  566. }
  567. const stopVoicePlayback = (message) => {
  568. stopPlayback()
  569. isAudioSpeaking.value = false
  570. message.showVoiceStop = false
  571. if (activeVoiceMessage === message) activeVoiceMessage = null
  572. }
  573. const toggleVoicePlayback = () => {
  574. voicePlaybackEnabled.value = !voicePlaybackEnabled.value
  575. if (!voicePlaybackEnabled.value) stopVoicePlayback(activeVoiceMessage || { showVoiceStop: false })
  576. }
  577. const startNewConversation = () => {
  578. persistConversation()
  579. stopResponse()
  580. messages.value = []
  581. attachments.value = []
  582. input.value = ''
  583. referenceScript.value = ''
  584. referenceScriptVisible.value = false
  585. initialConversationType.value = 'qa'
  586. conversationId.value = null
  587. clearCachedConversationId()
  588. userHasScrolled.value = false
  589. nextTick(() => textareaRef.value?.focus())
  590. }
  591. watch(messages, () => {
  592. if (!preserveMessageScroll.value) scrollToBottom()
  593. schedulePersistConversation()
  594. }, { deep: true })
  595. watch(input, resizeTextarea)
  596. onMounted(() => {
  597. loadConversationList()
  598. restoreActiveConversation()
  599. resizeTextarea()
  600. })
  601. onUnmounted(() => {
  602. if (persistTimer) window.clearTimeout(persistTimer)
  603. persistConversation()
  604. stopResponse()
  605. stopPlayback()
  606. })
  607. </script>
  608. <template>
  609. <main class="ai-qa-page">
  610. <header class="page-header">
  611. <div class="brand">
  612. <span class="brand-mark"><img src="@/assets/images/ai-qa/lighthouse-icon-transparent.png" alt="" /></span>
  613. <span>非临 AI 助教</span>
  614. </div>
  615. <div class="header-actions">
  616. <button class="header-toggle" :class="{ active: voicePlaybackEnabled }" type="button" @click="toggleVoicePlayback">
  617. <span class="speaker-dot"></span>{{ voicePlaybackEnabled ? '语音播报已开启' : '语音播报已关闭' }}
  618. </button>
  619. <button class="new-conversation" type="button" @click="startNewConversation">
  620. <el-icon><RefreshRight /></el-icon><span>新建对话</span>
  621. </button>
  622. </div>
  623. </header>
  624. <section class="qa-workspace" :class="{ 'history-collapsed': !historyDrawerOpen }">
  625. <aside class="conversation-history">
  626. <div class="history-heading">
  627. <div><span class="history-overline">HISTORY</span><h2>历史问答</h2></div>
  628. <button class="history-drawer-toggle" type="button" :title="historyDrawerOpen ? '收起历史记录' : '展开历史记录'" @click="historyDrawerOpen = !historyDrawerOpen"><el-icon><ArrowLeft v-if="historyDrawerOpen" /><ArrowRight v-else /></el-icon></button>
  629. </div>
  630. <button class="history-new" type="button" @click="startNewConversation"><el-icon><Plus /></el-icon>发起新的问答</button>
  631. <div v-if="conversations.length" class="history-list">
  632. <button v-for="conversation in conversations" :key="conversation.id" class="history-item" :class="{ active: conversation.id === conversationId }" type="button" @click="selectConversation(conversation)">
  633. <span class="history-item-title">{{ conversation.title }}</span>
  634. <span class="history-item-time">{{ formatHistoryTime(conversation.updatedAt) }}</span>
  635. </button>
  636. </div>
  637. <div v-else class="history-empty"><span>暂无历史记录</span><small>你的问答将在此处保存</small></div>
  638. </aside>
  639. <section class="right-workspace">
  640. <aside class="digital-human-panel" :class="{ speaking: isAudioSpeaking }">
  641. <div class="digital-human-copy"><span>AI ASSISTANT</span><h2>非临 AI 助教</h2><p>随时为您解答健康与服务问题</p></div>
  642. <div class="digital-human-stage">
  643. <span class="stage-orbit orbit-one"></span><span class="stage-orbit orbit-two"></span>
  644. <video
  645. ref="digitalHumanVideoRef"
  646. class="digital-human-video"
  647. src="@/assets/images/ai-qa/ai-qa.mp4"
  648. poster="@/assets/images/ai-qa/ai-qa-first-frame.png"
  649. muted
  650. playsinline
  651. preload="auto"
  652. @loadedmetadata="resetDigitalHumanVideo"
  653. @ended="resetDigitalHumanVideo"
  654. ></video>
  655. </div>
  656. <div class="digital-human-status"><i></i>{{ isAudioSpeaking ? '正在为您播报' : '在线为您服务' }}</div>
  657. </aside>
  658. <section class="chat-panel">
  659. <div v-if="!hasConversation" class="welcome empty-conversation">
  660. <p class="eyebrow">AI ASSISTANT</p><h1>开始一次新的问答</h1>
  661. <p class="welcome-copy">向非临 AI 助教描述您的问题,获得及时的智能解答。</p>
  662. <div class="initial-conversation-actions">
  663. <button class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="createInitialConversation('qa')"><el-icon><Plus /></el-icon>智能问答</button>
  664. <button class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="createInitialConversation('practice')"><el-icon><Plus /></el-icon>智能陪练</button>
  665. </div>
  666. </div>
  667. <div v-else-if="!hasMessages" class="welcome">
  668. <p class="eyebrow">AI ASSISTANT</p><h1>{{ welcomeTitle }}</h1>
  669. <p class="welcome-copy">请输入问题,您的本次对话将自动保存到左侧历史记录。</p>
  670. <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>
  671. </div>
  672. <div v-else ref="messagesRef" class="messages" @scroll="handleScroll">
  673. <article v-for="(message, index) in messages" :key="index" class="message-row" :class="message.type">
  674. <div v-if="message.type === 'assistant'" class="assistant-badge"><img src="@/assets/images/ai-qa/lighthouse-icon-transparent.png" alt="AI" /></div>
  675. <div class="message-card">
  676. <div v-if="message.type === 'assistant' && message.pending" class="thinking"><i></i><i></i><i></i><span>正在思考</span></div>
  677. <template v-else-if="message.type === 'assistant'">
  678. <MarkdownView
  679. :content="getAnswerBody(message)"
  680. theme="dark"
  681. :citation-ids="message.segments?.map((item) => item.id) || []"
  682. @citation-click="openCitationById(message, $event)"
  683. />
  684. <div v-if="message.segments?.length" class="citation-list">
  685. <button class="citation-summary" type="button" @click="toggleCitationList(message)">
  686. <span class="citation-summary-name">《{{ message.segments[0]?.documentName || '知识库文档' }}》</span>
  687. <span class="citation-summary-toggle">{{ getCitationSummaryAction(message) }}</span>
  688. </button>
  689. <div v-if="isCitationListExpanded(message)" class="citation-list-content">
  690. <div class="citation-title">引用资料</div>
  691. <div v-for="segment in message.segments" :key="segment.id" class="citation-item">
  692. <button class="citation-main" type="button" @click="showCitation(segment)">
  693. <div class="citation-document">[S{{ segment.id }}] 《{{ segment.documentName || '知识库文档' }}》</div>
  694. <div class="citation-content">{{ collapseText(segment.content) }}</div>
  695. </button>
  696. <div v-if="getCitationResource(segment)" class="citation-resource-actions">
  697. <button class="citation-resource-button" type="button" @click="openCitationDocument(getCitationResource(segment))">在线查看文档</button>
  698. <button class="citation-resource-button" type="button" @click="playCitationVideo(getCitationResource(segment))">在线观看视频</button>
  699. </div>
  700. </div>
  701. </div>
  702. </div>
  703. </template>
  704. <p v-else class="user-content">{{ message.content }}</p>
  705. <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>
  706. <div v-if="message.referenceScript" class="reference-script-summary"><span>评分参考话术</span><p>{{ message.referenceScript }}</p></div>
  707. <button v-if="voicePlaybackEnabled && message.type === 'assistant' && message.showVoiceStop" class="stop-voice-button" type="button" @click="stopVoicePlayback(message)"><el-icon><VideoPause /></el-icon>停止播报</button>
  708. <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>
  709. </div>
  710. </article>
  711. </div>
  712. <section v-if="hasConversation" class="composer-wrap">
  713. <p v-if="uploadEnabled && AI_QA_CONFIG.uploadHint" class="composer-limit-hint">{{ AI_QA_CONFIG.uploadHint }}</p>
  714. <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>
  715. <div class="composer" :class="{ recording: isVoiceRecording, 'reference-script-open': referenceScriptEnabled && referenceScriptVisible }">
  716. <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>
  717. <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>
  718. <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>
  719. </div>
  720. </section>
  721. </section>
  722. </section>
  723. </section>
  724. <el-dialog v-model="citationDialogVisible" title="引用资料" width="min(680px, 92vw)">
  725. <template v-if="currentCitation">
  726. <p class="citation-dialog-document">[S{{ currentCitation.id }}] 《{{ currentCitation.documentName || '知识库文档' }}》</p>
  727. <p class="citation-dialog-content">{{ currentCitation.content }}</p>
  728. <div v-if="currentCitationResource" class="citation-dialog-actions">
  729. <button class="citation-resource-button" type="button" @click="openCitationDocument(currentCitationResource)">在线查看《{{ currentCitationResource.documentTitle }}》</button>
  730. <button class="citation-resource-button" type="button" @click="playCitationVideo(currentCitationResource)">播放相关视频</button>
  731. </div>
  732. </template>
  733. </el-dialog>
  734. <el-dialog v-model="citationDocumentDialogVisible" :title="currentCitationDocument?.documentTitle || '在线文档'" width="min(1100px, 96vw)" @closed="clearCitationDocument">
  735. <iframe v-if="currentCitationDocumentPreviewUrl" class="citation-document-preview" :src="currentCitationDocumentPreviewUrl" title="在线文档预览"></iframe>
  736. <div class="citation-document-fallback">
  737. 如无法加载在线预览,可<a href="#" @click.prevent="downloadCitationDocument(currentCitationDocument)">在新窗口中打开 PDF 文档</a>。
  738. </div>
  739. </el-dialog>
  740. <el-dialog v-model="citationVideoDialogVisible" :title="currentCitationVideo?.videoTitle || '相关视频'" width="min(860px, 94vw)" @closed="clearCitationVideo">
  741. <video v-if="currentCitationVideo" class="citation-video-player" :src="currentCitationVideo.videoUrl" controls autoplay playsinline>
  742. 当前浏览器不支持视频播放。
  743. </video>
  744. </el-dialog>
  745. </main>
  746. </template>
  747. <style scoped lang="scss">
  748. :global(html), :global(body), :global(#app) { width: 100%; height: 100%; margin: 0; }
  749. /* This page is an application workspace: do not inherit the marketing-page
  750. max-width and padding applied to the global #app container. */
  751. :global(#app) { box-sizing: border-box; max-width: none; padding: 0; text-align: initial; }
  752. .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; }
  753. .ai-qa-page * { box-sizing: border-box; }
  754. .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); }
  755. .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; }
  756. .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); }
  757. .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; }
  758. .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); }
  759. .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; }
  760. .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; }
  761. .assistant-message-actions { margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(188, 216, 255, .14); }
  762. .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; }
  763. .copy-message-button .el-icon { display: grid; width: 16px; height: 16px; place-items: center; font-size: 16px; }
  764. .copy-message-button:hover { border-color: rgba(169, 207, 255, .74); color: #fff; background: rgba(103, 163, 246, .3); transform: translateY(-1px); }
  765. .copy-message-button:active { transform: translateY(0); }
  766. .citation-list { margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(188, 216, 255, .14); }
  767. .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; }
  768. .citation-summary-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
  769. .citation-summary-toggle { flex: 0 0 auto; color: #8fc2ff; font-size: 11px; }
  770. .citation-list-content { padding-top: 4px; }
  771. .citation-title { margin-bottom: 8px; color: #aecaef; font-size: 12px; font-weight: 600; }
  772. .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; }
  773. .citation-item:hover { border-color: rgba(151, 202, 255, .7); background: rgba(92, 145, 230, .22); }
  774. .citation-main { display: block; width: 100%; padding: 10px; border: 0; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; }
  775. .citation-document { margin: 0; color: #b9d7ff; font-size: 12px; font-weight: 600; }
  776. .citation-dialog-document { margin: 0; color: #2864a3; font-size: 12px; font-weight: 600; }
  777. .citation-content { margin: 5px 0 0; color: rgba(233, 241, 255, .8); font-size: 12px; line-height: 1.65; white-space: pre-wrap; }
  778. .citation-dialog-content { margin: 5px 0 0; color: #3f536d; font-size: 12px; line-height: 1.65; white-space: pre-wrap; }
  779. .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); }
  780. .citation-dialog-actions { margin-top: 16px; padding: 12px 0 0; border-top-color: #e4eaf2; }
  781. .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; }
  782. .citation-resource-button:hover { border-color: rgba(169, 211, 255, .82); color: #fff; background: rgba(92, 157, 244, .36); }
  783. .citation-dialog-actions .citation-resource-button { border-color: #9fc3ed; color: #1f5f9f; background: #edf6ff; }
  784. .citation-dialog-actions .citation-resource-button:hover { border-color: #5292d1; color: #164d83; background: #dceeff; }
  785. .citation-document-preview { display: block; width: 100%; height: min(70vh, 760px); border: 0; border-radius: 8px; background: #fff; }
  786. .citation-document-fallback { margin-top: 10px; color: rgba(233, 241, 255, .72); font-size: 12px; }
  787. .citation-document-fallback a { color: #8fc2ff; }
  788. .citation-video-player { display: block; width: 100%; max-height: min(70vh, 620px); border-radius: 8px; background: #000; }
  789. :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; }
  790. :deep(.citation-mark:hover) { color: #fff; background: rgba(111, 176, 255, .35); }
  791. .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; }
  792. .history-list, .reference-script-textarea { scrollbar-width: thin; scrollbar-color: rgba(138, 188, 255, .7) rgba(7, 27, 70, .35); scrollbar-gutter: stable; }
  793. .history-list::-webkit-scrollbar, .reference-script-textarea::-webkit-scrollbar { width: 8px; }
  794. .history-list::-webkit-scrollbar-track, .reference-script-textarea::-webkit-scrollbar-track { border-radius: 999px; background: rgba(7, 27, 70, .35); }
  795. .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; }
  796. .history-list::-webkit-scrollbar-thumb:hover, .reference-script-textarea::-webkit-scrollbar-thumb:hover { background-color: rgba(180, 216, 255, .92); }
  797. .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; }
  798. .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)); }
  799. .digital-human-panel { --digital-human-panel-padding: clamp(20px, 2vw, 30px); background: linear-gradient(180deg, #103261, #0a2148); backdrop-filter: none; }
  800. .digital-human-stage { margin-inline: calc(0px - var(--digital-human-panel-padding)); overflow: hidden; background: #0e2e59; }
  801. .digital-human-video { position: absolute; inset: 0; width: 100%; height: 100%; max-height: none; object-fit: cover; filter: none; }
  802. .digital-human-stage .stage-orbit { display: none; }
  803. .digital-human-panel.speaking .digital-human-image { animation: none; }
  804. .digital-human-panel.speaking .mouth-sync { opacity: .78; animation: medical-mouth-talk .3s ease-in-out infinite alternate; }
  805. .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); }
  806. .digital-human-panel.speaking .orbit-one { animation: halo-pulse 1.1s ease-in-out infinite alternate; }
  807. .digital-human-panel.speaking .orbit-two { animation: orbit 5s linear infinite, halo-glow 1.15s ease-in-out infinite alternate; }
  808. :deep(.markdown-view p:last-child) { margin-bottom: 0; }
  809. @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; } }
  810. @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; } }
  811. @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; } }
  812. @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; } }
  813. @media (max-width: 1100px) { .digital-human-panel { --digital-human-panel-padding: 18px; } }
  814. @media (max-width: 900px) { .digital-human-panel { --digital-human-panel-padding: 14px; } }
  815. </style>