Files
digital-pilates/components/coach/ChatMessage.tsx

450 lines
12 KiB
TypeScript

import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Markdown from 'react-native-markdown-display';
import { ChatMessage, MessageAttachment } from './types';
interface MessageAttachmentComponentProps {
attachment: MessageAttachment;
onPreview?: (uri: string) => void;
}
const MessageAttachmentComponent: React.FC<MessageAttachmentComponentProps> = ({
attachment,
onPreview
}) => {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'light'];
if (attachment.type === 'image') {
return (
<TouchableOpacity
style={styles.imageAttachment}
onPress={() => onPreview?.(attachment.url)}
activeOpacity={0.8}
>
<Image
source={{ uri: attachment.url }}
style={styles.attachmentImage}
contentFit="cover"
/>
{attachment.uploadProgress !== undefined && attachment.uploadProgress < 1 && (
<View style={styles.attachmentProgressOverlay}>
<Text style={styles.attachmentProgressText}>
{Math.round(attachment.uploadProgress * 100)}%
</Text>
</View>
)}
{attachment.uploadError && (
<View style={styles.attachmentErrorOverlay}>
<Text style={styles.attachmentErrorText}></Text>
</View>
)}
</TouchableOpacity>
);
}
if (attachment.type === 'video') {
return (
<View style={styles.videoAttachment}>
<View style={styles.videoPlaceholder}>
<Ionicons name="play-circle" size={32} color="#fff" />
{attachment.filename && (
<Text style={styles.videoFilename}>{attachment.filename}</Text>
)}
</View>
</View>
);
}
if (attachment.type === 'file') {
return (
<View style={styles.fileAttachment}>
<Ionicons name="document" size={20} color={theme.text} />
<Text style={[styles.fileFilename, { color: theme.text }]}>
{attachment.filename || '未知文件'}
</Text>
</View>
);
}
return null;
};
interface ChatMessageComponentProps {
message: ChatMessage;
onPreviewImage?: (uri: string) => void;
onChoiceSelect?: (messageId: string, choiceId: string) => void;
selectedChoices?: Record<string, string>;
pendingChoiceConfirmation?: Record<string, boolean>;
isStreaming?: boolean;
onCancelStream?: () => void;
}
const ChatMessageComponent: React.FC<ChatMessageComponentProps> = ({
message,
onPreviewImage,
onChoiceSelect,
selectedChoices,
pendingChoiceConfirmation,
isStreaming,
onCancelStream
}) => {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'light'];
const isUser = message.role === 'user';
const isAssistant = message.role === 'assistant';
return (
<View style={[styles.row, isUser && styles.userRow]}>
{isAssistant && (
<View style={[styles.avatar, { backgroundColor: theme.primary }]}>
<Text style={styles.avatarText}>AI</Text>
</View>
)}
<View style={[
styles.bubble,
{
backgroundColor: isUser ? theme.primary : theme.surface,
alignSelf: isUser ? 'flex-end' : 'flex-start',
maxWidth: '85%',
}
]}>
{/* 文本内容 */}
{message.content ? (
isUser ? (
<Text style={[styles.bubbleText, { color: theme.onPrimary }]}>
{message.content}
</Text>
) : (
<Markdown
style={{
body: { color: theme.text, fontSize: 15, lineHeight: 22 },
paragraph: { marginTop: 2, marginBottom: 2 },
bullet_list: { marginVertical: 4 },
ordered_list: { marginVertical: 4 },
list_item: { flexDirection: 'row' },
code_inline: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
code_block: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
fence: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
heading1: { fontSize: 20, fontWeight: '800', marginVertical: 6 },
heading2: { fontSize: 18, fontWeight: '800', marginVertical: 6 },
heading3: { fontSize: 16, fontWeight: '800', marginVertical: 6 },
link: { color: '#246BFD' },
}}
>
{message.content}
</Markdown>
)
) : null}
{/* 附件 */}
{message.attachments && message.attachments.length > 0 && (
<View style={styles.attachmentsContainer}>
{message.attachments.map((attachment) => (
<MessageAttachmentComponent
key={attachment.id}
attachment={attachment}
onPreview={onPreviewImage}
/>
))}
</View>
)}
{/* 选择选项 */}
{message.choices && message.choices.length > 0 && (
<View style={styles.choicesContainer}>
{message.choices.map((choice) => {
const isSelected = selectedChoices?.[message.id] === choice.id;
const isLoading = pendingChoiceConfirmation?.[message.id];
const isDisabled = isLoading || (selectedChoices?.[message.id] && !isSelected) || false;
return (
<TouchableOpacity
key={choice.id}
style={[
styles.choiceButton,
!!choice.recommended && styles.choiceButtonRecommended,
isSelected && styles.choiceButtonSelected,
isDisabled && styles.choiceButtonDisabled,
]}
onPress={() => onChoiceSelect?.(message.id, choice.id)}
disabled={isDisabled}
>
<View style={styles.choiceContent}>
<Text style={[
styles.choiceLabel,
!!choice.recommended && styles.choiceLabelRecommended,
isSelected && styles.choiceLabelSelected,
isDisabled && styles.choiceLabelDisabled,
]}>
{choice.emoji ? `${choice.emoji} ` : ''}{choice.label}
</Text>
<View style={styles.choiceStatusContainer}>
{!!choice.recommended && !isSelected && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}></Text>
</View>
)}
{isSelected && (
<View style={styles.selectedBadge}>
<Text style={styles.selectedText}></Text>
</View>
)}
</View>
</View>
</TouchableOpacity>
);
})}
</View>
)}
{/* 流式回复控制 */}
{isAssistant && isStreaming && !message.content && (
<View style={styles.streamingContainer}>
<Text style={[styles.bubbleText, { color: theme.text }]}>...</Text>
<TouchableOpacity
style={styles.cancelStreamBtn}
onPress={onCancelStream}
>
<Ionicons name="stop" size={12} color="#FF4444" />
<Text style={styles.cancelStreamText}></Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
};
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
marginVertical: 6,
},
userRow: {
justifyContent: 'flex-end',
},
avatar: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
color: '#192126',
fontSize: 12,
fontWeight: '800',
},
bubble: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 16,
},
bubbleText: {
fontSize: 15,
lineHeight: 22,
},
attachmentsContainer: {
marginTop: 8,
gap: 6,
},
imageAttachment: {
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
attachmentImage: {
width: '100%',
minHeight: 120,
maxHeight: 200,
borderRadius: 12,
},
attachmentProgressOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
attachmentProgressText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
attachmentErrorOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
attachmentErrorText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
videoAttachment: {
borderRadius: 12,
overflow: 'hidden',
backgroundColor: 'rgba(0,0,0,0.1)',
},
videoPlaceholder: {
height: 120,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
},
videoFilename: {
color: '#fff',
fontSize: 12,
marginTop: 4,
textAlign: 'center',
},
fileAttachment: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
gap: 8,
},
fileFilename: {
flex: 1,
fontSize: 14,
},
choicesContainer: {
gap: 8,
width: '100%',
marginTop: 8,
},
choiceButton: {
backgroundColor: 'rgba(255,255,255,0.9)',
borderWidth: 1,
borderColor: '#7a5af84d',
borderRadius: 12,
padding: 12,
width: '100%',
minWidth: 0,
},
choiceButtonRecommended: {
borderColor: '#7a5af899',
backgroundColor: '#7a5af81a',
},
choiceButtonSelected: {
borderColor: '#19b36e',
backgroundColor: '#19b36e33',
borderWidth: 2,
},
choiceButtonDisabled: {
backgroundColor: 'rgba(0,0,0,0.05)',
borderColor: 'rgba(0,0,0,0.1)',
opacity: 0.5,
},
choiceContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
choiceLabel: {
fontSize: 15,
fontWeight: '600',
color: '#192126',
flex: 1,
flexWrap: 'wrap',
},
choiceLabelRecommended: {
color: '#19b36e',
},
choiceLabelSelected: {
color: '#19b36e',
fontWeight: '700',
},
choiceLabelDisabled: {
color: '#687076',
},
choiceStatusContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
recommendedBadge: {
backgroundColor: '#7a5af8cc',
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 2,
},
recommendedText: {
fontSize: 12,
fontWeight: '700',
color: '#19b36e',
},
selectedBadge: {
backgroundColor: '#19b36e',
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 2,
},
selectedText: {
fontSize: 12,
fontWeight: '700',
color: '#FFFFFF',
},
streamingContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
cancelStreamBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(255,68,68,0.1)',
borderWidth: 1,
borderColor: 'rgba(255,68,68,0.3)',
},
cancelStreamText: {
fontSize: 12,
fontWeight: '600',
color: '#FF4444',
},
});
export default ChatMessageComponent;