- DB: segments 用户输入文本(Alembic 0002) - Chat: 阶段检测/阶段提示/回复限制,编排与访谈/画像 prompts 调整 - Memoir: 忠实度检查 agent,叙事与分类等链路更新 - Core: agent 日志、Alembic 启动、LangChain/日志/配置等 - Story: time_hints;Memory 检索与相关测试 - Expo: 助手头像、会话页与消息拆分、实时会话与文案/i18n - Docs/scripts/tests: 迁移脚本、LLM JSON/记忆检索文档、新增单测
1282 lines
35 KiB
TypeScript
1282 lines
35 KiB
TypeScript
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 逐字绑定。
|
||
* iOS:WillShow 提前标记键盘区,便于底部 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',
|
||
},
|
||
});
|