From c6e3c33979ab6ed1aa6a834f39c85356320c0f93 Mon Sep 17 00:00:00 2001 From: gerson <1405270578@qq.com> Date: 星期五, 19 七月 2024 23:02:11 +0800 Subject: [PATCH] 语音对话 --- src/views/project/ch/home/component/waterRight/top.vue | 4 src/components/chat/Chat.vue | 35 +++++++- src/components/chat/components/playBar/voicePage/VoicePage.vue | 116 +++++++++++++++++++++++++++-- src/components/chat/components/playBar/PlayBar.vue | 31 ++++++- src/stores/chatRoom.ts | 16 +++ src/components/chat/components/playBar/voicePage/types.ts | 20 +++++ 6 files changed, 201 insertions(+), 21 deletions(-) diff --git a/src/components/chat/Chat.vue b/src/components/chat/Chat.vue index 9f3968b..cc219c9 100644 --- a/src/components/chat/Chat.vue +++ b/src/components/chat/Chat.vue @@ -102,9 +102,11 @@ <div class="sticky bottom-0 w-full p-6 pb-8 bg-[rgb(247,248,250)] flex justify-center"> <PlayBar + v-model:voicePageIsShow="voicePageIsShow" :isTalking="isTalking" + :isHome="false" v-model="messageContent.values" - @sendClick="sendChatMessage" + @sendClick="sendClick" :style="{ width: chatWidth }" ></PlayBar> </div> @@ -124,7 +126,16 @@ import { GetHistoryAnswer, QueryHistoryDetail, QuestionAi, SetHistoryAnswerState, getQuestionProcess } from '/@/api/ai/chat'; import PlayBar from '/@/components/chat/components/playBar/PlayBar.vue'; import router from '/@/router'; -import { activeChatRoom, activeLLMId, activeRoomId, activeSampleId, activeSectionAId, roomConfig } from '/@/stores/chatRoom'; +import { + activeChatRoom, + activeLLMId, + activeRoomId, + activeSampleId, + activeSectionAId, + roomConfig, + setRoomConfig, + getRoomConfig, +} from '/@/stores/chatRoom'; import { v4 as uuidv4 } from 'uuid'; import _ from 'lodash'; import { ErrorCode } from '/@/utils/request'; @@ -133,7 +144,7 @@ import CustomDrawer from '/@/components/drawer/CustomDrawer.vue'; const chatWidth = '75%'; - +const voicePageIsShow = ref(false); let isTalking = ref(false); let messageContent = ref<ChatContent>({ type: AnswerType.Text, @@ -299,9 +310,11 @@ }); }; -const sendChatMessage = async (content: ChatContent = messageContent.value) => { +const sendChatMessage = async (content: ChatContent = messageContent.value, cb?: any) => { if (!content?.values) return; - if (messageList.value.length === 0) { + + const isNewChat = messageList.value.length === 0 + if (isNewChat) { if (activeSampleId.value) { currentSampleId = activeSampleId.value; } @@ -329,9 +342,17 @@ let resMsgContent: ChatContent = null; resMsgContent = await questionAi(content.values); + if (isNewChat) { + const firstResCb = getRoomConfig(currentRouteId, 'firstResCb'); + firstResCb(resMsgContent); + } else { + cb?.(resMsgContent); + } userItem.historyId = questionRes.history_id; assistantItem.historyId = questionRes.history_id; appendLastMessageContent(resMsgContent); + + return resMsgContent; } catch (error: any) { // appendLastMessageContent({ // type: AnswerType.Text, @@ -341,6 +362,10 @@ isTalking.value = false; } }; + +const sendClick = (cb) => { + sendChatMessage(messageContent.value, cb); +}; const appendLastMessageContent = (content: ChatContent) => { if (messageList.value.at(-1)) { messageList.value.at(-1).content = content; diff --git a/src/components/chat/components/playBar/PlayBar.vue b/src/components/chat/components/playBar/PlayBar.vue index c21331c..fa6337d 100644 --- a/src/components/chat/components/playBar/PlayBar.vue +++ b/src/components/chat/components/playBar/PlayBar.vue @@ -35,20 +35,35 @@ </div> </div> </div> - <VoicePage v-model:isShow="voicePageIsShow" v-show="voicePageIsShow" /> + <VoicePage + v-model:isShow="voicePageIsShow" + v-show="voicePageIsShow" + @submit="(cb) => emits('sendClick', cb)" + @updateInputValue="updateInputValue" + :isHome = "isHome" + /> </div> </template> <script setup lang="ts"> +import { ElMessage } from 'element-plus'; import { ref } from 'vue'; import VoicePage from './voicePage/VoicePage.vue'; const emits = defineEmits(['sendClick']); -const props = defineProps(['isTalking']); -const voicePageIsShow = ref(false); +const props = defineProps(['isTalking','isHome']); + +const voicePageIsShow = defineModel('voicePageIsShow', { + type: Boolean, + default: false, +}); const inputValue = defineModel({ type: String, }); + +const updateInputValue = (val) => { + inputValue.value = val; +}; const enterInput = (e) => { if (props.isTalking) return; @@ -61,7 +76,15 @@ } }; const audioChangeWord = () => { - voicePageIsShow.value = true; + navigator.getUserMedia( + { audio: true }, + function onSuccess(stream) { + voicePageIsShow.value = true; + }, + function onError(error) { + ElMessage.warning('璇锋墦寮�楹﹀厠椋庢潈闄�'); + } + ); }; </script> <style scoped lang="scss"> diff --git a/src/components/chat/components/playBar/voicePage/VoicePage.vue b/src/components/chat/components/playBar/voicePage/VoicePage.vue index 1987afd..62af532 100644 --- a/src/components/chat/components/playBar/voicePage/VoicePage.vue +++ b/src/components/chat/components/playBar/voicePage/VoicePage.vue @@ -24,7 +24,9 @@ ><span :style="{ 'animation-play-state': animationPlayState }"></span> </div> </div> - <div class="mt-5">璇峰紑濮嬭璇�</div> + <div class="mt-5" :class="{ 'cursor-pointer': currentVoiceType === VoiceTipType.Speak }" @click="voiceTipClick"> + {{ voiceTipMap[currentVoiceType] }} + </div> <div class="flex items-center justify-between bottom-16 absolute left-1/2 -translate-x-1/2 space-x-16"> <div class="size-[35px] flex items-center justify-center bg-[#292929] rounded-full cursor-pointer" @click="togglePlayClick"> @@ -43,30 +45,102 @@ </template> <script setup lang="ts"> -import { computed, ref, watch } from 'vue'; +import { computed, nextTick, ref, watch } from 'vue'; +import type { ChatContent } from '../../../model/types'; +import { AnswerType } from '../../../model/types'; +import { VoiceRecognitionErrorType, VoiceTipType, voiceTipMap } from './types'; +import router from '/@/router'; +import { setRoomConfig } from '/@/stores/chatRoom'; const animationPlayState = ref<'paused' | 'running'>('running'); const playIcon = computed(() => (animationPlayState.value === 'running' ? 'icon-zanting' : 'icon-bofang')); - +const isSpeak = ref(false); const togglePlayClick = () => { animationPlayState.value = animationPlayState.value === 'running' ? 'paused' : 'running'; + if (currentVoiceType.value === VoiceTipType.Speak) { + if (isSpeak.value) { + window.speechSynthesis.pause(); + } else { + window.speechSynthesis.resume(); + } + isSpeak.value = !isSpeak.value; + } }; +const props = defineProps(['isHome']); +const emit = defineEmits(['submit', 'updateInputValue']); const isShow = defineModel('isShow', { type: Boolean, }); + +const resetToListenVoice = () => { + currentVoiceType.value = VoiceTipType.NoSpeech; + audioChangeWord(); +}; const isListening = ref(false); -const inputValue = ref(''); const closeClick = () => { isShow.value = false; }; + +let recognition = null; +let speech = null; +const currentVoiceType = ref<VoiceTipType>(VoiceTipType.NoSpeech); +const handleAnswerRes = (res: ChatContent) => { + if (!res) { + return; + } + let text = ''; + + if (res.type === AnswerType.Text || res.type === AnswerType.Knowledge) { + if (res.type === AnswerType.Knowledge) { + text = res.values?.map((item) => item.answer) ?? ''; + } else { + text = res.values; + } + } else { + text = '鎶辨瓑锛屾垜鏃犳硶鍙h堪鍥炵瓟姝ら棶棰樼殑锛岄渶瑕佹煡鐪嬭鍏抽棴姝よ闊冲璇濈晫闈�'; + } + + currentVoiceType.value = VoiceTipType.Speak; + isSpeak.value = true; + var speech = new SpeechSynthesisUtterance(); + speech.text = text; // 鍐呭 + speech.lang = 'zh-cn'; // 璇█ + speech.voiceURI = 'Microsoft Huihui - Chinese (Simplified, PRC)'; // 澹伴煶鍜屾湇鍔� + // eslint-disable-next-line no-irregular-whitespace + speech.volume = 0.7; // 澹伴煶鐨勯煶閲忓尯闂磋寖鍥存槸鈥嬧��0鈥嬧�嬧�嬪埌鈥嬧��1榛樿鏄�嬧��1鈥嬧�� + // eslint-disable-next-line no-irregular-whitespace + speech.rate = 1; // 璇�燂紝鏁板�硷紝榛樿鍊兼槸鈥嬧��1鈥嬧�嬧�嬶紝鑼冨洿鏄�嬧��0.1鈥嬧�嬧�嬪埌鈥嬧��10鈥嬧�嬧�嬶紝琛ㄧず璇�熺殑鍊嶆暟锛屼緥濡傗�嬧��2鈥嬧�嬭〃绀烘甯歌閫熺殑涓ゅ�� + // eslint-disable-next-line no-irregular-whitespace + speech.pitch = 1; // 琛ㄧず璇磋瘽鐨勯煶楂橈紝鏁板�硷紝鑼冨洿浠庘�嬧��0鈥嬧�嬧�嬶紙鏈�灏忥級鍒扳�嬧��2鈥嬧�嬧�嬶紙鏈�澶э級銆傞粯璁ゅ�间负鈥嬧��1鈥嬧�嬨�� + + speech.onend = () => { + resetToListenVoice(); + }; + window.speechSynthesis.speak(speech); +}; + +const voiceTipClick = () => { + switch (currentVoiceType.value) { + case VoiceTipType.Speak: + window.speechSynthesis.cancel(); + setTimeout(() => { + resetToListenVoice(); + }, 0); + + break; + default: + break; + } + window.speechSynthesis.cancel(); +}; const audioChangeWord = () => { - inputValue.value = ''; + emit('updateInputValue', ''); // 鍒涘缓SpeechRecognition瀵硅薄 // eslint-disable-next-line no-undef - var recognition = new webkitSpeechRecognition(); + recognition = new webkitSpeechRecognition(); if (!recognition) { // eslint-disable-next-line no-undef recognition = new SpeechRecognition(); @@ -84,14 +158,35 @@ recognition.onresult = function (event) { var result = event.results[0][0].transcript; console.log('鐩戝惉缁撴灉:', result); - inputValue.value = result; + + emit('updateInputValue', result); + currentVoiceType.value = VoiceTipType.Think; + if (!props.isHome) { + emit('submit', handleAnswerRes); + } else { + setRoomConfig(router.currentRoute.value.query.id as string, 'firstResCb', handleAnswerRes); + emit('submit'); + } + }; + recognition.onspeechstart = (event) => { + currentVoiceType.value = VoiceTipType.Speech; }; // 鐩戝惉閿欒浜嬩欢 recognition.onerror = function (event) { isListening.value = false; - ElMessage.error('鐩戝惉璇煶澶辫触'); + // ElMessage.error('鐩戝惉璇煶澶辫触'); console.error(event.error); + switch (event.error) { + case VoiceRecognitionErrorType.NoSpeech: + if (isShow.value) { + resetToListenVoice(); + } + break; + + default: + break; + } }; // 鐩戝惉缁撴潫浜嬩欢锛堝寘鎷瘑鍒垚鍔熴�佽瘑鍒敊璇拰鐢ㄦ埛鍋滄锛� recognition.onend = function () { @@ -101,7 +196,10 @@ }; const resetStatus = () => { + currentVoiceType.value = VoiceTipType.NoSpeech; animationPlayState.value = 'running'; + recognition?.abort(); + window.speechSynthesis.cancel(); }; watch( @@ -109,6 +207,8 @@ (val) => { if (!val) { resetStatus(); + } else { + resetToListenVoice(); } } ); diff --git a/src/components/chat/components/playBar/voicePage/types.ts b/src/components/chat/components/playBar/voicePage/types.ts new file mode 100644 index 0000000..4005c8a --- /dev/null +++ b/src/components/chat/components/playBar/voicePage/types.ts @@ -0,0 +1,20 @@ +export const enum VoiceRecognitionErrorType { + /** @description 娌℃湁澹伴煶 */ + NoSpeech = 'no-speech', + /** @description 涓 */ + Abort = 'abort', +} + +export const enum VoiceTipType { + NoSpeech, + Speech, + Think, + Speak, +} + +export const voiceTipMap = { + [VoiceTipType.NoSpeech]: '璇峰紑濮嬭璇�', + [VoiceTipType.Speech]: '鎴戞鍦ㄥ惉', + [VoiceTipType.Think]: '鎬濊�冧腑...', + [VoiceTipType.Speak]: '鐐瑰嚮鎵撴柇', +}; diff --git a/src/stores/chatRoom.ts b/src/stores/chatRoom.ts index 5d86755..c947009 100644 --- a/src/stores/chatRoom.ts +++ b/src/stores/chatRoom.ts @@ -1,10 +1,11 @@ -import { computed, ref, watch } from 'vue'; +import { computed, ref } from 'vue'; import type { ChatRoomItem } from '../layout/component/sidebar/components/types'; -import { Local } from '../utils/storage'; export type RoomConfig = { /** 鏄惁鐩存帴璋冪敤澶фā鍨嬶紙閫氫箟鍗冮棶锛夊洖绛� */ isAnswerByLLM: boolean; + /** @description 浠庨椤佃繘鍘昏幏鍙栫殑绗竴涓洖澶嶏紝鍥炶皟鍑芥暟 */ + firstResCb: any; }; export type RoomConfigKey = keyof RoomConfig; @@ -23,6 +24,17 @@ } }; +export const getRoomConfig = <T extends RoomConfigKey>(roomId: string, key: T) => { + if (!roomConfig.value) { + return null; + } + if (!roomConfig.value[roomId]) { + return null; + } else { + return roomConfig.value[roomId][key]; + } +}; + export const chatRoomList = ref<ChatRoomItem[]>([]); export const activeRoomId = ref(null); diff --git a/src/views/project/ch/home/component/waterRight/top.vue b/src/views/project/ch/home/component/waterRight/top.vue index 8216001..e2b3583 100644 --- a/src/views/project/ch/home/component/waterRight/top.vue +++ b/src/views/project/ch/home/component/waterRight/top.vue @@ -17,7 +17,7 @@ </div> </div> --> </div> - <PlayBar v-model="inputValue" @send-click="sendClick" /> + <PlayBar v-model="inputValue" @send-click="sendClick" :is-home="true"/> </template> <script setup lang="ts"> @@ -32,7 +32,7 @@ const emits = defineEmits(['sendClick']); const inputValue = ref('浣犳槸璋侊紵'); -const sendClick = async () => { +const sendClick = async (cb) => { if (!inputValue.value) return; if (!activeSectionAId.value) { ElMessage.warning('璇烽�夋嫨搴旂敤鍦烘櫙'); -- Gitblit v1.9.3