refactor: 优化创建回忆流程

- 优化CreateMemoryScreen创建回忆页面
- 优化CreateMemoryViewModel创建回忆ViewModel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-10 17:10:10 +08:00
parent a29cb6ec29
commit b156fad24d
2 changed files with 107 additions and 24 deletions

View File

@@ -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.ChatHeader
import com.huaga.life_echo.ui.components.chat.ChatInputField 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.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.RecordingOverlayContent
import com.huaga.life_echo.ui.components.chat.RecordingState import com.huaga.life_echo.ui.components.chat.RecordingState
import com.huaga.life_echo.ui.components.debug.WebSocketDebugPanel import com.huaga.life_echo.ui.components.debug.WebSocketDebugPanel
@@ -159,7 +160,8 @@ fun CreateMemoryScreen(
ChatHeader( ChatHeader(
title = "回忆录助手", title = "回忆录助手",
isOnline = connectionStatus == "已连接", isOnline = connectionStatus == "已连接",
onBackClick = { navController?.popBackStack() } onBackClick = { navController?.popBackStack() },
onNewConversationClick = { viewModel.startConversation() }
) )
// WebSocket调试面板仅在开发模式下显示 // WebSocket调试面板仅在开发模式下显示
@@ -208,7 +210,8 @@ fun CreateMemoryScreen(
keyboardController?.hide() keyboardController?.hide()
} }
}, },
enabled = !isStreaming && !isVoiceRecording, // 录音过程中也必须保持语音按钮可用,否则会丢失手指抬起事件,导致无法停止录音
enabled = !isStreaming,
// 语音相关回调 // 语音相关回调
onStartRecording = { onStartRecording = {
if (hasRecordPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (hasRecordPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -226,11 +229,17 @@ fun CreateMemoryScreen(
recordingDuration = recordingDuration recordingDuration = recordingDuration
) )
} }
// 录音浮层:仅视觉反馈,不消费触摸,松开由按钮接收并立即发送 // 录音浮层:覆盖在聊天之上,只检测本层手势;左滑取消、右滑转文字、否则松开发送语音
if (recordingOverlayState != RecordingState.IDLE) { if (recordingOverlayState != RecordingState.IDLE) {
RecordingOverlayContent( RecordingOverlayContent(
state = recordingOverlayState,
duration = recordingDuration, duration = recordingDuration,
onRelease = { action ->
when (action) {
ReleaseAction.CANCEL -> viewModel.cancelRecordingVoice()
ReleaseAction.SEND_VOICE -> viewModel.stopAndSendRecording()
ReleaseAction.SEND_AS_TEXT -> viewModel.stopAndSendRecordingAsText()
}
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }

View File

@@ -19,11 +19,13 @@ import com.huaga.life_echo.network.ApiService
import com.huaga.life_echo.network.AuthService import com.huaga.life_echo.network.AuthService
import com.huaga.life_echo.network.models.MessageDto import com.huaga.life_echo.network.models.MessageDto
import com.huaga.life_echo.data.database.Chapter import com.huaga.life_echo.data.database.Chapter
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
class CreateMemoryViewModel( class CreateMemoryViewModel(
private val conversationRepository: ConversationRepository, private val conversationRepository: ConversationRepository,
@@ -97,6 +99,11 @@ class CreateMemoryViewModel(
private val _audioDurations = MutableStateFlow<Map<String, Int>>(emptyMap()) private val _audioDurations = MutableStateFlow<Map<String, Int>>(emptyMap())
val audioDurations: StateFlow<Map<String, Int>> = _audioDurations.asStateFlow() val audioDurations: StateFlow<Map<String, Int>> = _audioDurations.asStateFlow()
// 仅转写结果:发 transcribe_only 后等待 transcript 用
private val pendingTranscribeChannel = Channel<String>(Channel.RENDEZVOUS)
@Volatile
private var waitingForTranscribeOnly = false
init { init {
TokenManager.initialize(context) TokenManager.initialize(context)
} }
@@ -182,6 +189,16 @@ class CreateMemoryViewModel(
isRecording.value = true isRecording.value = true
connectionStatus.value = "连接中..." 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() apiService.clearTasks()
@@ -244,24 +261,20 @@ class CreateMemoryViewModel(
} }
/** /**
* 停止录音并发送 * 停止录音并发送(手势释放后立即调用,内部在协程中执行避免阻塞触摸回调,马上发送对应语音)
*/ */
fun stopAndSendRecording() { 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 { 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) sendAudioMessage(result.audioBytes, result.filePath, result.durationSeconds)
} }
} }
@@ -274,17 +287,75 @@ class CreateMemoryViewModel(
voiceRecorder.cancelRecording() 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) { private suspend fun sendAudioMessage(audioBytes: ByteArray, filePath: String, durationSeconds: Int) {
Log.d(TAG, "准备发送音频消息,大小: ${audioBytes.size}, 时长: ${durationSeconds}s") Log.d(TAG, "准备发送音频消息,大小: ${audioBytes.size}, 时长: ${durationSeconds}s")
// 确保已连接 // 确保已连接(手势松开后马上发语音,尽量缩短等待)
if (conversationId.value == null) { if (conversationId.value == null) {
Log.d(TAG, "对话ID为空开始创建新对话") Log.d(TAG, "对话ID为空开始创建新对话")
startConversation() startConversation()
delay(500) delay(300)
} }
// 检查连接状态 // 检查连接状态
@@ -301,7 +372,7 @@ class CreateMemoryViewModel(
onMessage = { message -> handleWebSocketMessage(message) }, onMessage = { message -> handleWebSocketMessage(message) },
onError = { errorMsg -> connectionStatus.value = "错误: $errorMsg" } onError = { errorMsg -> connectionStatus.value = "错误: $errorMsg" }
) )
delay(500) delay(300)
} catch (e: Exception) { } catch (e: Exception) {
connectionStatus.value = "重连失败: ${e.message}" connectionStatus.value = "重连失败: ${e.message}"
return return
@@ -461,10 +532,13 @@ class CreateMemoryViewModel(
when (message.type) { when (message.type) {
MessageType.transcript -> { MessageType.transcript -> {
// 语音转文字结果
val text = message.getString("text") ?: "" val text = message.getString("text") ?: ""
transcript.value = text transcript.value = text
Log.d(TAG, "收到语音转文字结果: $text") Log.d(TAG, "收到语音转文字结果: $text")
if (waitingForTranscribeOnly) {
waitingForTranscribeOnly = false
pendingTranscribeChannel.trySend(text)
}
} }
MessageType.agent_response -> { MessageType.agent_response -> {
// 处理Agent回复可能有多条消息每条作为单独气泡显示 // 处理Agent回复可能有多条消息每条作为单独气泡显示