feat:支持语音
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -73,3 +73,4 @@ coverage/
|
||||
unpackage/
|
||||
dist/
|
||||
.history/
|
||||
purple-energy-visualizer/
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user