refactor: 优化创建回忆流程,集成语音输入
- 优化CreateMemoryScreen创建回忆页面 - 扩展CreateMemoryViewModel支持语音消息 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<String>>(emptyList()) // 错误消息列表
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user