feat:支持语音
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -72,4 +72,5 @@ coverage/
|
|||||||
# UniApp specific
|
# UniApp specific
|
||||||
unpackage/
|
unpackage/
|
||||||
dist/
|
dist/
|
||||||
.history/
|
.history/
|
||||||
|
purple-energy-visualizer/
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060420250429001",
|
"@dcloudio/uni-quickapp-webview": "3.0.0-4060420250429001",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
"three": "^0.181.2",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-i18n": "^9.1.9"
|
"vue-i18n": "^9.1.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,9 +27,53 @@
|
|||||||
</view>
|
</view>
|
||||||
</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 -->
|
<!-- 聊天消息区域 - 使用 Ant Design X Bubble.List -->
|
||||||
<scroll-view
|
<scroll-view
|
||||||
|
v-else
|
||||||
class="chat-messages"
|
class="chat-messages"
|
||||||
:style="{ height: chatMessagesHeight }"
|
:style="{ height: chatMessagesHeight }"
|
||||||
scroll-y="true"
|
scroll-y="true"
|
||||||
@@ -109,6 +153,11 @@
|
|||||||
<view class="sender-container">
|
<view class="sender-container">
|
||||||
<!-- 文本输入模式 -->
|
<!-- 文本输入模式 -->
|
||||||
<view v-if="!isRecordingMode" class="text-input-mode">
|
<view v-if="!isRecordingMode" class="text-input-mode">
|
||||||
|
<!-- 🎧 语音模式切换按钮 -->
|
||||||
|
<view class="voice-mode-toggle" @click="toggleVoiceMode">
|
||||||
|
<text class="icon">{{ isVoiceMode ? '💬' : '🎧' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
class="text-input"
|
class="text-input"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
@@ -293,6 +342,11 @@ const touchStartY = ref(0);
|
|||||||
const recorderManager = ref(null);
|
const recorderManager = ref(null);
|
||||||
const audioContext = ref(null);
|
const audioContext = ref(null);
|
||||||
|
|
||||||
|
// 🎧 语音对话模式
|
||||||
|
const isVoiceMode = ref(false);
|
||||||
|
const voiceState = ref('idle'); // idle, listening, thinking, speaking
|
||||||
|
const isVoiceRecording = ref(false); // 是否正在录音
|
||||||
|
|
||||||
// AI配置
|
// AI配置
|
||||||
const availableTemplates = ref([]);
|
const availableTemplates = ref([]);
|
||||||
const currentModelId = ref(null);
|
const currentModelId = ref(null);
|
||||||
@@ -308,6 +362,38 @@ const currentTemplateName = computed(() => {
|
|||||||
return template?.templateName || '默认智能体';
|
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
|
// 处理头像URL
|
||||||
const processedAvatar = computed(() => {
|
const processedAvatar = computed(() => {
|
||||||
return getResourceUrl(currentCharacter.value.avatar);
|
return getResourceUrl(currentCharacter.value.avatar);
|
||||||
@@ -488,11 +574,21 @@ const initRecorder = () => {
|
|||||||
recorderManager.value = uni.getRecorderManager();
|
recorderManager.value = uni.getRecorderManager();
|
||||||
|
|
||||||
recorderManager.value.onStart(() => {
|
recorderManager.value.onStart(() => {
|
||||||
|
console.log('录音开始');
|
||||||
});
|
});
|
||||||
|
|
||||||
recorderManager.value.onStop((res) => {
|
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'
|
icon: 'none'
|
||||||
});
|
});
|
||||||
isActuallyRecording.value = false;
|
isActuallyRecording.value = false;
|
||||||
|
isVoiceRecording.value = false;
|
||||||
|
voiceState.value = 'idle';
|
||||||
});
|
});
|
||||||
// #endif
|
// #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>
|
</script>
|
||||||
|
|
||||||
<!-- 非 scoped 样式:影响页面根元素,防止键盘弹出时页面被推上去 -->
|
<!-- 非 scoped 样式:影响页面根元素,防止键盘弹出时页面被推上去 -->
|
||||||
@@ -1863,4 +2216,296 @@ page {
|
|||||||
color: #1a0b2e;
|
color: #1a0b2e;
|
||||||
font-weight: bold;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -565,6 +565,29 @@ export const voiceAPI = {
|
|||||||
authHeader = loginStatus.token.startsWith('Bearer ') ? loginStatus.token : 'Bearer ' + loginStatus.token;
|
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) => {
|
return new Promise((resolve) => {
|
||||||
uni.uploadFile({
|
uni.uploadFile({
|
||||||
url: getApiUrl(API_CONFIG.ENDPOINTS.VOICE_CHAT),
|
url: getApiUrl(API_CONFIG.ENDPOINTS.VOICE_CHAT),
|
||||||
@@ -573,11 +596,7 @@ export const voiceAPI = {
|
|||||||
header: authHeader ? {
|
header: authHeader ? {
|
||||||
'Authorization': authHeader
|
'Authorization': authHeader
|
||||||
} : {},
|
} : {},
|
||||||
formData: {
|
formData: formData,
|
||||||
modelId: options.modelId || null,
|
|
||||||
templateId: options.templateId || null,
|
|
||||||
voiceStyle: options.voiceStyle || 'default'
|
|
||||||
},
|
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
console.log('语音对话上传成功:', res);
|
console.log('语音对话上传成功:', res);
|
||||||
|
|
||||||
@@ -586,41 +605,46 @@ export const voiceAPI = {
|
|||||||
console.log('语音对话响应数据:', data);
|
console.log('语音对话响应数据:', data);
|
||||||
|
|
||||||
if (data.code === 200) {
|
if (data.code === 200) {
|
||||||
// 根据后端实际返回结构提取字段
|
// 返回完整的data对象,包括sttResult、llmResult、ttsResult
|
||||||
|
const responseData = data.data || {};
|
||||||
|
|
||||||
|
// 兼容旧格式:提取关键字段
|
||||||
let aiResponse = null;
|
let aiResponse = null;
|
||||||
let userText = null;
|
let userText = null;
|
||||||
let audioUrl = null;
|
let audioUrl = null;
|
||||||
|
let audioBase64 = null;
|
||||||
|
|
||||||
// 从 data.llmResult.response 提取AI回复
|
// 从 data.llmResult.response 提取AI回复
|
||||||
if (data.data && data.data.llmResult && data.data.llmResult.response) {
|
if (responseData.llmResult && responseData.llmResult.response) {
|
||||||
aiResponse = data.data.llmResult.response;
|
aiResponse = responseData.llmResult.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 data.sttResult.text 提取用户文本(语音转文字)
|
// 从 data.sttResult.text 提取用户文本(语音转文字)
|
||||||
if (data.data && data.data.sttResult && data.data.sttResult.text) {
|
if (responseData.sttResult && responseData.sttResult.text) {
|
||||||
userText = data.data.sttResult.text;
|
userText = responseData.sttResult.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 data.ttsResult.audioPath 提取音频路径
|
// 从 data.ttsResult 提取音频
|
||||||
if (data.data && data.data.ttsResult && data.data.ttsResult.audioPath) {
|
if (responseData.ttsResult) {
|
||||||
audioUrl = data.data.ttsResult.audioPath;
|
audioUrl = responseData.ttsResult.audioPath;
|
||||||
|
audioBase64 = responseData.ttsResult.audioBase64;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备用字段提取(保持向后兼容)
|
// 备用字段提取(保持向后兼容)
|
||||||
if (!aiResponse) {
|
if (!aiResponse) {
|
||||||
if (data.response && typeof data.response === 'string') {
|
if (data.response && typeof data.response === 'string') {
|
||||||
aiResponse = data.response;
|
aiResponse = data.response;
|
||||||
} else if (data.data && data.data.response) {
|
} else if (responseData.response) {
|
||||||
aiResponse = data.data.response;
|
aiResponse = responseData.response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userText) {
|
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) {
|
if (!audioUrl && !audioBase64) {
|
||||||
audioUrl = data.audioPath || data.audioUrl || data.data?.audioUrl || data.data?.url || data.data?.audio_url || data.data?.speechUrl || data.data?.ttsUrl || data.data?.audioPath;
|
audioUrl = data.audioPath || data.audioUrl || responseData.audioUrl || responseData.url || responseData.audio_url || responseData.speechUrl || responseData.ttsUrl || responseData.audioPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理AI回复文本
|
// 清理AI回复文本
|
||||||
@@ -631,9 +655,16 @@ export const voiceAPI = {
|
|||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
// 兼容旧接口
|
||||||
userText: userText,
|
userText: userText,
|
||||||
aiResponse: cleanedAiResponse,
|
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 {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// API配置统一管理
|
// API配置统一管理
|
||||||
export const API_CONFIG = {
|
export const API_CONFIG = {
|
||||||
// 基础API地址
|
// 基础API地址
|
||||||
BASE_URL: 'https://api.aixsy.com.cn',
|
BASE_URL: 'http://192.168.3.13:8091',
|
||||||
|
|
||||||
// 其他服务地址(如果需要)
|
// 其他服务地址(如果需要)
|
||||||
WEB_URL: 'https://www.aixsy.com.cn',
|
WEB_URL: 'https://www.aixsy.com.cn',
|
||||||
|
|||||||
Reference in New Issue
Block a user