From 8b9ccd49263e9ddf205ce22f7bf5dccd7183b828 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 10 Mar 2026 11:34:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E9=95=BF=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E4=B8=8A=E4=BC=A0=E4=BA=A4=E4=BA=92=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=BE=93=E5=85=A5=E6=A1=86=E9=AB=98=E5=BA=A6=E8=B7=B3?= =?UTF-8?q?=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/routers/websocket.py | 13 +- api/tests/test_websocket_baseline.py | 73 ++++ app-android/app/src/main/AndroidManifest.xml | 4 +- .../ConversationScopedTranscriptRouter.kt | 41 ++ .../voice/SegmentedVoiceTranscriptTimeline.kt | 73 ++++ .../ui/components/chat/ChatInputField.kt | 374 +++++++++++++----- .../components/chat/ChatInputPresentation.kt | 105 +++++ .../ui/components/chat/VoiceRecordButton.kt | 293 ++++---------- .../ui/screens/ConversationDrafts.kt | 23 ++ .../ui/screens/CreateMemoryScreen.kt | 40 +- .../ui/viewmodel/CreateMemoryViewModel.kt | 43 ++ .../ConversationScopedTranscriptRouterTest.kt | 87 ++++ .../SegmentedVoiceTranscriptTimelineTest.kt | 163 ++++++++ .../chat/ChatInputPresentationTest.kt | 109 +++++ .../ui/screens/ConversationDraftsTest.kt | 43 ++ skills-lock.json | 15 + 16 files changed, 1148 insertions(+), 351 deletions(-) create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/feature/voice/ConversationScopedTranscriptRouter.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/feature/voice/SegmentedVoiceTranscriptTimeline.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentation.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationDrafts.kt create mode 100644 app-android/app/src/test/java/com/huaga/life_echo/feature/voice/ConversationScopedTranscriptRouterTest.kt create mode 100644 app-android/app/src/test/java/com/huaga/life_echo/feature/voice/SegmentedVoiceTranscriptTimelineTest.kt create mode 100644 app-android/app/src/test/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentationTest.kt create mode 100644 app-android/app/src/test/java/com/huaga/life_echo/ui/screens/ConversationDraftsTest.kt create mode 100644 skills-lock.json diff --git a/api/routers/websocket.py b/api/routers/websocket.py index 745fb6d..688d4a2 100644 --- a/api/routers/websocket.py +++ b/api/routers/websocket.py @@ -314,6 +314,7 @@ async def _process_audio_segment_async( "data": { "text": transcript_text or "", "audio_duration": audio_duration, + "voice_session_id": voice_session_id, "segment_index": segment_index, "is_last": is_last, }, @@ -383,18 +384,6 @@ async def _process_audio_segment_async( user=user, ) - if is_last: - await manager.send_message(conversation_id, { - "type": MessageType.AGENT_RESPONSE, - "conversation_id": conversation_id, - "data": { - "text": "最后一段语音已收到,我会继续完善这一轮总结。", - "transition": True, - "is_last": True, - "segment_index": segment_index, - }, - "timestamp": datetime.now(timezone.utc).isoformat(), - }) break except Exception as e: diff --git a/api/tests/test_websocket_baseline.py b/api/tests/test_websocket_baseline.py index f7f436f..a9505e2 100644 --- a/api/tests/test_websocket_baseline.py +++ b/api/tests/test_websocket_baseline.py @@ -463,6 +463,7 @@ class WebSocketBaselineTest(unittest.IsolatedAsyncioTestCase): "type": "audio_segment", "data": { "audio_base64": "seg-1", + "voice_session_id": "voice-session-1", "segment_index": 1, "duration": 12, "is_last": False, @@ -472,6 +473,7 @@ class WebSocketBaselineTest(unittest.IsolatedAsyncioTestCase): "type": "audio_segment", "data": { "audio_base64": "seg-0", + "voice_session_id": "voice-session-1", "segment_index": 0, "duration": 10, "is_last": False, @@ -523,6 +525,15 @@ class WebSocketBaselineTest(unittest.IsolatedAsyncioTestCase): ] self.assertEqual(ordered_messages, ["这是第 0 段", "这是第 1 段"]) self.assertEqual(len([obj for obj in fake_db.added if isinstance(obj, Segment)]), 2) + transcript_msgs = [ + item["message"] + for item in fake_manager.sent_messages + if item["message"]["type"] == ws_router.MessageType.TRANSCRIPT + ] + self.assertEqual( + [msg["data"]["voice_session_id"] for msg in transcript_msgs], + ["voice-session-1", "voice-session-1"], + ) async def test_audio_segment_duplicate_index_is_idempotent(self): user = _make_user() @@ -727,6 +738,68 @@ class WebSocketBaselineTest(unittest.IsolatedAsyncioTestCase): ] self.assertGreaterEqual(len(transition_msgs), 1) + async def test_audio_segment_last_segment_does_not_emit_terminal_transition(self): + user = _make_user() + conversation = Conversation(id="conv-1", user_id=user.id, status="active") + fake_db = _FakeAsyncDB(user=user, conversation=conversation) + fake_manager = _FakeManager() + fake_websocket = _FakeWebSocket( + messages=[ + { + "type": "audio_segment", + "data": { + "audio_base64": "last-seg-0", + "voice_session_id": "voice-session-last", + "client_segment_id": "voice-session-last-0", + "segment_index": 0, + "duration": 15, + "is_last": True, + }, + }, + WebSocketDisconnect(), + ] + ) + + process_user_message_mock = AsyncMock() + transcribe_mock = AsyncMock(return_value="最后一段转写") + + with ExitStack() as stack: + stack.enter_context( + patch.object( + ws_router, + "verify_token", + return_value={"type": "access", "sub": user.id}, + ) + ) + stack.enter_context( + patch.object(ws_router, "get_async_db", _db_provider(fake_db)) + ) + stack.enter_context(patch.object(ws_router, "manager", fake_manager)) + stack.enter_context( + patch("routers.quota.get_segment_count", new=AsyncMock(return_value=0)) + ) + stack.enter_context( + patch("routers.quota.check_can_send_message", return_value=(True, "")) + ) + stack.enter_context( + patch.object(ws_router, "process_user_message", process_user_message_mock) + ) + stack.enter_context( + patch.object(ws_router.asr_service, "transcribe", transcribe_mock) + ) + + await ws_router.websocket_endpoint(fake_websocket, "conv-1") + await asyncio.sleep(0.05) + + transition_msgs = [ + item["message"] + for item in fake_manager.sent_messages + if item["message"]["type"] == ws_router.MessageType.AGENT_RESPONSE + and item["message"].get("data", {}).get("transition") is True + ] + self.assertEqual(len(transition_msgs), 1) + self.assertIsNone(transition_msgs[0]["data"].get("is_last")) + async def test_audio_segment_continues_after_reconnect_with_existing_previous_segment(self): user = _make_user() conversation = Conversation(id="conv-1", user_id=user.id, status="active") diff --git a/app-android/app/src/main/AndroidManifest.xml b/app-android/app/src/main/AndroidManifest.xml index b1926e1..071ceb7 100644 --- a/app-android/app/src/main/AndroidManifest.xml +++ b/app-android/app/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.Lifeecho" - android:windowSoftInputMode="adjustPan" > + android:windowSoftInputMode="adjustResize" > @@ -42,4 +42,4 @@ android:theme="@android:style/Theme.Translucent.NoTitleBar" /> - \ No newline at end of file + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/ConversationScopedTranscriptRouter.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/ConversationScopedTranscriptRouter.kt new file mode 100644 index 0000000..b0b4630 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/ConversationScopedTranscriptRouter.kt @@ -0,0 +1,41 @@ +package com.huaga.life_echo.feature.voice + +import com.huaga.life_echo.network.models.MessageDto + +object ConversationScopedTranscriptRouter { + + fun belongsToActiveConversation( + activeConversationId: String?, + messageConversationId: String?, + ): Boolean { + return !activeConversationId.isNullOrBlank() + && !messageConversationId.isNullOrBlank() + && activeConversationId == messageConversationId + } + + fun applyIfActive( + historyMessages: List, + state: SegmentedVoiceTranscriptState, + activeConversationId: String?, + messageConversationId: String?, + voiceSessionId: String, + segmentIndex: Int, + transcriptText: String, + timestamp: Long, + ): SegmentedVoiceTranscriptResult? { + if (!belongsToActiveConversation(activeConversationId, messageConversationId)) { + return null + } + val scopedConversationId = messageConversationId ?: return null + + return SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = historyMessages, + state = state, + conversationId = scopedConversationId, + voiceSessionId = voiceSessionId, + segmentIndex = segmentIndex, + transcriptText = transcriptText, + timestamp = timestamp, + ) + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/SegmentedVoiceTranscriptTimeline.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/SegmentedVoiceTranscriptTimeline.kt new file mode 100644 index 0000000..227900a --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/SegmentedVoiceTranscriptTimeline.kt @@ -0,0 +1,73 @@ +package com.huaga.life_echo.feature.voice + +import com.huaga.life_echo.network.models.MessageDto + +data class SegmentedVoiceTranscriptState( + val sessions: Map = emptyMap(), +) + +data class SegmentedVoiceTranscriptSession( + val messageId: String, + val createdAt: Long, + val segments: Map, +) + +data class SegmentedVoiceTranscriptResult( + val historyMessages: List, + val state: SegmentedVoiceTranscriptState, +) + +object SegmentedVoiceTranscriptTimeline { + + fun applyTranscript( + historyMessages: List, + state: SegmentedVoiceTranscriptState, + conversationId: String, + voiceSessionId: String, + segmentIndex: Int, + transcriptText: String, + timestamp: Long, + ): SegmentedVoiceTranscriptResult { + val existingSession = state.sessions[voiceSessionId] + val messageId = existingSession?.messageId ?: "voice_user_$voiceSessionId" + val updatedSegments = (existingSession?.segments ?: emptyMap()) + (segmentIndex to transcriptText) + val messageTimestamp = existingSession?.createdAt + ?: historyMessages.firstOrNull { it.id == messageId }?.timestamp + ?: timestamp + val updatedContent = updatedSegments + .toSortedMap() + .values + .filter { it.isNotBlank() } + .joinToString("\n") + + val updatedMessage = MessageDto( + id = messageId, + conversationId = conversationId, + content = updatedContent, + senderType = "user", + timestamp = messageTimestamp, + messageType = "text", + ) + + val updatedHistory = if (historyMessages.any { it.id == messageId }) { + historyMessages.map { message -> + if (message.id == messageId) updatedMessage else message + } + } else { + historyMessages + updatedMessage + } + + return SegmentedVoiceTranscriptResult( + historyMessages = updatedHistory, + state = SegmentedVoiceTranscriptState( + sessions = state.sessions + ( + voiceSessionId to SegmentedVoiceTranscriptSession( + messageId = messageId, + createdAt = messageTimestamp, + segments = updatedSegments, + ) + ), + ), + ) + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt index 2864eb2..418c7b0 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt @@ -1,18 +1,51 @@ package com.huaga.life_echo.ui.components.chat -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.huaga.life_echo.ui.icons.AppIcons -import com.huaga.life_echo.ui.theme.LightPurple +import com.huaga.life_echo.ui.theme.AppWhite +import com.huaga.life_echo.ui.theme.DeepPurple +import com.huaga.life_echo.ui.theme.ErrorRed import com.huaga.life_echo.ui.theme.MediumPurple import com.huaga.life_echo.ui.theme.SlatePurple @@ -20,12 +53,16 @@ import com.huaga.life_echo.ui.theme.SlatePurple * 输入模式 */ enum class InputMode { - TEXT, // 文本输入模式 - VOICE // 语音输入模式 + TEXT, + VOICE, } /** - * 聊天输入框组件(支持文本和语音两种模式) + * 微信式聊天输入框组件,空态单行,支持多行扩展。 + * + * 文字/语音两种模式通过 [SubcomposeLayout] 在同一帧内同时测量, + * 取 max(textHeight, voiceHeight) 作为容器高度,只 place 当前激活模式。 + * 这保证了模式切换时零高度跳变,且无需 hardcoded dp 或历史高度追踪。 */ @Composable fun ChatInputField( @@ -35,80 +72,138 @@ fun ChatInputField( modifier: Modifier = Modifier, placeholder: String = "输入消息...", enabled: Boolean = true, - // 语音相关回调 + onEmojiClick: () -> Unit = {}, + onAddClick: () -> Unit = {}, onStartRecording: () -> Unit = {}, onStopRecording: () -> Unit = {}, onCancelRecording: () -> Unit = {}, - onRecordingStateChange: (RecordingState) -> Unit = {}, isRecording: Boolean = false, - recordingDuration: Int = 0 // 录音时长(秒) + recordingDuration: Int = 0, ) { - var textFieldHeight by remember { mutableStateOf(56.dp) } var inputMode by remember { mutableStateOf(InputMode.TEXT) } - - // 使用 windowInsetsPadding 实现键盘自适应贴合,确保输入框紧贴键盘 - // 同时处理导航栏安全区域,避免输入框被手势导航条遮挡 + val keyboardController = LocalSoftwareKeyboardController.current + val layoutPresentation = remember(inputMode, isRecording, value) { + ChatInputLayoutPresentation.from( + inputMode = inputMode, + isRecording = isRecording, + hasText = value.isNotBlank(), + ) + } + + LaunchedEffect(isRecording) { + if (isRecording) { + inputMode = InputMode.VOICE + keyboardController?.hide() + } + } + Surface( modifier = modifier .fillMaxWidth() .shadow(4.dp) - .windowInsetsPadding(WindowInsets.navigationBars) - .windowInsetsPadding(WindowInsets.ime), // 自适应键盘高度,紧贴键盘 + .windowInsetsPadding(WindowInsets.navigationBars), color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - // 语音/键盘切换按钮 - IconButton( + RoundIconAction( + icon = if (layoutPresentation.leadingAction == ChatInputLeadingAction.SWITCH_TO_VOICE) { + AppIcons.Mic + } else { + AppIcons.Keyboard + }, + contentDescription = if (layoutPresentation.leadingAction == ChatInputLeadingAction.SWITCH_TO_VOICE) { + "切换到语音输入" + } else { + "切换到文字输入" + }, + enabled = enabled && !isRecording, onClick = { inputMode = if (inputMode == InputMode.TEXT) InputMode.VOICE else InputMode.TEXT + if (inputMode == InputMode.VOICE) { + keyboardController?.hide() + } }, - enabled = enabled && !isRecording - ) { - Icon( - imageVector = if (inputMode == InputMode.TEXT) AppIcons.Mic else AppIcons.Keyboard, - contentDescription = if (inputMode == InputMode.TEXT) "切换到语音" else "切换到键盘", - tint = if (enabled) MediumPurple else SlatePurple - ) - } - - when (inputMode) { - InputMode.TEXT -> { - // 文本输入模式 + ) + + // 同时测量两个 slot 取最大高度,只 place 当前模式,消除切换跳变 + SubcomposeLayout( + modifier = Modifier.weight(1f), + ) { constraints -> + val textPlaceables = subcompose("text") { TextInputContent( value = value, - onValueChange = { newValue -> - onValueChange(newValue) - // 根据内容动态调整高度(模仿微信效果) - val lineCount = newValue.split("\n").size - textFieldHeight = when { - lineCount <= 1 -> 56.dp - lineCount <= 4 -> (56 + (lineCount - 1) * 20).dp - else -> 136.dp // 最大4行 - } - }, - onSend = onSend, + onValueChange = onValueChange, placeholder = placeholder, enabled = enabled, - modifier = Modifier.weight(1f) ) - } - InputMode.VOICE -> { - // 语音输入模式 + }.map { it.measure(constraints) } + + val voicePlaceables = subcompose("voice") { VoiceRecordButton( onStartRecording = onStartRecording, onStopRecording = onStopRecording, - onCancelRecording = onCancelRecording, - onRecordingStateChange = onRecordingStateChange, isRecording = isRecording, recordingDuration = recordingDuration, enabled = enabled, - modifier = Modifier.weight(1f) + ) + }.map { it.measure(constraints) } + + val textHeight = textPlaceables.maxOfOrNull { it.height } ?: 0 + val voiceHeight = voicePlaceables.maxOfOrNull { it.height } ?: 0 + val height = maxOf(textHeight, voiceHeight) + + layout(constraints.maxWidth, height) { + when (inputMode) { + InputMode.TEXT -> textPlaceables.forEach { + it.placeRelative(0, 0) + } + InputMode.VOICE -> voicePlaceables.forEach { + it.placeRelative(0, 0) + } + } + } + } + + if (layoutPresentation.showEmojiAction) { + RoundIconAction( + icon = AppIcons.SentimentSatisfied, + contentDescription = "表情", + enabled = enabled && !isRecording, + onClick = onEmojiClick, + ) + } + + when (layoutPresentation.trailingAction) { + ChatInputTrailingAction.ADD -> { + RoundIconAction( + icon = AppIcons.Add, + contentDescription = "更多功能", + enabled = enabled && !isRecording, + onClick = onAddClick, + ) + } + + ChatInputTrailingAction.SEND -> { + SendActionButton( + enabled = enabled && value.isNotBlank(), + onClick = onSend, + ) + } + + ChatInputTrailingAction.CANCEL -> { + RoundIconAction( + icon = AppIcons.Close, + contentDescription = "取消录音发送", + enabled = enabled, + tint = ErrorRed, + onClick = onCancelRecording, ) } } @@ -116,65 +211,142 @@ fun ChatInputField( } } +@Composable +private fun RoundIconAction( + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + onClick: () -> Unit, + tint: Color = Color.Unspecified, +) { + val interactionSource = remember { MutableInteractionSource() } + val resolvedTint = if (tint == Color.Unspecified) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.76f) + } else { + tint + } + Surface( + modifier = Modifier + .size(ChatInputChromeDimensions.ActionButtonSize) + .clickable( + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + color = Color.Transparent, + shape = CircleShape, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(22.dp), + tint = if (enabled) resolvedTint else SlatePurple.copy(alpha = 0.56f), + ) + } + } +} + +@Composable +private fun SendActionButton( + enabled: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .height(ChatInputChromeDimensions.ActionButtonSize) + .clip(RoundedCornerShape(22.dp)) + .clickable(enabled = enabled, onClick = onClick), + color = if (enabled) MediumPurple else MediumPurple.copy(alpha = 0.45f), + shape = RoundedCornerShape(22.dp), + ) { + Text( + text = "发送", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 11.dp), + color = AppWhite, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + ) + } +} + /** - * 文本输入内容组件 + * 文字/语音输入区的共享外壳:统一 minHeight、圆角、背景、padding。 + * 由 [TextInputContent] 的 decorationBox 和 [VoiceRecordButton] 共用, + * 确保两种模式的内在尺寸基线一致。 */ +@Composable +internal fun CenterAreaShell( + modifier: Modifier = Modifier, + backgroundColor: Color, + contentAlignment: Alignment = Alignment.CenterStart, + onClick: (() -> Unit)? = null, + clickEnabled: Boolean = true, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = ChatInputChromeDimensions.CenterAreaHeight) + .clip(RoundedCornerShape(ChatInputChromeDimensions.CenterAreaCornerRadius)) + .background(backgroundColor) + .then( + if (onClick != null) + Modifier.clickable(enabled = clickEnabled, onClick = onClick) + else Modifier + ) + .padding( + horizontal = ChatInputChromeDimensions.CenterAreaHorizontalPadding, + vertical = ChatInputChromeDimensions.CenterAreaVerticalPadding, + ), + contentAlignment = contentAlignment, + content = content, + ) +} + @Composable private fun TextInputContent( value: String, onValueChange: (String) -> Unit, - onSend: () -> Unit, placeholder: String, enabled: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - Row( + BasicTextField( + value = value, + onValueChange = onValueChange, modifier = modifier, - verticalAlignment = Alignment.CenterVertically - ) { - // 输入框区域 - OutlinedTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .weight(1f) - .heightIn(min = 56.dp, max = 136.dp), - placeholder = { - Text( - text = placeholder, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 14.sp - ) - }, - shape = RoundedCornerShape(36.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = LightPurple, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), - singleLine = false, - maxLines = 4, - enabled = enabled - ) - - Spacer(modifier = Modifier.width(8.dp)) - - // 发送按钮 - if (value.isNotBlank() && enabled) { - TextButton( - onClick = onSend, - colors = ButtonDefaults.textButtonColors( - contentColor = LightPurple - ) - ) { - Text( - text = "发送", - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) + textStyle = MaterialTheme.typography.bodyLarge.copy( + fontSize = 16.sp, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant } - } - } + ), + cursorBrush = SolidColor(DeepPurple), + maxLines = 4, + minLines = 1, + singleLine = false, + enabled = enabled, + decorationBox = { innerTextField -> + CenterAreaShell( + backgroundColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.14f), + ) { + if (value.isEmpty()) { + Text( + text = placeholder, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 15.sp, + ) + } + innerTextField() + } + }, + ) } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentation.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentation.kt new file mode 100644 index 0000000..499b6dd --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentation.kt @@ -0,0 +1,105 @@ +package com.huaga.life_echo.ui.components.chat + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +// ── Chrome dimensions ──────────────────────────────────────────────── + +object ChatInputChromeDimensions { + val ActionButtonSize: Dp = 44.dp + val CenterAreaHeight: Dp = 48.dp + val CenterAreaCornerRadius: Dp = 16.dp + val CenterAreaHorizontalPadding: Dp = 16.dp + val CenterAreaVerticalPadding: Dp = 11.dp + + fun textInputMinHeight(): Dp = CenterAreaHeight + + fun voiceInputHeight(): Dp = CenterAreaHeight +} + +// ── Layout presentation ────────────────────────────────────────────── + +enum class ChatInputLeadingAction { + SWITCH_TO_VOICE, + SWITCH_TO_TEXT, +} + +enum class ChatInputTrailingAction { + ADD, + SEND, + CANCEL, +} + +data class ChatInputLayoutPresentation( + val leadingAction: ChatInputLeadingAction, + val showEmojiAction: Boolean, + val trailingAction: ChatInputTrailingAction, +) { + companion object { + fun from( + inputMode: InputMode, + isRecording: Boolean, + hasText: Boolean, + ): ChatInputLayoutPresentation { + return when (inputMode) { + InputMode.TEXT -> ChatInputLayoutPresentation( + leadingAction = ChatInputLeadingAction.SWITCH_TO_VOICE, + showEmojiAction = true, + trailingAction = if (hasText) { + ChatInputTrailingAction.SEND + } else { + ChatInputTrailingAction.ADD + }, + ) + + InputMode.VOICE -> ChatInputLayoutPresentation( + leadingAction = ChatInputLeadingAction.SWITCH_TO_TEXT, + showEmojiAction = !isRecording, + trailingAction = if (isRecording) { + ChatInputTrailingAction.CANCEL + } else { + ChatInputTrailingAction.ADD + }, + ) + } + } + } +} + +// ── Voice recording toggle presentation ────────────────────────────── + +data class VoiceRecordingTogglePresentation( + val primaryLabel: String, + val secondaryLabel: String, + val showRecordingPulse: Boolean, + val statusLabel: String? = null, +) { + companion object { + fun from( + isRecording: Boolean, + durationSeconds: Int, + ): VoiceRecordingTogglePresentation { + return if (isRecording) { + VoiceRecordingTogglePresentation( + primaryLabel = "点击结束", + secondaryLabel = "再次点击即可结束", + showRecordingPulse = true, + statusLabel = formatVoiceRecordingDuration(durationSeconds), + ) + } else { + VoiceRecordingTogglePresentation( + primaryLabel = "点击开始录音", + secondaryLabel = "支持长语音,点击后持续录制", + showRecordingPulse = false, + ) + } + } + } +} + +fun formatVoiceRecordingDuration(seconds: Int): String { + val safeSeconds = seconds.coerceAtLeast(0) + val mins = safeSeconds / 60 + val secs = safeSeconds % 60 + return "$mins:${secs.toString().padStart(2, '0')}" +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt index f9e9d0d..1f3c15b 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt @@ -1,258 +1,117 @@ package com.huaga.life_echo.ui.components.chat -import android.annotation.SuppressLint import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon +import androidx.compose.material3.Surface import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.changedToDown -import androidx.compose.ui.input.pointer.changedToUp -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.huaga.life_echo.ui.icons.AppIcons -import com.huaga.life_echo.ui.theme.* +import com.huaga.life_echo.ui.theme.AppWhite +import com.huaga.life_echo.ui.theme.ErrorRed +import com.huaga.life_echo.ui.theme.SlatePurple /** - * 录音状态 - */ -enum class RecordingState { - IDLE, // 空闲状态 - RECORDING, // 录音中 - CANCEL // 准备取消 -} - -/** - * 语音录音按钮组件(仿微信按住说话) - * 松开即发送:浮层仅做视觉反馈,不拦截触摸,由按钮直接接收 release 并触发发送。 + * 微信式语音录音按钮。 * - * 关键实现:通过 consume() 消费所有 pointer 事件,防止父容器(如滚动列表) - * 偷走手势,确保按住期间能正确检测释放和上滑取消。 - * - * @param onStartRecording 开始录音回调 - * @param onStopRecording 停止录音并发送回调 - * @param onCancelRecording 取消录音回调 - * @param onRecordingStateChange 录音状态变化(用于上层显示浮层,不消费触摸) - * @param isRecording 是否正在录音 - * @param recordingDuration 录音时长(秒) - * @param enabled 是否启用 + * 录音胶囊(脉冲圆点 + 时长)始终参与布局(通过 [Modifier.alpha] 控制可见性), + * 确保 idle ↔ recording 切换时容器高度不变。 */ @Composable fun VoiceRecordButton( onStartRecording: () -> Unit, onStopRecording: () -> Unit, - onCancelRecording: () -> Unit, - onRecordingStateChange: (RecordingState) -> Unit = {}, isRecording: Boolean, recordingDuration: Int, enabled: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - var recordingState by remember { mutableStateOf(RecordingState.IDLE) } - // 上滑取消阈值:手指在按钮局部坐标中 Y < -threshold 即进入取消区 - val cancelThresholdPx = with(LocalDensity.current) { 70.dp.toPx() } - - // 动画 - val scale by animateFloatAsState( - targetValue = if (recordingState == RecordingState.RECORDING) 1.05f else 1f, - animationSpec = tween(100), - label = "scale" + val presentation = VoiceRecordingTogglePresentation.from( + isRecording = isRecording, + durationSeconds = recordingDuration, ) - - val backgroundColor by animateColorAsState( - targetValue = when (recordingState) { - RecordingState.IDLE -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - RecordingState.RECORDING -> Lavender.copy(alpha = 0.5f) - RecordingState.CANCEL -> ErrorRed.copy(alpha = 0.2f) - }, - animationSpec = tween(200), - label = "bgColor" + val containerColor by animateColorAsState( + targetValue = MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = if (isRecording) 0.20f else 0.14f, + ), + animationSpec = tween(durationMillis = 220), + label = "voiceButtonContainer", ) - - // 同步外部录音状态 - LaunchedEffect(isRecording) { - if (!isRecording && recordingState != RecordingState.IDLE) { - recordingState = RecordingState.IDLE - } - } - // 通知上层当前录音状态(用于全屏浮层展示,浮层不消费触摸,松开由本按钮接收并立即发送) - LaunchedEffect(recordingState) { - onRecordingStateChange(recordingState) - } - - Box(modifier = modifier) { - // 按住说话按钮(按下即开始录音,松开结束;上滑取消;浮层由上层全屏绘制且不拦截触摸) - Box( + val pulseTransition = rememberInfiniteTransition(label = "voicePulse") + val pulseScale by pulseTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900), + repeatMode = RepeatMode.Reverse, + ), + label = "voicePulseScale", + ) + + CenterAreaShell( + modifier = modifier, + backgroundColor = containerColor, + contentAlignment = Alignment.Center, + onClick = { if (isRecording) onStopRecording() else onStartRecording() }, + clickEnabled = enabled, + ) { + Text( + text = if (isRecording) presentation.primaryLabel else "点击开始录音", + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 17.sp), + fontWeight = FontWeight.Medium, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface.copy(alpha = if (isRecording) 0.92f else 0.72f) + } else { + SlatePurple.copy(alpha = 0.68f) + }, + ) + + Surface( modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .scale(scale) - .clip(RoundedCornerShape(36.dp)) - .background(backgroundColor) - .pointerInput(enabled) { - if (!enabled) return@pointerInput - // 按下即开始录音;同一 pointer 的 release 会先到本按钮,必须在此处理才能终止录音 - // 上滑取消:手指在按钮局部坐标中 Y < -threshold 即进入取消区 - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - val changes = event.changes - val justDown = changes.any { it.changedToDown() } - val justUp = changes.any { it.changedToUp() } - val allUp = changes.all { !it.pressed } - if (justDown) { - changes.forEach { it.consume() } - recordingState = RecordingState.RECORDING - onStartRecording() - } else if (recordingState != RecordingState.IDLE) { - changes.forEach { it.consume() } - // 检测手指 Y 位置:局部坐标中 Y 为负代表手指在按钮上方 - val currentPos = changes.firstOrNull()?.position - if (currentPos != null) { - recordingState = if (currentPos.y < -cancelThresholdPx) { - RecordingState.CANCEL - } else { - RecordingState.RECORDING - } - } - // 手指抬起时根据当前状态决定发送还是取消 - if (justUp || allUp) { - if (recordingState == RecordingState.CANCEL) { - onCancelRecording() - } else { - onStopRecording() - } - recordingState = RecordingState.IDLE - } - } - } - } - }, - contentAlignment = Alignment.Center + .align(Alignment.CenterEnd) + .alpha(if (presentation.showRecordingPulse) 1f else 0f), + color = AppWhite.copy(alpha = 0.8f), + shape = RoundedCornerShape(999.dp), ) { Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center ) { - Icon( - imageVector = if (recordingState == RecordingState.CANCEL) AppIcons.Close else AppIcons.Mic, - contentDescription = null, - tint = when (recordingState) { - RecordingState.IDLE -> SlatePurple - RecordingState.RECORDING -> MediumPurple - RecordingState.CANCEL -> ErrorRed - }, - modifier = Modifier.size(20.dp) + Box( + modifier = Modifier + .size(7.dp) + .scale(if (presentation.showRecordingPulse) pulseScale else 1f) + .clip(CircleShape) + .background(ErrorRed), ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(6.dp)) Text( - text = when (recordingState) { - RecordingState.IDLE -> "按住说话" - RecordingState.RECORDING -> "松开发送" - RecordingState.CANCEL -> "松开取消" - }, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = when (recordingState) { - RecordingState.IDLE -> SlatePurple - RecordingState.RECORDING -> DeepPurple - RecordingState.CANCEL -> ErrorRed - } + text = presentation.statusLabel ?: "0:00", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.86f), ) } } } } - -/** - * 录音浮层:按住说话后覆盖在聊天之上的全屏层,仅做视觉反馈。 - * 取消检测由 [VoiceRecordButton] 通过上滑手势完成,本浮层不拦截触摸。 - * - * @param duration 当前录音时长(秒) - * @param isCancelling 是否处于取消状态(由上层根据录音状态传入) - */ -@Composable -fun RecordingOverlayContent( - duration: Int, - isCancelling: Boolean = false, - @SuppressLint("ModifierParameter") modifier: Modifier = Modifier -) { - val boxBgColor by animateColorAsState( - targetValue = if (isCancelling) ErrorRed.copy(alpha = 0.85f) else DeepPurple.copy(alpha = 0.92f), - animationSpec = tween(200), - label = "boxBg" - ) - - Box( - modifier = modifier.background(Color.Black.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - // 中心指示框 - Box( - modifier = Modifier - .size(200.dp) - .clip(RoundedCornerShape(20.dp)) - .background(boxBgColor), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - if (isCancelling) { - Icon( - imageVector = AppIcons.Close, - contentDescription = null, - tint = AppWhite, - modifier = Modifier.size(48.dp) - ) - } else { - RecordingIndicator(modifier = Modifier.size(48.dp)) - } - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = formatDuration(duration), - fontSize = 26.sp, - fontWeight = FontWeight.Bold, - color = AppWhite - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (isCancelling) "松开取消发送" else "松开发送,上滑取消", - fontSize = 13.sp, - color = AppWhite.copy(alpha = 0.85f) - ) - } - } - } - } -} - - -/** - * 格式化录音时长 - */ -private fun formatDuration(seconds: Int): String { - val mins = seconds / 60 - val secs = seconds % 60 - return "$mins:${secs.toString().padStart(2, '0')}" -} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationDrafts.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationDrafts.kt new file mode 100644 index 0000000..b03d240 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationDrafts.kt @@ -0,0 +1,23 @@ +package com.huaga.life_echo.ui.screens + +object ConversationDrafts { + + fun draftForConversation( + drafts: Map, + conversationId: String, + ): String { + return drafts[conversationId].orEmpty() + } + + fun updateDraft( + drafts: Map, + conversationId: String, + text: String, + ): Map { + return if (text.isEmpty()) { + drafts - conversationId + } else { + drafts + (conversationId to text) + } + } +} 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 9f83124..9a507e7 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 @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,8 +30,6 @@ 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.RecordingOverlayContent -import com.huaga.life_echo.ui.components.chat.RecordingState import com.huaga.life_echo.ui.components.debug.WebSocketDebugPanel import com.huaga.life_echo.ui.theme.LightPurple import com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModel @@ -59,11 +58,6 @@ fun CreateMemoryScreen( // 语音录制相关状态 val isVoiceRecording by viewModel.isVoiceRecording.collectAsState() val recordingDuration by viewModel.recordingDuration.collectAsState() - // 录音浮层状态(由按钮上报,用于全屏浮层;浮层不消费触摸,松开由按钮接收并立即发送) - var recordingOverlayState by remember { mutableStateOf(RecordingState.IDLE) } - LaunchedEffect(isVoiceRecording) { - if (!isVoiceRecording) recordingOverlayState = RecordingState.IDLE - } // 音频播放相关状态 val playbackInfo by viewModel.playbackInfo.collectAsState() @@ -120,7 +114,13 @@ fun CreateMemoryScreen( } // 输入框状态 - var inputText by remember { mutableStateOf("") } + var conversationDrafts by rememberSaveable { + mutableStateOf>(emptyMap()) + } + val inputText = ConversationDrafts.draftForConversation( + drafts = conversationDrafts, + conversationId = conversationId, + ) val keyboardController = LocalSoftwareKeyboardController.current // 构建消息列表(包含历史消息和当前消息) @@ -200,12 +200,23 @@ fun CreateMemoryScreen( // 使用新的ChatInputField组件(支持语音输入) ChatInputField( + modifier = Modifier.imePadding(), value = inputText, - onValueChange = { inputText = it }, + onValueChange = { + conversationDrafts = ConversationDrafts.updateDraft( + drafts = conversationDrafts, + conversationId = conversationId, + text = it, + ) + }, onSend = { if (inputText.isNotBlank()) { viewModel.sendTextMessage(inputText) - inputText = "" + conversationDrafts = ConversationDrafts.updateDraft( + drafts = conversationDrafts, + conversationId = conversationId, + text = "", + ) keyboardController?.hide() } }, @@ -223,19 +234,10 @@ fun CreateMemoryScreen( onCancelRecording = { viewModel.cancelRecordingVoice() }, - onRecordingStateChange = { recordingOverlayState = it }, isRecording = isVoiceRecording, recordingDuration = recordingDuration ) } - // 录音浮层:覆盖在聊天之上,仅做视觉反馈;取消/发送由 VoiceRecordButton 的上滑手势决定 - if (recordingOverlayState != RecordingState.IDLE) { - RecordingOverlayContent( - duration = recordingDuration, - isCancelling = recordingOverlayState == RecordingState.CANCEL, - 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 70d3fd7..83fe9de 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 @@ -13,6 +13,9 @@ import com.huaga.life_echo.feature.voice.PendingVoiceSegment import com.huaga.life_echo.feature.voice.PendingVoiceSegmentStore import com.huaga.life_echo.feature.voice.PlaybackInfo import com.huaga.life_echo.feature.voice.RecordingResult +import com.huaga.life_echo.feature.voice.ConversationScopedTranscriptRouter +import com.huaga.life_echo.feature.voice.SegmentedVoiceTranscriptState +import com.huaga.life_echo.feature.voice.SegmentedVoiceTranscriptTimeline import com.huaga.life_echo.feature.voice.SegmentedRecordingDuration import com.huaga.life_echo.feature.voice.VoiceRecorder import com.huaga.life_echo.network.WebSocketClient @@ -67,6 +70,7 @@ class CreateMemoryViewModel( private val pendingDispatchLock = Any() private val pendingDispatchInFlight = mutableSetOf() private var pendingSegmentRetryJob: Job? = null + private var segmentedVoiceTranscriptState = SegmentedVoiceTranscriptState() // 语音录制器 private val voiceRecorder = VoiceRecorder(context).apply { @@ -161,6 +165,7 @@ class CreateMemoryViewModel( viewModelScope.launch { conversationId.value = convId connectionStatus.value = "连接中..." + segmentedVoiceTranscriptState = SegmentedVoiceTranscriptState() try { // 加载历史消息 @@ -234,6 +239,7 @@ class CreateMemoryViewModel( // 新建对话:清空当前会话展示状态 historyMessages.value = emptyList() userMessages.value = emptyList() + segmentedVoiceTranscriptState = SegmentedVoiceTranscriptState() streamingText.value = "" agentResponse.value = "" isStreaming.value = false @@ -830,11 +836,48 @@ class CreateMemoryViewModel( when (message.type) { MessageType.transcript -> { val text = message.getString("text") ?: "" + val activeConversationId = conversationId.value + val messageConversationId = message.conversation_id + if (!ConversationScopedTranscriptRouter.belongsToActiveConversation( + activeConversationId = activeConversationId, + messageConversationId = messageConversationId, + ) + ) { + Log.w( + TAG, + "丢弃跨会话 transcript: activeConversationId=$activeConversationId, messageConversationId=$messageConversationId" + ) + return + } transcript.value = text Log.d(TAG, "收到语音转文字结果: $text") if (waitingForTranscribeOnly) { waitingForTranscribeOnly = false pendingTranscribeChannel.trySend(text) + return + } + val voiceSessionId = message.getString("voice_session_id") + val segmentIndex = message.getInt("segment_index") + if ( + text.isNotBlank() + && !text.startsWith("转写失败") + && !voiceSessionId.isNullOrBlank() + && segmentIndex != null + ) { + val transcriptResult = ConversationScopedTranscriptRouter.applyIfActive( + historyMessages = historyMessages.value, + state = segmentedVoiceTranscriptState, + activeConversationId = activeConversationId, + messageConversationId = messageConversationId, + voiceSessionId = voiceSessionId, + segmentIndex = segmentIndex, + transcriptText = text, + timestamp = System.currentTimeMillis(), + ) + if (transcriptResult != null) { + historyMessages.value = transcriptResult.historyMessages + segmentedVoiceTranscriptState = transcriptResult.state + } } } MessageType.agent_response -> { diff --git a/app-android/app/src/test/java/com/huaga/life_echo/feature/voice/ConversationScopedTranscriptRouterTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/feature/voice/ConversationScopedTranscriptRouterTest.kt new file mode 100644 index 0000000..70985ea --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/feature/voice/ConversationScopedTranscriptRouterTest.kt @@ -0,0 +1,87 @@ +package com.huaga.life_echo.feature.voice + +import com.huaga.life_echo.network.models.MessageDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConversationScopedTranscriptRouterTest { + + @Test + fun applyIfActive_updatesMatchingConversation() { + val result = ConversationScopedTranscriptRouter.applyIfActive( + historyMessages = emptyList(), + state = SegmentedVoiceTranscriptState(), + activeConversationId = "conv-a", + messageConversationId = "conv-a", + voiceSessionId = "voice-session-1", + segmentIndex = 0, + transcriptText = "当前会话转写", + timestamp = 1000L, + ) + + assertEquals( + listOf( + MessageDto( + id = "voice_user_voice-session-1", + conversationId = "conv-a", + content = "当前会话转写", + senderType = "user", + timestamp = 1000L, + messageType = "text", + ) + ), + result?.historyMessages, + ) + } + + @Test + fun applyIfActive_rejectsTranscriptFromDifferentConversation() { + val result = ConversationScopedTranscriptRouter.applyIfActive( + historyMessages = listOf( + MessageDto( + id = "existing", + conversationId = "conv-a", + content = "当前会话消息", + senderType = "assistant", + timestamp = 900L, + messageType = "text", + ) + ), + state = SegmentedVoiceTranscriptState(), + activeConversationId = "conv-a", + messageConversationId = "conv-b", + voiceSessionId = "voice-session-1", + segmentIndex = 0, + transcriptText = "旧会话转写", + timestamp = 1000L, + ) + + assertNull(result) + } + + @Test + fun applyIfActive_rejectsDelayedTranscriptAfterConversationSwitch() { + val result = ConversationScopedTranscriptRouter.applyIfActive( + historyMessages = listOf( + MessageDto( + id = "current-user", + conversationId = "conv-b", + content = "B 会话中的消息", + senderType = "user", + timestamp = 1500L, + messageType = "text", + ) + ), + state = SegmentedVoiceTranscriptState(), + activeConversationId = "conv-b", + messageConversationId = "conv-a", + voiceSessionId = "voice-session-old", + segmentIndex = 2, + transcriptText = "A 会话迟到 transcript", + timestamp = 2000L, + ) + + assertNull(result) + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/feature/voice/SegmentedVoiceTranscriptTimelineTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/feature/voice/SegmentedVoiceTranscriptTimelineTest.kt new file mode 100644 index 0000000..c97303e --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/feature/voice/SegmentedVoiceTranscriptTimelineTest.kt @@ -0,0 +1,163 @@ +package com.huaga.life_echo.feature.voice + +import com.huaga.life_echo.network.models.MessageDto +import org.junit.Assert.assertEquals +import org.junit.Test + +class SegmentedVoiceTranscriptTimelineTest { + + @Test + fun applyTranscript_createsSingleUserBubble_andUpdatesItForSameSession() { + val first = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = emptyList(), + state = SegmentedVoiceTranscriptState(), + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 0, + transcriptText = "第一段", + timestamp = 1000L, + ) + + val second = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = first.historyMessages, + state = first.state, + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 1, + transcriptText = "第二段", + timestamp = 2000L, + ) + + assertEquals(1, second.historyMessages.size) + assertEquals("第一段\n第二段", second.historyMessages.single().content) + assertEquals("voice_user_voice-session-1", second.historyMessages.single().id) + } + + @Test + fun applyTranscript_ordersOutOfOrderSegments_andSeparatesDifferentSessions() { + val outOfOrder = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = emptyList(), + state = SegmentedVoiceTranscriptState(), + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 1, + transcriptText = "第二段", + timestamp = 1000L, + ) + + val reordered = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = outOfOrder.historyMessages, + state = outOfOrder.state, + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 0, + transcriptText = "第一段", + timestamp = 2000L, + ) + + val nextSession = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = reordered.historyMessages, + state = reordered.state, + conversationId = "conv-1", + voiceSessionId = "voice-session-2", + segmentIndex = 0, + transcriptText = "新一轮", + timestamp = 3000L, + ) + + assertEquals( + listOf( + MessageDto( + id = "voice_user_voice-session-1", + conversationId = "conv-1", + content = "第一段\n第二段", + senderType = "user", + timestamp = 1000L, + messageType = "text", + ), + MessageDto( + id = "voice_user_voice-session-2", + conversationId = "conv-1", + content = "新一轮", + senderType = "user", + timestamp = 3000L, + messageType = "text", + ), + ), + nextSession.historyMessages, + ) + } + + @Test + fun applyTranscript_keepsOriginalTimestamp_whenSegmentIsRetried() { + val first = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = emptyList(), + state = SegmentedVoiceTranscriptState(), + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 0, + transcriptText = "第一段", + timestamp = 1000L, + ) + + val second = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = first.historyMessages, + state = first.state, + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 1, + transcriptText = "第二段", + timestamp = 2000L, + ) + + val retried = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = second.historyMessages, + state = second.state, + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 1, + transcriptText = "第二段(重试)", + timestamp = 3000L, + ) + + assertEquals("第一段\n第二段(重试)", retried.historyMessages.single().content) + assertEquals(1000L, retried.historyMessages.single().timestamp) + } + + @Test + fun applyTranscript_keepsUserBubbleBeforeAssistant_whenLateSegmentArrives() { + val first = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = emptyList(), + state = SegmentedVoiceTranscriptState(), + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 0, + transcriptText = "第一段", + timestamp = 1000L, + ) + + val withAssistantReply = first.historyMessages + MessageDto( + id = "ai-1", + conversationId = "conv-1", + content = "AI 回复", + senderType = "assistant", + timestamp = 1500L, + messageType = "text", + ) + + val updated = SegmentedVoiceTranscriptTimeline.applyTranscript( + historyMessages = withAssistantReply, + state = first.state, + conversationId = "conv-1", + voiceSessionId = "voice-session-1", + segmentIndex = 1, + transcriptText = "第二段", + timestamp = 3000L, + ) + + assertEquals( + listOf("voice_user_voice-session-1", "ai-1"), + updated.historyMessages.sortedBy { it.timestamp }.map { it.id }, + ) + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentationTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentationTest.kt new file mode 100644 index 0000000..1e698c5 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentationTest.kt @@ -0,0 +1,109 @@ +package com.huaga.life_echo.ui.components.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import androidx.compose.ui.unit.dp + +class ChatInputPresentationTest { + + // ── ChatInputChromeDimensions ───────────────────────────────────── + + @Test + fun textAndVoiceCenterArea_useSameMinHeight() { + assertEquals( + ChatInputChromeDimensions.CenterAreaHeight, + ChatInputChromeDimensions.textInputMinHeight(), + ) + assertEquals( + ChatInputChromeDimensions.CenterAreaHeight, + ChatInputChromeDimensions.voiceInputHeight(), + ) + } + + @Test + fun actionButtons_keepMinimumTouchTarget() { + assertTrue(ChatInputChromeDimensions.ActionButtonSize >= 44.dp) + } + + // ── ChatInputLayoutPresentation ────────────────────────────────── + + @Test + fun textMode_showsVoiceSwitch_emoji_andAdd() { + val presentation = ChatInputLayoutPresentation.from( + inputMode = InputMode.TEXT, + isRecording = false, + hasText = false, + ) + + assertEquals(ChatInputLeadingAction.SWITCH_TO_VOICE, presentation.leadingAction) + assertTrue(presentation.showEmojiAction) + assertEquals(ChatInputTrailingAction.ADD, presentation.trailingAction) + } + + @Test + fun textMode_withContent_replacesAddWithSend() { + val presentation = ChatInputLayoutPresentation.from( + inputMode = InputMode.TEXT, + isRecording = false, + hasText = true, + ) + + assertEquals(ChatInputTrailingAction.SEND, presentation.trailingAction) + assertTrue(presentation.showEmojiAction) + } + + @Test + fun voiceMode_idle_matchesWechatChrome() { + val presentation = ChatInputLayoutPresentation.from( + inputMode = InputMode.VOICE, + isRecording = false, + hasText = false, + ) + + assertEquals(ChatInputLeadingAction.SWITCH_TO_TEXT, presentation.leadingAction) + assertTrue(presentation.showEmojiAction) + assertEquals(ChatInputTrailingAction.ADD, presentation.trailingAction) + } + + @Test + fun voiceMode_recording_hidesEmoji_andShowsCancel() { + val presentation = ChatInputLayoutPresentation.from( + inputMode = InputMode.VOICE, + isRecording = true, + hasText = false, + ) + + assertEquals(ChatInputLeadingAction.SWITCH_TO_TEXT, presentation.leadingAction) + assertFalse(presentation.showEmojiAction) + assertEquals(ChatInputTrailingAction.CANCEL, presentation.trailingAction) + } + + // ── VoiceRecordingTogglePresentation ───────────────────────────── + + @Test + fun idlePresentation_promptsTapToStartRecording() { + val presentation = VoiceRecordingTogglePresentation.from( + isRecording = false, + durationSeconds = 0, + ) + + assertEquals("点击开始录音", presentation.primaryLabel) + assertEquals("支持长语音,点击后持续录制", presentation.secondaryLabel) + assertFalse(presentation.showRecordingPulse) + } + + @Test + fun recordingPresentation_showsStopAction_andFormattedDuration() { + val presentation = VoiceRecordingTogglePresentation.from( + isRecording = true, + durationSeconds = 65, + ) + + assertEquals("点击结束", presentation.primaryLabel) + assertEquals("再次点击即可结束", presentation.secondaryLabel) + assertTrue(presentation.showRecordingPulse) + assertEquals("1:05", presentation.statusLabel) + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/ConversationDraftsTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/ConversationDraftsTest.kt new file mode 100644 index 0000000..b69e242 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/ConversationDraftsTest.kt @@ -0,0 +1,43 @@ +package com.huaga.life_echo.ui.screens + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationDraftsTest { + + @Test + fun updateDraft_keepsDraftsScopedPerConversation() { + var drafts = emptyMap() + + drafts = ConversationDrafts.updateDraft( + drafts = drafts, + conversationId = "conv-a", + text = "A 的草稿", + ) + drafts = ConversationDrafts.updateDraft( + drafts = drafts, + conversationId = "conv-b", + text = "B 的草稿", + ) + + assertEquals("A 的草稿", ConversationDrafts.draftForConversation(drafts, "conv-a")) + assertEquals("B 的草稿", ConversationDrafts.draftForConversation(drafts, "conv-b")) + } + + @Test + fun updateDraft_clearingOneConversationDoesNotLeakAnotherDraft() { + var drafts = mapOf( + "conv-a" to "A 的草稿", + "conv-b" to "B 的草稿", + ) + + drafts = ConversationDrafts.updateDraft( + drafts = drafts, + conversationId = "conv-b", + text = "", + ) + + assertEquals("A 的草稿", ConversationDrafts.draftForConversation(drafts, "conv-a")) + assertEquals("", ConversationDrafts.draftForConversation(drafts, "conv-b")) + } +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..21ab388 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "skills": { + "brainstorming": { + "source": "obra/superpowers", + "sourceType": "github", + "computedHash": "5cafa1558b0b6bd4d4c71f23d5567b7fcbbcdb4b0a50b0c5f69a80a3cebaf9b8" + }, + "mobile-android-design": { + "source": "wshobson/agents", + "sourceType": "github", + "computedHash": "0dfa08663016e0faa2db0611420bd5c17ba2d635a057e17a6b71c2a0c3f2ed79" + } + } +}