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 973d592..8bcee71 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 @@ -29,6 +29,7 @@ import com.huaga.life_echo.network.models.MessageDto import com.huaga.life_echo.ui.components.chat.ChatHeader import com.huaga.life_echo.ui.components.chat.ChatInputField import com.huaga.life_echo.ui.components.chat.MessageList +import com.huaga.life_echo.ui.components.chat.ReleaseAction import com.huaga.life_echo.ui.components.chat.RecordingOverlayContent import com.huaga.life_echo.ui.components.chat.RecordingState import com.huaga.life_echo.ui.components.debug.WebSocketDebugPanel @@ -159,7 +160,8 @@ fun CreateMemoryScreen( ChatHeader( title = "回忆录助手", isOnline = connectionStatus == "已连接", - onBackClick = { navController?.popBackStack() } + onBackClick = { navController?.popBackStack() }, + onNewConversationClick = { viewModel.startConversation() } ) // WebSocket调试面板(仅在开发模式下显示) @@ -208,7 +210,8 @@ fun CreateMemoryScreen( keyboardController?.hide() } }, - enabled = !isStreaming && !isVoiceRecording, + // 录音过程中也必须保持语音按钮可用,否则会丢失手指抬起事件,导致无法停止录音 + enabled = !isStreaming, // 语音相关回调 onStartRecording = { if (hasRecordPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -226,11 +229,17 @@ fun CreateMemoryScreen( recordingDuration = recordingDuration ) } - // 录音浮层:仅视觉反馈,不消费触摸,松开由按钮接收并立即发送 + // 录音浮层:覆盖在聊天之上,只检测本层手势;左滑取消、右滑转文字、否则松开发送语音 if (recordingOverlayState != RecordingState.IDLE) { RecordingOverlayContent( - state = recordingOverlayState, duration = recordingDuration, + onRelease = { action -> + when (action) { + ReleaseAction.CANCEL -> viewModel.cancelRecordingVoice() + ReleaseAction.SEND_VOICE -> viewModel.stopAndSendRecording() + ReleaseAction.SEND_AS_TEXT -> viewModel.stopAndSendRecordingAsText() + } + }, modifier = Modifier.fillMaxSize() ) } 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 360b501..935ac87 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 @@ -19,11 +19,13 @@ import com.huaga.life_echo.network.ApiService import com.huaga.life_echo.network.AuthService import com.huaga.life_echo.network.models.MessageDto import com.huaga.life_echo.data.database.Chapter +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull class CreateMemoryViewModel( private val conversationRepository: ConversationRepository, @@ -97,6 +99,11 @@ class CreateMemoryViewModel( private val _audioDurations = MutableStateFlow>(emptyMap()) val audioDurations: StateFlow> = _audioDurations.asStateFlow() + // 仅转写结果:发 transcribe_only 后等待 transcript 用 + private val pendingTranscribeChannel = Channel(Channel.RENDEZVOUS) + @Volatile + private var waitingForTranscribeOnly = false + init { TokenManager.initialize(context) } @@ -182,6 +189,16 @@ class CreateMemoryViewModel( isRecording.value = true connectionStatus.value = "连接中..." + // 新建对话:清空当前会话展示状态 + historyMessages.value = emptyList() + userMessages.value = emptyList() + streamingText.value = "" + agentResponse.value = "" + isStreaming.value = false + isTyping.value = false + _audioFilePaths.value = emptyMap() + _audioDurations.value = emptyMap() + // 清除旧的任务记录 apiService.clearTasks() @@ -244,24 +261,20 @@ class CreateMemoryViewModel( } /** - * 停止录音并发送 + * 停止录音并发送(手势释放后立即调用,内部在协程中执行避免阻塞触摸回调,马上发送对应语音) */ 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 { + Log.d(TAG, "停止录音并发送") + val result = voiceRecorder.stopRecording() + if (result == null) { + Log.e(TAG, "录音失败,result 为空") + return@launch + } + if (result.durationSeconds < MIN_RECORDING_DURATION) { + Log.w(TAG, "录音时间太短: ${result.durationSeconds}s") + return@launch + } sendAudioMessage(result.audioBytes, result.filePath, result.durationSeconds) } } @@ -274,17 +287,75 @@ class CreateMemoryViewModel( voiceRecorder.cancelRecording() } + /** + * 停止录音并转为文字发送(用于浮层「转文字」) + */ + fun stopAndSendRecordingAsText() { + viewModelScope.launch { + Log.d(TAG, "停止录音并转文字发送") + val result = voiceRecorder.stopRecording() ?: run { + Log.e(TAG, "录音结果为空") + return@launch + } + if (result.durationSeconds < MIN_RECORDING_DURATION) { + Log.w(TAG, "录音太短: ${result.durationSeconds}s") + return@launch + } + if (conversationId.value == null) { + startConversation() + delay(300) + } + if (!webSocketClient.isConnected()) { + connectionStatus.value = "未连接,正在重连..." + conversationId.value?.let { id -> + val token = TokenManager.getAccessToken() + try { + webSocketClient.connect( + id, + token, + onMessage = { m -> handleWebSocketMessage(m) }, + onError = { connectionStatus.value = "错误: $it" } + ) + delay(300) + } catch (e: Exception) { + connectionStatus.value = "重连失败: ${e.message}" + return@launch + } + } ?: run { + connectionStatus.value = "对话ID为空" + return@launch + } + } + val id = conversationId.value ?: return@launch + try { + waitingForTranscribeOnly = true + webSocketClient.sendTranscribeOnly(result.audioBytes, id) + val text = withTimeoutOrNull(15000L) { pendingTranscribeChannel.receive() } + waitingForTranscribeOnly = false + if (!text.isNullOrBlank() && !text.startsWith("转写失败")) { + sendTextMessage(text) + } else { + connectionStatus.value = "转写失败或超时,请重试" + } + } catch (e: Exception) { + waitingForTranscribeOnly = false + Log.e(TAG, "转文字失败: ${e.message}", e) + connectionStatus.value = "转写失败: ${e.message}" + } + } + } + /** * 发送音频消息 */ 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) + delay(300) } // 检查连接状态 @@ -301,7 +372,7 @@ class CreateMemoryViewModel( onMessage = { message -> handleWebSocketMessage(message) }, onError = { errorMsg -> connectionStatus.value = "错误: $errorMsg" } ) - delay(500) + delay(300) } catch (e: Exception) { connectionStatus.value = "重连失败: ${e.message}" return @@ -311,7 +382,7 @@ class CreateMemoryViewModel( return } } - + conversationId.value?.let { id -> // 生成临时消息 ID val tempMessageId = "audio_user_${System.currentTimeMillis()}" @@ -461,10 +532,13 @@ class CreateMemoryViewModel( when (message.type) { MessageType.transcript -> { - // 语音转文字结果 val text = message.getString("text") ?: "" transcript.value = text Log.d(TAG, "收到语音转文字结果: $text") + if (waitingForTranscribeOnly) { + waitingForTranscribeOnly = false + pendingTranscribeChannel.trySend(text) + } } MessageType.agent_response -> { // 处理Agent回复(可能有多条消息,每条作为单独气泡显示)