|
@@ -31,7 +31,58 @@
|
|
<div>小新老师</div>
|
|
<div>小新老师</div>
|
|
<div class="header-exit-btn">退出</div>
|
|
<div class="header-exit-btn">退出</div>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
@@ -39,11 +90,625 @@
|
|
<script setup>
|
|
<script setup>
|
|
import headComponent from '@/views/xjc-integratedmachine/components/head_component.vue'
|
|
import headComponent from '@/views/xjc-integratedmachine/components/head_component.vue'
|
|
import Drag_component from "@/views/xjc-integratedmachine/components/drag_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 router = useRouter()
|
|
const route = useRoute()
|
|
const route = useRoute()
|
|
|
|
|
|
const param = route.query
|
|
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>
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
<style scoped lang="scss">
|