refactor: 优化创建回忆流程,集成语音输入

- 优化CreateMemoryScreen创建回忆页面
- 扩展CreateMemoryViewModel支持语音消息

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-03 11:30:00 +08:00
parent d060703e64
commit eb842ffcea
2 changed files with 256 additions and 5 deletions

View File

@@ -1,6 +1,12 @@
package com.huaga.life_echo.ui.screens 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.compose.foundation.background
import androidx.core.content.ContextCompat
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -18,6 +24,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.huaga.life_echo.config.AppConfig import com.huaga.life_echo.config.AppConfig
import com.huaga.life_echo.data.database.Message 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.network.models.MessageDto
import com.huaga.life_echo.ui.components.chat.* import com.huaga.life_echo.ui.components.chat.*
import com.huaga.life_echo.ui.components.debug.WebSocketDebugPanel import com.huaga.life_echo.ui.components.debug.WebSocketDebugPanel
@@ -45,6 +52,46 @@ fun CreateMemoryScreen(
val streamingText by viewModel.streamingText.collectAsState() val streamingText by viewModel.streamingText.collectAsState()
val isTyping by viewModel.isTyping.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 wsIsConnected by viewModel.wsIsConnected.collectAsState()
val lastMessageType by viewModel.lastMessageType.collectAsState() val lastMessageType by viewModel.lastMessageType.collectAsState()
@@ -91,8 +138,14 @@ fun CreateMemoryScreen(
}.sortedBy { it.timestamp } // 按时间排序 }.sortedBy { it.timestamp } // 按时间排序
} }
Column( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) { ) {
// 使用新的ChatHeader组件内部已处理WindowInsets // 使用新的ChatHeader组件内部已处理WindowInsets
ChatHeader( ChatHeader(
@@ -126,10 +179,17 @@ fun CreateMemoryScreen(
streamingText = streamingText, streamingText = streamingText,
isTyping = isTyping, isTyping = isTyping,
modifier = Modifier.weight(1f), 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( ChatInputField(
value = inputText, value = inputText,
onValueChange = { inputText = it }, onValueChange = { inputText = it },
@@ -140,7 +200,22 @@ fun CreateMemoryScreen(
keyboardController?.hide() 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
) )
} }
}
} }

View File

@@ -1,6 +1,7 @@
package com.huaga.life_echo.ui.viewmodel package com.huaga.life_echo.ui.viewmodel
import android.content.Context import android.content.Context
import android.os.Build
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.ConversationRepository
import com.huaga.life_echo.data.repository.ChapterRepository import com.huaga.life_echo.data.repository.ChapterRepository
import com.huaga.life_echo.data.repository.MessageRepository 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.WebSocketClient
import com.huaga.life_echo.network.WebSocketMessage import com.huaga.life_echo.network.WebSocketMessage
import com.huaga.life_echo.network.MessageType import com.huaga.life_echo.network.MessageType
@@ -30,11 +34,26 @@ class CreateMemoryViewModel(
companion object { companion object {
private const val TAG = "CreateMemoryViewModel" private const val TAG = "CreateMemoryViewModel"
private const val MIN_RECORDING_DURATION = 1 // 最小录音时长(秒)
} }
private val webSocketClient = WebSocketClient() private val webSocketClient = WebSocketClient()
private val apiService = ApiService(TokenManager, AuthService()) 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 isRecording = MutableStateFlow(false)
val transcript = MutableStateFlow("") val transcript = MutableStateFlow("")
val agentResponse = MutableStateFlow("") val agentResponse = MutableStateFlow("")
@@ -63,6 +82,21 @@ class CreateMemoryViewModel(
val errorMessages = MutableStateFlow<List<String>>(emptyList()) // 错误消息列表 val errorMessages = MutableStateFlow<List<String>>(emptyList()) // 错误消息列表
val messageCount = MutableStateFlow(0) // 消息计数 val messageCount = MutableStateFlow(0) // 消息计数
// 语音录制相关状态
val isVoiceRecording: StateFlow<Boolean> = voiceRecorder.isRecording
val recordingDuration: StateFlow<Int> = voiceRecorder.recordingDuration
// 音频播放相关状态
val playbackInfo: StateFlow<PlaybackInfo> = audioPlayer.playbackInfo
// 音频文件路径映射 (messageId -> filePath)
private val _audioFilePaths = MutableStateFlow<Map<String, String>>(emptyMap())
val audioFilePaths: StateFlow<Map<String, String>> = _audioFilePaths.asStateFlow()
// 音频时长映射 (messageId -> duration in seconds)
private val _audioDurations = MutableStateFlow<Map<String, Int>>(emptyMap())
val audioDurations: StateFlow<Map<String, Int>> = _audioDurations.asStateFlow()
init { init {
TokenManager.initialize(context) 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) { fun sendTextMessage(text: String) {
if (text.isBlank()) return if (text.isBlank()) return
@@ -291,7 +461,10 @@ class CreateMemoryViewModel(
when (message.type) { when (message.type) {
MessageType.transcript -> { MessageType.transcript -> {
transcript.value = message.getString("text") ?: "" // 语音转文字结果
val text = message.getString("text") ?: ""
transcript.value = text
Log.d(TAG, "收到语音转文字结果: $text")
} }
MessageType.agent_response -> { MessageType.agent_response -> {
// 处理Agent回复可能有多条消息每条作为单独气泡显示 // 处理Agent回复可能有多条消息每条作为单独气泡显示
@@ -524,6 +697,9 @@ class CreateMemoryViewModel(
viewModelScope.launch { viewModelScope.launch {
webSocketClient.disconnect() webSocketClient.disconnect()
} }
// 释放录音器和播放器资源
voiceRecorder.release()
audioPlayer.release()
} }
} }