DialogContent.vue 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477
  1. <template>
  2. <div class="dialog-content-wrapper">
  3. <!-- 遮罩层 -->
  4. <div v-if="showMask" class="mask-layer" ref="maskLayer">
  5. <div class="play-button-container">
  6. <button class="play-button" @click="startPlayback">
  7. <el-icon class="play-icon"><VideoPlay /></el-icon>
  8. </button>
  9. </div>
  10. </div>
  11. <!-- 标题 -->
  12. <div class="title-box">
  13. <!-- 返回 -->
  14. <div class="title-left">
  15. <div class="box-icon" @click="goBackToMain">
  16. <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
  17. {{ backText }}
  18. </div>
  19. </div>
  20. <!-- 标题 -->
  21. <div class="title-center">
  22. <div class="title-text" :title="currentSection?.name">
  23. {{ currentSection?.name }}
  24. </div>
  25. </div>
  26. <!-- 左右按钮 -->
  27. <div class="title-right">
  28. <div class="arrow-icon-circle" @click="playPrevious" :class="{ 'disabled': currentSectionIndex === 0 && currentDialogueIndex === 0 }">
  29. <el-icon class="arrow-icon"><CaretLeft /></el-icon>
  30. </div>
  31. <div class="arrow-icon-circle" @click="togglePlay">
  32. <span class="play-text">{{ isPlaying ? '暂停' : '自动' }}</span>
  33. </div>
  34. <div class="arrow-icon-circle" @click="playNext" :class="{ 'disabled': currentSectionIndex === scriptData.sections.length - 1 && currentDialogueIndex === currentSection.dialogues.length - 1 }">
  35. <el-icon class="arrow-icon"><CaretRight /></el-icon>
  36. </div>
  37. </div>
  38. </div>
  39. <!-- 内容区域 -->
  40. <div class="content-box">
  41. <!-- 人物形象 -->
  42. <div
  43. v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
  44. :key="`character-${currentDialogueIndex}`"
  45. class="character"
  46. :class="{
  47. 'left': getCharacterSide(currentDialogue.roleName) === 'left',
  48. 'right': getCharacterSide(currentDialogue.roleName) === 'right'
  49. }"
  50. :style="{ backgroundImage: `url(${getCharacterImage(currentDialogue.roleName)})` }"
  51. ></div>
  52. <!-- 对话卡片 -->
  53. <div
  54. v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
  55. :key="`dialogue-${currentDialogueIndex}`"
  56. class="dialogue-card"
  57. :class="{
  58. 'left': getCharacterSide(currentDialogue.roleName) === 'left',
  59. 'right': getCharacterSide(currentDialogue.roleName) === 'right'
  60. }"
  61. >
  62. <div class="dialogue-header">
  63. <span class="role-name">{{ currentDialogue?.roleName }}</span>
  64. </div>
  65. <div class="dialogue-content" v-html="parseMarkdown(currentDialogue.content)"></div>
  66. </div>
  67. <!-- 用户输入卡片 -->
  68. <div
  69. v-if="currentDialogue.type === 'user'"
  70. class="dialogue-card user-input-card"
  71. >
  72. <div class="dialogue-header">
  73. <span class="role-name">我</span>
  74. </div>
  75. <div class="dialogue-content">
  76. <textarea
  77. :value="userInput"
  78. @input="e => userInput = e.target.value"
  79. class="user-input-textarea"
  80. placeholder="请输入内容..."
  81. @keyup.enter.exact="submitUserInput"
  82. ></textarea>
  83. <div class="input-actions">
  84. <button class="cancel-btn" @click="cancelUserInput">清空</button>
  85. <button class="submit-btn" @click="submitUserInput">发送</button>
  86. </div>
  87. </div>
  88. </div>
  89. <!-- 输入按钮区域 -->
  90. <div class="input-buttons-container" v-if="currentDialogue.type === 'user'">
  91. <!-- 语音输入按钮 -->
  92. <div class="voice-input-outer" :class="{ 'recording': isVoiceRecording }">
  93. <VoiceInput
  94. @voiceRecognized="handleVoiceRecognized"
  95. @recordingStatusChanged="handleRecordingStatusChanged"
  96. />
  97. </div>
  98. </div>
  99. </div>
  100. <img :src="currentBackgroundImage" alt="背景图" class="background-image">
  101. </div>
  102. </template>
  103. <script setup>
  104. import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
  105. import { useRouter } from 'vue-router'
  106. import { ArrowLeftBold, CaretLeft, CaretRight, Grid, VideoPlay } from '@element-plus/icons-vue'
  107. import VoiceInput from '@/components/ai/voice/VoiceInput.vue'
  108. import { marked } from 'marked'
  109. import {CreateDialogue, sendChatMessageStream} from "@/api/questions.js";
  110. import {useAudioPlayer} from "@/api/tts/useAudioPlayer.js";
  111. import {Message} from "@/utils/message/Message.js";
  112. const { playAudioChunk , stopPlayback, setOnPlaybackComplete } = useAudioPlayer();
  113. // 路由实例
  114. const router = useRouter()
  115. const props = defineProps({
  116. scriptData: {
  117. type: Object,
  118. default: () => ({
  119. title: "",
  120. sections: []
  121. })
  122. },
  123. scriptRoles: {
  124. type: Array,
  125. default: () => []
  126. },
  127. backText: {
  128. type: String,
  129. default: '返回课程'
  130. }
  131. })
  132. // 对话相关状态
  133. // 当前章节索引
  134. const currentSectionIndex = ref(0)
  135. // 当前对话索引
  136. const currentDialogueIndex = ref(0)
  137. // 是否正在播放
  138. const isPlaying = ref(false)
  139. // 用户输入内容
  140. const userInput = ref('')
  141. // 语音录音状态
  142. const isVoiceRecording = ref(false)
  143. // 实时语音识别结果
  144. const voiceRecognizedText = ref("")
  145. // 录音开始时的光标位置
  146. const recordingStartCursorPos = ref(0)
  147. // 录音开始时的原始文本
  148. const recordingStartText = ref("")
  149. // 音频对象
  150. // 背景音频
  151. const backgroundAudio = ref(null)
  152. // 对话音频
  153. const dialogueAudio = ref(null)
  154. // 遮罩层显示状态
  155. const showMask = ref(true)
  156. // 计算属性
  157. // 当前章节信息
  158. const currentSection = computed(() => {
  159. return props.scriptData.sections[currentSectionIndex.value]
  160. })
  161. // 当前对话信息
  162. const currentDialogue = computed(() => {
  163. if (!currentSection.value) return null
  164. return currentSection.value.dialogues[currentDialogueIndex.value]
  165. })
  166. const currentBackgroundImage = computed(() => {
  167. if (!currentSection.value || !currentSection.value.backgroundImage || !currentSection.value.backgroundImage.url) {
  168. return ''
  169. }
  170. return currentSection.value.backgroundImage.url
  171. })
  172. // 当前对话缓存
  173. const currentDialogueCache = ref(null)
  174. // 方法
  175. const handleVoiceRecognized = (text) => {
  176. console.log('语音识别结果:', text)
  177. if (isVoiceRecording.value) {
  178. // 在同一次录音过程中,实时更新文本框内容
  179. voiceRecognizedText.value = text
  180. const textarea = document.querySelector('.user-input-textarea')
  181. if (textarea) {
  182. // 使用录音开始时的原始文本和光标位置
  183. const startPos = recordingStartCursorPos.value
  184. const originalText = recordingStartText.value
  185. // 在光标位置插入实时识别结果
  186. userInput.value = originalText.substring(0, startPos) + text + originalText.substring(startPos)
  187. }
  188. } else {
  189. // 在录音结束时,将最终的语音内容追加到userInput.value
  190. const textarea = document.querySelector('.user-input-textarea')
  191. if (textarea) {
  192. // 使用录音开始时的光标位置和原始文本
  193. const startPos = recordingStartCursorPos.value
  194. const originalText = recordingStartText.value
  195. // 在光标位置插入文本
  196. userInput.value = originalText.substring(0, startPos) + text + originalText.substring(startPos)
  197. // 重新设置光标位置到插入文本的末尾
  198. setTimeout(() => {
  199. textarea.selectionStart = textarea.selectionEnd = startPos + text.length
  200. }, 0)
  201. } else {
  202. // 如果没有找到输入框,直接替换整个内容
  203. userInput.value = text
  204. }
  205. // 清空临时变量
  206. voiceRecognizedText.value = ""
  207. }
  208. }
  209. // 处理录音状态变化
  210. const handleRecordingStatusChanged = (isRecording) => {
  211. console.log('录音状态:', isRecording)
  212. const wasRecording = isVoiceRecording.value
  213. isVoiceRecording.value = isRecording
  214. // 如果是从未录音状态切换到录音状态,记录当前光标位置和文本内容
  215. if (!wasRecording && isRecording) {
  216. const textarea = document.querySelector('.user-input-textarea')
  217. if (textarea) {
  218. recordingStartCursorPos.value = textarea.selectionStart
  219. recordingStartText.value = userInput.value
  220. } else {
  221. recordingStartCursorPos.value = 0
  222. recordingStartText.value = userInput.value
  223. }
  224. }
  225. // 如果是从录音状态切换到非录音状态,只需要清空临时变量
  226. if (wasRecording && !isRecording) {
  227. // 清空临时变量
  228. voiceRecognizedText.value = ""
  229. }
  230. }
  231. // 提交用户输入
  232. const submitUserInput = async () => {
  233. if (userInput.value.trim()) {
  234. console.log('用户输入:', userInput.value)
  235. await createAiChart();
  236. await doSendMessage();
  237. }
  238. }
  239. // 取消用户输入
  240. const cancelUserInput = () => {
  241. userInput.value = ''
  242. }
  243. // 解析 Markdown 内容
  244. const parseMarkdown = (content) => {
  245. if (!content) return ''
  246. return marked(content)
  247. }
  248. const getCharacterSide = () => {
  249. return currentDialogueIndex.value % 2 === 0 ? 'left' : 'right'
  250. }
  251. // 根据角色ID获取角色名称
  252. const getRole = (roleName) => {
  253. return props.scriptRoles.find(r => r.name === roleName)
  254. }
  255. const getCharacterImage = (roleName) => {
  256. const role = getRole(roleName)
  257. return role ? role.avatar : ''
  258. }
  259. const stopAllAudio = () => {
  260. if (backgroundAudio.value) {
  261. backgroundAudio.value.pause()
  262. backgroundAudio.value.currentTime = 0
  263. }
  264. if (dialogueAudio.value) {
  265. dialogueAudio.value.pause()
  266. dialogueAudio.value.currentTime = 0
  267. }
  268. }
  269. const playBackgroundAudio = () => {
  270. // 停止之前的背景音
  271. if (backgroundAudio.value) {
  272. backgroundAudio.value.pause()
  273. backgroundAudio.value.currentTime = 0
  274. }
  275. // 播放当前环节的背景音
  276. if (currentSection.value?.backgroundAudio?.url && isPlaying.value) {
  277. backgroundAudio.value = new Audio(currentSection.value.backgroundAudio.url)
  278. backgroundAudio.value.loop = true
  279. backgroundAudio.value.volume = 1
  280. backgroundAudio.value.play().catch(e => console.error('背景音播放失败:', e))
  281. }
  282. }
  283. const playDialogueAudio = (isAutoPlay = false) => {
  284. // 停止之前的对话语音
  285. if (dialogueAudio.value) {
  286. dialogueAudio.value.pause()
  287. dialogueAudio.value.currentTime = 0
  288. }
  289. // 播放当前对话的语音
  290. if (currentDialogue.value?.voiceoverUrl) {
  291. const audio = new Audio(currentDialogue.value.voiceoverUrl)
  292. dialogueAudio.value = audio
  293. // 音频结束事件
  294. audio.onended = () => {
  295. // 如果是自动播放状态,继续播放下一条
  296. if (isAutoPlay && isPlaying.value) {
  297. setTimeout(() => {
  298. if (!playNext(true)) {
  299. // 播放完毕
  300. isPlaying.value = false
  301. stopAllAudio()
  302. }
  303. }, 100)
  304. }
  305. }
  306. // 播放音频
  307. audio.play().catch(e => {
  308. console.error('对话语音播放失败:', e)
  309. // 播放失败时,2秒后跳转(暂时注销,因为会导致user类型数字人回复播报跳转后语音播报报错,直接2秒跳转)
  310. // if (isAutoPlay && isPlaying.value) {
  311. // setTimeout(() => {
  312. // if (!playNext(true)) {
  313. // // 播放完毕
  314. // isPlaying.value = false
  315. // stopAllAudio()
  316. // }
  317. // }, 2000)
  318. // }
  319. })
  320. } else {
  321. // 如果没有语音文件,2秒后跳转
  322. if (isAutoPlay && isPlaying.value && currentDialogue.value?.type !== 'user') {
  323. setTimeout(() => {
  324. if (!playNext(true)) {
  325. // 播放完毕
  326. isPlaying.value = false
  327. stopAllAudio()
  328. }
  329. }, 2000)
  330. }
  331. }
  332. }
  333. const togglePlay = () => {
  334. isPlaying.value = !isPlaying.value
  335. if (isPlaying.value) {
  336. // 播放背景音
  337. playBackgroundAudio()
  338. // 开始播放序列
  339. playSequence()
  340. } else {
  341. // 暂停所有音频
  342. stopAllAudio()
  343. }
  344. }
  345. // 自动播放序列
  346. const playSequence = () => {
  347. if (!isPlaying.value) return
  348. // 如果当前是用户输入卡片,暂停播放等待用户输入
  349. if (currentDialogue.value?.type === 'user') return
  350. // 播放当前对话语音,传递isAutoPlay参数
  351. playDialogueAudio(true)
  352. }
  353. const playPrevious = () => {
  354. // 停止当前音频
  355. stopAllAudio()
  356. // 如果正在进行数字人对话,调用stopStream清理
  357. recoverQuestDialogue()
  358. stopPlayback(false) // 不触发回调
  359. if (conversationInProgress.value) {
  360. stopStream()
  361. }
  362. if (currentDialogueIndex.value > 0) {
  363. currentDialogueIndex.value--
  364. } else if (currentSectionIndex.value > 0) {
  365. currentSectionIndex.value--
  366. const section = props.scriptData.sections[currentSectionIndex.value]
  367. currentDialogueIndex.value = section.dialogues.length - 1
  368. }
  369. // 播放背景音
  370. playBackgroundAudio()
  371. // 播放当前对话语音
  372. if (isPlaying.value) {
  373. playDialogueAudio(true)
  374. } else {
  375. playDialogueAudio()
  376. }
  377. }
  378. const playNext = (isAutoPlay = false) => {
  379. // 停止当前对话语音
  380. if (dialogueAudio.value) {
  381. dialogueAudio.value.pause()
  382. dialogueAudio.value.currentTime = 0
  383. }
  384. // 如果正在进行数字人对话,调用stopStream清理
  385. recoverQuestDialogue()
  386. stopPlayback(false)
  387. if (conversationInProgress.value) {
  388. stopStream()
  389. }
  390. if (currentSection.value && currentDialogueIndex.value < currentSection.value.dialogues.length - 1) {
  391. currentDialogueIndex.value++
  392. // 根据是否为自动播放状态决定如何播放语音
  393. if (isPlaying.value) {
  394. playDialogueAudio(true)
  395. } else {
  396. playDialogueAudio()
  397. }
  398. return true
  399. } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
  400. currentSectionIndex.value++
  401. currentDialogueIndex.value = 0
  402. // 播放背景音
  403. playBackgroundAudio()
  404. // 根据是否为自动播放状态决定如何播放语音
  405. if (isPlaying.value) {
  406. playDialogueAudio(true)
  407. } else {
  408. playDialogueAudio()
  409. }
  410. return true
  411. }
  412. return false
  413. }
  414. // 返回主页按钮点击事件
  415. const goBackToMain = () => {
  416. // 停止所有音频
  417. stopAllAudio()
  418. // 跳转到 ai-general-course 页面,保持侧边栏选中状态
  419. router.push('/ai-general-course')
  420. }
  421. // 开始播放
  422. const maskLayer = ref(null)
  423. const startPlayback = () => {
  424. // 消失动画
  425. if (maskLayer.value) {
  426. maskLayer.value.classList.add('fade-out')
  427. // 等待动画完成后隐藏遮罩层
  428. setTimeout(() => {
  429. showMask.value = false
  430. }, 500)
  431. }
  432. // 播放当前对话语音
  433. playDialogueAudio()
  434. }
  435. // 键盘事件处理,键盘左右箭头控制对话
  436. const handleKeydown = (event) => {
  437. // 如果遮罩层显示,不处理键盘事件
  438. if (showMask.value) {
  439. return
  440. }
  441. // 如果当前是用户输入对话,不处理键盘事件,让默认行为生效(在输入框中左右移动光标)
  442. if (currentDialogue.value?.type === 'user') {
  443. return
  444. }
  445. // 处理左右箭头键
  446. switch (event.key) {
  447. case 'ArrowLeft':
  448. playPrevious()
  449. event.preventDefault() // 防止默认行为
  450. break
  451. case 'ArrowRight':
  452. playNext()
  453. event.preventDefault() // 防止默认行为
  454. break
  455. default:
  456. // 其他按键不做处理
  457. break
  458. }
  459. }
  460. // 监听环节变化
  461. watch(currentSectionIndex, () => {
  462. playBackgroundAudio()
  463. })
  464. // 监听 scriptData 变化(侧边栏切换话题时)
  465. watch(() => props.scriptData, (newVal, oldVal) => {
  466. if (newVal && oldVal && newVal !== oldVal) {
  467. // 停止所有音频
  468. stopAllAudio()
  469. // 如果正在进行数字人对话,调用stopStream清理
  470. recoverQuestDialogue()
  471. stopPlayback(false)
  472. if (conversationInProgress.value) {
  473. stopStream()
  474. }
  475. // 清空索引
  476. currentSectionIndex.value = 0
  477. currentDialogueIndex.value = 0
  478. // 重置播放状态
  479. isPlaying.value = false
  480. // 显示遮罩层
  481. showMask.value = true
  482. // 清空用户输入
  483. userInput.value = ''
  484. // 清空对话缓存
  485. currentDialogueCache.value = null
  486. }
  487. }, { deep: true })
  488. // 会话ID
  489. const activeConversationId = ref(null)
  490. const conversationInProgress = ref(false)
  491. const conversationInAbortController = ref()
  492. const receiveMessageFullText = ref('')
  493. //创建对话
  494. const createAiChart = async () => {
  495. let role = props.scriptRoles.find(r => r.name === currentDialogue.value.roleName)
  496. // 智能问答
  497. await CreateDialogue({ roleId: role.id })
  498. .then(res => {
  499. console.log("创建会话:", res.data);
  500. activeConversationId.value = res.data
  501. })
  502. .catch(error => {
  503. console.error('请求出错:', error)
  504. })
  505. }
  506. /** 真正执行【发送】消息操作 */
  507. const doSendMessage = async () => {
  508. // 校验
  509. if (userInput.value.length < 1) {
  510. console.error('发送失败,原因:内容为空!')
  511. return
  512. }
  513. if (activeConversationId.value == null) {
  514. console.error('还没创建对话,不能发送!')
  515. return
  516. }
  517. let userInputTemp = userInput.value;
  518. let currentDialogueTemp = currentSection.value.dialogues[currentDialogueIndex.value-1]
  519. userInputTemp += "(此内容是帮我解答的问题,问题是:" + currentDialogueTemp.content + ",回复要求:根据问题处理我回答的内容是否正确并给予鼓励或夸赞;注意请使用精简回答,尽量控制字体数量在50个字内)"
  520. // 执行发送
  521. await doSendMessageStream({
  522. conversationId: activeConversationId.value,
  523. content: userInputTemp,
  524. contentAnswer: null,
  525. })
  526. // 清空输入框
  527. userInput.value = ''
  528. }
  529. /** 显示问题回答对话 */
  530. const showQuestAnswerDialogue = () => {
  531. // 缓存当前对话
  532. currentDialogueCache.value = JSON.parse(JSON.stringify(currentDialogue.value))
  533. // 将当前对话类型设置为数字人对话
  534. currentDialogue.value.type = "digital"
  535. // 设置默认内容为"让我思考一下..."
  536. currentDialogue.value.content = "让我思考一下..."
  537. }
  538. //延时恢复对话,避免立即回复导致对话内容被覆盖
  539. const delayRecoverQuestDialogue = () => {
  540. // Message().error('当前网络无反应,请稍后重试!', true);
  541. // 设置默认内容为"让我思考一下..."
  542. currentDialogue.value.content = "当前网络无反应,请稍后重试!"
  543. setTimeout(() => {
  544. recoverQuestDialogue()
  545. }, 1500)
  546. }
  547. /** 回复对话 */
  548. const recoverQuestDialogue = () => {
  549. // 如果有缓存的对话
  550. if (currentDialogueCache.value){
  551. // 恢复当前对话
  552. const currentSection = props.scriptData.sections[currentSectionIndex.value]
  553. if (currentSection) {
  554. currentSection.dialogues[currentDialogueIndex.value] = JSON.parse(JSON.stringify(currentDialogueCache.value))
  555. }
  556. // 清空缓存
  557. currentDialogueCache.value = null
  558. }
  559. }
  560. /** 真正执行【发送】消息操作 */
  561. const doSendMessageStream = async userMessage => {
  562. // 创建 AbortController 实例,以便中止请求
  563. conversationInAbortController.value = new AbortController()
  564. // 标记对话进行中
  565. conversationInProgress.value = true
  566. // 设置为空
  567. receiveMessageFullText.value = ''
  568. showQuestAnswerDialogue()
  569. try {
  570. // 发送 event stream
  571. let isFirstChunk = true // 是否是第一个 chunk 消息段
  572. await sendChatMessageStream(
  573. userMessage.conversationId,
  574. userMessage.content,
  575. userMessage.contentAnswer,
  576. conversationInAbortController.value,
  577. true, // enableContext 参数
  578. async res => {
  579. const { code, data, msg } = JSON.parse(res.data)
  580. if (code !== 0) {
  581. console.log(`对话异常! ${msg}`)
  582. stopStream();
  583. return
  584. }
  585. if (data.eventType === 'TEXT') {
  586. // 如果内容为空,就不处理。
  587. if (data.receive?.content === '') {
  588. return
  589. }
  590. receiveMessageFullText.value += data.receive.content
  591. // 更新数字人对话框内容
  592. currentDialogue.value.content = receiveMessageFullText.value
  593. // 首次返回需要添加一个 message 到页面,后面的都是更新
  594. if (isFirstChunk) {
  595. isFirstChunk = false
  596. //第一次返回
  597. } else {
  598. //更新最后一条消息
  599. }
  600. }
  601. if (data.eventType === 'AUDIO') {
  602. // 处理音频消息
  603. await playAudioChunk(data.audioData);
  604. }
  605. },
  606. error => {
  607. console.log(`对话异常! ${error}`)
  608. stopStream()
  609. delayRecoverQuestDialogue()
  610. // 需要抛出异常,禁止重试
  611. throw error
  612. },
  613. () => {
  614. console.log(`结束对话! `)
  615. stopStream()
  616. }
  617. )
  618. } catch (error) {
  619. console.error('发送消息失败:', error)
  620. stopStream()
  621. delayRecoverQuestDialogue()
  622. }
  623. }
  624. /** 停止 stream 流式调用 */
  625. const stopStream = async () => {
  626. // 如果 stream 进行中的 message,就需要调用 controller 结束
  627. if (conversationInAbortController.value) {
  628. conversationInAbortController.value.abort()
  629. }
  630. // 销毁语音读取
  631. // stopPlayback();
  632. // 设置为 false
  633. conversationInProgress.value = false
  634. console.log(`结束对话!更改状态: `,conversationInProgress.value)
  635. }
  636. // 处理音频播放完成
  637. const handleAudioPlaybackComplete = () => {
  638. console.log('智能问答音频播放完成');
  639. // AI回答完成后,如果之前是播放状态,继续播放
  640. stopAllAudio();
  641. // 如果处于自动播放状态,继续播放下一条对话
  642. if (isPlaying.value) {
  643. if (playNext()) {
  644. // 继续自动播放序列
  645. playSequence();
  646. } else {
  647. // 播放完毕
  648. isPlaying.value = false;
  649. stopAllAudio();
  650. }
  651. }
  652. };
  653. // 组件挂载时添加键盘事件监听
  654. onMounted(() => {
  655. window.addEventListener('keydown', handleKeydown)
  656. // 播放背景音
  657. playBackgroundAudio()
  658. // // 播放当前对话语音
  659. // playDialogueAudio()
  660. // 设置音频播放完成回调
  661. setOnPlaybackComplete(handleAudioPlaybackComplete)
  662. })
  663. // 组件卸载时移除键盘事件监听和停止音频
  664. onUnmounted(() => {
  665. window.removeEventListener('keydown', handleKeydown)
  666. stopAllAudio()
  667. stopPlayback(false)
  668. })
  669. </script>
  670. <style scoped lang="scss">
  671. @use "sass:math";
  672. @function rpx($px) {
  673. @return math.div($px, 750) * 100vw;
  674. }
  675. .dialog-content-wrapper {
  676. width: 100%;
  677. height: 100%;
  678. position: absolute;
  679. top: 0;
  680. left: 0;
  681. z-index: 100;
  682. }
  683. .title-box {
  684. position: absolute;
  685. top: 0;
  686. left: 0;
  687. right: 0;
  688. height: rpx(60);
  689. display: flex;
  690. align-items: center;
  691. justify-content: space-between;
  692. color: white;
  693. padding: 0 rpx(20);
  694. z-index: 10;
  695. }
  696. .title-left {
  697. width: 33.33%;
  698. height: 100%;
  699. display: flex;
  700. align-items: center;
  701. gap: rpx(5);
  702. .box-icon {
  703. display: flex;
  704. align-items: center;
  705. color: #0064BE;
  706. gap: rpx(5);
  707. padding: rpx(5) rpx(10);
  708. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  709. border: rpx(1) solid rgba(0, 100, 192);
  710. border-radius: rpx(30);
  711. backdrop-filter: blur(10px);
  712. cursor: pointer;
  713. transition: all 0.3s ease;
  714. font-size: rpx(9);
  715. font-weight: 500;
  716. width: fit-content;
  717. }
  718. .box-icon:hover {
  719. background-color: rgba(255, 255, 255, 90%);
  720. transform: translateX(-3px);
  721. }
  722. .left-icon {
  723. font-size: rpx(12);
  724. }
  725. }
  726. .title-center {
  727. width: 33.33%;
  728. display: flex;
  729. align-items: center;
  730. justify-content: center;
  731. background-image: url('@/assets/dialogue/number-title.png');
  732. background-size: contain;
  733. background-repeat: no-repeat;
  734. background-position: center;
  735. height: 100%;
  736. }
  737. .title-text {
  738. height: 100%;
  739. font-size: rpx(12);
  740. font-weight: bold;
  741. white-space: nowrap;
  742. overflow: hidden;
  743. text-overflow: ellipsis;
  744. max-width: 50%;
  745. display: flex;
  746. align-items: center;
  747. justify-content: left;
  748. }
  749. .title-right {
  750. width: 33.33%;
  751. height: 100%;
  752. display: flex;
  753. align-items: center;
  754. justify-content: flex-end;
  755. gap: rpx(10);
  756. .arrow-icon-circle {
  757. width: rpx(20);
  758. height: rpx(20);
  759. border-radius: 50%;
  760. border: rpx(1) solid rgba(0, 100, 192);
  761. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  762. display: flex;
  763. align-items: center;
  764. justify-content: center;
  765. cursor: pointer;
  766. transition: all 0.3s ease;
  767. }
  768. .arrow-icon-circle:hover:not(.disabled) {
  769. transform: scale(1.1);
  770. box-shadow: 0 rpx(1) rpx(6) rgba(0, 0, 0, 0.3);
  771. }
  772. .arrow-icon-circle:active:not(.disabled) {
  773. transform: scale(0.95);
  774. }
  775. .arrow-icon-circle.disabled {
  776. opacity: 0.5;
  777. cursor: not-allowed;
  778. border-color: rgba(0, 100, 192, 0.3);
  779. background: linear-gradient(135deg, rgba(160, 220, 240, 0.5), rgba(80, 190, 240, 0.5));
  780. }
  781. .arrow-icon {
  782. font-size: rpx(15);
  783. color: #0064BE;
  784. }
  785. .play-text {
  786. font-size: rpx(7);
  787. color: #0064BE;
  788. font-weight: 500;
  789. }
  790. }
  791. .background-image {
  792. width: 100%;
  793. height: 100%;
  794. object-fit: cover;
  795. z-index: 1;
  796. position: relative;
  797. }
  798. /* 遮罩层样式 */
  799. .mask-layer {
  800. position: absolute;
  801. top: 0;
  802. left: 0;
  803. right: 0;
  804. bottom: 0;
  805. background-color: rgba(0, 0, 0, 0.7);
  806. z-index: 20;
  807. display: flex;
  808. justify-content: center;
  809. align-items: center;
  810. opacity: 1;
  811. transition: all 0.5s ease-out;
  812. }
  813. .mask-layer.fade-out {
  814. opacity: 0;
  815. transform: scale(1.1);
  816. z-index: -1;
  817. }
  818. .play-button-container {
  819. display: flex;
  820. justify-content: center;
  821. align-items: center;
  822. }
  823. .play-button {
  824. width: rpx(80);
  825. height: rpx(80);
  826. border-radius: 50%;
  827. border: none;
  828. background: transparent;
  829. display: flex;
  830. justify-content: center;
  831. align-items: center;
  832. cursor: pointer;
  833. outline: none;
  834. -webkit-tap-highlight-color: transparent;
  835. }
  836. .play-icon {
  837. font-size: rpx(40);
  838. color: #A0DCF0;
  839. transition: all 0.3s ease;
  840. cursor: pointer;
  841. }
  842. .play-icon:hover {
  843. transform: scale(1.2);
  844. color: #50BEF0;
  845. text-shadow: 0 0 rpx(10) rgba(64, 158, 255, 0.5);
  846. }
  847. .content-box {
  848. position: absolute;
  849. top: rpx(60);
  850. left: 0;
  851. right: 0;
  852. bottom: 0;
  853. display: flex;
  854. align-items: flex-end;
  855. justify-content: center;
  856. padding-bottom: rpx(10);
  857. z-index: 10;
  858. }
  859. .character {
  860. position: absolute;
  861. bottom: 0;
  862. width: rpx(135);
  863. height: rpx(240);
  864. background-size: contain;
  865. background-position: bottom;
  866. background-repeat: no-repeat;
  867. opacity: 0;
  868. z-index: 1;
  869. // background-color: #fff;
  870. }
  871. .character.left {
  872. left: rpx(17);
  873. transform: translateX(-100%);
  874. animation: characterEnterLeft 0.8s ease forwards;
  875. }
  876. .character.right {
  877. right: rpx(17);
  878. transform: translateX(100%) scaleX(-1);
  879. animation: characterEnterRight 0.8s ease forwards;
  880. }
  881. @keyframes characterEnterLeft {
  882. 0% {
  883. opacity: 0;
  884. transform: translateX(-100%) scale(0.8);
  885. }
  886. 70% {
  887. opacity: 0.9;
  888. transform: translateX(10%) scale(1.05);
  889. }
  890. 100% {
  891. opacity: 1;
  892. transform: translateX(0) scale(1);
  893. }
  894. }
  895. @keyframes characterEnterRight {
  896. 0% {
  897. opacity: 0;
  898. transform: translateX(100%) scale(0.8) scaleX(-1);
  899. }
  900. 70% {
  901. opacity: 0.9;
  902. transform: translateX(-10%) scale(1.05) scaleX(-1);
  903. }
  904. 100% {
  905. opacity: 1;
  906. transform: translateX(0) scale(1) scaleX(-1);
  907. }
  908. }
  909. .dialogue-card {
  910. background: rgba(255, 255, 255, 0.9);
  911. border-radius: rpx(6);
  912. padding: rpx(8);
  913. max-width: 20%;
  914. min-width: rpx(150);
  915. box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
  916. position: absolute;
  917. bottom: rpx(50);
  918. width: auto;
  919. display: inline-block;
  920. z-index: 2;
  921. }
  922. .dialogue-card.left {
  923. left: rpx(145);
  924. animation: dialogueEnterLeft 0.6s ease forwards;
  925. }
  926. .dialogue-card.right {
  927. right: rpx(145);
  928. animation: dialogueEnterRight 0.6s ease forwards;
  929. }
  930. .dialogue-card.left::before {
  931. content: '';
  932. position: absolute;
  933. left: rpx(-11.5);
  934. bottom: rpx(12);
  935. width: 0;
  936. height: 0;
  937. border-top: rpx(7) solid transparent;
  938. border-bottom: rpx(7) solid transparent;
  939. border-right: rpx(12) solid rgba(255, 255, 255, 0.9);
  940. transform: translateY(0);
  941. }
  942. .dialogue-card.right::before {
  943. content: '';
  944. position: absolute;
  945. right: rpx(-11.5);
  946. bottom: rpx(12);
  947. width: 0;
  948. height: 0;
  949. border-top: rpx(7) solid transparent;
  950. border-bottom: rpx(7) solid transparent;
  951. border-left: rpx(12) solid rgba(255, 255, 255, 0.9);
  952. transform: translateY(0);
  953. }
  954. @keyframes dialogueEnterLeft {
  955. from {
  956. opacity: 0;
  957. transform: translateX(-rpx(30)) translateY(rpx(20));
  958. }
  959. to {
  960. opacity: 1;
  961. transform: translateX(0) translateY(0);
  962. }
  963. }
  964. @keyframes dialogueEnterRight {
  965. from {
  966. opacity: 0;
  967. transform: translateX(rpx(30)) translateY(rpx(20));
  968. }
  969. to {
  970. opacity: 1;
  971. transform: translateX(0) translateY(0);
  972. }
  973. }
  974. .dialogue-header {
  975. position: absolute;
  976. top: rpx(-11);
  977. left: rpx(12);
  978. background: #409EFF;
  979. color: white;
  980. padding: rpx(1.2) rpx(6);
  981. border-radius: rpx(5);
  982. font-size: rpx(8);
  983. box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
  984. }
  985. .dialogue-card.right .dialogue-header {
  986. left: rpx(10);
  987. }
  988. .role-name {
  989. font-weight: 600;
  990. color: white;
  991. font-size: rpx(10);
  992. }
  993. .dialogue-content {
  994. font-size: rpx(12);
  995. line-height: 1.2;
  996. color: #333;
  997. text-align: left;
  998. display: flex;
  999. flex-direction: column;
  1000. align-items: center;
  1001. justify-content: center;
  1002. width: 100%;
  1003. }
  1004. .user-input-card {
  1005. max-width: 50%;
  1006. position: absolute;
  1007. left: 50%;
  1008. transform: translateX(-50%);
  1009. bottom: rpx(70);
  1010. right: auto;
  1011. animation: dialogueEnterCenter 0.6s ease forwards;
  1012. }
  1013. .user-input-card::before {
  1014. display: none;
  1015. }
  1016. @keyframes dialogueEnterCenter {
  1017. from {
  1018. opacity: 0;
  1019. transform: translateX(-50%) translateY(rpx(20));
  1020. }
  1021. to {
  1022. opacity: 1;
  1023. transform: translateX(-50%) translateY(0);
  1024. }
  1025. }
  1026. .user-input-textarea {
  1027. width: 95%;
  1028. min-height: rpx(50);
  1029. max-height: rpx(150);
  1030. border: none;
  1031. outline: none;
  1032. background: transparent;
  1033. font-size: rpx(10);
  1034. line-height: 1.2;
  1035. color: #333;
  1036. resize: none;
  1037. font-family: inherit;
  1038. overflow-y: auto;
  1039. text-align: left;
  1040. padding: rpx(5) 0;
  1041. }
  1042. .voice-input-content {
  1043. width: 95%;
  1044. min-height: rpx(50);
  1045. max-height: rpx(150);
  1046. font-size: rpx(10);
  1047. line-height: 1.2;
  1048. color: #333;
  1049. text-align: left;
  1050. padding: rpx(5) 0;
  1051. overflow-y: auto;
  1052. }
  1053. .voice-input-placeholder {
  1054. width: 95%;
  1055. min-height: rpx(50);
  1056. font-size: rpx(10);
  1057. line-height: 1.2;
  1058. color: #999;
  1059. text-align: left;
  1060. padding: rpx(5) 0;
  1061. font-style: italic;
  1062. }
  1063. /* 滚动条样式 */
  1064. .user-input-textarea::-webkit-scrollbar {
  1065. width: rpx(0);
  1066. }
  1067. .user-input-textarea::-webkit-scrollbar-track {
  1068. background: rgba(0, 0, 0, 0.05);
  1069. border-radius: rpx(3);
  1070. }
  1071. .user-input-textarea::-webkit-scrollbar-thumb {
  1072. background: rgba(64, 158, 255, 0.5);
  1073. border-radius: rpx(3);
  1074. }
  1075. .user-input-textarea::-webkit-scrollbar-thumb:hover {
  1076. background: rgba(64, 158, 255, 0.8);
  1077. }
  1078. .input-actions {
  1079. display: flex;
  1080. justify-content: flex-end;
  1081. gap: rpx(6);
  1082. margin-top: rpx(6);
  1083. width: 100%;
  1084. }
  1085. .cancel-btn, .submit-btn {
  1086. padding: rpx(2.5) rpx(10);
  1087. border: none;
  1088. border-radius: rpx(4);
  1089. font-size: rpx(8);
  1090. cursor: pointer;
  1091. transition: all 0.3s ease;
  1092. }
  1093. .cancel-btn {
  1094. background: #E0E0E0;
  1095. color: #666;
  1096. }
  1097. .submit-btn {
  1098. background: #409EFF;
  1099. color: white;
  1100. }
  1101. .cancel-btn:hover, .submit-btn:hover {
  1102. transform: scale(1.05);
  1103. }
  1104. .cancel-btn:active, .submit-btn:active {
  1105. transform: scale(0.95);
  1106. }
  1107. .dialogue-content :deep(p) {
  1108. margin: 0 0 rpx(4) 0;
  1109. }
  1110. .dialogue-content :deep(strong),
  1111. .dialogue-content :deep(b) {
  1112. font-weight: bold;
  1113. }
  1114. .dialogue-content :deep(em),
  1115. .dialogue-content :deep(i) {
  1116. font-style: italic;
  1117. }
  1118. .dialogue-content :deep(ul),
  1119. .dialogue-content :deep(ol) {
  1120. margin: rpx(4) 0;
  1121. padding-left: rpx(10);
  1122. }
  1123. .dialogue-content :deep(li) {
  1124. margin: rpx(2) 0;
  1125. }
  1126. .dialogue-content :deep(code) {
  1127. background-color: #f0f0f0;
  1128. padding: rpx(1) rpx(3);
  1129. border-radius: rpx(2);
  1130. font-family: monospace;
  1131. font-size: rpx(9);
  1132. }
  1133. .dialogue-content :deep(a) {
  1134. color: #409EFF;
  1135. text-decoration: underline;
  1136. }
  1137. .input-buttons-container {
  1138. position: relative;
  1139. display: flex;
  1140. align-items: center;
  1141. justify-content: center;
  1142. width: 100%;
  1143. z-index: 10;
  1144. transition: all 0.3s ease;
  1145. transform: scale(0.9);
  1146. }
  1147. .voice-input-outer,
  1148. .keyboard-input-outer {
  1149. position: absolute;
  1150. display: flex;
  1151. align-items: center;
  1152. justify-content: center;
  1153. transition: all 0.3s ease;
  1154. }
  1155. .voice-input-outer {
  1156. position: relative;
  1157. width: rpx(50);
  1158. height: rpx(50);
  1159. border-radius: 50%;
  1160. background: transparent;
  1161. display: flex;
  1162. align-items: center;
  1163. justify-content: center;
  1164. // 正常状态只显示一圈
  1165. &::before {
  1166. content: '';
  1167. position: absolute;
  1168. width: 85%;
  1169. height: 85%;
  1170. border-radius: 50%;
  1171. border: rpx(2) solid rgba(80, 190, 240, 0.6);
  1172. }
  1173. // 录音时显示波纹效果
  1174. &.recording {
  1175. &::before {
  1176. animation: pulse 2s infinite;
  1177. }
  1178. &::after {
  1179. content: '';
  1180. position: absolute;
  1181. width: 85%;
  1182. height: 85%;
  1183. border-radius: 50%;
  1184. border: rpx(2) solid rgba(80, 190, 240, 0.4);
  1185. animation: pulse 2s infinite 0.5s;
  1186. }
  1187. }
  1188. :deep(.voice-input-container) {
  1189. position: relative;
  1190. z-index: 10;
  1191. .speech-btn {
  1192. width: rpx(40);
  1193. height: rpx(40);
  1194. border-radius: 50%;
  1195. border: rpx(2) solid rgba(0, 100, 192);
  1196. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1197. display: flex;
  1198. align-items: center;
  1199. justify-content: center;
  1200. padding: 0;
  1201. gap: 0;
  1202. transition: all 0.3s ease;
  1203. cursor: pointer;
  1204. position: relative;
  1205. overflow: hidden;
  1206. &:hover {
  1207. transform: scale(1.05);
  1208. box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.3);
  1209. }
  1210. &:active {
  1211. transform: scale(0.95);
  1212. }
  1213. &::before {
  1214. content: '';
  1215. position: absolute;
  1216. top: 50%;
  1217. left: 50%;
  1218. width: 0;
  1219. height: 0;
  1220. border-radius: 50%;
  1221. background: rgba(255, 255, 255, 0.5);
  1222. transform: translate(-50%, -50%);
  1223. transition: width 0.6s, height 0.6s;
  1224. }
  1225. &:active::before {
  1226. width: rpx(80);
  1227. height: rpx(80);
  1228. }
  1229. .el-icon {
  1230. font-size: rpx(20);
  1231. color: #0064BE;
  1232. z-index: 1;
  1233. }
  1234. .countdown-text {
  1235. display: none;
  1236. }
  1237. .waveform-container {
  1238. display: none;
  1239. }
  1240. }
  1241. }
  1242. }
  1243. @keyframes pulse {
  1244. 0% {
  1245. transform: scale(1);
  1246. opacity: 1;
  1247. }
  1248. 100% {
  1249. transform: scale(1.15);
  1250. opacity: 0;
  1251. }
  1252. }
  1253. .keyboard-input-outer {
  1254. width: rpx(30);
  1255. height: rpx(30);
  1256. border-radius: 50%;
  1257. background: transparent;
  1258. &.active {
  1259. width: rpx(40);
  1260. height: rpx(40);
  1261. left: 50%;
  1262. transform: translateX(-50%);
  1263. z-index: 11;
  1264. .keyboard-btn {
  1265. width: rpx(36);
  1266. height: rpx(36);
  1267. border: rpx(2) solid rgba(0, 100, 192);
  1268. box-shadow: 0 rpx(3) rpx(8) rgba(0, 0, 0, 0.3);
  1269. .keyboard-icon {
  1270. font-size: rpx(18);
  1271. }
  1272. }
  1273. }
  1274. &.inactive {
  1275. width: rpx(24);
  1276. height: rpx(24);
  1277. left: calc(50% + rpx(40));
  1278. transform: translateX(-50%);
  1279. z-index: 10;
  1280. .keyboard-btn {
  1281. width: rpx(22);
  1282. height: rpx(22);
  1283. border: rpx(1) solid rgba(128, 128, 128, 0.5);
  1284. box-shadow: none;
  1285. background: linear-gradient(135deg, #E0E0E0, #C0C0C0);
  1286. .keyboard-icon {
  1287. font-size: rpx(12);
  1288. color: #808080;
  1289. }
  1290. }
  1291. }
  1292. }
  1293. .keyboard-btn {
  1294. width: rpx(28);
  1295. height: rpx(28);
  1296. border-radius: 50%;
  1297. border: rpx(1) solid rgba(0, 100, 192);
  1298. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1299. display: flex;
  1300. align-items: center;
  1301. justify-content: center;
  1302. padding: 0;
  1303. gap: 0;
  1304. transition: all 0.3s ease;
  1305. cursor: pointer;
  1306. &:hover {
  1307. transform: scale(1.1);
  1308. box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.3);
  1309. }
  1310. &:active {
  1311. transform: scale(0.95);
  1312. }
  1313. .keyboard-icon {
  1314. font-size: rpx(14);
  1315. color: #0064BE;
  1316. }
  1317. }
  1318. </style>