From 792fa980f9aa89a13144940f160060619ec20575 Mon Sep 17 00:00:00 2001 From: liqupan Date: Sat, 29 Nov 2025 00:18:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=94=AF=E6=8C=81=E8=AF=AD=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- package.json | 1 + src/components/ChatBox.vue | 649 ++++++++++++++++++++++++++++++++++++- src/utils/api.js | 69 ++-- src/utils/config.js | 2 +- 5 files changed, 701 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 84db5d5..1ce4fd3 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,5 @@ coverage/ # UniApp specific unpackage/ dist/ -.history/ \ No newline at end of file +.history/ +purple-energy-visualizer/ \ No newline at end of file diff --git a/package.json b/package.json index 7459470..51f305b 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/components/ChatBox.vue b/src/components/ChatBox.vue index c101c9c..72d0656 100644 --- a/src/components/ChatBox.vue +++ b/src/components/ChatBox.vue @@ -27,9 +27,53 @@ + + + + + + + + + + + {{ voiceStateText }} + + + + + + + + + + {{ voiceHintText }} + + + + {{ isVoiceRecording ? '⏹' : '🎙️' }} + {{ isVoiceRecording ? '停止并发送' : '开始录音' }} + + + + + + {{ voiceState === 'thinking' ? '处理中...' : '回复中...' }} + + + + + + {{ isVoiceMode ? '💬' : '🎧' }} + + { 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 + }); +}; @@ -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; +} diff --git a/src/utils/api.js b/src/utils/api.js index 69d589e..e3bb97b 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -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 { diff --git a/src/utils/config.js b/src/utils/config.js index c800673..c0f0596 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -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',