import { Image } from 'expo-image'; import { useLocalSearchParams } from 'expo-router'; import { Mic, Pause, Play, PlusCircle, Type, X } from 'lucide-react-native'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { NativeSyntheticEvent, TextInputContentSizeChangeEventData, } from 'react-native'; import { Alert, Animated, FlatList, InteractionManager, Keyboard, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text as RNText, TextInput, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; import { useThemeColors } from '@/hooks/use-theme-colors'; import { useMessages, useRealtimeSession } from '@/features/conversation/hooks'; import type { MessageItem } from '@/features/conversation/types'; import { isVoiceMessage } from '@/features/conversation/types'; import { usePlayer } from '@/features/voice/hooks/use-player'; import { useRecorder } from '@/features/voice/hooks/use-recorder'; // Life-Echo chat colors (from HTML reference) const CHAT_COLORS = { background: '#F8F9FA', surface: '#fbf8fd', surfaceContainer: '#f5f3f7', primary: '#8177A6', primaryBorder: '#49416c', onPrimary: '#ffffff', onSurface: '#1b1b1f', onSurfaceVariant: '#8D8C90', outline: '#cac4cf', secondaryContainer: '#edd4ff', primaryFixed: '#e7deff', errorRed: '#ba1a1a', }; /** 后端用 [SPLIT] 分隔多条消息,需按块渲染为多个气泡 */ const SPLIT_DELIMITER = '[SPLIT]'; /** 聊天输入框:单行内容高度与最多约 4 行(与 lineHeight 对齐) */ const CHAT_INPUT_LINE_H = 22; const CHAT_INPUT_MAX_H = CHAT_INPUT_LINE_H * 4; function splitContent(content: string): string[] { return content .split(SPLIT_DELIMITER) .map((s) => s.trim()) .filter((s) => s.length > 0); } const AGENT_AVATAR = 'https://lh3.googleusercontent.com/aida-public/AB6AXuC2cLdqTitOjFBnGTNsoJGh45K9zvV89vxB0EEHh0DT-YlbiOVyTuSmni5SO2eDOMC1HrW8cuhj29_KJJJFMV8aEPLHCwQgGA006Q9EfoDtEHP4GnzB4SEnT8bjCk6lQwjgh2Qp5dvq8oQTU5aQnKXtqOTAIIw1I3GWYlKimGa7Zqw4lnax84XRRGeYp6wj_1B9WsXuDvY7mNHIN7y_Tw0-TbRK1MGMHgd_nMNEqoqUCX6VAaDF2BjLB7BQnzIql8_8mw0TPaw1R3wO'; const USER_AVATAR = 'https://lh3.googleusercontent.com/aida-public/AB6AXuAMCjDBVhsUUXRAz9AGYejbTGoEYhzyiggYt_QIFqHCc3odRcBPNRhsE2Klg7gOeOV9V_qOy5qPqjU0GmpfgjGAWKGXZCizwRVz96N0n1IFMx4JH7QwV81zQsaVvCdJct_uABUBEawhncvQcbl0jUt_EUlNgzB-gIgUS_oLlT1TtRb8S5s7sAqwLRdGBa61yxL1X1iSWSFIn5N-WPIDs_vpCgS47q9SQjkT1q7VKvPzHzTiGF1bwVvjB7Bl2JgtaIUj6rkwlLbPG6xb'; type InputMode = 'text' | 'voice'; /** 展平消息列表:assistant 消息按 [SPLIT] 拆成多条,每条一个 listKey */ function flattenMessagesForList( messages: MessageItem[], ): (MessageItem & { listKey: string })[] { const result: (MessageItem & { listKey: string })[] = []; for (const msg of messages) { if (msg.senderType === 'user') { result.push({ ...msg, listKey: msg.id }); } else { const parts = splitContent(msg.content); if (parts.length > 1) { parts.forEach((part, i) => { result.push({ ...msg, content: part, listKey: `${msg.id}_part_${i}`, }); }); } else { result.push({ ...msg, listKey: msg.id }); } } } return result; } function MessageBubble({ item, agentName, meLabel, currentPlaybackUri, playbackIsPlaying, onPlayVoiceExclusive, onPausePlayback, }: { item: MessageItem; agentName: string; meLabel: string; currentPlaybackUri: string | null; playbackIsPlaying: boolean; onPlayVoiceExclusive: (uri: string) => void; onPausePlayback: () => void; }) { const isUser = item.senderType === 'user'; const isVoice = isVoiceMessage(item); return ( {isUser {isUser ? meLabel : agentName} {isVoice ? ( { if (!item.audioUri) return; if (playbackIsPlaying && currentPlaybackUri === item.audioUri) { onPausePlayback(); } else { onPlayVoiceExclusive(item.audioUri); } }} /> ) : ( {item.content} )} ); } function StreamingBubbles({ streamingText, isComplete, agentName, }: { streamingText: string; isComplete: boolean; agentName: string; }) { const parts = splitContent(streamingText); const completedParts = parts.length > 1 ? parts.slice(0, -1) : []; const streamingPart = parts.length > 1 ? parts[parts.length - 1]! : (parts[0] ?? streamingText); return ( {completedParts.map((part, i) => ( {agentName} {agentName} {part} ))} {agentName} {agentName} {streamingPart} {!isComplete && '▌'} ); } function formatRecordingDuration(seconds: number): string { const s = Math.max(0, Math.floor(seconds)); const mins = Math.floor(s / 60); const secs = s % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; } function VoiceMessageBubble({ durationSeconds, audioUri, isUser, isPlaying, onPlayPress, }: { durationSeconds: number; audioUri?: string; isUser: boolean; isPlaying: boolean; onPlayPress: () => void; }) { const handlePlayPause = useCallback(() => { onPlayPress(); }, [onPlayPress]); return ( [ styles.voicePlayButton, pressed && { opacity: 0.7 }, ]} disabled={!audioUri} accessibilityLabel={isPlaying ? 'Pause' : 'Play'} accessibilityRole="button" > {formatRecordingDuration(durationSeconds)} ); } function VoiceRecordButton({ isRecording, recordingDuration, onPress, tapToStartLabel, tapToEndLabel, enabled, }: { isRecording: boolean; recordingDuration: number; onPress: () => void; tapToStartLabel: string; tapToEndLabel: string; enabled: boolean; }) { const pulseAnim = useRef(new Animated.Value(0.85)).current; useEffect(() => { if (!isRecording) return; const anim = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { toValue: 1.15, duration: 450, useNativeDriver: true, }), Animated.timing(pulseAnim, { toValue: 0.85, duration: 450, useNativeDriver: true, }), ]), ); anim.start(); return () => anim.stop(); }, [isRecording, pulseAnim]); return ( {isRecording ? ( {tapToEndLabel} ) : ( {tapToStartLabel} )} {isRecording && ( {formatRecordingDuration(recordingDuration)} )} ); } function ChatInputBar({ value, onChangeText, onSend, inputMode, onInputModeToggle, onAddPress, onStartRecording, onStopRecording, onCancelRecording, isRecording, recordingDuration, placeholder, placeholderVoice, addMoreLabel, sendLabel, switchToVoiceLabel, switchToTextLabel, tapToStartLabel, tapToEndLabel, cancelRecordingLabel, disabled, textInputKey = 0, }: { value: string; onChangeText: (v: string) => void; onSend: () => void; inputMode: InputMode; onInputModeToggle: () => void; onAddPress: () => void; onStartRecording: () => void; onStopRecording: () => void; onCancelRecording: () => void; isRecording: boolean; recordingDuration: number; placeholder: string; placeholderVoice: string; addMoreLabel: string; sendLabel: string; switchToVoiceLabel: string; switchToTextLabel: string; tapToStartLabel: string; tapToEndLabel: string; cancelRecordingLabel: string; disabled?: boolean; /** 发送后递增,强制重建 TextInput,避免多行高度卡在 4 行 */ textInputKey?: number; }) { const colors = useThemeColors(); const [textHeight, setTextHeight] = useState(CHAT_INPUT_LINE_H); /** 空串时立即单行高度,避免仅依赖 state 时发送后仍沿用 4 行测量值 */ const inputDisplayHeight = value === '' ? CHAT_INPUT_LINE_H : textHeight; useEffect(() => { if (value === '') { setTextHeight(CHAT_INPUT_LINE_H); } }, [value]); const onInputContentSizeChange = useCallback( (e: NativeSyntheticEvent) => { const h = e.nativeEvent.contentSize.height; const next = Math.min(Math.max(h, CHAT_INPUT_LINE_H), CHAT_INPUT_MAX_H); setTextHeight(next); }, [], ); const hasText = value.trim().length > 0; const showSend = inputMode === 'text' && hasText; // 右侧按钮:文字模式有内容->发送;语音模式录音中->取消;否则->更多 const trailingAction = showSend ? 'send' : inputMode === 'voice' && isRecording ? 'cancel' : 'add'; return ( {/* 左侧:麦克风/键盘切换 */} [ styles.iconButton, pressed && styles.iconButtonPressed, ]} disabled={disabled && !isRecording} accessibilityLabel={ inputMode === 'text' ? switchToVoiceLabel : switchToTextLabel } accessibilityRole="button" > {/* 中间:文字输入 或 语音录制按钮 */} {inputMode === 'text' ? ( ) : ( )} {/* 右侧:发送 / 取消录音 / 更多 */} {trailingAction === 'send' ? ( [ styles.sendButton, { backgroundColor: colors.primary }, disabled && styles.sendButtonDisabled, pressed && !disabled && styles.sendButtonPressed, ]} disabled={disabled} accessibilityLabel={sendLabel} accessibilityRole="button" > {sendLabel} ) : trailingAction === 'cancel' ? ( [ styles.iconButton, pressed && styles.iconButtonPressed, ]} accessibilityLabel={cancelRecordingLabel} accessibilityRole="button" > ) : ( [ styles.iconButton, pressed && styles.iconButtonPressed, ]} disabled={disabled && !isRecording} accessibilityLabel={addMoreLabel} accessibilityRole="button" > )} ); } export default function ConversationScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const insets = useSafeAreaInsets(); const { t } = useTranslation('conversation'); const { t: tApp } = useTranslation('app'); const { data: messages } = useMessages(id); const { enqueue, enqueueExclusive, stop, status: playerStatus, currentSource, } = usePlayer(); const handleTtsSegment = useCallback( (p: { audioBase64?: string; audioUrl?: string }) => { if (p.audioBase64) { void enqueue({ uri: `data:audio/mp3;base64,${p.audioBase64}`, label: 'TTS', }); } else if (p.audioUrl) { void enqueue({ uri: p.audioUrl, label: 'TTS' }); } }, [enqueue], ); const handlePlayVoiceExclusive = useCallback( (uri: string) => { void enqueueExclusive({ uri, label: 'voice' }); }, [enqueueExclusive], ); const handlePausePlayback = useCallback(() => { void stop(); }, [stop]); const { connectionState, streamingMessage, sendText, sendVoiceMessage } = useRealtimeSession({ conversationId: id ?? '', enabled: !!id, onTtsSegment: handleTtsSegment, }); const handleRecordingComplete = useCallback( (uri: string, durationMs: number) => { void sendVoiceMessage(uri, durationMs); }, [sendVoiceMessage], ); const { status: recorderStatus, durationMs: recordingDurationMs, start: startRecording, stop: stopRecording, cancel: cancelRecording, } = useRecorder(handleRecordingComplete); const [input, setInput] = useState(''); const [inputResetKey, setInputResetKey] = useState(0); const [inputMode, setInputMode] = useState('text'); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); const listRef = useRef(null); useEffect(() => { const onShow = (e: { endCoordinates: { height: number } }) => { setIsKeyboardVisible(true); setKeyboardHeight(e.endCoordinates.height); InteractionManager.runAfterInteractions(() => { listRef.current?.scrollToEnd({ animated: true }); }); }; const onHide = () => { setIsKeyboardVisible(false); setKeyboardHeight(0); }; // iOS:Will* 与系统动画同步;KeyboardAvoidingView 在 iOS 上易与 safe area 叠出缝(见 RN #52626) const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const subShow = Keyboard.addListener(showEvt, onShow); const subHide = Keyboard.addListener(hideEvt, onHide); return () => { subShow.remove(); subHide.remove(); }; }, [insets.bottom]); const flattenedData = flattenMessagesForList(messages ?? []); const isRecording = recorderStatus === 'recording'; const recordingDuration = Math.floor(recordingDurationMs / 1000); const handleStartRecording = useCallback(async () => { const ok = await startRecording(); if (!ok) { Alert.alert(t('recordingPermissionDenied')); } }, [startRecording, t]); const handleSend = () => { const text = input.trim(); if (!text) return; sendText(text); setInput(''); setInputResetKey((k) => k + 1); }; const connectionLabel = connectionState === 'connected' ? t('connectionConnected') : connectionState === 'connecting' ? t('connectionConnecting') : t('connectionDisconnected'); /** iOS:用键盘高度直接顶起根布局,替代 KAV(避免与 safe area 叠出缝,见 RN #52626) */ const keyboardLift = Platform.OS === 'ios' && inputMode === 'text' && isKeyboardVisible ? keyboardHeight : 0; const androidKavOn = Platform.OS === 'android' && inputMode === 'text' && isKeyboardVisible; const composerZeroBottomInset = isKeyboardVisible && inputMode === 'text'; const screen = ( {tApp('name')} {connectionLabel} } backAccessibilityLabel={t('chatTitle')} /> {/* Message list - flex 1, takes remaining space */} item.listKey} renderItem={({ item }) => ( )} onContentSizeChange={() => InteractionManager.runAfterInteractions(() => { listRef.current?.scrollToEnd({ animated: true }); }) } ListFooterComponent={ streamingMessage ? ( ) : null } /> {/* WeChat-style input bar - 贴在底部,非浮空 */} { setInputMode((m) => { if (m === 'text') { Keyboard.dismiss(); } return m === 'text' ? 'voice' : 'text'; }); }} onAddPress={() => {}} onStartRecording={handleStartRecording} onStopRecording={() => void stopRecording()} onCancelRecording={() => void cancelRecording()} isRecording={isRecording} recordingDuration={recordingDuration} placeholder={t('inputPlaceholder')} placeholderVoice={t('inputPlaceholderVoice')} addMoreLabel={t('addMore')} sendLabel={t('send')} switchToVoiceLabel={t('switchToVoice')} switchToTextLabel={t('switchToText')} tapToStartLabel={t('tapToStartRecording')} tapToEndLabel={t('tapToEndRecording')} cancelRecordingLabel={t('cancelRecording')} disabled={connectionState !== 'connected'} /> ); if (Platform.OS === 'ios') { return ( {screen} ); } return ( {screen} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: CHAT_COLORS.background, }, column: { flex: 1, flexDirection: 'column', }, headerTitleBlock: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 10, }, headerTitle: { fontSize: 24, fontWeight: '700', color: CHAT_COLORS.primary, letterSpacing: -0.5, }, statusBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 4, backgroundColor: 'rgba(141, 140, 144, 0.2)', borderWidth: 1, borderColor: 'rgba(141, 140, 144, 0.3)', }, statusBadgeConnected: { backgroundColor: 'rgba(34, 197, 94, 0.15)', borderColor: 'rgba(34, 197, 94, 0.3)', }, statusBadgeText: { fontSize: 12, fontFamily: 'monospace', color: CHAT_COLORS.onSurfaceVariant, }, statusBadgeTextConnected: { color: '#16a34a', }, list: { flex: 1, }, listContent: { flexGrow: 1, paddingHorizontal: 12, paddingTop: 16, paddingBottom: 12, }, messageRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, marginBottom: 16, overflow: 'visible', }, messageRowReverse: { flexDirection: 'row-reverse', }, streamingRow: { marginBottom: 0, }, avatarWrapper: { width: 40, height: 40, flexShrink: 0, borderWidth: 2, overflow: 'hidden', }, avatarWrapperAgent: { backgroundColor: CHAT_COLORS.secondaryContainer, borderColor: CHAT_COLORS.primary, }, avatarWrapperUser: { backgroundColor: CHAT_COLORS.primaryFixed, borderColor: CHAT_COLORS.onSurfaceVariant, }, avatarImage: { width: '100%', height: '100%', }, bubbleColumn: { maxWidth: '80%', alignSelf: 'flex-start', }, bubbleColumnEnd: { alignItems: 'flex-end', alignSelf: 'flex-end', }, nickname: { fontSize: 16, fontWeight: '700', marginBottom: 2, }, nicknameAgent: { color: CHAT_COLORS.primary, }, nicknameUser: { color: CHAT_COLORS.onSurfaceVariant, }, bubble: { padding: 12, borderWidth: 2, shadowOffset: { width: 4, height: 4 }, shadowOpacity: 1, shadowRadius: 0, }, bubbleAgent: { backgroundColor: CHAT_COLORS.primary, borderColor: CHAT_COLORS.primaryBorder, shadowColor: CHAT_COLORS.primaryBorder, }, bubbleUser: { backgroundColor: '#ffffff', borderColor: CHAT_COLORS.onSurfaceVariant, shadowColor: CHAT_COLORS.outline, }, bubbleText: { fontSize: 22, lineHeight: 34, fontWeight: '400', }, bubbleTextAgent: { color: CHAT_COLORS.onPrimary, }, bubbleTextUser: { color: CHAT_COLORS.onSurface, }, voiceMessageBubble: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 4, }, voiceMessageBubbleUser: {}, voiceMessageBubbleAgent: {}, voicePlayButton: { width: 44, height: 44, borderRadius: 22, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.2)', }, voiceDurationText: { fontSize: 18, fontWeight: '500', }, voiceDurationTextUser: { color: CHAT_COLORS.onSurface, }, voiceDurationTextAgent: { color: CHAT_COLORS.onPrimary, }, inputBarWrapper: { backgroundColor: CHAT_COLORS.surface, borderTopLeftRadius: 24, borderTopRightRadius: 24, shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.08, shadowRadius: 12, elevation: 8, }, inputBar: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 12, }, iconButton: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, iconButtonPressed: { opacity: 0.7, }, inputCenter: { flex: 1, minHeight: 48, borderRadius: 16, backgroundColor: 'rgba(141, 140, 144, 0.14)', paddingHorizontal: 16, paddingVertical: 11, justifyContent: 'flex-start', }, inputCenterFlex: { flex: 1, minHeight: 48, }, voiceRecordCenter: { flex: 1, minHeight: 48, borderRadius: 16, backgroundColor: 'rgba(141, 140, 144, 0.14)', paddingHorizontal: 16, paddingVertical: 11, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, voiceRecordCenterRecording: { backgroundColor: 'rgba(141, 140, 144, 0.20)', }, voiceRecordLabel: { fontSize: 16, fontWeight: '500', color: 'rgba(27, 27, 31, 0.72)', flex: 1, }, voiceRecordLabelCenter: { textAlign: 'center', }, voiceRecordLabelCenterWrap: { flex: 1, justifyContent: 'center', alignItems: 'center', }, voiceRecordLabelRecording: { color: 'rgba(27, 27, 31, 0.92)', }, voiceRecordLabelDisabled: { color: 'rgba(141, 140, 144, 0.68)', }, voiceRecordPill: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(255, 255, 255, 0.8)', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 5, }, voiceRecordPulseDot: { width: 7, height: 7, borderRadius: 3.5, backgroundColor: CHAT_COLORS.errorRed, }, voiceRecordDurationWrap: { width: 32, alignItems: 'center', justifyContent: 'center', }, voiceRecordDuration: { fontSize: 12, lineHeight: 12, color: 'rgba(27, 27, 31, 0.86)', textAlign: 'center', }, voiceRecordDurationAndroid: { textAlignVertical: 'center', } as const, textInput: { fontSize: 16, lineHeight: CHAT_INPUT_LINE_H, color: CHAT_COLORS.onSurface, padding: 0, maxHeight: CHAT_INPUT_MAX_H, width: '100%', }, sendButton: { height: 44, paddingHorizontal: 16, paddingVertical: 11, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, sendButtonPressed: { opacity: 0.9, }, sendButtonDisabled: { opacity: 0.45, }, sendButtonText: { fontSize: 16, fontWeight: '500', }, });