Files
life-echo/app-expo/src/app/(main)/conversation/[id].tsx
Kevin 8af37e5e8e 修复:CI 部署环境与 ref 错配、迁移碎片化、图片意图 source_span、章节物化脏版式、会话历史与本地语音不一致
新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试
2026-03-20 16:43:02 +08:00

1151 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<View style={[styles.messageRow, isUser && styles.messageRowReverse]}>
<View
style={[
styles.avatarWrapper,
isUser ? styles.avatarWrapperUser : styles.avatarWrapperAgent,
]}
>
<Image
source={{ uri: isUser ? USER_AVATAR : AGENT_AVATAR }}
style={styles.avatarImage}
alt={isUser ? meLabel : agentName}
/>
</View>
<View style={[styles.bubbleColumn, isUser && styles.bubbleColumnEnd]}>
<Text
style={[
styles.nickname,
isUser ? styles.nicknameUser : styles.nicknameAgent,
]}
>
{isUser ? meLabel : agentName}
</Text>
{isVoice ? (
<View
style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAgent,
]}
>
<VoiceMessageBubble
durationSeconds={item.durationSeconds ?? 0}
audioUri={item.audioUri}
isUser={isUser}
isPlaying={
!!item.audioUri &&
playbackIsPlaying &&
currentPlaybackUri === item.audioUri
}
onPlayPress={() => {
if (!item.audioUri) return;
if (playbackIsPlaying && currentPlaybackUri === item.audioUri) {
onPausePlayback();
} else {
onPlayVoiceExclusive(item.audioUri);
}
}}
/>
</View>
) : (
<View
style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAgent,
]}
>
<Text
selectable
style={[
styles.bubbleText,
isUser ? styles.bubbleTextUser : styles.bubbleTextAgent,
]}
>
{item.content}
</Text>
</View>
)}
</View>
</View>
);
}
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 (
<View>
{completedParts.map((part, i) => (
<View
key={`streaming_complete_${i}`}
style={[styles.messageRow, styles.streamingRow]}
>
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
<Image
source={{ uri: AGENT_AVATAR }}
style={styles.avatarImage}
alt={agentName}
/>
</View>
<View style={[styles.bubbleColumn]}>
<Text style={[styles.nickname, styles.nicknameAgent]}>
{agentName}
</Text>
<View style={[styles.bubble, styles.bubbleAgent]}>
<Text
selectable
style={[styles.bubbleText, styles.bubbleTextAgent]}
>
{part}
</Text>
</View>
</View>
</View>
))}
<View style={[styles.messageRow, styles.streamingRow]}>
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
<Image
source={{ uri: AGENT_AVATAR }}
style={styles.avatarImage}
alt={agentName}
/>
</View>
<View style={[styles.bubbleColumn]}>
<Text style={[styles.nickname, styles.nicknameAgent]}>
{agentName}
</Text>
<View style={[styles.bubble, styles.bubbleAgent]}>
<Text
selectable
style={[styles.bubbleText, styles.bubbleTextAgent]}
>
{streamingPart}
{!isComplete && '▌'}
</Text>
</View>
</View>
</View>
</View>
);
}
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 (
<View
style={[
styles.voiceMessageBubble,
isUser ? styles.voiceMessageBubbleUser : styles.voiceMessageBubbleAgent,
]}
>
<Pressable
onPress={handlePlayPause}
style={({ pressed }) => [
styles.voicePlayButton,
pressed && { opacity: 0.7 },
]}
disabled={!audioUri}
accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
accessibilityRole="button"
>
<Icon
as={isPlaying ? Pause : Play}
size={24}
color={isUser ? CHAT_COLORS.onSurface : CHAT_COLORS.onPrimary}
/>
</Pressable>
<Text
style={[
styles.voiceDurationText,
isUser ? styles.voiceDurationTextUser : styles.voiceDurationTextAgent,
]}
>
{formatRecordingDuration(durationSeconds)}
</Text>
</View>
);
}
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 (
<Pressable
onPress={onPress}
style={[
styles.voiceRecordCenter,
isRecording && styles.voiceRecordCenterRecording,
]}
disabled={!enabled}
>
{isRecording ? (
<View style={styles.voiceRecordLabelCenterWrap}>
<Text
style={[
styles.voiceRecordLabel,
styles.voiceRecordLabelCenter,
styles.voiceRecordLabelRecording,
!enabled && styles.voiceRecordLabelDisabled,
]}
numberOfLines={1}
>
{tapToEndLabel}
</Text>
</View>
) : (
<Text
style={[
styles.voiceRecordLabel,
!enabled && styles.voiceRecordLabelDisabled,
styles.voiceRecordLabelCenter,
]}
numberOfLines={1}
>
{tapToStartLabel}
</Text>
)}
{isRecording && (
<View style={styles.voiceRecordPill}>
<Animated.View
style={[
styles.voiceRecordPulseDot,
{ transform: [{ scale: pulseAnim }] },
]}
/>
<View style={styles.voiceRecordDurationWrap}>
<RNText
style={[
styles.voiceRecordDuration,
Platform.OS === 'android' && styles.voiceRecordDurationAndroid,
]}
{...(Platform.OS === 'android' && { includeFontPadding: false })}
>
{formatRecordingDuration(recordingDuration)}
</RNText>
</View>
</View>
)}
</Pressable>
);
}
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<TextInputContentSizeChangeEventData>) => {
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 (
<View style={styles.inputBar}>
{/* 左侧:麦克风/键盘切换 */}
<Pressable
onPress={onInputModeToggle}
style={({ pressed }) => [
styles.iconButton,
pressed && styles.iconButtonPressed,
]}
disabled={disabled && !isRecording}
accessibilityLabel={
inputMode === 'text' ? switchToVoiceLabel : switchToTextLabel
}
accessibilityRole="button"
>
<Icon
as={inputMode === 'text' ? Mic : Type}
size={22}
color={
disabled && !isRecording
? 'rgba(141, 140, 144, 0.56)'
: CHAT_COLORS.onSurfaceVariant
}
/>
</Pressable>
{/* 中间:文字输入 或 语音录制按钮 */}
{inputMode === 'text' ? (
<View style={styles.inputCenter}>
<TextInput
key={`chat-input-${textInputKey}`}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={CHAT_COLORS.onSurfaceVariant}
style={[styles.textInput, { height: inputDisplayHeight }]}
multiline
scrollEnabled
textAlignVertical="top"
maxLength={2000}
editable={!disabled}
onContentSizeChange={onInputContentSizeChange}
onSubmitEditing={onSend}
returnKeyType="send"
/>
</View>
) : (
<View style={styles.inputCenterFlex}>
<VoiceRecordButton
isRecording={isRecording}
recordingDuration={recordingDuration}
onPress={isRecording ? onStopRecording : onStartRecording}
tapToStartLabel={tapToStartLabel}
tapToEndLabel={tapToEndLabel}
enabled={!disabled}
/>
</View>
)}
{/* 右侧:发送 / 取消录音 / 更多 */}
{trailingAction === 'send' ? (
<Pressable
onPress={onSend}
style={({ pressed }) => [
styles.sendButton,
{ backgroundColor: colors.primary },
disabled && styles.sendButtonDisabled,
pressed && !disabled && styles.sendButtonPressed,
]}
disabled={disabled}
accessibilityLabel={sendLabel}
accessibilityRole="button"
>
<Text
style={[styles.sendButtonText, { color: CHAT_COLORS.onSurface }]}
>
{sendLabel}
</Text>
</Pressable>
) : trailingAction === 'cancel' ? (
<Pressable
onPress={onCancelRecording}
style={({ pressed }) => [
styles.iconButton,
pressed && styles.iconButtonPressed,
]}
accessibilityLabel={cancelRecordingLabel}
accessibilityRole="button"
>
<Icon as={X} size={22} color={CHAT_COLORS.errorRed} />
</Pressable>
) : (
<Pressable
onPress={onAddPress}
style={({ pressed }) => [
styles.iconButton,
pressed && styles.iconButtonPressed,
]}
disabled={disabled && !isRecording}
accessibilityLabel={addMoreLabel}
accessibilityRole="button"
>
<Icon
as={PlusCircle}
size={22}
color={
disabled && !isRecording
? 'rgba(141, 140, 144, 0.56)'
: CHAT_COLORS.onSurfaceVariant
}
/>
</Pressable>
)}
</View>
);
}
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<InputMode>('text');
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const listRef = useRef<FlatList>(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);
};
// iOSWill* 与系统动画同步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 = (
<View style={styles.column}>
<ScreenHeader
variant="chat"
title={
<View style={styles.headerTitleBlock}>
<Text style={styles.headerTitle}>{tApp('name')}</Text>
<View
style={[
styles.statusBadge,
connectionState === 'connected' && styles.statusBadgeConnected,
]}
>
<Text
style={[
styles.statusBadgeText,
connectionState === 'connected' &&
styles.statusBadgeTextConnected,
]}
>
{connectionLabel}
</Text>
</View>
</View>
}
backAccessibilityLabel={t('chatTitle')}
/>
{/* Message list - flex 1, takes remaining space */}
<FlatList
ref={listRef}
style={styles.list}
contentContainerStyle={styles.listContent}
data={flattenedData}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
keyExtractor={(item) => item.listKey}
renderItem={({ item }) => (
<MessageBubble
item={item}
agentName={t('agentName')}
meLabel={t('me')}
currentPlaybackUri={currentSource}
playbackIsPlaying={playerStatus === 'playing'}
onPlayVoiceExclusive={handlePlayVoiceExclusive}
onPausePlayback={handlePausePlayback}
/>
)}
onContentSizeChange={() =>
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
})
}
ListFooterComponent={
streamingMessage ? (
<StreamingBubbles
streamingText={streamingMessage.text}
isComplete={streamingMessage.isComplete}
agentName={t('agentName')}
/>
) : null
}
/>
{/* WeChat-style input bar - 贴在底部,非浮空 */}
<View
style={[
styles.inputBarWrapper,
{
paddingBottom: composerZeroBottomInset ? 0 : insets.bottom,
},
]}
>
<ChatInputBar
value={input}
onChangeText={setInput}
onSend={handleSend}
textInputKey={inputResetKey}
inputMode={inputMode}
onInputModeToggle={() => {
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'}
/>
</View>
</View>
);
if (Platform.OS === 'ios') {
return (
<View style={[styles.container, { paddingBottom: keyboardLift }]}>
{screen}
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={androidKavOn ? 'height' : undefined}
>
{screen}
</KeyboardAvoidingView>
);
}
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',
},
});