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') 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> <messageComponent :messagesList="messages" :is-deep="isDeep" :is-search="isSearching" :think-ok='isThink' @setProductName='setProductName'></messageComponent>
</div> </div>
</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> </div>
</main> </main>
<section class="section"> <section class="section">
@@ -30,8 +27,48 @@
</button> </button>
</section> </section>
<footer class="chat-footer"> <footer class="chat-footer">
<input type="text" v-model="newMessage" placeholder="请简短描述您的问题" @keyup.enter="sendMessage" /> <!-- 输入框 or 按住说话提示 -->
<button @click="sendMessage" :disabled="messageStatus === 'send'" :class="{ disabled: messageStatus === 'send' }">发送</button> <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> </footer>
</div> </div>
</template> </template>
@@ -41,7 +78,7 @@ import { Icon } from 'vant'
import messageComponent from './components/message.vue' import messageComponent from './components/message.vue'
import SvgIcon from '@/components/svg-icon/index.vue' import SvgIcon from '@/components/svg-icon/index.vue'
import HotProducts from '@/views/AI/components/HotProducts.vue' import HotProducts from '@/views/AI/components/HotProducts.vue'
import { chat, chatProduct } from '@/api/generatedApi' import { chat, chatProduct,audioToText } from '@/api/generatedApi'
export default { export default {
components: { components: {
@@ -50,7 +87,6 @@ export default {
messageComponent, messageComponent,
HotProducts, HotProducts,
}, },
data() { data() {
return { return {
productName:'', productName:'',
@@ -67,9 +103,15 @@ export default {
isDeep: false, isDeep: false,
autoScrollEnabled: true, autoScrollEnabled: true,
scrollPosition: 0, scrollPosition: 0,
// 录音相关状态
isRecording: false,
mediaRecorder: null,
audioChunks: [],
isRecognizing: false,
// 语音模式开关
isVoiceMode: false,
} }
}, },
methods: { methods: {
deepInternet() { deepInternet() {
this.isDeep = !this.isDeep this.isDeep = !this.isDeep
@@ -82,38 +124,29 @@ export default {
this.conversationId = '' this.conversationId = ''
this.productName = '' this.productName = ''
}, },
hasTreasureBox() { hasTreasureBox() {
chatProduct({ query: this.newMessage }).then((res) => { chatProduct({ query: this.newMessage }).then((res) => {
if (res) { if (res) {
this.messageStatus = 'stop' 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 = '' this.newMessage = ''
} }
}).catch(()=>{ }).catch(() => {
this.messageStatus = 'stop' this.messageStatus = 'stop'
}) })
}, },
sendMessage() { sendMessage() {
if(this.messageStatus === 'send'){ if (this.messageStatus === 'send') return
return
}
if (this.newMessage.trim() === '') return if (this.newMessage.trim() === '') return
this.messages.push({ type: 'user', text: this.newMessage }) this.messages.push({ type: 'user', text: this.newMessage })
this.messageStatus = 'send' this.messageStatus = 'send'
if (this.newMessage.includes('工具箱')) { if (this.newMessage.includes('工具箱')) {
this.hasTreasureBox() this.hasTreasureBox()
return return
} }
this.autoScrollEnabled = true this.autoScrollEnabled = true
this.axiosGetAiChat() this.axiosGetAiChat()
}, },
throttle(fn, delay = 50) { throttle(fn, delay = 50) {
let lastCall = 0 let lastCall = 0
return (...args) => { return (...args) => {
@@ -124,15 +157,14 @@ export default {
} }
} }
}, },
scrollToTop() {
scrollToTop(){
const messageArea = this.$refs.messageArea const messageArea = this.$refs.messageArea
messageArea.scrollTop = 0 if (messageArea) {
messageArea.scrollTop = 0
}
}, },
scrollToBottom() {
scrollToBottom: function () {
if (!this.autoScrollEnabled) return if (!this.autoScrollEnabled) return
this.$nextTick(() => { this.$nextTick(() => {
const messageArea = this.$refs.messageArea const messageArea = this.$refs.messageArea
if (messageArea) { if (messageArea) {
@@ -140,22 +172,81 @@ export default {
} }
}) })
}, },
setProductName(e){ setProductName(e) {
console.log(e) console.log(e)
}, },
handleScroll() { handleScroll() {
const messageArea = this.$refs.messageArea const messageArea = this.$refs.messageArea
if (!messageArea) return if (!messageArea) return
const threshold = 10 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.autoScrollEnabled = isAtBottom
this.scrollPosition = messageArea.scrollTop 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() { axiosGetAiChat() {
const abortController = new AbortController() const abortController = new AbortController()
this.currentMessage = JSON.parse( this.currentMessage = JSON.parse(
JSON.stringify({ JSON.stringify({
type: 'bot', type: 'bot',
@@ -168,55 +259,46 @@ export default {
}) })
) )
this.messages.push(this.currentMessage) this.messages.push(this.currentMessage)
const params = { const params = {
query: this.newMessage, query: this.newMessage,
isDeep: this.isDeep ? 1 : 0, isDeep: this.isDeep ? 1 : 0,
isOnline: this.isSearching ? 1 : 0, isOnline: this.isSearching ? 1 : 0,
user: 'chenyuda', user: 'chenyuda',
conversationId: this.conversationId, conversationId: this.conversationId,
productName:this.productName, productName: this.productName,
} }
fetch(chat(), { fetch(chat(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal, signal: abortController.signal,
body: JSON.stringify(params), body: JSON.stringify(params),
}) })
.then(async (res) =>{ .then(async (res) => {
this.newMessage = '' this.newMessage = ''
await this.processStreamResponse(res) await this.processStreamResponse(res)
}) })
.catch((err) => { .catch((err) => {
this.messageStatus = 'stop' this.messageStatus = 'stop'
}) })
}, },
async processStreamResponse(response) { async processStreamResponse(response) {
if (!response.ok) throw new Error(`HTTP错误: ${response.status}`) if (!response.ok) throw new Error(`HTTP错误: ${response.status}`)
if (!response.body) { if (!response.body) {
console.error('响应体不存在:', response) console.error('响应体不存在:', response)
return return
} }
const reader = response.body.getReader() const reader = response.body.getReader()
let buffer = '' let buffer = ''
while (true) { while (true) {
try { try {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
buffer += new TextDecoder().decode(value) buffer += new TextDecoder().decode(value)
const lines = buffer.split('\n') const lines = buffer.split('\n')
lines.slice(0, -1).forEach((line) => { lines.slice(0, -1).forEach((line) => {
const parsed = this.parseStreamLine(line) const parsed = this.parseStreamLine(line)
if (parsed) this.updateMessageContent(parsed) if (parsed) this.updateMessageContent(parsed)
}) })
buffer = lines[lines.length - 1] || '' buffer = lines[lines.length - 1] || ''
} catch (error) { } catch (error) {
console.error('读取流数据时发生错误:', error) console.error('读取流数据时发生错误:', error)
@@ -224,17 +306,14 @@ export default {
} }
} }
}, },
parseStreamLine(line) { parseStreamLine(line) {
try { try {
const cleanLine = line.replace(/^data:\s*/, '') const cleanLine = line.replace(/^data:\s*/, '')
if (!cleanLine) return null if (!cleanLine) return null
const data = JSON.parse(cleanLine) const data = JSON.parse(cleanLine)
if(data.answer){ if (data.answer) {
this.answerMap+=data.answer this.answerMap += data.answer
} }
this.updateConversationState(data) this.updateConversationState(data)
return data return data
} catch (error) { } catch (error) {
@@ -243,48 +322,20 @@ export default {
} }
}, },
updateConversationState(data) { updateConversationState(data) {
this.conversationId = data.conversation_id || conversationId.value this.conversationId = data.conversation_id || this.conversationId
if (data.answer && data.answer.indexOf('<think>') !== -1) { if (data.answer && data.answer.indexOf('withTo') !== -1) {
this.isThink = true
}
if (data.answer && data.answer.indexOf('</think>') !== -1) {
this.isThink = false this.isThink = false
} }
}, },
updateMessageContent({ answer, event }) { updateMessageContent({ answer, event }) {
if(event === 'message_end'){ if (event === 'message_end') {
this.messageStatus = 'stop' this.messageStatus = 'stop'
} }
if (!this.currentMessage || !answer) return; if (!this.currentMessage || !answer) return
const mode = this.isThink ? 'think' : 'text'
const mode = this.isThink ? 'think' : 'text';
this.currentMessage[mode] += answer 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: { watch: {
messages: { messages: {
handler() { handler() {
@@ -305,6 +356,10 @@ $primary-trans-color: rgba(135, 162, 208, 0.5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
.chat-main { .chat-main {
flex: 1; flex: 1;
@@ -312,12 +367,14 @@ $primary-trans-color: rgba(135, 162, 208, 0.5);
padding: 10px; padding: 10px;
background: #f7f8fa; background: #f7f8fa;
position: relative; position: relative;
.button-container{
.button-container {
position: fixed; position: fixed;
bottom: 120px; bottom: 120px;
right:10px; right: 10px;
border-radius: 50%; border-radius: 50%;
} }
.chat-content { .chat-content {
height: 100%; height: 100%;
.message-area { .message-area {
@@ -358,13 +415,88 @@ $primary-trans-color: rgba(135, 162, 208, 0.5);
padding: 10px; padding: 10px;
background-color: #fff; background-color: #fff;
input { .input-wrapper {
flex: 1; flex: 1;
padding: 10px;
border: none;
background: #f5f5f5;
border-radius: 5px;
margin-right: 10px; 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 { button {
@@ -376,11 +508,20 @@ $primary-trans-color: rgba(135, 162, 208, 0.5);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
.disabled {
pointer-events: none;
opacity: 0.5;
}
} }
} }
.disabled { @keyframes wave-animation {
pointer-events: none; 0%, 100% {
opacity: 0.5; transform: scaleY(1);
}
50% {
transform: scaleY(1.5);
}
} }
</style> </style>