feat(AI): 添加语音识别功能

- 新增 audioToText API 用于语音识别
- 实现录音和停止录音功能
- 添加语音识别结果处理逻辑
-优化聊天输入框,支持语音输入模式切换
- 调整聊天界面布局,增加录音相关UI
This commit is contained in:
陈昱达
2025-06-10 11:17:08 +08:00
parent 85b1a0e697
commit fd43fae0f7
2 changed files with 247 additions and 95 deletions

View File

@@ -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,
})
}

View File

@@ -7,12 +7,9 @@
<messageComponent :messagesList="messages" :is-deep="isDeep" :is-search="isSearching" :think-ok='isThink' @setProductName='setProductName'></messageComponent>
</div>
</div>
<!-- 按钮点击滚动到顶-->
<div class="button-container" style='background: #fff'>
<van-icon name="upgrade" size='2em' @click='scrollToTop' />
<!-- 滚动到顶部按钮 -->
<div class="button-container" style="background: #fff">
<van-icon name="upgrade" size="2em" @click="scrollToTop" />
</div>
</main>
<section class="section">
@@ -30,8 +27,48 @@
</button>
</section>
<footer class="chat-footer">
<input type="text" v-model="newMessage" placeholder="请简短描述您的问题" @keyup.enter="sendMessage" />
<button @click="sendMessage" :disabled="messageStatus === 'send'" :class="{ disabled: messageStatus === 'send' }">发送</button>
<!-- 输入框 or 按住说话提示 -->
<div class="input-wrapper">
<input
v-if="!isVoiceMode"
type="text"
v-model="newMessage"
placeholder="请简短描述您的问题"
@keyup.enter="sendMessage"
/>
<div v-else class="voice-hint-container"
@mousedown="startRecording"
@selectstart="() => false"
@mouseup="stopRecording"
@mouseleave="stopRecording"
@touchend="stopRecording"
@touchstart="startRecording">
<div class="waveform" :class="{ active: isRecording }" >
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</div>
<!-- <div class="hint-text" v-if='!isRecording'>按住说话</div>-->
</div>
</div>
<!-- 语音按钮按住说话 -->
<button
@click='isVoiceMode = !isVoiceMode'
class="mic-button ml10 mr10"
>
<svg-icon v-if='!isVoiceMode' icon-class="voice" class-name="chat-icon" style='width: 20px;height: 20px'></svg-icon>
<span v-else style='font-size: 18px;color:#707070' class='ml5 mr5'></span>
</button>
<!-- 发送按钮 -->
<button
@click="sendMessage"
:disabled="messageStatus === 'send'"
:class="{ disabled: messageStatus === 'send' }"
>
发送
</button>
</footer>
</div>
</template>
@@ -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('<think>') !== -1) {
this.isThink = true
}
if (data.answer && data.answer.indexOf('</think>') !== -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);
}
}
</style>