SsChatReplyMarkdown.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. <template>
  2. <view class="markdown">
  3. <view class="content">
  4. <ua-markdown ref="markdown" :source="content" />
  5. </view>
  6. <view v-if="!last || !globalStore.chatLoading" class="options">
  7. <uni-load-more v-if="audioStatus == 'loading'" class="option-icon" iconType="circle" :show-text="false"
  8. status="loading" color="#7E8AA2" :icon-size="16" @tap="playAudio" />
  9. <image v-else-if="audioStatus == 'play'" class="option-icon" mode="aspectFit"
  10. :src="getStaticImageUrl('/images/audio-play.gif')" @tap="playAudio" />
  11. <image v-else-if="audioStatus == 'pause'" class="option-icon" mode="aspectFit"
  12. :src="getStaticImageUrl('/images/audio-pause.png')" @tap="playAudio" />
  13. <image v-else class="option-icon" mode="aspectFit" :src="getStaticImageUrl('/images/trumpet.png')"
  14. @tap="playAudio" />
  15. <image class="option-icon" mode="aspectFit" :src="getStaticImageUrl('/images/copy.png')"
  16. @tap="copyContent" />
  17. <view class="separate"></view>
  18. <image class="option-icon" mode="aspectFit"
  19. :src="getStaticImageUrl(likeFlag ? '/images/praise-1.png' : '/images/praise.png')" @tap="likeClick" />
  20. <image class="option-icon" mode="aspectFit"
  21. :src="getStaticImageUrl(stepFlag ? '/images/trample-1.png' : '/images/trample.png')" @tap="stepClick" />
  22. </view>
  23. </view>
  24. </template>
  25. <script>
  26. import {
  27. useGlobalStore
  28. } from '@/stores/global';
  29. import {
  30. RequestApi
  31. } from '@/api/requestApi';
  32. import PCMAudioPlayer from '@/utils/PCMAudioPlayer';
  33. import {
  34. safeTruncate,
  35. displayLength,
  36. isEmpty
  37. } from '@/utils/string.extensions';
  38. export default {
  39. name: 'SsChatReplyMarkdown',
  40. props: {
  41. chatId: {
  42. type: String,
  43. default: ''
  44. },
  45. content: {
  46. type: String,
  47. default: ''
  48. },
  49. title: {
  50. type: String,
  51. default: ''
  52. },
  53. last: {
  54. type: Boolean,
  55. default: false
  56. }
  57. },
  58. data() {
  59. return {
  60. globalStore: useGlobalStore(),
  61. mpHtmlOptions: {
  62. markdown: true,
  63. sanitize: true,
  64. highlight: code => {
  65. return this.$hljs.highlightAuto(code).value
  66. },
  67. styles: {
  68. 'code': 'padding: 3px 5px; border-radius: 4px;',
  69. 'table': 'border-collapse: collapse;',
  70. 'th, td': 'border: 1px solid #ddd; padding: 8px;'
  71. }
  72. },
  73. likeFlag: false,
  74. stepFlag: false,
  75. loading: false,
  76. audioStatus: 'wait',
  77. ttsAudioPlayer: null,
  78. ttsToken: null,
  79. WS: null,
  80. ttsBuffer: null,
  81. ttsPaused: false,
  82. }
  83. },
  84. computed: {
  85. playChatId() {
  86. return this.globalStore.playChatId ?? '';
  87. },
  88. appHide() {
  89. return this.globalStore.appHide ?? '';
  90. }
  91. },
  92. mounted() {
  93. this.initAudioPlayer();
  94. },
  95. destroyed() {
  96. if (this.WS) {
  97. this.WS?.close();
  98. }
  99. },
  100. onUnload() {
  101. if (this.WS) {
  102. this.WS?.close();
  103. }
  104. },
  105. onHide() {
  106. console.log('隐藏...')
  107. this.pauseAudio();
  108. },
  109. methods: {
  110. previewImage(src) {
  111. uni.previewImage({
  112. urls: [src],
  113. current: src
  114. })
  115. },
  116. copyContent() {
  117. if (this.$refs.markdown) {
  118. this.$refs.markdown.copyContent();
  119. }
  120. },
  121. likeClick() {
  122. if (this.loading) return;
  123. this.loading = true;
  124. RequestApi.LikeOrStep(this.chatId, this.likeFlag ? 1 : 2).then(result => {
  125. uni.showToast({
  126. title: this.likeFlag ? '取消成功' : '点赞成功,感谢你的反馈',
  127. icon: 'none'
  128. });
  129. this.likeFlag = !this.likeFlag;
  130. if (this.likeFlag) {
  131. this.stepFlag = false;
  132. }
  133. }).finally(() => {
  134. this.loading = false;
  135. });
  136. },
  137. stepClick() {
  138. if (this.loading) return;
  139. this.loading = true;
  140. RequestApi.LikeOrStep(this.chatId, this.stepFlag ? 1 : 3).then(result => {
  141. uni.showToast({
  142. title: this.stepFlag ? '取消成功' : '操作成功,感谢你的反馈',
  143. icon: 'none'
  144. });
  145. this.stepFlag = !this.stepFlag;
  146. if (this.stepFlag) {
  147. this.likeFlag = false;
  148. }
  149. }).finally(() => {
  150. this.loading = false;
  151. });
  152. },
  153. initAudioPlayer() {
  154. try {
  155. this.ttsAudioPlayer = new PCMAudioPlayer({
  156. sampleRate: 24000,
  157. channels: 1,
  158. bitDepth: 16,
  159. littleEndian: true,
  160. loop: false
  161. });
  162. // 播放完成回调
  163. this.ttsAudioPlayer.setOnPlaybackComplete(() => {
  164. this.globalStore.setPlayChatId('');
  165. this.audioStatus = 'stop';
  166. });
  167. // 播放暂停回调
  168. this.ttsAudioPlayer.setOnPause(() => {
  169. console.log('TTS播放暂停');
  170. this.pauseAudio();
  171. })
  172. return true;
  173. } catch (err) {
  174. console.log('TTS初始化失败', err)
  175. return false;
  176. }
  177. },
  178. playAudio() {
  179. if (this.$refs.markdown) {
  180. const content = this.getContent();
  181. if (content !== '' && this.audioStatus) {
  182. this.globalStore.setPlayChatId(this.chatId);
  183. switch (this.audioStatus.toLowerCase()) {
  184. case 'wait':
  185. this.audioStatus = 'loading'
  186. this.loadTextToSpeech()
  187. break;
  188. case 'loading':
  189. this.audioStatus = 'wait'
  190. break;
  191. case 'play':
  192. this.pauseAudio()
  193. break;
  194. case 'pause':
  195. if (this.ttsAudioPlayer) {
  196. this.ttsPaused = false;
  197. this.audioStatus = 'play';
  198. this.ttsAudioPlayer.resume();
  199. }
  200. break;
  201. case 'stop':
  202. if (this.ttsAudioPlayer) {
  203. this.ttsPaused = false;
  204. this.audioStatus = 'play';
  205. this.ttsAudioPlayer.replay();
  206. }
  207. break;
  208. default:
  209. uni.showToast({
  210. title: '操作方式错误',
  211. icon: 'none'
  212. });
  213. }
  214. }
  215. }
  216. },
  217. pauseAudio() {
  218. // TTS 播放处理
  219. if (this.ttsAudioPlayer) {
  220. this.ttsPaused = true;
  221. this.ttsAudioPlayer.pause()
  222. this.$nextTick(() => {
  223. this.audioStatus = 'pause'
  224. });
  225. }
  226. },
  227. stopAudit() {
  228. if (this.ttsAudioPlayer) {
  229. this.ttsPaused = false;
  230. this.ttsAudioPlayer.stop();
  231. this.$nextTick(() => {
  232. this.audioStatus = 'wait'
  233. });
  234. }
  235. },
  236. async loadTextToSpeech() {
  237. try {
  238. const result = await RequestApi.getTtsToken();
  239. this.ttsToken = result.oauthToken;
  240. if (this.ttsToken) {
  241. if (this.connectWsSpeech()) {
  242. console.log('connect ws')
  243. } else {
  244. this.audioStatus = 'wait';
  245. uni.showToast({
  246. title: '语音服务器连接失败',
  247. icon: 'none'
  248. });
  249. }
  250. }
  251. return true;
  252. } catch (err) {
  253. uni.showToast({
  254. title: err.message,
  255. icon: 'none'
  256. });
  257. this.audioStatus = 'wait';
  258. }
  259. },
  260. connectWsSpeech() {
  261. if (isEmpty(this.ttsToken)) {
  262. return false;
  263. }
  264. try {
  265. const url = `wss://ws.coze.cn/v1/audio/speech`;
  266. this.WS = uni.connectSocket({
  267. url: url,
  268. header: {
  269. 'Authorization': `Bearer ${this.ttsToken}`,
  270. 'content-type': 'application/json'
  271. },
  272. success: () => {
  273. console.log('WebSocket连接创建成功');
  274. },
  275. fail: () => {
  276. console.error('WebSocket连接创建失败', err);
  277. }
  278. });
  279. this.setupSocketListeners();
  280. return true;
  281. } catch (err) {
  282. console.log('TTS连接失败', err)
  283. this.audioStatus = 'wait'
  284. return false
  285. }
  286. },
  287. setupSocketListeners() {
  288. this.WS.onOpen(() => {
  289. console.log('WebSocket连接成功');
  290. });
  291. this.WS.onMessage((event) => {
  292. const message = JSON.parse(event.data);
  293. if (message.event_type === 'speech.created') {
  294. this.ttsBuffer = message;
  295. this.ttsSpeechUpdate();
  296. this.ttsSendMessage(this.getContent());
  297. }
  298. if (message.event_type === 'speech.audio.update') {
  299. this.handleTTSAudio(message.data.delta)
  300. }
  301. });
  302. this.WS.onError((err) => {
  303. console.error('WebSocket连接错误', err);
  304. });
  305. this.WS.onClose(() => {
  306. console.log('TTS连接关闭');
  307. });
  308. return true;
  309. },
  310. isValidBase64(str) {
  311. const base64Regex = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/;
  312. if (!base64Regex.test(str) || str.length % 4 !== 0) {
  313. return false;
  314. }
  315. try {
  316. // 微信小程序环境下使用uni.base64ToArrayBuffer和uni.arrayBufferToBase64
  317. const buffer = uni.base64ToArrayBuffer(str);
  318. const reencoded = uni.arrayBufferToBase64(buffer);
  319. return reencoded === str;
  320. } catch (e) {
  321. return false;
  322. }
  323. },
  324. getContent() {
  325. let content = this.$refs.markdown.extractPlainText(this.content);
  326. content = content.replace(/<[^>]*>/gi, '');
  327. return content.trim();
  328. },
  329. async handleTTSAudio(audioData) {
  330. if (!this.isValidBase64(audioData)) {
  331. console.log('无效的base64数据', audioData);
  332. }
  333. try {
  334. // 播放Base64音频数据
  335. this.ttsAudioPlayer.feedBase64(audioData, !this.ttsPaused);
  336. if (!this.ttsPaused) {
  337. this.audioStatus = 'play'
  338. }
  339. } catch (err) {
  340. console.log('TTS音频播放失败', err)
  341. }
  342. return true;
  343. },
  344. ttsSpeechUpdate() {
  345. if (this.WS && this.ttsBuffer) {
  346. this.WS?.send({
  347. data: JSON.stringify({
  348. "id": this.ttsBuffer.id,
  349. "event_type": "speech.update",
  350. "data": {
  351. "output_audio": {
  352. "codec": "pcm",
  353. "speech_rate": 0,
  354. "voice_id": "7426725529589596187"
  355. }
  356. }
  357. })
  358. });
  359. }
  360. },
  361. ttsSendMessage(content, start = 0, length = 50) {
  362. if (!this.ttsBuffer) {
  363. return false;
  364. }
  365. let l = displayLength(content);
  366. let msg = safeTruncate(content, start, length, '');
  367. this.WS?.send({
  368. data: JSON.stringify({
  369. "id": this.ttsBuffer.id,
  370. "event_type": "input_text_buffer.append",
  371. "data": {
  372. "delta": msg
  373. }
  374. })
  375. });
  376. if (l > start + length + 1) {
  377. this.ttsSendMessage(content, start + length, length);
  378. }
  379. }
  380. },
  381. watch: {
  382. playChatId(val) {
  383. if (val !== this.chatId) {
  384. this.pauseAudio();
  385. this.stopAudit();
  386. }
  387. },
  388. appHide(val) {
  389. if (this.chatId == this.playChatId) {
  390. if (val) {
  391. this.pauseAudio();
  392. } else {
  393. if (this.audioStatus == 'pause') {
  394. this.playAudio();
  395. }
  396. }
  397. } else {
  398. if (val) {
  399. this.stopAudit();
  400. }
  401. }
  402. }
  403. }
  404. };
  405. </script>
  406. <style lang="scss">
  407. .markdown {
  408. margin-bottom: 32rpx;
  409. h1,
  410. h2,
  411. h3,
  412. h4,
  413. strong {
  414. color: #101333;
  415. font-weight: bold;
  416. }
  417. .content {
  418. margin-bottom: 16rpx;
  419. }
  420. .options {
  421. display: flex;
  422. align-items: center;
  423. .option-icon {
  424. width: 32rpx;
  425. height: 32rpx;
  426. margin-right: 16rpx;
  427. cursor: pointer;
  428. }
  429. .separate {
  430. display: inline-block;
  431. width: 1px;
  432. height: 32rpx;
  433. align-self: flex-start;
  434. background-color: #7e8aa2;
  435. margin-right: 16rpx;
  436. }
  437. }
  438. }
  439. </style>