Files
life-echo/app-expo/src/app/(main)/conversation/[id].tsx
Kevin a3f61fcc0f feat(api+app): 对话阶段化、回忆录流水线与客户端会话体验
- DB: segments 用户输入文本(Alembic 0002)
- Chat: 阶段检测/阶段提示/回复限制,编排与访谈/画像 prompts 调整
- Memoir: 忠实度检查 agent,叙事与分类等链路更新
- Core: agent 日志、Alembic 启动、LangChain/日志/配置等
- Story: time_hints;Memory 检索与相关测试
- Expo: 助手头像、会话页与消息拆分、实时会话与文案/i18n
- Docs/scripts/tests: 迁移脚本、LLM JSON/记忆检索文档、新增单测
2026-03-26 12:13:36 +08:00

1282 lines
35 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 {
LayoutChangeEvent,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
} from 'react-native';
import {
Alert,
Animated,
FlatList,
InteractionManager,
Keyboard,
Platform,
Pressable,
StyleSheet,
Text as RNText,
TextInput,
View,
} from 'react-native';
import { KeyboardAvoidingView as KeyboardControllerAvoidingView } from 'react-native-keyboard-controller';
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 {
splitMessageParts,
splitStreamingSegments,
} from '@/features/conversation/message-split';
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',
};
/** 聊天输入框:单行内容高度与最多约 4 行(与 lineHeight 对齐) */
const CHAT_INPUT_LINE_H = 22;
const CHAT_INPUT_MAX_H = CHAT_INPUT_LINE_H * 4;
/** 与 archive/app-ios-react-app 中 `app-android/.../drawable/avatar_assistant.png` 同源(岁月知己) */
const AGENT_AVATAR = require('@/assets/images/avatar-assistant.png');
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 = splitMessageParts(msg.content);
if (parts.length === 0) {
continue;
}
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={isUser ? { uri: USER_AVATAR } : AGENT_AVATAR}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
alt={isUser ? meLabel : agentName}
/>
</View>
<View style={[styles.bubbleColumn, isUser && styles.bubbleColumnEnd]}>
{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 segments = splitStreamingSegments(streamingText);
const completedParts =
segments.length > 1
? segments.slice(0, -1).filter((s) => s.length > 0)
: [];
const streamingPart =
segments.length > 0 ? segments[segments.length - 1]! : 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={AGENT_AVATAR}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
alt={agentName}
/>
</View>
<View style={[styles.bubbleColumn]}>
<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={AGENT_AVATAR}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
alt={agentName}
/>
</View>
<View style={[styles.bubbleColumn]}>
<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,
isUser ? styles.voicePlayButtonUser : styles.voicePlayButtonAgent,
pressed && { opacity: 0.7 },
]}
disabled={!audioUri}
accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
accessibilityRole="button"
>
<Icon
as={isPlaying ? Pause : Play}
size={24}
color={isUser ? CHAT_COLORS.onPrimary : CHAT_COLORS.primary}
/>
</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,
onInputDisplayHeightChange,
}: {
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;
/** 文字输入框实际绘制高度变化(单行/多行),供列表滚到底 */
onInputDisplayHeightChange?: (height: number) => void;
}) {
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]);
useEffect(() => {
if (inputMode !== 'text' || !onInputDisplayHeightChange) return;
onInputDisplayHeightChange(inputDisplayHeight);
}, [inputDisplayHeight, inputMode, onInputDisplayHeightChange]);
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,
]}
accessibilityLabel={
inputMode === 'text' ? switchToVoiceLabel : switchToTextLabel
}
accessibilityRole="button"
>
<Icon
as={inputMode === 'text' ? Mic : Type}
size={22}
color={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
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 listRef = useRef<FlatList>(null);
/** 底部输入区(含连接提示 + 输入条)高度,用于多行输入增高时把列表滚到底,避免挡住最新消息 */
const composerBlockHeightRef = useRef<number | null>(null);
/** 连接中connecting时点发送排队连上后自动发出 */
const pendingTextSendRef = useRef<string | null>(null);
const connectingSendTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const CONNECTING_SEND_TIMEOUT_MS = 15_000;
const PENDING_SEND_FLUSH_MS = 50;
const clearConnectingSendTimeout = useCallback(() => {
if (connectingSendTimeoutRef.current) {
clearTimeout(connectingSendTimeoutRef.current);
connectingSendTimeoutRef.current = null;
}
}, []);
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 scrollListToEndAfterComposerLayout = useCallback(() => {
InteractionManager.runAfterInteractions(() => {
requestAnimationFrame(() => {
listRef.current?.scrollToEnd({ animated: true });
});
});
}, []);
/**
* 仅随系统键盘DidShow 时布局已稳定再 scrollToEnd不与 input 逐字绑定。
* iOSWillShow 提前标记键盘区,便于底部 inset 与动画同步。
*/
useEffect(() => {
const onKeyboardShown = () => {
setIsKeyboardVisible(true);
scrollListToEndAfterComposerLayout();
};
const onKeyboardHidden = () => {
setIsKeyboardVisible(false);
};
const subs: ReturnType<typeof Keyboard.addListener>[] = [];
if (Platform.OS === 'ios') {
subs.push(
Keyboard.addListener('keyboardWillShow', () =>
setIsKeyboardVisible(true),
),
);
}
subs.push(Keyboard.addListener('keyboardDidShow', onKeyboardShown));
subs.push(
Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
onKeyboardHidden,
),
);
return () => subs.forEach((s) => s.remove());
}, [scrollListToEndAfterComposerLayout]);
const onComposerBlockLayout = useCallback(
(e: LayoutChangeEvent) => {
const h = e.nativeEvent.layout.height;
const prev = composerBlockHeightRef.current;
if (prev !== null && Math.abs(h - prev) < 1) return;
composerBlockHeightRef.current = h;
scrollListToEndAfterComposerLayout();
},
[scrollListToEndAfterComposerLayout],
);
useEffect(() => {
return () => {
clearConnectingSendTimeout();
};
}, [clearConnectingSendTimeout]);
useEffect(() => {
pendingTextSendRef.current = null;
clearConnectingSendTimeout();
}, [id, clearConnectingSendTimeout]);
/** 连接中排队:一旦变为 connected短延迟后发送给 WebSocket onopen 一点时间 */
useEffect(() => {
if (connectionState !== 'connected') return;
const text = pendingTextSendRef.current;
if (!text) return;
const t = setTimeout(() => {
if (pendingTextSendRef.current !== text) return;
pendingTextSendRef.current = null;
clearConnectingSendTimeout();
sendText(text);
}, PENDING_SEND_FLUSH_MS);
return () => clearTimeout(t);
}, [connectionState, sendText, clearConnectingSendTimeout]);
/** 排队期间若变为断开,把内容退回输入框,并取消连接等待超时 */
useEffect(() => {
if (connectionState !== 'disconnected') return;
clearConnectingSendTimeout();
const text = pendingTextSendRef.current;
if (!text) return;
pendingTextSendRef.current = null;
setInput(text);
}, [connectionState, clearConnectingSendTimeout]);
const handleSend = () => {
const text = input.trim();
if (!text) return;
if (connectionState === 'disconnected') {
Alert.alert(t('chatUnavailableTitle'), t('chatUnavailableDisconnected'));
return;
}
if (connectionState === 'connecting') {
pendingTextSendRef.current = text;
setInput('');
setInputResetKey((k) => k + 1);
clearConnectingSendTimeout();
connectingSendTimeoutRef.current = setTimeout(() => {
connectingSendTimeoutRef.current = null;
if (pendingTextSendRef.current !== text) return;
pendingTextSendRef.current = null;
setInput(text);
Alert.alert(t('chatUnavailableTitle'), t('chatQueueSendTimeout'));
}, CONNECTING_SEND_TIMEOUT_MS);
return;
}
sendText(text);
setInput('');
setInputResetKey((k) => k + 1);
};
/** 仅完全断开时禁用发送/语音;连接中可点发送(排队) */
const composerDisabled = connectionState === 'disconnected';
const connectionLabel =
connectionState === 'connected'
? t('connectionConnected')
: connectionState === 'connecting'
? t('connectionConnecting')
: t('connectionDisconnected');
const showConnectionBadge = __DEV__;
const showConnectionNotice = connectionState !== 'connected';
const connectionNoticeText =
connectionState === 'connecting'
? t('chatUnavailableConnecting')
: t('chatUnavailableDisconnected');
/** 键盘打开时去掉底部 safe area避免与键盘区重复留白 */
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>
{showConnectionBadge ? (
<View
style={[
styles.statusBadge,
connectionState === 'connected' &&
styles.statusBadgeConnected,
]}
>
<Text
style={[
styles.statusBadgeText,
connectionState === 'connected' &&
styles.statusBadgeTextConnected,
]}
>
{connectionLabel}
</Text>
</View>
) : null}
</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,
},
]}
onLayout={onComposerBlockLayout}
>
{showConnectionNotice ? (
<View style={styles.connectionNotice}>
<Text style={styles.connectionNoticeTitle}>
{t('chatUnavailableTitle')}
</Text>
<Text style={styles.connectionNoticeText}>
{connectionNoticeText}
</Text>
</View>
) : null}
<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={composerDisabled}
onInputDisplayHeightChange={scrollListToEndAfterComposerLayout}
/>
</View>
</View>
);
return (
<KeyboardControllerAvoidingView
style={styles.container}
behavior="padding"
enabled={inputMode === 'text'}
>
{screen}
</KeyboardControllerAvoidingView>
);
}
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,
borderRadius: 20,
flexShrink: 0,
borderWidth: 1,
overflow: 'hidden',
},
avatarWrapperAgent: {
backgroundColor: CHAT_COLORS.secondaryContainer,
borderColor: 'rgba(129, 119, 166, 0.35)',
},
avatarWrapperUser: {
backgroundColor: CHAT_COLORS.primaryFixed,
borderColor: 'rgba(141, 140, 144, 0.35)',
},
/** 远程图在 Android 上若用 100% 尺寸可能解析为 0×0需写死数值见 Expo 文档) */
avatarImage: {
width: 40,
height: 40,
borderRadius: 20,
},
bubbleColumn: {
maxWidth: '80%',
alignSelf: 'flex-start',
},
bubbleColumnEnd: {
alignItems: 'flex-end',
alignSelf: 'flex-end',
},
/** 微信式:左白右主题色,靠头像一侧圆角略小;轻阴影替代像素风描边 */
bubble: {
paddingHorizontal: 14,
paddingVertical: 10,
maxWidth: '100%',
borderWidth: 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 1,
},
bubbleAgent: {
backgroundColor: '#ffffff',
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderBottomRightRadius: 12,
borderBottomLeftRadius: 4,
},
bubbleUser: {
backgroundColor: CHAT_COLORS.primary,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderBottomLeftRadius: 12,
borderBottomRightRadius: 4,
shadowColor: CHAT_COLORS.primaryBorder,
shadowOpacity: 0.12,
elevation: 2,
},
bubbleText: {
fontSize: 17,
lineHeight: 24,
fontWeight: '400',
},
bubbleTextAgent: {
color: CHAT_COLORS.onSurface,
},
bubbleTextUser: {
color: CHAT_COLORS.onPrimary,
},
voiceMessageBubble: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 4,
},
voiceMessageBubbleUser: {},
voiceMessageBubbleAgent: {},
voicePlayButton: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
voicePlayButtonUser: {
backgroundColor: 'rgba(255, 255, 255, 0.22)',
},
voicePlayButtonAgent: {
backgroundColor: 'rgba(129, 119, 166, 0.12)',
},
voiceDurationText: {
fontSize: 18,
fontWeight: '500',
},
voiceDurationTextUser: {
color: CHAT_COLORS.onPrimary,
},
voiceDurationTextAgent: {
color: CHAT_COLORS.onSurface,
},
inputBarWrapper: {
backgroundColor: CHAT_COLORS.surface,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 8,
},
connectionNotice: {
marginHorizontal: 14,
marginTop: 14,
marginBottom: 4,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 14,
backgroundColor: 'rgba(186, 26, 26, 0.08)',
borderWidth: 1,
borderColor: 'rgba(186, 26, 26, 0.14)',
},
connectionNoticeTitle: {
fontSize: 13,
fontWeight: '700',
color: CHAT_COLORS.errorRed,
marginBottom: 2,
},
connectionNoticeText: {
fontSize: 13,
lineHeight: 18,
color: CHAT_COLORS.onSurface,
},
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',
},
});