feat:支持语音

This commit is contained in:
liqupan
2025-11-29 00:18:09 +08:00
parent 6087a3f195
commit 792fa980f9
5 changed files with 701 additions and 23 deletions

1
.gitignore vendored
View File

@@ -73,3 +73,4 @@ coverage/
unpackage/
dist/
.history/
purple-energy-visualizer/

View File

@@ -54,6 +54,7 @@
"@dcloudio/uni-quickapp-webview": "3.0.0-4060420250429001",
"ant-design-vue": "^4.2.6",
"pinia": "^2.1.7",
"three": "^0.181.2",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},

View File

@@ -27,9 +27,53 @@
</view>
</view>
<!-- 🎧 语音对话模式 -->
<view v-if="isVoiceMode" class="voice-mode-container" :style="{ height: chatMessagesHeight }">
<view class="voice-mode-content">
<!-- 角色头像 -->
<view class="voice-avatar-wrapper">
<image class="voice-avatar" :src="processedAvatar" mode="aspectFill" />
<view class="avatar-glow" :class="[voiceState]"></view>
</view>
<!-- 状态文本 -->
<text class="voice-state-text">{{ voiceStateText }}</text>
<!-- 声波动画 -->
<view class="sound-wave-container">
<view class="sound-wave" :class="[voiceState]">
<view class="wave-bar" v-for="i in 40" :key="i" :style="{ animationDelay: (i * 0.05) + 's' }"></view>
</view>
</view>
<!-- 提示文本 -->
<text class="voice-hint-text">{{ voiceHintText }}</text>
<!-- 录音控制按钮 -->
<view
v-if="voiceState === 'idle' || voiceState === 'listening'"
class="voice-record-btn"
:class="{ 'recording': isVoiceRecording }"
@click="toggleVoiceRecording"
>
<text class="voice-record-icon">{{ isVoiceRecording ? '⏹' : '🎙️' }}</text>
<text class="voice-record-text">{{ isVoiceRecording ? '停止并发送' : '开始录音' }}</text>
</view>
<!-- 处理中状态显示 -->
<view
v-else
class="voice-processing-btn"
>
<text class="voice-processing-icon"></text>
<text class="voice-processing-text">{{ voiceState === 'thinking' ? '处理中...' : '回复中...' }}</text>
</view>
</view>
</view>
<!-- 聊天消息区域 - 使用 Ant Design X Bubble.List -->
<scroll-view
v-else
class="chat-messages"
:style="{ height: chatMessagesHeight }"
scroll-y="true"
@@ -109,6 +153,11 @@
<view class="sender-container">
<!-- 文本输入模式 -->
<view v-if="!isRecordingMode" class="text-input-mode">
<!-- 🎧 语音模式切换按钮 -->
<view class="voice-mode-toggle" @click="toggleVoiceMode">
<text class="icon">{{ isVoiceMode ? '💬' : '🎧' }}</text>
</view>
<input
class="text-input"
v-model="inputText"
@@ -293,6 +342,11 @@ const touchStartY = ref(0);
const recorderManager = ref(null);
const audioContext = ref(null);
// 🎧 语音对话模式
const isVoiceMode = ref(false);
const voiceState = ref('idle'); // idle, listening, thinking, speaking
const isVoiceRecording = ref(false); // 是否正在录音
// AI配置
const availableTemplates = ref([]);
const currentModelId = ref(null);
@@ -308,6 +362,38 @@ const currentTemplateName = computed(() => {
return template?.templateName || '默认智能体';
});
// 🎧 语音模式状态文本
const voiceStateText = computed(() => {
const stateMap = {
idle: '准备就绪',
listening: '正在倾听...',
thinking: '思考中...',
speaking: '回复中...'
};
return stateMap[voiceState.value] || '准备就绪';
});
// 🎧 语音模式提示文本
const voiceHintText = computed(() => {
const hintMap = {
idle: '点击下方按钮开始录音',
listening: '说完后再次点击按钮发送',
thinking: 'AI 正在思考你的问题...',
speaking: 'AI 正在回复...'
};
return hintMap[voiceState.value] || '点击下方按钮开始录音';
});
// 🎧 语音按钮文本
const voiceButtonText = computed(() => {
if (voiceState === 'idle' && !isVoiceRecording.value) {
return '开始录音';
} else if (voiceState === 'listening' || isVoiceRecording.value) {
return '停止并发送';
}
return '处理中...';
});
// 处理头像URL
const processedAvatar = computed(() => {
return getResourceUrl(currentCharacter.value.avatar);
@@ -488,11 +574,21 @@ const initRecorder = () => {
recorderManager.value = uni.getRecorderManager();
recorderManager.value.onStart(() => {
console.log('录音开始');
});
recorderManager.value.onStop((res) => {
if (!isRecordingCancelled.value) {
handleVoiceMessage(res.tempFilePath);
// 判断是语音模式还是普通录音模式
if (isVoiceMode.value) {
// 语音模式:处理语音对话
if (isVoiceRecording.value) {
handleVoiceModeMessage(res.tempFilePath);
}
} else {
// 普通录音模式:原有逻辑
if (!isRecordingCancelled.value) {
handleVoiceMessage(res.tempFilePath);
}
}
});
@@ -502,6 +598,8 @@ const initRecorder = () => {
icon: 'none'
});
isActuallyRecording.value = false;
isVoiceRecording.value = false;
voiceState.value = 'idle';
});
// #endif
};
@@ -1162,6 +1260,261 @@ const clearContext = async () => {
}
});
};
// 🎧 切换语音模式
const toggleVoiceMode = () => {
isVoiceMode.value = !isVoiceMode.value;
if (isVoiceMode.value) {
// 进入语音模式
voiceState.value = 'idle';
uni.showToast({
title: '已切换到语音模式',
icon: 'none',
duration: 1500
});
} else {
// 退出语音模式
voiceState.value = 'idle';
uni.showToast({
title: '已切换到文本模式',
icon: 'none',
duration: 1500
});
}
};
// 🎧 切换录音状态(开始/停止录音)
const toggleVoiceRecording = async () => {
if (voiceState.value === 'thinking' || voiceState.value === 'speaking') {
uni.showToast({
title: 'AI正在处理中请稍候',
icon: 'none'
});
return;
}
if (!isVoiceRecording.value) {
// 开始录音
startVoiceRecording();
} else {
// 停止录音并发送
await stopVoiceRecordingAndSend();
}
};
// 🎧 开始录音
const startVoiceRecording = () => {
// #ifdef MP-WEIXIN
uni.authorize({
scope: 'scope.record',
success: () => {
isVoiceRecording.value = true;
voiceState.value = 'listening';
recorderManager.value.start({
duration: 60000, // 最长60秒
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'aac' // AAC 格式(需要 ffmpeg 转换)
});
uni.showToast({
title: '开始录音',
icon: 'none',
duration: 1000
});
},
fail: () => {
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限',
showCancel: false
});
}
});
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '语音功能仅支持微信小程序',
icon: 'none'
});
// #endif
};
// 🎧 停止录音并发送
const stopVoiceRecordingAndSend = async () => {
// #ifdef MP-WEIXIN
recorderManager.value.stop();
// 停止后会触发 onStop 回调,在那里处理录音文件
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '语音功能仅支持微信小程序',
icon: 'none'
});
isVoiceRecording.value = false;
voiceState.value = 'idle';
// #endif
};
// 🎧 处理语音模式的录音消息
const handleVoiceModeMessage = async (filePath) => {
isVoiceRecording.value = false;
voiceState.value = 'thinking';
try {
console.log('🎧 语音模式:开始处理录音文件', filePath);
// 调用后端voice-chat接口
const result = await voiceAPI.voiceChat(filePath, {
sessionId: conversationId.value, // 使用当前会话ID保持上下文
modelId: currentCharacter.value.modelId || 10,
templateId: currentCharacter.value.templateId || currentCharacter.value.roleId || 6,
ttsConfigId: currentCharacter.value.ttsId || null,
sttConfigId: currentCharacter.value.sttId || null,
useFunctionCall: false
});
if (result.success && result.data) {
const { sttResult, llmResult, ttsResult } = result.data;
console.log('🎧 STT识别结果:', sttResult?.text);
console.log('🎧 AI回复文本:', llmResult?.response);
console.log('🎧 TTS音频:', ttsResult?.audioBase64 ? '已获取' : '未获取');
// 切换到说话状态
voiceState.value = 'speaking';
// 播放AI返回的语音
if (ttsResult && ttsResult.audioBase64) {
// 如果有audioBase64播放Base64音频
await playVoiceFromBase64(ttsResult.audioBase64, llmResult?.response);
} else if (ttsResult && ttsResult.audioPath) {
// 如果有audioPath播放路径音频
const audioUrl = getResourceUrl(ttsResult.audioPath);
await playVoiceResponse(llmResult?.response, audioUrl);
} else {
// 没有音频,只显示文本
uni.showToast({
title: llmResult?.response || 'AI已回复',
icon: 'none',
duration: 2000
});
await sleep(2000);
}
// 可选:将对话记录到聊天列表
if (sttResult?.text) {
addMessage('user', sttResult.text);
}
if (llmResult?.response) {
const cleanedResponse = cleanText(llmResult.response);
await addSegmentedAIResponse(cleanedResponse);
}
} else {
// API调用失败
throw new Error(result.error?.message || '语音处理失败');
}
// 回到待机状态
voiceState.value = 'idle';
} catch (error) {
console.error('🎧 语音处理失败:', error);
voiceState.value = 'idle';
uni.showToast({
title: '语音处理失败: ' + (error.message || '未知错误'),
icon: 'none',
duration: 2000
});
}
};
// 🎧 播放Base64编码的音频
const playVoiceFromBase64 = async (audioBase64, text = '') => {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
try {
// 将Base64转换为临时文件
const fs = uni.getFileSystemManager();
const filePath = `${wx.env.USER_DATA_PATH}/voice_temp_${Date.now()}.wav`;
// 写入文件
fs.writeFile({
filePath: filePath,
data: audioBase64,
encoding: 'base64',
success: () => {
console.log('🎧 Base64音频已转换为文件:', filePath);
// 播放音频
if (audioContext.value) {
audioContext.value.destroy();
}
audioContext.value = uni.createInnerAudioContext();
audioContext.value.src = filePath;
audioContext.value.onPlay(() => {
console.log('🎧 开始播放AI语音');
});
audioContext.value.onEnded(() => {
console.log('🎧 AI语音播放完成');
resolve();
});
audioContext.value.onError((err) => {
console.error('🎧 语音播放失败:', err);
reject(err);
});
audioContext.value.play();
},
fail: (err) => {
console.error('🎧 Base64转文件失败:', err);
reject(err);
}
});
} catch (error) {
console.error('🎧 播放Base64音频异常:', error);
reject(error);
}
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '语音播放仅支持微信小程序',
icon: 'none'
});
resolve();
// #endif
});
};
// 🎧 取消录音
const cancelVoiceRecording = () => {
if (!isVoiceRecording.value) return;
// #ifdef MP-WEIXIN
recorderManager.value.stop();
// #endif
isVoiceRecording.value = false;
voiceState.value = 'idle';
uni.showToast({
title: '已取消录音',
icon: 'none',
duration: 1000
});
};
</script>
<!-- scoped 样式影响页面根元素防止键盘弹出时页面被推上去 -->
@@ -1863,4 +2216,296 @@ page {
color: #1a0b2e;
font-weight: bold;
}
/* 🎧 语音模式切换按钮 */
.voice-mode-toggle {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(249, 224, 118, 0.15);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
flex-shrink: 0;
}
.voice-mode-toggle:active {
background: rgba(249, 224, 118, 0.3);
transform: scale(0.95);
}
.voice-mode-toggle .icon {
font-size: 28rpx;
}
/* 🎧 语音对话模式容器 */
.voice-mode-container {
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx 32rpx;
}
.voice-mode-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 48rpx;
width: 100%;
}
/* 🎧 语音模式头像 */
.voice-avatar-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.voice-avatar {
width: 240rpx;
height: 240rpx;
border-radius: 50%;
border: 6rpx solid rgba(249, 224, 118, 0.3);
box-shadow: 0 8rpx 32rpx rgba(249, 224, 118, 0.3);
position: relative;
z-index: 2;
}
.avatar-glow {
position: absolute;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(249, 224, 118, 0.3) 0%, transparent 70%);
z-index: 1;
}
.avatar-glow.listening {
animation: pulse-glow 2s infinite;
background: radial-gradient(circle, rgba(76, 175, 80, 0.4) 0%, transparent 70%);
}
.avatar-glow.thinking {
animation: pulse-glow 1.5s infinite;
background: radial-gradient(circle, rgba(33, 150, 243, 0.4) 0%, transparent 70%);
}
.avatar-glow.speaking {
animation: pulse-glow 1s infinite;
background: radial-gradient(circle, rgba(249, 224, 118, 0.5) 0%, transparent 70%);
}
@keyframes pulse-glow {
0%, 100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.1);
opacity: 1;
}
}
/* 🎧 状态文本 */
.voice-state-text {
font-size: 36rpx;
font-weight: 600;
color: #f9e076;
text-shadow: 0 0 20rpx rgba(249, 224, 118, 0.5);
letter-spacing: 2rpx;
}
/* 🎧 声波动画容器 */
.sound-wave-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
height: 160rpx;
}
.sound-wave {
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
height: 100%;
}
.wave-bar {
width: 6rpx;
background: linear-gradient(180deg, #f9e076 0%, #f5d042 100%);
border-radius: 3rpx;
height: 20rpx;
opacity: 0.3;
transition: all 0.3s;
}
/* 🎧 倾听状态 */
.sound-wave.listening .wave-bar {
animation: wave-listening 1.2s infinite ease-in-out;
background: linear-gradient(180deg, #4caf50 0%, #45a049 100%);
opacity: 0.8;
}
@keyframes wave-listening {
0%, 100% {
height: 30rpx;
}
50% {
height: 80rpx;
}
}
/* 🎧 思考状态 */
.sound-wave.thinking .wave-bar {
animation: wave-thinking 2s infinite ease-in-out;
background: linear-gradient(180deg, #2196f3 0%, #1976d2 100%);
opacity: 0.7;
}
@keyframes wave-thinking {
0%, 100% {
height: 20rpx;
opacity: 0.3;
}
50% {
height: 50rpx;
opacity: 0.9;
}
}
/* 🎧 说话状态 */
.sound-wave.speaking .wave-bar {
animation: wave-speaking 0.8s infinite ease-in-out;
background: linear-gradient(180deg, #f9e076 0%, #f5d042 100%);
opacity: 1;
}
@keyframes wave-speaking {
0%, 100% {
height: 40rpx;
}
50% {
height: 100rpx;
}
}
/* 每个波形条使用不同的延迟创造连续效果 */
.sound-wave .wave-bar:nth-child(1) { animation-delay: 0s; }
.sound-wave .wave-bar:nth-child(2) { animation-delay: 0.05s; }
.sound-wave .wave-bar:nth-child(3) { animation-delay: 0.1s; }
.sound-wave .wave-bar:nth-child(4) { animation-delay: 0.15s; }
.sound-wave .wave-bar:nth-child(5) { animation-delay: 0.2s; }
.sound-wave .wave-bar:nth-child(6) { animation-delay: 0.25s; }
.sound-wave .wave-bar:nth-child(7) { animation-delay: 0.3s; }
.sound-wave .wave-bar:nth-child(8) { animation-delay: 0.35s; }
.sound-wave .wave-bar:nth-child(9) { animation-delay: 0.4s; }
.sound-wave .wave-bar:nth-child(10) { animation-delay: 0.45s; }
/* 🎧 提示文本 */
.voice-hint-text {
font-size: 28rpx;
color: rgba(249, 224, 118, 0.7);
text-align: center;
line-height: 1.6;
}
/* 🎧 录音控制按钮 */
.voice-record-btn {
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
box-shadow: 0 12rpx 48rpx rgba(249, 224, 118, 0.5), 0 0 80rpx rgba(249, 224, 118, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
transition: all 0.3s;
margin-top: 32rpx;
}
.voice-record-btn:active {
transform: scale(0.95);
box-shadow: 0 8rpx 32rpx rgba(249, 224, 118, 0.4), 0 0 60rpx rgba(249, 224, 118, 0.2);
}
/* 录音中状态 */
.voice-record-btn.recording {
background: linear-gradient(135deg, rgba(244, 67, 54, 0.9) 0%, rgba(211, 47, 47, 0.9) 100%);
box-shadow: 0 12rpx 48rpx rgba(244, 67, 54, 0.5), 0 0 80rpx rgba(244, 67, 54, 0.3);
animation: pulse-recording-btn 1.5s infinite;
}
@keyframes pulse-recording-btn {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.voice-record-icon {
font-size: 72rpx;
filter: drop-shadow(0 4rpx 8rpx rgba(26, 11, 46, 0.3));
}
.voice-record-btn.recording .voice-record-icon {
color: #fff;
filter: drop-shadow(0 4rpx 8rpx rgba(0, 0, 0, 0.3));
}
.voice-record-text {
font-size: 32rpx;
font-weight: 600;
color: #1a0b2e;
letter-spacing: 2rpx;
}
.voice-record-btn.recording .voice-record-text {
color: #fff;
}
/* 🎧 处理中状态按钮 */
.voice-processing-btn {
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(249, 224, 118, 0.3) 0%, rgba(249, 224, 118, 0.15) 100%);
border: 4rpx solid rgba(249, 224, 118, 0.5);
box-shadow: 0 8rpx 32rpx rgba(249, 224, 118, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-top: 32rpx;
opacity: 0.8;
}
.voice-processing-icon {
font-size: 72rpx;
animation: rotate-processing 2s linear infinite;
}
@keyframes rotate-processing {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.voice-processing-text {
font-size: 28rpx;
font-weight: 600;
color: #f9e076;
letter-spacing: 2rpx;
}
</style>

View File

@@ -565,6 +565,29 @@ export const voiceAPI = {
authHeader = loginStatus.token.startsWith('Bearer ') ? loginStatus.token : 'Bearer ' + loginStatus.token;
}
// 构建formData支持更多参数
const formData = {
modelId: options.modelId || null,
templateId: options.templateId || null,
voiceStyle: options.voiceStyle || 'default'
};
// 添加可选参数
if (options.sessionId) {
formData.sessionId = options.sessionId;
}
if (options.ttsConfigId) {
formData.ttsConfigId = options.ttsConfigId;
}
if (options.sttConfigId) {
formData.sttConfigId = options.sttConfigId;
}
if (options.useFunctionCall !== undefined) {
formData.useFunctionCall = options.useFunctionCall;
}
console.log('语音对话参数:', formData);
return new Promise((resolve) => {
uni.uploadFile({
url: getApiUrl(API_CONFIG.ENDPOINTS.VOICE_CHAT),
@@ -573,11 +596,7 @@ export const voiceAPI = {
header: authHeader ? {
'Authorization': authHeader
} : {},
formData: {
modelId: options.modelId || null,
templateId: options.templateId || null,
voiceStyle: options.voiceStyle || 'default'
},
formData: formData,
success: (res) => {
console.log('语音对话上传成功:', res);
@@ -586,41 +605,46 @@ export const voiceAPI = {
console.log('语音对话响应数据:', data);
if (data.code === 200) {
// 根据后端实际返回结构提取字段
// 返回完整的data对象包括sttResult、llmResult、ttsResult
const responseData = data.data || {};
// 兼容旧格式:提取关键字段
let aiResponse = null;
let userText = null;
let audioUrl = null;
let audioBase64 = null;
// 从 data.llmResult.response 提取AI回复
if (data.data && data.data.llmResult && data.data.llmResult.response) {
aiResponse = data.data.llmResult.response;
if (responseData.llmResult && responseData.llmResult.response) {
aiResponse = responseData.llmResult.response;
}
// 从 data.sttResult.text 提取用户文本(语音转文字)
if (data.data && data.data.sttResult && data.data.sttResult.text) {
userText = data.data.sttResult.text;
if (responseData.sttResult && responseData.sttResult.text) {
userText = responseData.sttResult.text;
}
// 从 data.ttsResult.audioPath 提取音频路径
if (data.data && data.data.ttsResult && data.data.ttsResult.audioPath) {
audioUrl = data.data.ttsResult.audioPath;
// 从 data.ttsResult 提取音频
if (responseData.ttsResult) {
audioUrl = responseData.ttsResult.audioPath;
audioBase64 = responseData.ttsResult.audioBase64;
}
// 备用字段提取(保持向后兼容)
if (!aiResponse) {
if (data.response && typeof data.response === 'string') {
aiResponse = data.response;
} else if (data.data && data.data.response) {
aiResponse = data.data.response;
} else if (responseData.response) {
aiResponse = responseData.response;
}
}
if (!userText) {
userText = data.userText || data.data?.userText || data.data?.text || data.data?.user_text || data.data?.recognizedText || data.data?.transcription;
userText = data.userText || responseData.userText || responseData.text || responseData.user_text || responseData.recognizedText || responseData.transcription;
}
if (!audioUrl) {
audioUrl = data.audioPath || data.audioUrl || data.data?.audioUrl || data.data?.url || data.data?.audio_url || data.data?.speechUrl || data.data?.ttsUrl || data.data?.audioPath;
if (!audioUrl && !audioBase64) {
audioUrl = data.audioPath || data.audioUrl || responseData.audioUrl || responseData.url || responseData.audio_url || responseData.speechUrl || responseData.ttsUrl || responseData.audioPath;
}
// 清理AI回复文本
@@ -631,9 +655,16 @@ export const voiceAPI = {
resolve({
success: true,
data: {
// 兼容旧接口
userText: userText,
aiResponse: cleanedAiResponse,
audioUrl: audioUrl
audioUrl: audioUrl,
// 新增完整数据结构
sttResult: responseData.sttResult || { text: userText },
llmResult: responseData.llmResult || { response: cleanedAiResponse, inputText: userText },
ttsResult: responseData.ttsResult || { audioPath: audioUrl, audioBase64: audioBase64 },
sessionId: responseData.sessionId || null,
timestamp: responseData.timestamp || Date.now()
}
});
} else {

View File

@@ -1,7 +1,7 @@
// API配置统一管理
export const API_CONFIG = {
// 基础API地址
BASE_URL: 'https://api.aixsy.com.cn',
BASE_URL: 'http://192.168.3.13:8091',
// 其他服务地址(如果需要)
WEB_URL: 'https://www.aixsy.com.cn',