From eb842ffcea43e0307419e6fa8808f9d37974a032 Mon Sep 17 00:00:00 2001 From: iammm0 Date: Tue, 3 Feb 2026 11:30:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=9B=9E=E5=BF=86=E6=B5=81=E7=A8=8B=EF=BC=8C=E9=9B=86?= =?UTF-8?q?=E6=88=90=E8=AF=AD=E9=9F=B3=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化CreateMemoryScreen创建回忆页面 - 扩展CreateMemoryViewModel支持语音消息 Co-authored-by: Cursor --- .../ui/screens/CreateMemoryScreen.kt | 83 +++++++- .../ui/viewmodel/CreateMemoryViewModel.kt | 178 +++++++++++++++++- 2 files changed, 256 insertions(+), 5 deletions(-) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt index b11d032..74c005f 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt @@ -1,6 +1,12 @@ package com.huaga.life_echo.ui.screens +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.core.content.ContextCompat +import kotlinx.coroutines.launch import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* @@ -18,6 +24,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.huaga.life_echo.config.AppConfig import com.huaga.life_echo.data.database.Message +import com.huaga.life_echo.feature.voice.PlaybackInfo import com.huaga.life_echo.network.models.MessageDto import com.huaga.life_echo.ui.components.chat.* import com.huaga.life_echo.ui.components.debug.WebSocketDebugPanel @@ -45,6 +52,46 @@ fun CreateMemoryScreen( val streamingText by viewModel.streamingText.collectAsState() val isTyping by viewModel.isTyping.collectAsState() + // 语音录制相关状态 + val isVoiceRecording by viewModel.isVoiceRecording.collectAsState() + val recordingDuration by viewModel.recordingDuration.collectAsState() + + // 音频播放相关状态 + val playbackInfo by viewModel.playbackInfo.collectAsState() + val audioFilePaths by viewModel.audioFilePaths.collectAsState() + val audioDurations by viewModel.audioDurations.collectAsState() + + // 录音权限 + val context = LocalContext.current + var hasRecordPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + android.content.pm.PackageManager.PERMISSION_GRANTED + ) + } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + hasRecordPermission = isGranted + if (!isGranted) { + scope.launch { + snackbarHostState.showSnackbar( + "需要麦克风权限才能发送语音", + withDismissAction = true + ) + } + } + } + + // 进入聊天页时请求录音权限(若未授予会弹出系统对话框) + LaunchedEffect(Unit) { + if (!hasRecordPermission) { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + // 调试信息 val wsIsConnected by viewModel.wsIsConnected.collectAsState() val lastMessageType by viewModel.lastMessageType.collectAsState() @@ -91,8 +138,14 @@ fun CreateMemoryScreen( }.sortedBy { it.timestamp } // 按时间排序 } - Column( + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = Modifier.fillMaxSize() + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) ) { // 使用新的ChatHeader组件(内部已处理WindowInsets) ChatHeader( @@ -126,10 +179,17 @@ fun CreateMemoryScreen( streamingText = streamingText, isTyping = isTyping, modifier = Modifier.weight(1f), - onStopGeneration = if (isStreaming) { { viewModel.cancelGeneration() } } else null + onStopGeneration = if (isStreaming) { { viewModel.cancelGeneration() } } else null, + // 音频相关参数 + playbackInfo = playbackInfo, + onAudioPlayClick = { messageId, filePath -> + viewModel.toggleAudioPlayback(messageId, filePath) + }, + audioFilePaths = audioFilePaths, + audioDurations = audioDurations ) - // 使用新的ChatInputField组件 + // 使用新的ChatInputField组件(支持语音输入) ChatInputField( value = inputText, onValueChange = { inputText = it }, @@ -140,7 +200,22 @@ fun CreateMemoryScreen( keyboardController?.hide() } }, - enabled = !isStreaming + enabled = !isStreaming && !isVoiceRecording, + // 语音相关回调 + onStartRecording = { + if (hasRecordPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + viewModel.startRecordingVoice() + } + }, + onStopRecording = { + viewModel.stopAndSendRecording() + }, + onCancelRecording = { + viewModel.cancelRecordingVoice() + }, + isRecording = isVoiceRecording, + recordingDuration = recordingDuration ) } + } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt index b5d1670..360b501 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt @@ -1,6 +1,7 @@ package com.huaga.life_echo.ui.viewmodel import android.content.Context +import android.os.Build import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -8,6 +9,9 @@ import com.huaga.life_echo.data.auth.TokenManager import com.huaga.life_echo.data.repository.ConversationRepository import com.huaga.life_echo.data.repository.ChapterRepository import com.huaga.life_echo.data.repository.MessageRepository +import com.huaga.life_echo.feature.voice.AudioPlayer +import com.huaga.life_echo.feature.voice.PlaybackInfo +import com.huaga.life_echo.feature.voice.VoiceRecorder import com.huaga.life_echo.network.WebSocketClient import com.huaga.life_echo.network.WebSocketMessage import com.huaga.life_echo.network.MessageType @@ -30,11 +34,26 @@ class CreateMemoryViewModel( companion object { private const val TAG = "CreateMemoryViewModel" + private const val MIN_RECORDING_DURATION = 1 // 最小录音时长(秒) } private val webSocketClient = WebSocketClient() private val apiService = ApiService(TokenManager, AuthService()) + // 语音录制器 + private val voiceRecorder = VoiceRecorder(context).apply { + maxDuration = 60 // 最大录音60秒 + onMaxDurationReached = { + // 达到最大时长时自动停止并发送 + viewModelScope.launch { + stopAndSendRecording() + } + } + } + + // 音频播放器 + private val audioPlayer = AudioPlayer(context) + val isRecording = MutableStateFlow(false) val transcript = MutableStateFlow("") val agentResponse = MutableStateFlow("") @@ -63,6 +82,21 @@ class CreateMemoryViewModel( val errorMessages = MutableStateFlow>(emptyList()) // 错误消息列表 val messageCount = MutableStateFlow(0) // 消息计数 + // 语音录制相关状态 + val isVoiceRecording: StateFlow = voiceRecorder.isRecording + val recordingDuration: StateFlow = voiceRecorder.recordingDuration + + // 音频播放相关状态 + val playbackInfo: StateFlow = audioPlayer.playbackInfo + + // 音频文件路径映射 (messageId -> filePath) + private val _audioFilePaths = MutableStateFlow>(emptyMap()) + val audioFilePaths: StateFlow> = _audioFilePaths.asStateFlow() + + // 音频时长映射 (messageId -> duration in seconds) + private val _audioDurations = MutableStateFlow>(emptyMap()) + val audioDurations: StateFlow> = _audioDurations.asStateFlow() + init { TokenManager.initialize(context) } @@ -199,6 +233,142 @@ class CreateMemoryViewModel( } } + // ==================== 语音录制功能 ==================== + + /** + * 开始录音(需 API 26+ 与录音权限) + */ + fun startRecordingVoice() { + Log.d(TAG, "开始录音") + voiceRecorder.startRecording() + } + + /** + * 停止录音并发送 + */ + fun stopAndSendRecording() { + Log.d(TAG, "停止录音并发送") + val result = voiceRecorder.stopRecording() + + if (result == null) { + Log.e(TAG, "录音失败,result 为空") + return + } + + if (result.durationSeconds < MIN_RECORDING_DURATION) { + Log.w(TAG, "录音时间太短: ${result.durationSeconds}s") + // 可以在这里添加提示用户的逻辑 + return + } + + viewModelScope.launch { + sendAudioMessage(result.audioBytes, result.filePath, result.durationSeconds) + } + } + + /** + * 取消录音 + */ + fun cancelRecordingVoice() { + Log.d(TAG, "取消录音") + voiceRecorder.cancelRecording() + } + + /** + * 发送音频消息 + */ + private suspend fun sendAudioMessage(audioBytes: ByteArray, filePath: String, durationSeconds: Int) { + Log.d(TAG, "准备发送音频消息,大小: ${audioBytes.size}, 时长: ${durationSeconds}s") + + // 确保已连接 + if (conversationId.value == null) { + Log.d(TAG, "对话ID为空,开始创建新对话") + startConversation() + delay(500) + } + + // 检查连接状态 + if (!webSocketClient.isConnected()) { + Log.w(TAG, "WebSocket未连接,尝试重连") + connectionStatus.value = "未连接,正在重连..." + // 重连逻辑类似 sendTextMessage + conversationId.value?.let { id -> + val token = TokenManager.getAccessToken() + try { + webSocketClient.connect( + id, + token, + onMessage = { message -> handleWebSocketMessage(message) }, + onError = { errorMsg -> connectionStatus.value = "错误: $errorMsg" } + ) + delay(500) + } catch (e: Exception) { + connectionStatus.value = "重连失败: ${e.message}" + return + } + } ?: run { + connectionStatus.value = "对话ID为空,无法发送消息" + return + } + } + + conversationId.value?.let { id -> + // 生成临时消息 ID + val tempMessageId = "audio_user_${System.currentTimeMillis()}" + + // 添加到历史消息(本地先显示) + val tempMessage = MessageDto( + id = tempMessageId, + conversationId = id, + content = "[语音消息]", + senderType = "user", + timestamp = System.currentTimeMillis(), + messageType = "audio" + ) + historyMessages.value = historyMessages.value + tempMessage + + // 保存音频文件路径和时长 + _audioFilePaths.value = _audioFilePaths.value + (tempMessageId to filePath) + _audioDurations.value = _audioDurations.value + (tempMessageId to durationSeconds) + + try { + // 显示加载动画 + isTyping.value = true + + // 发送音频消息 + webSocketClient.sendAudioMessage(audioBytes, id, durationSeconds) + Log.d(TAG, "音频消息发送成功") + + } catch (e: Exception) { + isTyping.value = false + Log.e(TAG, "音频消息发送失败: ${e.message}", e) + connectionStatus.value = "发送失败: ${e.message}" + errorMessages.value = (errorMessages.value + "发送失败: ${e.message}").takeLast(10) + // 移除临时消息 + historyMessages.value = historyMessages.value.filter { it.id != tempMessageId } + _audioFilePaths.value = _audioFilePaths.value - tempMessageId + _audioDurations.value = _audioDurations.value - tempMessageId + } + } + } + + // ==================== 音频播放功能 ==================== + + /** + * 播放/暂停音频 + */ + fun toggleAudioPlayback(messageId: String, filePath: String) { + Log.d(TAG, "切换音频播放状态: $messageId, $filePath") + audioPlayer.play(messageId, filePath) + } + + /** + * 停止音频播放 + */ + fun stopAudioPlayback() { + audioPlayer.stop() + } + fun sendTextMessage(text: String) { if (text.isBlank()) return @@ -291,7 +461,10 @@ class CreateMemoryViewModel( when (message.type) { MessageType.transcript -> { - transcript.value = message.getString("text") ?: "" + // 语音转文字结果 + val text = message.getString("text") ?: "" + transcript.value = text + Log.d(TAG, "收到语音转文字结果: $text") } MessageType.agent_response -> { // 处理Agent回复(可能有多条消息,每条作为单独气泡显示) @@ -524,6 +697,9 @@ class CreateMemoryViewModel( viewModelScope.launch { webSocketClient.disconnect() } + // 释放录音器和播放器资源 + voiceRecorder.release() + audioPlayer.release() } }