From fd43fae0f7407a66254ded634f2ab43710d4b33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=98=B1=E8=BE=BE?= Date: Tue, 10 Jun 2025 11:17:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(AI):=20=E6=B7=BB=E5=8A=A0=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E8=AF=86=E5=88=AB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 audioToText API 用于语音识别 - 实现录音和停止录音功能 - 添加语音识别结果处理逻辑 -优化聊天输入框,支持语音输入模式切换 - 调整聊天界面布局,增加录音相关UI --- src/api/generatedApi/index.js | 11 ++ src/views/AI/index.vue | 331 ++++++++++++++++++++++++---------- 2 files changed, 247 insertions(+), 95 deletions(-) diff --git a/src/api/generatedApi/index.js b/src/api/generatedApi/index.js index 2f1a3dd..0b5d34e 100644 --- a/src/api/generatedApi/index.js +++ b/src/api/generatedApi/index.js @@ -36,3 +36,14 @@ export function chat(data) { return getUrl('/hasl/chat') } + + +export function audioToText(data) { + //聊天获取产品百宝箱 + // 聊天框输入内容以“百宝箱”结尾时,调用这个接口 + return request({ + url: getUrl('/chat/audio-to-text'), + method: 'post', + data, + }) +} diff --git a/src/views/AI/index.vue b/src/views/AI/index.vue index 064b5e4..d209430 100644 --- a/src/views/AI/index.vue +++ b/src/views/AI/index.vue @@ -7,12 +7,9 @@ - - - - -
- + +
+
@@ -30,8 +27,48 @@
- - + +
+ +
+
+ + + + + +
+ +
+
+ + + +
@@ -41,7 +78,7 @@ import { Icon } from 'vant' import messageComponent from './components/message.vue' import SvgIcon from '@/components/svg-icon/index.vue' import HotProducts from '@/views/AI/components/HotProducts.vue' -import { chat, chatProduct } from '@/api/generatedApi' +import { chat, chatProduct,audioToText } from '@/api/generatedApi' export default { components: { @@ -50,7 +87,6 @@ export default { messageComponent, HotProducts, }, - data() { return { productName:'', @@ -67,9 +103,15 @@ export default { isDeep: false, autoScrollEnabled: true, scrollPosition: 0, + // 录音相关状态 + isRecording: false, + mediaRecorder: null, + audioChunks: [], + isRecognizing: false, + // 语音模式开关 + isVoiceMode: false, } }, - methods: { deepInternet() { this.isDeep = !this.isDeep @@ -82,38 +124,29 @@ export default { this.conversationId = '' this.productName = '' }, - hasTreasureBox() { chatProduct({ query: this.newMessage }).then((res) => { if (res) { this.messageStatus = 'stop' - this.messages.push({ type: 'box', text: this.newMessage,detail:res.content }) + this.messages.push({ type: 'box', text: this.newMessage, detail: res.content }) this.newMessage = '' } - }).catch(()=>{ + }).catch(() => { this.messageStatus = 'stop' }) }, - sendMessage() { - if(this.messageStatus === 'send'){ - return - } + if (this.messageStatus === 'send') return if (this.newMessage.trim() === '') return - this.messages.push({ type: 'user', text: this.newMessage }) this.messageStatus = 'send' - if (this.newMessage.includes('工具箱')) { this.hasTreasureBox() return } this.autoScrollEnabled = true this.axiosGetAiChat() - - }, - throttle(fn, delay = 50) { let lastCall = 0 return (...args) => { @@ -124,15 +157,14 @@ export default { } } }, - - scrollToTop(){ + scrollToTop() { const messageArea = this.$refs.messageArea - messageArea.scrollTop = 0 + if (messageArea) { + messageArea.scrollTop = 0 + } }, - - scrollToBottom: function () { + scrollToBottom() { if (!this.autoScrollEnabled) return - this.$nextTick(() => { const messageArea = this.$refs.messageArea if (messageArea) { @@ -140,22 +172,81 @@ export default { } }) }, - setProductName(e){ + setProductName(e) { console.log(e) }, handleScroll() { const messageArea = this.$refs.messageArea if (!messageArea) return - const threshold = 10 - const isAtBottom = messageArea.scrollHeight - messageArea.clientHeight <= messageArea.scrollTop + threshold + const isAtBottom = + messageArea.scrollHeight - messageArea.clientHeight <= + messageArea.scrollTop + threshold this.autoScrollEnabled = isAtBottom this.scrollPosition = messageArea.scrollTop }, - + async startRecording() { + if (this.isRecognizing) return + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + this.mediaRecorder = new MediaRecorder(stream) + this.audioChunks = [] + this.mediaRecorder.ondataavailable = event => { + if (event.data.size > 0) { + this.audioChunks.push(event.data) + } + } + this.mediaRecorder.onstop = () => { + this.handleStopRecording() + } + this.mediaRecorder.start() + this.isRecording = true + } catch (err) { + alert('无法访问麦克风,请检查权限') + console.error(err) + } + }, + stopRecording() { + if (this.mediaRecorder && this.isRecording) { + this.mediaRecorder.stop() + this.isRecording = false + } + }, + async handleStopRecording() { + this.isRecognizing = true + const blob = new Blob(this.audioChunks, { type: 'audio/webm' }) + try { + const text = await this.callVoiceRecognitionAPI(blob) + console.log(text) + if (text) { + this.newMessage = text + this.messageStatus = 'stop' + this.sendMessage() + } + } catch (error) { + console.error('语音识别失败:', error) + this.newMessage = '' + } finally { + this.isRecognizing = false + } + }, + callVoiceRecognitionAPI(blob) { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', blob); + formData.append('appType', 'haslBigHelper'); + formData.append('user', 'chenyuda'); + audioToText(formData).then(res => { + if(res){ + resolve(res.content) + } + }).catch(err => { + reject(err) + }) + }) + }, axiosGetAiChat() { const abortController = new AbortController() - this.currentMessage = JSON.parse( JSON.stringify({ type: 'bot', @@ -168,55 +259,46 @@ export default { }) ) this.messages.push(this.currentMessage) - const params = { query: this.newMessage, isDeep: this.isDeep ? 1 : 0, isOnline: this.isSearching ? 1 : 0, user: 'chenyuda', conversationId: this.conversationId, - productName:this.productName, + productName: this.productName, } - fetch(chat(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: abortController.signal, body: JSON.stringify(params), }) - .then(async (res) =>{ + .then(async (res) => { this.newMessage = '' - await this.processStreamResponse(res) + await this.processStreamResponse(res) }) .catch((err) => { this.messageStatus = 'stop' }) }, - async processStreamResponse(response) { if (!response.ok) throw new Error(`HTTP错误: ${response.status}`) - if (!response.body) { console.error('响应体不存在:', response) return } - const reader = response.body.getReader() let buffer = '' - while (true) { try { const { done, value } = await reader.read() if (done) break - buffer += new TextDecoder().decode(value) const lines = buffer.split('\n') - lines.slice(0, -1).forEach((line) => { const parsed = this.parseStreamLine(line) if (parsed) this.updateMessageContent(parsed) }) - buffer = lines[lines.length - 1] || '' } catch (error) { console.error('读取流数据时发生错误:', error) @@ -224,17 +306,14 @@ export default { } } }, - parseStreamLine(line) { try { const cleanLine = line.replace(/^data:\s*/, '') if (!cleanLine) return null const data = JSON.parse(cleanLine) - if(data.answer){ - this.answerMap+=data.answer + if (data.answer) { + this.answerMap += data.answer } - - this.updateConversationState(data) return data } catch (error) { @@ -243,48 +322,20 @@ export default { } }, updateConversationState(data) { - this.conversationId = data.conversation_id || conversationId.value - if (data.answer && data.answer.indexOf('') !== -1) { - this.isThink = true - } - - if (data.answer && data.answer.indexOf('') !== -1) { + this.conversationId = data.conversation_id || this.conversationId + if (data.answer && data.answer.indexOf('withTo') !== -1) { this.isThink = false } }, updateMessageContent({ answer, event }) { - if(event === 'message_end'){ + if (event === 'message_end') { this.messageStatus = 'stop' } - if (!this.currentMessage || !answer) return; - - const mode = this.isThink ? 'think' : 'text'; - + if (!this.currentMessage || !answer) return + const mode = this.isThink ? 'think' : 'text' this.currentMessage[mode] += answer - return - // 清除之前的动画任务 - if (this.timer) { - cancelAnimationFrame(this.timer); - } - - const renderChar = () => { - - - if (this.answerIndex < this.answerMap.length) { - this.currentMessage[mode] += this.answerMap[this.answerIndex++]; - // this.$nextTick(() => { - this.timer = requestAnimationFrame(renderChar); - // }); - } else { - this.scrollToBottom(); - } - }; - - this.timer = requestAnimationFrame(renderChar); - } - + }, }, - watch: { messages: { handler() { @@ -305,6 +356,10 @@ $primary-trans-color: rgba(135, 162, 208, 0.5); display: flex; flex-direction: column; height: 100vh; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; .chat-main { flex: 1; @@ -312,12 +367,14 @@ $primary-trans-color: rgba(135, 162, 208, 0.5); padding: 10px; background: #f7f8fa; position: relative; - .button-container{ + + .button-container { position: fixed; bottom: 120px; - right:10px; + right: 10px; border-radius: 50%; } + .chat-content { height: 100%; .message-area { @@ -358,13 +415,88 @@ $primary-trans-color: rgba(135, 162, 208, 0.5); padding: 10px; background-color: #fff; - input { + .input-wrapper { flex: 1; - padding: 10px; - border: none; - background: #f5f5f5; - border-radius: 5px; margin-right: 10px; + + input { + width: 100%; + padding: 10px; + border: none; + background: #f5f5f5; + border-radius: 5px; + font-size: 14px; + } + + .voice-hint-container { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 10px; + background-color: #f5f5f5; + border-radius: 5px; + color: #888; + font-size: 14px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + &:active { + background-color: #eaeaea; + } + + .waveform { + display: flex; + align-items: flex-end; + align-items: center; + //height: 30px; + //width: 60px; + gap: 2px; + + &.active .bar { + animation: wave-animation 1s infinite ease-in-out; + } + + .bar { + width: 4px; + background-color: $primary-color; + margin: 0 1px; + + &:nth-child(1) { height: 10px; animation-delay: 0s; } + &:nth-child(2) { height: 16px; animation-delay: 0.1s; } + &:nth-child(3) { height: 12px; animation-delay: 0.2s; } + &:nth-child(4) { height: 18px; animation-delay: 0.3s; } + &:nth-child(5) { height: 14px; animation-delay: 0.4s; } + } + } + + .hint-text { + margin-top: 4px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + } + } + + .mic-button { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + border: none; + background: transparent; + color: $primary-color; + cursor: pointer; + font-size: 12px; + margin-left: 5px; + + &:active { + color: #e6454a; /* 按下变红 */ + } } button { @@ -376,11 +508,20 @@ $primary-trans-color: rgba(135, 162, 208, 0.5); font-weight: 600; cursor: pointer; } + + .disabled { + pointer-events: none; + opacity: 0.5; + } } } -.disabled { - pointer-events: none; - opacity: 0.5; +@keyframes wave-animation { + 0%, 100% { + transform: scaleY(1); + } + 50% { + transform: scaleY(1.5); + } }