You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

959 lines
28 KiB

2 months ago
<template>
<view class="chat-wrap__main">
<view class="chat-wrap__main-content" :style="{ 'margin-bottom': `${chatMainMrgBottom}px` }">
<!-- <view>显示内容</view> -->
<!-- <ClientChat @send="onSendQuestion" /> -->
<scroll-view class="chat-wrapper" :scroll-into-view="bottom" :scroll-y="true" :scroll-with-animation="true" scroll-into-view-offset="10">
<view class="list-container">
<view class="info">
<view class="head-img">
<image class="rot-head-img" :src="robotObj.headImage"></image>
</view>
<view class="rot-title">
<view>你好我是数字领航员</view>
<view>{{ robotObj.name }}</view>
</view>
</view>
2 months ago
<!-- for循环 -->
<view class="chat-list" v-for="n,index in msgList" :key="index">
<view class="msg-container other" v-if="n.type =='reply' ">
<!-- <image class="ava" :src="robotObj.headImage"></image> -->
2 months ago
<view class="msg">
<!-- <view class="msg-nickname">{{robotObj.name}}</view> -->
2 months ago
<view :class="n.contentType==='img' ? 'msg-content-img' : '' " class="msg-content">
<template v-if="n.pending">
<Pending></Pending>
</template>
<template v-else>
<template v-if="n.contentType === 'text'">
<rich-text v-if="n.contentType === 'text'" :nodes="n.content"></rich-text>
<view v-if="n.is_final" class="msg-btns">
<image class="btn-img" @click="copy(n.content)" src="./../../static/imgs/icon-copy.png"></image>
<image class="btn-img" @click="audio(n)" :src="audioActive == n.timestamp ? './../../static/imgs/icon-bf-active.png':'./../../static/imgs/icon-bf.png'"></image>
2 months ago
</view>
</template>
<image v-else-if="n.contentType === 'img'" mode="widthFix" class="cimg" src="./../../static/imgs/more.png"></image>
</template>
</view>
</view>
</view>
<view v-if="n.type==='send' " class="msg-container self">
<view class="msg">
<!-- <view class="msg-nickname">本人</view> -->
<view class="msg-content" :class="n.contentType==='img' ? 'msg-content-img' : '' ">
<text v-if="n.contentType === 'text'">{{n.content}}</text>
<image v-else-if="n.contentType === 'img'" mode="widthFix" class="cimg" :src="n.content"></image>
</view>
</view>
<!-- <image class="ava" src="./../../static/imgs/more.png"></image> -->
</view>
</view>
</view>
<view id="bottom" style="opacity:0">
</view>
</scroll-view>
</view>
<view class="chat-wrap__main-footer">
<view class="disabled-loadding" v-if="disabledStatus"></view>
<view class="chatinput-wrapper">
<view class="chatinput-content">
<view class='chatinput-input-wrap' v-if="videoStatus">
<view style="text-align: center; flex: 1; color:#9FA0A0" @longpress="startVideo" @touchend="stopVideo">
按住 说话
2 months ago
</view>
</view>
<view v-else class='chatinput-input-wrap'>
<input v-model="inputValue" @focus="inputFocus" @input="inputChange" @confirm="inputSend" placeholder="想对TA说点什么呢…" confirm-type='send' />
</view>
<view class="chatinput-btn-wrap">
<!-- <image v-if="!isEditInput" :src="videoStatus ? './../../static/imgs/icon-txt.png' :'./../../static/imgs/icon-video.png'" class='chatinput-img' @click="changeVideoStatus"></image> -->
<!-- <image v-if="!isEditInput" src="./../../static/imgs/icon-pic.png" class='chatinput-img' @click="tapChooseImage"></image> -->
<!-- <button v-if="isEditInput" class="chatinput-send">发送</button> -->
<template v-if="videoStatus">
<image src="./../../static/imgs/icon-txt.png" class="chatinput-img" @click="changeVideoStatus"></image>
</template>
<template v-else>
<image v-if="inputValue" src="./../../static/imgs/icon-up.png" class='chatinput-img' @click="inputSend"></image>
<image v-else src="./../../static/imgs/icon-video.png" class='chatinput-img' @click="changeVideoStatus"></image>
</template>
2 months ago
</view>
</view>
</view>
</view>
<!-- <live-player src="https://domain/pull_stream" mode="RTC" autoplay bindstatechange="statechange" binderror="error" style="width: 300px; height: 225px;" /> -->
2 months ago
</view>
</template>
<script>
import Socket from './utils/socket'
import Audio from './utils/audio'
import PCMPlayer from './utils/pcm-player'
2 months ago
import { splitTextForTTS } from './utils/util'
import {
getHistroyMsg,
2 months ago
setMsgData
2 months ago
} from './utils/message'
import ClientData from './utils/ClientData';
import Pending from './components/pending';
export default {
props: {
agentId: {
type: String,
default: '',
}
},
components: {
Pending
},
watch: {
agentId: {
handler(val) {
if (val) {
this.init();
}
},
immediate: true,
},
},
data() {
return {
title: 'Hello',
socketObj: null,
isEditInput: false,
inputValue: '',
chatMainMrgBottom: '',
bottom: '',
videoStatus: false,
msgList: [],
recorderManager: null,
audioCtx: '',
audioActive: '',
$ClientData: '',
robotObj: {},
audioArray: [],
asrStatus: false,
tmpMsg: {},
lastTxt: {},
txtAudioStaus: {},
2 months ago
requestMsg: {},
disabledStatus: false,
audioOBJ: null,
reAudioType: false
2 months ago
}
},
onLoad() {
},
mounted() {
console.log('【init message connect type------>】',);
// this.init()
},
destroyed() {
console.log('【destroy message connect type------>】',);
this.socketObj.selfCloseStatus = true
this.socketObj.destroy()
this.audioOBJ.selfCloseStatus = true
this.audioOBJ.destroy()
2 months ago
this.audioCtx && this.audioCtx.destroy()
this.audioCtx = null
this.audioActive = ''
this.audioStatus = false
this.asrStatus = false
this.player && this.player.destroy()
},
onHide() {
console.log('【hide message connect type------>】',);
2 months ago
},
onShow() {
this.scrollToBottom()
},
methods: {
/* 4. 滚动日志 */
appendLog(str) {
console.log(1111, str)
},
2 months ago
async init() {
this.player = new PCMPlayer({
sampleRate: 16000,
onPlay: (obj) => {
console.log('播放文字', obj)
this.disabledStatus = true
if (obj.is_final) {
// 最后一条语音
console.log('=========最后一条语音=========', obj);
this.disabledStatus = false // 语音播发结束
this.audioActive = ''
}
if (this.reAudioType) {
return
}
const index = self.msgList.findIndex(it => it.request_id == obj.request_id && it.type == 'reply')
if (index > -1) {
self.msgList[index].content += obj.content
self.msgList[index].is_final = obj.is_final
self.$nextTick(() => {
self.scrollToBottom()
})
} else {
if (self.msgList.length) {
const item = self.msgList[self.msgList.length - 1]
const tmpinfo = {
timestamp: new Date().getTime(),
name: this.robotObj.name,
headImage: this.robotObj.headImage,
type: 'reply',
contentType: 'text',
...item,
pending: false,
request_id: obj.request_id,
content: obj.content,
}
if (item.pending) {
self.msgList[self.msgList.length - 1] = tmpinfo
self.$nextTick(() => {
self.scrollToBottom()
})
} else {
self.msgList.push(tmpinfo)
}
} else {
const tmp = {
...obj,
type: 'reply',
contentType: 'text',
timestamp: new Date().getTime(),
name: this.robotObj.name,
headImage: this.robotObj.headImage,
content: obj.content,
pending: false
}
self.msgList.push(tmp)
self.$nextTick(() => {
self.scrollToBottom()
})
}
}
self.scrollToBottom()
},
onAudioEnd: () => {
console.log('语音播放结束')
},
})
// 使用示例
// const player = new AudioStreamPlayer()
this.audioOBJ = new Audio({
onMessage: (e) => {
if (e instanceof ArrayBuffer) {
this.player.feed(e)
return
}
if (e.type == 2) {
// 文本
if (e.data.result && e.data) {
this.player.addText(e.data)
}
}
if (e.type == 3) {
// 合成结束
}
return
if (e instanceof ArrayBuffer) {
// 语音
player.processChunk(e)
return
}
if (e.type == 2) {
// 文本
player.processChunkTxt(e.data.result)
}
if (e.type == 0) {
// 语音传输结束
// player.play((txt) => {
// const index = self.msgList.findIndex(it => it.request_id == e.request_id && it.type == 'reply')
// if (index > -1) {
// self.msgList[index].content += txt
// self.$nextTick(() => {
// self.scrollToBottom()
// })
// } else {
// if (self.msgList.length) {
// const obj = self.msgList[self.msgList.length - 1]
// if (obj.pending) {
// const tmp = {
// ...e,
// pending: false,
// content: txt
// }
// self.msgList[self.msgList.length - 1] = tmp
// self.$nextTick(() => {
// self.scrollToBottom()
// })
// } else {
// self.msgList.push(e)
// }
// } else {
// const tmp = {
// ...e,
// content: txt,
// pending: false
// }
// self.msgList.push(tmp)
// self.$nextTick(() => {
// self.scrollToBottom()
// })
// }
// }
// self.scrollToBottom()
// })
// 结束之后开始播放
// player.processChunk()
}
}
})
this.audioOBJ.init()
2 months ago
this.audioStatus = true
this.asrStatus = true
uni.showLoading()
console.log('【init message connect type------>】',);
const self = this
2 months ago
this.socketObj = new Socket({
agentId: this.agentId,
onMessage: (e) => {
console.log(e.request_id + '回复内容', e.content)
e.pending = false
self.requestMsg[e.request_id] = (self.requestMsg[e.request_id] ? self.requestMsg[e.request_id] : '') + e.content
2 months ago
if (!e.is_from_self && e.is_final) {
e.content = self.requestMsg[e.request_id]
2 months ago
const audioParams = e
// 回复结束 写入缓存
2 months ago
setMsgData(audioParams)
console.log('开始', audioParams);
self.audio(audioParams, true)
2 months ago
}
self.scrollToBottom()
2 months ago
}
})
this.getRecord(this.agentId)
this.scrollToBottom(1000)
await this.socketObj.init()
this.$ClientData = new ClientData()
this.$ClientData.init()
this.$ClientData.setAttr({ socketObj: this.socketObj })
this.robotObj = this.socketObj.robotObj
wx.hideLoading()
this.sendMsg()
wx.setNavigationBarTitle({
title: '数字领航员-' + this.robotObj.name
})
2 months ago
},
async renderTxt(e) {
for (let index = 0; index < this.tmpMsg[e.request_id].length; index++) {
const txt = this.tmpMsg[e.request_id][index];
const params = {
content: txt,
timestamp: e.timestamp
}
await this.audio(params)
2 months ago
}
},
sendMsg() {
if (!this.msgList.length) {
this.$ClientData.triggerSendMsg(this.socketObj.robotObj.firstWord, 'text', false);
}
},
removeSpecificText(originalText, textToRemove) {
// 使用字符串替换方法删除指定文本
return originalText.replace(new RegExp(textToRemove, 'g'), '');
},
// 复制
copy(e) {
wx.setClipboardData({
data: e,
success: function () {
wx.showToast({ title: '复制成功' });
}
});
},
async audio(n, status) {
console.log('【audio------>】', n, status);
this.reAudioType = !status
this.audioArray = []
// this.audioArray = this.audioArray.slice(-10)
2 months ago
const txt = this.stripHtmlTags(n.content)
if (!txt) { return }
this.audioCtx && this.audioCtx.destroy()
this.audioCtx = null
if (this.audioActive == n.timestamp) {
this.audioActive = ''
return
}
this.player && this.player.destroy()
uni.request({
method: "post",
data: {
sessionId: this.audioOBJ.session_id,
text: txt,
voiceType: this.socketObj.robotObj.voiceType ?? 1002
},
dataType: "json",
url: `https://des.js-dyyj.com/xcx/api/voice/tts/flow`,
success: (res) => {
this.audioActive = n.timestamp
},
fail: (err) => {
wx.showToast({
title: '识别失败',
icon: 'error'
});
},
complete: () => {
2 months ago
}
});
2 months ago
},
audioText(text, cb) {
return new Promise(async (resolve, reject) => {
2 months ago
if (!this.audioStatus) { reject() }
cb && cb(text)
2 months ago
const url = await this.loadAudioUrl(text)
2 months ago
if (!this.audioStatus) { reject() }
2 months ago
console.log('开始播放文字', text)
2 months ago
2 months ago
this.audioCtx = wx.createInnerAudioContext();
2 months ago
this.audioCtx.src = 'data:audio/wav;base64,' + url
// this.audioCtx.src = 'data:audio/wav;base64,'+ url
// console.log('App Launch', this.audioCtx.src)
2 months ago
this.audioCtx.play()
this.audioCtx.onStop(() => {
console.log('语音播放停止', text)
2 months ago
resolve()
})
this.audioCtx.onEnded(() => {
console.log('语音播放结束', text)
resolve()
})
this.audioCtx.onError((error) => {
console.log('语音播放失败', text, error)
2 months ago
resolve()
})
});
},
loadAudioUrl(text) {
if (this.audioArray[text]) {
return Promise.resolve(this.audioArray[text])
} else {
2 months ago
console.log('-----开始请求语音合成-----', text)
2 months ago
return new Promise((resolve, reject) => {
uni.request({
method: "post",
data: {
text,
voiceType: this.socketObj.robotObj.voiceType ?? 1002
},
dataType: "json",
2 months ago
url: `https://des.js-dyyj.com/xcx/api/voice/tts/new`,
2 months ago
success: (res) => {
2 months ago
console.log('-----请求语音合成回参-----', res)
if (res.data.code == 200) {
console.log('-----请求语音合成成功-----', text)
this.audioArray[text] = res.data.data.audio
resolve(res.data.data.audio)
2 months ago
} else {
2 months ago
console.log('-----请求语音合成失败-----', text)
2 months ago
wx.showToast({ title: '文字转化失败' });
reject()
}
},
fail: (err) => {
2 months ago
console.log("-------请求语音合成失败---->】", err);
wx.showToast({ title: '文字转化失败' });
2 months ago
},
});
})
}
},
stripHtmlTags(html) {
return html.replace(/<\/?[^>]+(>\$)/g, '')
.replace(/\u00a0/g, '')
// .replace(/\s+/g, ' ')
.replace(/\([^)]*\)/g, '')
.replace(/\([^)]*\)/g, '')
.replace(/\<br\/\>/g, '')
.replace(/\<br\>/g, '')
},
getRecord(id) {
// 获取聊天记录
let { msgList } = getHistroyMsg(id)
if (msgList && msgList.length) {
this.msgList = msgList.slice(-10)
} else {
this.msgList = []
}
},
changeVideoStatus() {
this.videoStatus = !this.videoStatus
},
startVideo() {
if (!this.recorderManager) {
this.recorderManager = wx.getRecorderManager()
}
this.recorderManager.start({
duration: 10000,
sampleRate: 44100,
numberOfChannels: 1,
encodeBitRate: 192000,
format: 'aac',
frameSize: 50
})
this.recorderManager.onStart(() => {
console.log('开始录音')
wx.showLoading({
title: '录音中',
})
})
this.recorderManager.onStop(async (res) => {
wx.showLoading({
title: '识别中',
mask: true
})
uni.uploadFile({
url: 'https://des.js-dyyj.com/xcx/system/oss/upload',
filePath: res.tempFilePath,
name: 'file',
success: async (res) => {
let data = JSON.parse(res.data);
console.log('上传成功', res, data);
if (!this.asrStatus) { return }
if (data.code == 200) {
uni.request({
method: "get",
dataType: "json",
url: `https://des.js-dyyj.com/xcx/api/voice/asr?audioFilePath=${encodeURI(data.data.url)}`,
success: (res) => {
console.log("【init msg-------res---->】", res);
if (!this.asrStatus) { return }
if (res.data.code == 200) {
wx.hideLoading()
if (res.data.msg) {
this.onSendQuestion(res.data.msg);
this.scrollToBottom()
} else {
wx.showToast({
title: '没有听清,请再说一遍',
icon: 'none'
});
}
} else {
wx.showToast({
title: '识别失败',
icon: 'error'
});
}
},
fail: (err) => {
wx.showToast({
title: '识别失败',
icon: 'error'
});
},
complete: () => {
this.delAudioFile(data.data.url)
}
});
}
},
fail: (err) => {
console.log('上传失败', err);
wx.showToast({
title: '识别失败',
icon: 'error'
});
}
});
})
this.recorderManager.onFrameRecorded((res) => {
const { frameBuffer } = res
console.log('frameBuffer', frameBuffer)
// 实时处理音频帧数据
this.getVideoText(frameBuffer)
})
},
delAudioFile(path) {
if (path) {
uni.request({
method: "get",
dataType: "json",
url: `https://des.js-dyyj.com/xcx/api/voice/deleteSingleFile?audioFilePath=${encodeURI(path)}`,
success: (res) => {
console.log("【删除文件msg-------res---->】", res);
},
fail: (err) => {
console.log("【init msg-------getDemoToken---->】", err);
},
});
}
},
getVideoText(frameBuffer) {
const params = {
Action: 'SentenceRecognition',
Version: '2019-06-14',
EngineModelType: '8k_zh',
ChannelNum: 1,
VoiceFormat: 'acc',
ResTextFormat: 3,
ResTextFormat: 1
}
},
stopVideo() {
console.log('结束录音')
this.recorderManager && this.recorderManager.stop && this.recorderManager.stop()
this.recorderManager = null
},
inputSend() {
console.log('【inputSend------>】',);
this.onSendQuestion(this.inputValue)
this.scrollToBottom()
},
inputFocus() {
console.log('inputFocus------>】',);
this.scrollToBottom()
},
inputChange() {
console.log('inputChange------>】',);
},
// *发送图片
async tapChooseImage() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
uni.uploadFile({
url: 'https://des.js-dyyj.com/xcx/system/oss/upload',
filePath: res.tempFiles[0].tempFilePath,
name: 'file',
success: (res) => {
console.log('上传成功', res);
let data = JSON.parse(res.data);
if (data.code == 200) {
const img = data.data.url
this.$ClientData.triggerSendMsg(img, 'img');
const postData = {
content: img,
type: 'send',
contentType: 'img',
timestamp: +new Date(),
chatId: this.agentId
}
console.log('postData', postData)
this.msgList.push(postData)
// 加入恢复空信息
this.inputTmpReply()
this.scrollToBottom()
this.disabledStatus = true
}
},
fail: (err) => {
console.log('上传失败', err);
}
});
}
})
},
onSendQuestion(e) {
let self = this
if (e === '') {
return wx.showToast({ title: '不能发送空白消息', icon: 'none' })
}
this.disabledStatus = true
console.log('发送问题', e)
this.$ClientData.triggerSendMsg(e, 'text');
const postData = {
content: e,
type: 'send',
contentType: 'text',
timestamp: +new Date(),
chatId: this.agentId
}
self.msgList.push(postData)
self.inputValue = ''
this.inputTmpReply()
},
inputTmpReply() {
// 加入恢复空信息
const reData = {
content: '',
pending: true,
type: 'reply',
contentType: 'text',
// timestamp: +new Date(),
chatId: this.agentId,
nickName: this.socketObj.robotObj.name,
headImage: this.socketObj.robotObj.headImage,
}
this.msgList.push(reData)
},
// !滑倒最底部
scrollToBottom(time = 300) {
this.$nextTick(() => {
setTimeout(() => {
this.bottom = ''
this.$nextTick(function () {
this.bottom = 'bottom'
})
}, time)
});
}
}
}
</script>
<style scoped lang="scss">
.info {
display: flex;
align-items: center;
flex-direction: column;
padding-top: 100rpx;
.head-img {
width: 35vw;
height: 35vw;
.rot-head-img {
width: 100%;
height: 100%;
}
}
.rot-title {
margin-top: 40rpx;
font-weight: bold;
font-size: 26rpx;
text-align: center;
}
}
2 months ago
.chat-wrap__main {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 700px;
overflow: hidden;
background: #f8f8f8;
2 months ago
border-radius: 12px;
&-chat-content {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
&-content {
height: calc(100% - 80px);
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
&-footer {
position: relative;
z-index: 3;
}
}
.chat-wrap__main-content {
flex: 1;
}
.chat-wrap__main-footer {
height: 140rpx;
padding: 10rpx;
padding-bottom: 10px;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
position: relative;
.disabled-loadding {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: rgba($color: #ffffff, $alpha: 0.4);
z-index: 9;
}
}
// 输入框
.chatinput-wrapper {
width: 100%;
// border-top: 1px solid #ddd;
// border-bottom: 1px solid #ddd;
.chatinput-content {
height: 80rpx;
padding: 20rpx;
border-radius: 10rpx;
2 months ago
display: flex;
align-items: center;
margin: 0 20rpx;
background: #fff;
2 months ago
.chatinput-input-wrap {
flex: 1;
height: 80rpx;
box-sizing: border-box;
display: flex;
align-items: center;
color: #9fa0a0;
2 months ago
background: #fff;
input {
height: 76rpx;
font-size: 16px;
display: inline-block;
width: 100%;
margin: 10px;
}
}
.chatinput-btn-wrap {
width: 50px;
display: flex;
align-items: center;
justify-content: center;
image {
width: 30px;
height: 30px;
}
.chatinput-send {
height: 35px;
width: 43px;
border-radius: 4px;
font-size: 12px;
line-height: 35px;
padding: 0;
margin: 0 8px;
color: #fff;
background: #0097ff;
&::after {
border: none;
}
}
}
image {
width: 40rpx;
height: 40rpx;
}
2 months ago
}
}
.chat-wrapper {
height: 100%;
.chat-list {
padding: 10px 0;
.msg-container {
display: flex;
.ava {
width: 35px;
height: 35px;
border-radius: 4px;
margin: 0 10px;
}
.msg-nickname {
color: #999;
line-height: 1;
margin-bottom: 4px;
}
.msg-content {
box-sizing: border-box;
word-wrap: break-word;
max-width: 80vw;
padding: 30rpx 30rpx 20rpx;
border-radius: 10rpx;
2 months ago
background: #fff;
/* border: 1px solid #e7e7e7; */
2 months ago
max-width: "calc(100vw - 110px)";
color: #000;
font-size: 28rpx;
2 months ago
.msg-btns {
border-top: 1px solid #e7e7e7;
margin-top: 40rpx;
padding-top: 20rpx;
border-top: 1px dashed #000;
2 months ago
.btn-img {
margin-right: 10px;
width: 40rpx;
height: 40rpx;
2 months ago
cursor: pointer;
}
}
}
&.self {
justify-content: flex-end;
padding-right: 40rpx;
2 months ago
.msg {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
}
.msg-nickname {
text-align: right;
}
.msg-content {
width: auto;
color: #000;
background-color: #acf8f8;
/* border-color:#acf8f8; */
padding: 30rpx;
2 months ago
}
}
&.other {
padding-left: 40rpx;
2 months ago
.msg {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
}
}
.msg-content.msg-content-img {
background: none;
border: none;
padding: 0;
image {
max-width: 120px;
border-radius: 2px;
}
}
}
}
}
</style>