ソースを参照

[feat][ai生涯访谈][聊天静态页面]

hizhangling 1 日 前
コミット
84ca8df264

+ 667 - 2
src/views/xjc-integratedmachine/environment/ai_interview/ai_career_interview_chat.vue

@@ -31,7 +31,58 @@
                 <div>小新老师</div>
                 <div class="header-exit-btn">退出</div>
             </div>
-            <div class="chat-box"></div>
+            <div v-loading="loadingHistoryRecord" class="chat-container">
+                <div class="message-list" ref="chatContainerRef">
+                    <div v-for="(item, index) in chatRecordList" :key="item.id">
+                        <div v-if="item.isUser == 1" class="user-message">
+                            <img src="@/assets/images/common/ai/user.png" alt="icon" style="margin-left: 4px"/>
+                            <span class="user-message-content">{{item.content}}</span>
+                        </div>
+                        <div v-else class="bot-message">
+                            <img src="@/assets/images/common/ai/system.png" alt="icon" style="margin-left: 4px"/>
+                            <div style="margin-left: 4px;">
+                                <div class="bot-message-content">
+                                    <div v-if="index === chatRecordList.length-1">
+                                        <div v-if="item.isTyping">
+                                            <div v-html="item.content"></div>
+                                            <span class="loading-dots" style="display: flex;justify-content: center">
+                                                <span class="dot"></span>
+                                                <span class="dot"></span>
+                                            </span>
+                                        </div>
+                                        <div v-else>
+                                            <div v-html="item.content"></div>
+                                        </div>
+                                    </div>
+                                    <div v-else>
+                                        <div v-show="!expandIndexList.includes(index)" v-html="item.content.substring(0, 80)"></div>
+                                        <div v-show="expandIndexList.includes(index)" v-html="item.content"></div>
+                                    </div>
+                                </div>
+                                <div style="margin: 4px">
+                                    <el-text>{{item.createTime}}</el-text>
+                                </div>
+                            </div>
+                            <div v-if="index !== chatRecordList.length-1">
+                                <el-button v-if="item.content.length>80" style="margin-left: 4px"
+                                           @click="foldOrExpandMessage(index, expandIndexList.includes(index))">
+                                    {{expandIndexList.includes(index)?'折叠':'展开'}}
+                                </el-button>
+                            </div>
+                            <el-button @click="ttsStartPlay(item.content, index)">{{playActiveIndex === index && playButtonFlag ?'停止':'播放'}}</el-button>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="input-container">
+                <el-input
+                        v-model="inputMessage"
+                        type="textarea"
+                        placeholder="请输入消息"
+                        @keyup.enter="sendMessage"
+                ></el-input>
+                <el-button @click="sendMessage" :disabled="isSending" type="primary">发送</el-button>
+            </div>
         </div>
     </div>
 </template>
@@ -39,11 +90,625 @@
 <script setup>
     import headComponent from '@/views/xjc-integratedmachine/components/head_component.vue'
     import Drag_component from "@/views/xjc-integratedmachine/components/drag_component.vue";
-    import {onMounted, ref} from "vue";
+    import {onMounted, ref, nextTick} from "vue";
+    import { getToken } from '@/utils/auth'
+    // md转换为html
+    import { marked } from 'marked'
+    // 语音识别
+    import CryptoJS from 'crypto-js';
+    import * as RecorderManager from "/public/ai/iat/dist/index.umd.js"
+    // 语音播放
+    import {Base64} from 'js-base64'
+    import * as AudioPlayer from "/public/ai/tts/dist/index.umd.js"
     const router = useRouter()
     const route = useRoute()
 
     const param = route.query
