| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- <template>
- <view class="markdown">
- <view class="content">
- <ua-markdown ref="markdown" :source="content" />
- </view>
- <view v-if="!last || !globalStore.chatLoading" class="options">
- <uni-load-more v-if="audioStatus == 'loading'" class="option-icon" iconType="circle" :show-text="false"
- status="loading" color="#7E8AA2" :icon-size="16" @tap="playAudio" />
- <image v-else-if="audioStatus == 'play'" class="option-icon" mode="aspectFit"
- :src="getStaticImageUrl('/images/audio-play.gif')" @tap="playAudio" />
- <image v-else-if="audioStatus == 'pause'" class="option-icon" mode="aspectFit"
- :src="getStaticImageUrl('/images/audio-pause.png')" @tap="playAudio" />
- <image v-else class="option-icon" mode="aspectFit" :src="getStaticImageUrl('/images/trumpet.png')"
- @tap="playAudio" />
- <image class="option-icon" mode="aspectFit" :src="getStaticImageUrl('/images/copy.png')"
- @tap="copyContent" />
- <view class="separate"></view>
- <image class="option-icon" mode="aspectFit"
- :src="getStaticImageUrl(likeFlag ? '/images/praise-1.png' : '/images/praise.png')" @tap="likeClick" />
- <image class="option-icon" mode="aspectFit"
- :src="getStaticImageUrl(stepFlag ? '/images/trample-1.png' : '/images/trample.png')" @tap="stepClick" />
- </view>
- </view>
- </template>
- <script>
- import {
- useGlobalStore
- } from '@/stores/global';
- import {
- RequestApi
- } from '@/api/requestApi';
- import PCMAudioPlayer from '@/utils/PCMAudioPlayer';
- import {
- safeTruncate,
- displayLength,
- isEmpty
- } from '@/utils/string.extensions';
- export default {
- name: 'SsChatReplyMarkdown',
- props: {
- chatId: {
- type: String,
- default: ''
- },
- content: {
- type: String,
- default: ''
- },
- title: {
- type: String,
- default: ''
- },
- last: {
- type: Boolean,
- default: false
- }
- },
- data() {
- return {
- globalStore: useGlobalStore(),
- mpHtmlOptions: {
- markdown: true,
- sanitize: true,
- highlight: code => {
- return this.$hljs.highlightAuto(code).value
- },
- styles: {
- 'code': 'padding: 3px 5px; border-radius: 4px;',
- 'table': 'border-collapse: collapse;',
- 'th, td': 'border: 1px solid #ddd; padding: 8px;'
- }
- },
- likeFlag: false,
- stepFlag: false,
- loading: false,
- audioStatus: 'wait',
- ttsAudioPlayer: null,
- ttsToken: null,
- WS: null,
- ttsBuffer: null,
- ttsPaused: false,
- }
- },
- computed: {
- playChatId() {
- return this.globalStore.playChatId ?? '';
- },
- appHide() {
- return this.globalStore.appHide ?? '';
- }
- },
- mounted() {
- this.initAudioPlayer();
- },
- destroyed() {
- if (this.WS) {
- this.WS?.close();
- }
- },
- onUnload() {
- if (this.WS) {
- this.WS?.close();
- }
- },
- onHide() {
- console.log('隐藏...')
- this.pauseAudio();
- },
- methods: {
- previewImage(src) {
- uni.previewImage({
- urls: [src],
- current: src
- })
- },
- copyContent() {
- if (this.$refs.markdown) {
- this.$refs.markdown.copyContent();
- }
- },
- likeClick() {
- if (this.loading) return;
- this.loading = true;
- RequestApi.LikeOrStep(this.chatId, this.likeFlag ? 1 : 2).then(result => {
- uni.showToast({
- title: this.likeFlag ? '取消成功' : '点赞成功,感谢你的反馈',
- icon: 'none'
- });
- this.likeFlag = !this.likeFlag;
- if (this.likeFlag) {
- this.stepFlag = false;
- }
- }).finally(() => {
- this.loading = false;
- });
- },
- stepClick() {
- if (this.loading) return;
- this.loading = true;
- RequestApi.LikeOrStep(this.chatId, this.stepFlag ? 1 : 3).then(result => {
- uni.showToast({
- title: this.stepFlag ? '取消成功' : '操作成功,感谢你的反馈',
- icon: 'none'
- });
- this.stepFlag = !this.stepFlag;
- if (this.stepFlag) {
- this.likeFlag = false;
- }
- }).finally(() => {
- this.loading = false;
- });
- },
- initAudioPlayer() {
- try {
- this.ttsAudioPlayer = new PCMAudioPlayer({
- sampleRate: 24000,
- channels: 1,
- bitDepth: 16,
- littleEndian: true,
- loop: false
- });
- // 播放完成回调
- this.ttsAudioPlayer.setOnPlaybackComplete(() => {
- this.globalStore.setPlayChatId('');
- this.audioStatus = 'stop';
- });
- // 播放暂停回调
- this.ttsAudioPlayer.setOnPause(() => {
- console.log('TTS播放暂停');
- this.pauseAudio();
- })
- return true;
- } catch (err) {
- console.log('TTS初始化失败', err)
- return false;
- }
- },
- playAudio() {
- if (this.$refs.markdown) {
- const content = this.getContent();
- if (content !== '' && this.audioStatus) {
- this.globalStore.setPlayChatId(this.chatId);
- switch (this.audioStatus.toLowerCase()) {
- case 'wait':
- this.audioStatus = 'loading'
- this.loadTextToSpeech()
- break;
- case 'loading':
- this.audioStatus = 'wait'
- break;
- case 'play':
- this.pauseAudio()
- break;
- case 'pause':
- if (this.ttsAudioPlayer) {
- this.ttsPaused = false;
- this.audioStatus = 'play';
- this.ttsAudioPlayer.resume();
- }
- break;
- case 'stop':
- if (this.ttsAudioPlayer) {
- this.ttsPaused = false;
- this.audioStatus = 'play';
- this.ttsAudioPlayer.replay();
- }
- break;
- default:
- uni.showToast({
- title: '操作方式错误',
- icon: 'none'
- });
- }
- }
- }
- },
- pauseAudio() {
- // TTS 播放处理
- if (this.ttsAudioPlayer) {
- this.ttsPaused = true;
- this.ttsAudioPlayer.pause()
- this.$nextTick(() => {
- this.audioStatus = 'pause'
- });
- }
- },
- stopAudit() {
- if (this.ttsAudioPlayer) {
- this.ttsPaused = false;
- this.ttsAudioPlayer.stop();
- this.$nextTick(() => {
- this.audioStatus = 'wait'
- });
- }
- },
- async loadTextToSpeech() {
- try {
- const result = await RequestApi.getTtsToken();
- this.ttsToken = result.oauthToken;
- if (this.ttsToken) {
- if (this.connectWsSpeech()) {
- console.log('connect ws')
- } else {
- this.audioStatus = 'wait';
- uni.showToast({
- title: '语音服务器连接失败',
- icon: 'none'
- });
- }
- }
- return true;
- } catch (err) {
- uni.showToast({
- title: err.message,
- icon: 'none'
- });
- this.audioStatus = 'wait';
- }
- },
- connectWsSpeech() {
- if (isEmpty(this.ttsToken)) {
- return false;
- }
- try {
- const url = `wss://ws.coze.cn/v1/audio/speech`;
- this.WS = uni.connectSocket({
- url: url,
- header: {
- 'Authorization': `Bearer ${this.ttsToken}`,
- 'content-type': 'application/json'
- },
- success: () => {
- console.log('WebSocket连接创建成功');
- },
- fail: () => {
- console.error('WebSocket连接创建失败', err);
- }
- });
- this.setupSocketListeners();
- return true;
- } catch (err) {
- console.log('TTS连接失败', err)
- this.audioStatus = 'wait'
- return false
- }
- },
- setupSocketListeners() {
- this.WS.onOpen(() => {
- console.log('WebSocket连接成功');
- });
- this.WS.onMessage((event) => {
- const message = JSON.parse(event.data);
- if (message.event_type === 'speech.created') {
- this.ttsBuffer = message;
- this.ttsSpeechUpdate();
- this.ttsSendMessage(this.getContent());
- }
- if (message.event_type === 'speech.audio.update') {
- this.handleTTSAudio(message.data.delta)
- }
- });
- this.WS.onError((err) => {
- console.error('WebSocket连接错误', err);
- });
- this.WS.onClose(() => {
- console.log('TTS连接关闭');
- });
- return true;
- },
- isValidBase64(str) {
- const base64Regex = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/;
- if (!base64Regex.test(str) || str.length % 4 !== 0) {
- return false;
- }
- try {
- // 微信小程序环境下使用uni.base64ToArrayBuffer和uni.arrayBufferToBase64
- const buffer = uni.base64ToArrayBuffer(str);
- const reencoded = uni.arrayBufferToBase64(buffer);
- return reencoded === str;
- } catch (e) {
- return false;
- }
- },
- getContent() {
- let content = this.$refs.markdown.extractPlainText(this.content);
- content = content.replace(/<[^>]*>/gi, '');
- return content.trim();
- },
- async handleTTSAudio(audioData) {
- if (!this.isValidBase64(audioData)) {
- console.log('无效的base64数据', audioData);
- }
- try {
- // 播放Base64音频数据
- this.ttsAudioPlayer.feedBase64(audioData, !this.ttsPaused);
- if (!this.ttsPaused) {
- this.audioStatus = 'play'
- }
- } catch (err) {
- console.log('TTS音频播放失败', err)
- }
- return true;
- },
- ttsSpeechUpdate() {
- if (this.WS && this.ttsBuffer) {
- this.WS?.send({
- data: JSON.stringify({
- "id": this.ttsBuffer.id,
- "event_type": "speech.update",
- "data": {
- "output_audio": {
- "codec": "pcm",
- "speech_rate": 0,
- "voice_id": "7426725529589596187"
- }
- }
- })
- });
- }
- },
- ttsSendMessage(content, start = 0, length = 50) {
- if (!this.ttsBuffer) {
- return false;
- }
- let l = displayLength(content);
- let msg = safeTruncate(content, start, length, '');
- this.WS?.send({
- data: JSON.stringify({
- "id": this.ttsBuffer.id,
- "event_type": "input_text_buffer.append",
- "data": {
- "delta": msg
- }
- })
- });
- if (l > start + length + 1) {
- this.ttsSendMessage(content, start + length, length);
- }
- }
- },
- watch: {
- playChatId(val) {
- if (val !== this.chatId) {
- this.pauseAudio();
- this.stopAudit();
- }
- },
- appHide(val) {
- if (this.chatId == this.playChatId) {
- if (val) {
- this.pauseAudio();
- } else {
- if (this.audioStatus == 'pause') {
- this.playAudio();
- }
- }
- } else {
- if (val) {
- this.stopAudit();
- }
- }
- }
- }
- };
- </script>
- <style lang="scss">
- .markdown {
- margin-bottom: 32rpx;
- h1,
- h2,
- h3,
- h4,
- strong {
- color: #101333;
- font-weight: bold;
- }
- .content {
- margin-bottom: 16rpx;
- }
- .options {
- display: flex;
- align-items: center;
- .option-icon {
- width: 32rpx;
- height: 32rpx;
- margin-right: 16rpx;
- cursor: pointer;
- }
- .separate {
- display: inline-block;
- width: 1px;
- height: 32rpx;
- align-self: flex-start;
- background-color: #7e8aa2;
- margin-right: 16rpx;
- }
- }
- }
- </style>
|