+
+
+    const loadingHistoryRecord = ref(false)
+    // 聊天记录
+    let chatRecordList = ref([])
+    // md流式数据
+    const streamMarkdownData = ref('');
+    // html流式数据
+    let streamHtmlData = ref('');
+    // 流式加载状态
+    const isLoading = ref(false);
+    // 读取器实例
+    let reader = null;
+    // AbortController用于中止请求
+    let controller = null;
+    // 输入的问题
+    let inputMessage = ref('')
+    // 发送标识
+    let isSending = ref(false)
+    // 选中的消息,用于展开折叠
+    let expandIndexList = ref([])
+    // 播放按钮状态
+    let playActiveIndex = ref(0)
+    let playButtonFlag = ref(false)
+    // 查看所有聊天记录
+    function list() {
+        loadingHistoryRecord.value = true
+        let queryForm = {
+            pageNum: 1,
+            pageSize: 10000
+        }
+        aiChatRecordList(queryForm).then(resp =>{
+            chatRecordList.value = resp.rows;
+            loadingHistoryRecord.value = false
+            setTimeout(()=>{
+                scrollToBottom();
+            }, 100);
+            if(resp.total == 0){
+                sayHi();
+            }
+        })
+    }
+
+    function addRecord(content) {
+        let queryForm = {
+            content: content,
+            isUser: 0
+        }
+        aiChatRecordAdd(queryForm).then(resp =>{
+            console.log(resp)
+
+        })
+    }
+
+    function sayHi() {
+        const botMsg = {
+            isUser: false,
+            content: '你好,我是来帮助你进行学习规划、选科辅导以及志愿填报的生涯教育专家。如果你有关于这些方面的问题,欢迎随时向我咨询!', // 增量填充
+            isTyping: false, // 显示加载动画
+        }
+        chatRecordList.value.push(botMsg)
+    }
+
+    const sendMessage = () => {
+        if (inputMessage.value.trim()) {
+            sendRequest(inputMessage.value.trim())
+            inputMessage.value = ''
+        }
+    }
+
+    const sendRequest = async(message) => {
+        // 用户信息
+        const userMessage = {
+            isUser: true,
+            content: message,
+            isTyping: false
+        }
+        // 消息加入聊天记录
+        chatRecordList.value.push(userMessage)
+        const botMsg = {
+            isUser: false,
+            content: '', // 增量填充
+            isTyping: true, // 显示加载动画
+        }
+        chatRecordList.value.push(botMsg)
+        const lastMsg = chatRecordList.value[chatRecordList.value.length - 1]
+
+
+        try{
+            isLoading.value = true;
+            streamMarkdownData.value = ''; // 清空之前的数据
+
+            // 创建AbortController以便可以中止请求
+            controller = new AbortController();
+
+            // 请求体
+            let form = {
+                "content": message? message: "你是谁?"
+            }
+            // 发送fetch请求
+            const response = await fetch('/dev-api/ai/chat/record/stream', {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                    'Authorization': 'Bearer ' + getToken()
+                },
+                body: JSON.stringify(form)
+            });
+
+            // 获取可读流的读取器
+            reader = response.body.getReader();
+            const decoder = new TextDecoder('utf-8');
+
+            // 循环读取流数据
+            while (true) {
+                const { done, value } = await reader.read();
+                if (done) break; // 如果流读取完成则退出循环
+
+                // 解码并追加数据
+                let chunk = decoder.decode(value, { stream: true });
+                chunk = chunk.replace(/\n\n/g, '').replace(/data:/g, '')
+                streamMarkdownData.value += chunk;
+                streamHtmlData.value = marked(streamMarkdownData.value)
+                lastMsg.content = marked(streamMarkdownData.value)
+            }
+        }catch (error) {
+            // 如果是手动中止,不显示错误
+            if (error.name !== 'AbortError') {
+                console.error('流式读取失败:', error);
+                streamHtmlData.value = 'Error: ' + error.message;
+            }
+        } finally {
+            isLoading.value = false;
+            let htmlData = marked(streamMarkdownData.value);
+            addRecord(htmlData);
+            streamHtmlData.value = htmlData;
+            lastMsg.content = marked(streamMarkdownData.value)
+            lastMsg.isTyping = false
+            // 组件挂载后自动滚动到底部
+            scrollToBottom();
+        }
+    }
+
+    const stopMessage = () => {
+        if (reader) {
+            // 取消读取
+            reader.cancel().catch(() => {});
+            reader = null;
+        }
+        if (controller) {
+            // 中止请求
+            controller.abort();
+            controller = null;
+        }
+        isLoading.value = false;
+    }
+
+    function foldOrExpandMessage(index, flag){
+        if(flag){
+            expandIndexList.value = expandIndexList.value.filter((item)=>{
+                return item !== index;
+            })
+        }else{
+            expandIndexList.value.push(index)
+        }
+    }
+
+    /* 语音识别 ↓*/
+    // 控制录音弹窗
+    let iatVoiceOpen = false
+    // 是否开始录音
+    let iatStartVoiceStatus = false
+    // 识别中状态
+    let iatIdentifyStatus = false
+    // 录音对象
+    let iatRecorder = null
+    let iatTranscription = ''
+    let iatBtnStatus = ''
+    let iatResultText = ''
+    let iatResultTextTemp = ''
+    let iatCountdownInterval = null
+    let iatWS = null
+    let iatRecognition = null
+    let iatButtonDisabled = true
+    let iatLoading = false
+    let xfIatKeys = {
+        APPID: '5a2643f4',
+        APISecret: 'MTg4MWIzY2VmYTg2YTEwMjliMDY1N2Iz',
+        APIKey: '8b1a53486bec887eb817b4410aa743ed',
+    }
+    // 初始化语音识别
+    function initSpeechRecognition(){
+        // 初始化录音
+        iatRecorder = new window.RecorderManager('/ai/iat/dist');
+        // 开始录音
+        iatRecorder.onStart = () => {
+            iatChangeBtnStatus('OPEN');
+        };
+        iatRecorder.onFrameRecorded = ({ isLastFrame, frameBuffer }) => {
+            if (iatWS.readyState === iatWS.OPEN) {
+                iatWS.send(
+                    JSON.stringify({
+                        data: {
+                            status: isLastFrame ? 2 : 1,
+                            format: 'audio/L16;rate=16000',
+                            encoding: 'raw',
+                            audio: toBase64(frameBuffer),
+                        },
+                    }),
+                );
+                if (isLastFrame) {
+                    iatChangeBtnStatus('CLOSING');
+                }
+            }
+        };
+        iatRecorder.onStop = () => {
+            console.log('录音结束,停止定时器');
+            clearInterval(iatCountdownInterval);
+            iatStartVoiceStatus = false;
+        };
+
+    }
+
+    async function iatStartVoice() {
+        if (iatLoading) {
+            return;
+        }
+        iatVoiceOpen = true;
+        await iatPlayIatVoice();
+    }
+
+    async function iatPlayIatVoice() {
+        if (iatLoading) {
+            return;
+        }
+        iatStartVoiceStatus = !iatStartVoiceStatus;
+        // 浏览器自带的识别
+        if (iatRecognition) {
+            if (iatStartVoiceStatus) {
+                iatRecognition.start();
+            } else {
+                iatRecognition.stop();
+            }
+            return;
+        }
+        if (iatStartVoiceStatus) {
+            iatConnectWebSocket();
+        } else {
+            iatRecorder.stop();
+        }
+    }
+
+    /**
+     * 关闭录音弹窗
+     */
+    function iatCloseVoiceOpen() {
+        iatVoiceOpen = false;
+        iatStartVoiceStatus = false;
+        if (iatRecorder) {
+            iatRecorder.stop();
+        }
+        if (iatRecognition) {
+            iatRecognition.stop();
+        }
+        iatTranscription = '';
+    }
+
+    function iatRenderResult(resultData) {
+        // 识别结束
+        const jsonData = JSON.parse(resultData);
+        if (jsonData.data && jsonData.data.result) {
+            const data = jsonData.data.result;
+            let str = '';
+            const { ws } = data;
+            for (let i = 0; i < ws.length; i += 1) {
+                str += ws[i].cw[0].w;
+            }
+            // 开启wpgs会有此字段(前提:在控制台开通动态修正功能)
+            // 取值为 "apd"时表示该片结果是追加到前面的最终结果;取值为"rpl" 时表示替换前面的部分结果,替换范围为rg字段
+            if (data.pgs) {
+                if (data.pgs === 'apd') {
+                    // 将resultTextTemp同步给resultText
+                    iatResultText = iatResultTextTemp;
+                }
+                // 将结果存储在resultTextTemp中
+                iatResultTextTemp = iatResultText + str;
+            } else {
+                iatResultText += str;
+            }
+            inputMessage.value = iatResultTextTemp || iatResultText || '';
+        }
+        if (jsonData.code === 0 && jsonData.data.status === 2) {
+            iatWS.close();
+        }
+        if (jsonData.code !== 0) {
+            iatWS.close();
+            console.error(jsonData);
+        }
+    }
+
+    function iatConnectWebSocket() {
+        const websocketUrl = iatGetWebSocketUrl();
+        if ('WebSocket' in window) {
+            iatWS = new window.WebSocket(websocketUrl);
+        } else if ('MozWebSocket' in window) {
+            iatWS = new window.MozWebSocket(websocketUrl);
+        } else {
+            message.error('浏览器不支持WebSocket');
+            return;
+        }
+        iatChangeBtnStatus('CONNECTING');
+        iatWS.onopen = e => {
+            console.log('iatWS.onopen', e);
+            // 开始录音
+            iatRecorder.start({
+                sampleRate: 16000,
+                frameSize: 1280,
+            });
+            const params = {
+                common: {
+                    app_id: xfIatKeys.APPID,
+                },
+                business: {
+                    language: 'zh_cn',
+                    domain: 'iat',
+                    accent: 'mandarin',
+                    vad_eos: 5000,
+                    dwa: 'wpgs',
+                    nbest: 1,
+                    wbest: 1,
+                },
+                data: {
+                    status: 0,
+                    format: 'audio/L16;rate=16000',
+                    encoding: 'raw',
+                },
+            };
+            iatWS.send(JSON.stringify(params));
+        };
+        iatWS.onmessage = e => {
+            iatRenderResult(e.data);
+        };
+        iatWS.onerror = e => {
+            console.error(e);
+            iatRecorder.stop();
+            iatChangeBtnStatus('CLOSED');
+        };
+        iatWS.onclose = e => {
+            console.log(e);
+            iatRecorder.stop();
+            iatChangeBtnStatus('CLOSED');
+        };
+    }
+
+    function iatGetWebSocketUrl() {
+        const { APIKey, APISecret } = xfIatKeys;
+        if (!APIKey) {
+            message.error('语音识别配置未生效');
+            return null;
+        }
+        // 请求地址根据语种不同变化
+        let url = 'wss://iat-api.xfyun.cn/v2/iat';
+        const host = 'iat-api.xfyun.cn';
+        const apiKey = APIKey;
+        const apiSecret = APISecret;
+        const date = new Date().toGMTString();
+        const algorithm = 'hmac-sha256';
+        const headers = 'host date request-line';
+        const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
+        const signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
+        const signature = CryptoJS.enc.Base64.stringify(signatureSha);
+        const authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`;
+        const authorization = btoa(authorizationOrigin);
+        url = `${url}?authorization=${authorization}&date=${date}&host=${host}`;
+        return url;
+    }
+
+    function iatCountdown() {
+        let seconds = 60;
+        console.log(`录音中(${seconds}s)`);
+        iatCountdownInterval = setInterval(() => {
+            seconds -= 1;
+            if (seconds <= 0) {
+                clearInterval(iatCountdownInterval);
+                iatRecorder.stop();
+            } else {
+                console.log(`录音中(${seconds}s)`);
+            }
+        }, 1000);
+    }
+
+    function iatChangeBtnStatus(status) {
+        iatBtnStatus = status;
+        if (status === 'CONNECTING') {
+            console.log('建立连接中');
+            iatResultText = '';
+            iatResultTextTemp = '';
+        } else if (status === 'OPEN') {
+            if (iatRecorder) {
+                iatCountdown();
+            }
+        } else if (status === 'CLOSING') {
+            console.log('关闭连接中');
+        } else if (status === 'CLOSED') {
+            console.log('开始录音');
+        }
+    }
+
+    function toBase64(buffer) {
+        let binary = '';
+        const bytes = new Uint8Array(buffer);
+        const len = bytes.byteLength;
+        for (let i = 0; i < len; i += 1) {
+            binary += String.fromCharCode(bytes[i]);
+        }
+        return window.btoa(binary);
+    }
+
+    /* 语音识别 ↑*/
+
+    /*语音合成 ↓*/
+    // 合成对象
+    let audioPlayer = null
+    // 按钮状态:"UNDEFINED" "CONNECTING" "PLAY" "STOP"
+    let btnStatus = "UNDEFINED";
+    //
+    let ttsWS = null;
+
+    function initSpeechSynthesis(){
+        // 初始化语音播放
+        audioPlayer = new window.AudioPlayer("/ai/tts/dist");
+        // 开始播放
+        audioPlayer.onPlay = () => {
+            changeBtnStatus("PLAY");
+        };
+        audioPlayer.onStop = (audioDatas) => {
+            console.log('停止播放');
+            // 修改为undefined,下次播放重头开始
+            btnStatus === "PLAY" && changeBtnStatus("UNDEFINED");
+        };
+    }
+
+    function changeBtnStatus(status) {
+        btnStatus = status;
+        if (status === "UNDEFINED") {
+            console.log("立即合成");
+        } else if (status === "CONNECTING") {
+            console.log("正在合成");
+        } else if (status === "PLAY") {
+            console.log("停止播放");
+        } else if (status === "STOP") {
+            console.log("重新播放");
+        }
+    }
+
+    function getWebSocketUrl() {
+        const { APIKey, APISecret } = xfIatKeys;
+        if (!APIKey) {
+            message.error('语音合成配置未生效');
+            return null;
+        }
+        let url = "wss://tts-api.xfyun.cn/v2/tts";
+        let host = "tts-api.xfyun.cn";
+        const apiKey = APIKey;
+        const apiSecret = APISecret;
+        let date = new Date().toGMTString();
+        let algorithm = "hmac-sha256";
+        let headers = "host date request-line";
+        let signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/tts HTTP/1.1`;
+        let signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
+        let signature = CryptoJS.enc.Base64.stringify(signatureSha);
+        let authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`;
+        let authorization = btoa(authorizationOrigin);
+        url = `${url}?authorization=${authorization}&date=${date}&host=${host}`;
+        return url;
+    }
+
+    function encodeText(text, type) {
+        if (type === "unicode") {
+            let buf = new ArrayBuffer(text.length * 4);
+            let bufView = new Uint16Array(buf);
+            for (let i = 0, strlen = text.length; i < strlen; i++) {
+                bufView[i] = text.charCodeAt(i);
+            }
+            let binary = "";
+            let bytes = new Uint8Array(buf);
+            let len = bytes.byteLength;
+            for (let i = 0; i < len; i++) {
+                binary += String.fromCharCode(bytes[i]);
+            }
+            return window.btoa(binary);
+        } else {
+            return Base64.encode(text);
+        }
+    }
+
+    function ttsStartPlay(text, index) {
+        playActiveIndex.value = index
+        playButtonFlag.value = !playButtonFlag.value;
+        if (btnStatus === "UNDEFINED") {
+            // 开始合成
+            connectWebSocket(text.replace(/<\/?.+?\/?>/g,''));
+        } else if (btnStatus === "CONNECTING") {
+            // 停止合成
+            changeBtnStatus("UNDEFINED");
+            ttsWS?.close();
+            audioPlayer.reset();
+            return;
+        } else if (btnStatus === "PLAY") {
+            audioPlayer.stop();
+        } else if (btnStatus === "STOP") {
+            audioPlayer.play();
+        }
+    };
+
+    function connectWebSocket(text) {
+        const url = getWebSocketUrl();
+        if ("WebSocket" in window) {
+            ttsWS = new WebSocket(url);
+        } else if ("MozWebSocket" in window) {
+            ttsWS = new MozWebSocket(url);
+        } else {
+            alert("浏览器不支持WebSocket");
+            return;
+        }
+        changeBtnStatus("CONNECTING");
+
+        ttsWS.onopen = (e) => {
+            audioPlayer.start({
+                autoPlay: true,
+                sampleRate: 16000,
+                resumePlayDuration: 1000
+            });
+            changeBtnStatus("PLAY");
+            let tte = "UTF8";
+            let params = {
+                common: {
+                    app_id: xfIatKeys.APPID,
+                },
+                business: {
+                    aue: "raw",
+                    auf: "audio/L16;rate=16000",
+                    vcn: "xiaoyan",
+                    // vcn: "aisbabyxu",
+                    // vcn: "aisjinger",
+                    // vcn: "aisxping",
+                    // vcn: "aisjinger",
+                    // vcn: "x4_lingxiaoyao_em",
+                    // vcn: "x4_lingxiaoyao_en",
+                    speed: +50,
+                    volume: +50,
+                    pitch: +50,
+                    bgs: 1,
+                    tte,
+                },
+                data: {
+                    status: 2,
+                    text: encodeText(text, tte),
+                },
+            };
+            ttsWS.send(JSON.stringify(params));
+        };
+        ttsWS.onmessage = (e) => {
+            let jsonData = JSON.parse(e.data);
+            // 合成失败
+            if (jsonData.code !== 0) {
+                console.error(jsonData);
+                changeBtnStatus("UNDEFINED");
+                return;
+            }
+            audioPlayer.postMessage({
+                type: "base64",
+                data: jsonData.data.audio,
+                isLastData: jsonData.data.status === 2,
+            });
+            if (jsonData.code === 0 && jsonData.data.status === 2) {
+                ttsWS.close();
+            }
+        };
+        ttsWS.onerror = (e) => {
+            console.error(e);
+        };
+        ttsWS.onclose = (e) => {
+            // console.log(e);
+        };
+    }
+
+    /*语音合成 ↑*/
+
+    let chatContainerRef = ref(null)
+    function scrollToBottom(){
+        if(chatContainerRef.value.scrollHeight){
+            chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight+20;
+        }
+    }
+
+    onMounted(()=>{
+        nextTick(()=>{
+            initSpeechRecognition();
+            initSpeechSynthesis();
+            list();
+        })
+
+    })
+
 </script>
 
 <style  scoped lang="scss">