refactor:: 消息体重构

This commit is contained in:
Junhui Chen 2025-08-03 16:27:39 +08:00
parent 1f00a39e80
commit 4a30e7f43c
9 changed files with 380 additions and 298 deletions

View File

@ -1,269 +0,0 @@
import CancelSvg from '@/assets/icons/svg/cancel.svg';
import ChatSvg from "@/assets/icons/svg/chat.svg";
import DownloadSvg from '@/assets/icons/svg/download.svg';
import MoreSvg from "@/assets/icons/svg/more.svg";
import { Message, Video } from "@/types/ask";
import { MaterialItem } from "@/types/personal-info";
import { TFunction } from "i18next";
import React from 'react';
import {
Image,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import ContextMenu from "../gusture/contextMenu";
import { ThemedText } from "../ThemedText";
import SelectModel from "./selectModel";
import SingleContentModel from "./singleContentModel";
import Loading from './threeCircle';
import { mergeArrays, saveMediaToGallery } from "./utils";
interface RenderMessageProps {
insets: { top: number };
item: Message;
sessionId: string;
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
modalVisible: { visible: boolean, data: Video | MaterialItem };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
modalDetailsVisible: { visible: boolean, content: any };
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[];
t: TFunction;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
}
const MessageItem = ({ setCancel, cancel = true, t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => {
const isUser = item.role === 'User';
return (
<View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
{!isUser && <ChatSvg width={36} height={36} />}
<View className="max-w-[90%] mb-[1rem] flex flex-col gap-2 ">
<View style={{ width: "100%", flexDirection: "row", alignItems: "flex-end" }}>
<View
className={`${isUser ? '!bg-bgPrimary ml-10 rounded-full' : '!bg-aiBubble rounded-2xl'} border-0 ${!isUser && (item.content.video_material_infos && item.content.video_material_infos.length > 0 || item.content.image_material_infos && item.content.image_material_infos.length > 0) ? '!rounded-t-3xl !rounded-b-2xl' : '!rounded-3xl'}`}
style={[
styles.messageBubble,
isUser ? styles.userBubble : styles.aiBubble,
{ marginRight: item.content.text == "keepSearchIng" ? 0 : isUser ? 0 : 10 }
]}
>
<View className={`${isUser ? 'bg-bgPrimary' : 'bg-aiBubble'}`}>
<Text style={isUser ? styles.userText : styles.aiText}>
{!isUser
? item.content.text == "keepSearchIng"
? <Loading />
: item.content.text
: item.content.text
}
</Text>
{(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.length || 0 > 0) && (
<View className="relative">
<View style={[styles.imageGridContainer, { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' }]}>
{mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image) => (
<Pressable
key={image?.id || image?.video?.id}
onPress={() => {
setModalVisible({ visible: true, data: image });
}}
style={{
width: '32%',
aspectRatio: 1,
marginBottom: 8,
}}
>
<ContextMenu
items={[
{
svg: <DownloadSvg width={20} height={20} />,
label: t("ask:ask.save"),
onPress: () => {
const imageUrl = image?.preview_file_info?.url || image.video?.preview_file_info?.url;
if (imageUrl) {
saveMediaToGallery(imageUrl, t);
}
},
textStyle: { color: '#4C320C' }
},
{
svg: <CancelSvg width={20} height={20} color='red' />,
label: t("ask:ask.cancel"),
onPress: () => {
setCancel(true);
},
textStyle: { color: 'red' }
}
]}
cancel={cancel}
menuStyle={{
backgroundColor: 'white',
borderRadius: 8,
padding: 8,
minWidth: 150,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
}}
>
<Image
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }}
style={{
width: '100%',
height: '100%',
borderRadius: 12,
}}
resizeMode="cover"
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
</ContextMenu>
</Pressable>
))}
</View>
{
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3
&& <TouchableOpacity className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full" onPress={() => {
setSelectedImages([])
setModalDetailsVisible({ visible: true, content: item.content });
}}>
<ThemedText className="!text-white font-semibold">{((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0))}</ThemedText>
<View className="bg-white rounded-full p-2">
<MoreSvg />
</View>
</TouchableOpacity>
}
</View>
)}
</View>
</View>
{
item.content.text == "keepSearchIng"
&&
<Text style={{ color: "d9d9d9" }}>
{t("ask:ask.think")}
</Text>
}
</View>
{/* {item.askAgain && item.askAgain.length > 0 && (
<View className={`mr-10`}>
{item.askAgain.map((suggestion, index, array) => (
<TouchableOpacity
key={suggestion.id}
className={`bg-yellow-50 rounded-xl px-4 py-2 border border-yellow-200 border-0 mb-2 ${index === array.length - 1 ? 'mb-0 rounded-b-3xl rounded-t-2xl' : 'rounded-2xl'}`}
>
<Text className="text-gray-700">{suggestion.text}</Text>
</TouchableOpacity>
))}
</View>
)} */}
{/* 单个图片弹窗 */}
<SingleContentModel modalVisible={modalVisible} setModalVisible={setModalVisible} />
{/* 全部图片详情弹窗 */}
<SelectModel
modalDetailsVisible={modalDetailsVisible}
setModalDetailsVisible={setModalDetailsVisible}
insets={insets}
setSelectedImages={setSelectedImages}
selectedImages={selectedImages}
t={t}
setCancel={setCancel}
cancel={cancel}
/>
</View>
</View>
);
};
export default MessageItem;
const styles = StyleSheet.create({
imageGridContainer: {
flexDirection: 'row',
flexWrap: 'nowrap',
width: '100%',
marginTop: 8,
},
video: {
width: '100%',
height: '100%',
},
imageContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
width: '100%',
maxHeight: '60%',
},
fullWidthImage: {
width: '100%',
height: "54%",
marginBottom: 8,
},
gridImage: {
aspectRatio: 1,
marginBottom: 8,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
modalView: {
borderRadius: 20,
alignItems: 'center',
height: '100%',
width: "100%",
justifyContent: 'center',
alignSelf: 'center',
},
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
userAvatar: {
width: 30,
height: 30,
borderRadius: 15,
},
messageList: {
padding: 16,
},
messageBubble: {
paddingHorizontal: 16,
paddingVertical: 12,
fontWeight: "600"
},
userBubble: {
alignSelf: 'flex-end',
backgroundColor: '#FFB645',
marginLeft: 10,
},
aiBubble: {
alignSelf: 'flex-start',
backgroundColor: '#fff',
marginRight: 10,
borderWidth: 1,
borderColor: '#e5e5ea',
},
userText: {
color: '#4C320C',
fontSize: 16,
lineHeight: 24,
},
aiText: {
color: '#000',
fontSize: 16,
lineHeight: 24,
},
});

View File

@ -9,7 +9,7 @@ import {
View View
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import MessageItem from './aiChat'; import MessageItem from '../chat/message-item/message-item';
// 继承 FlatListProps 来接收所有 FlatList 的属性 // 继承 FlatListProps 来接收所有 FlatList 的属性
interface ChatProps extends Omit<FlatListProps<Message>, 'data' | 'renderItem'> { interface ChatProps extends Omit<FlatListProps<Message>, 'data' | 'renderItem'> {

View File

@ -1,15 +1,14 @@
import { Video } from "@/types/ask"; import { ContentPart } from "@/types/ask";
import { MaterialItem } from "@/types/personal-info";
import { Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native"; import { Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native";
import VideoPlayer from "./VideoPlayer"; import VideoPlayer from "./VideoPlayer";
interface SingleContentModelProps { interface SingleContentModelProps {
modalVisible: { visible: boolean, data: Video | MaterialItem }; modalVisible: { visible: boolean, data: ContentPart };
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>; setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: ContentPart }>>;
} }
const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentModelProps) => { const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentModelProps) => {
const isVideo = (data: Video | MaterialItem): data is Video => { const isVideo = (data: ContentPart) => {
return 'video' in data; return data.type === 'video';
}; };
return ( return (
@ -18,16 +17,16 @@ const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentMode
transparent={true} transparent={true}
visible={modalVisible.visible} visible={modalVisible.visible}
onRequestClose={() => { onRequestClose={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem }); setModalVisible({ visible: false, data: {} as ContentPart });
}}> }}>
<View style={styles.centeredView}> <View style={styles.centeredView}>
<TouchableOpacity <TouchableOpacity
style={styles.background} style={styles.background}
onPress={() => { onPress={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem }) setModalVisible({ visible: false, data: {} as ContentPart })
}} }}
/> />
<TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}> <TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as ContentPart })}>
{isVideo(modalVisible.data) ? ( {isVideo(modalVisible.data) ? (
// 视频播放器 // 视频播放器
<TouchableOpacity <TouchableOpacity
@ -38,28 +37,28 @@ const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentMode
alignItems: 'center', alignItems: 'center',
maxHeight: "60%", maxHeight: "60%",
}} }}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })} onPress={() => setModalVisible({ visible: false, data: {} as ContentPart })}
> >
<VideoPlayer <VideoPlayer
videoUrl={modalVisible.data.video.file_info.url} videoUrl={modalVisible.data.url || ""}
style={{ style={{
width: '100%', width: '100%',
height: '100%', height: '100%',
alignSelf: 'center', alignSelf: 'center',
justifyContent: 'center', justifyContent: 'center',
}} }}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })} onPress={() => setModalVisible({ visible: false, data: {} as ContentPart })}
/> />
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
// 图片预览 // 图片预览
<TouchableOpacity <TouchableOpacity
activeOpacity={1} activeOpacity={1}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })} onPress={() => setModalVisible({ visible: false, data: {} as ContentPart })}
style={styles.imageContainer} style={styles.imageContainer}
> >
<Image <Image
source={{ uri: modalVisible.data.preview_file_info?.url }} source={{ uri: modalVisible.data.url }}
style={styles.fullWidthImage} style={styles.fullWidthImage}
resizeMode="contain" resizeMode="contain"
/> />

View File

@ -0,0 +1,81 @@
import React from 'react';
import { View, Pressable, Image, StyleSheet } from 'react-native';
import ContextMenu from "../../gusture/contextMenu";
import DownloadSvg from "@/assets/icons/svg/download.svg";
import CancelSvg from "@/assets/icons/svg/cancel.svg";
import { ContentPart } from "@/types/ask";
import { TFunction } from 'i18next';
import { saveMediaToGallery } from "../../ask/utils";
interface MediaGridProps {
mediaItems: ContentPart[];
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: ContentPart }>>;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
t: TFunction;
}
const MediaGrid = ({ mediaItems, setModalVisible, setCancel, cancel, t }: MediaGridProps) => {
// 只取前6个元素2行每行3个
const displayItems = mediaItems.slice(0, 6);
return (
<View className="flex-row flex-wrap justify-between">
{displayItems.map((media) => (
<Pressable
key={media.id}
onPress={() => {
setModalVisible({ visible: true, data: media });
}}
className="mb-2 w-[32%] aspect-square"
>
<ContextMenu
items={[
{
svg: <DownloadSvg width={20} height={20} />,
label: t("ask:ask.save"),
onPress: () => {
if (media?.url) {
saveMediaToGallery(media?.url, t);
}
},
textStyle: { color: '#4C320C' }
},
{
svg: <CancelSvg width={20} height={20} color='red' />,
label: t("ask:ask.cancel"),
onPress: () => {
setCancel(true);
},
textStyle: { color: 'red' }
}
]}
cancel={cancel}
menuStyle={{
backgroundColor: 'white',
borderRadius: 8,
padding: 8,
minWidth: 150,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
}}
>
<Image
source={{ uri: media?.url }}
className="w-full h-full rounded-xl"
resizeMode="cover"
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
</ContextMenu>
</Pressable>
))}
</View>
);
};
export default MediaGrid;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { View } from 'react-native';
import MessageContent from './MessageContent';
import { getMessageText, isMessageContainMedia } from "@/types/ask";
interface MessageBubbleProps {
item: any;
isUser: boolean;
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: any }>>;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
t: any;
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
}
const MessageBubble = ({
item,
isUser,
setModalVisible,
setCancel,
cancel,
t,
setSelectedImages,
setModalDetailsVisible
}: MessageBubbleProps) => {
return (
<View
className={`${isUser ? '!bg-bgPrimary ml-10 rounded-full' : '!bg-aiBubble rounded-2xl'} border-0 ${!isUser && isMessageContainMedia(item) ? '!rounded-t-3xl !rounded-b-2xl' : '!rounded-3xl'} px-4 py-3`}
style={{ marginRight: getMessageText(item) == "keepSearchIng" ? 0 : isUser ? 0 : 10 }}
>
<MessageContent
item={item}
isUser={isUser}
setModalVisible={setModalVisible}
setCancel={setCancel}
cancel={cancel}
t={t}
setSelectedImages={setSelectedImages}
setModalDetailsVisible={setModalDetailsVisible}
/>
</View>
);
};
export default MessageBubble;

View File

@ -0,0 +1,77 @@
import MoreSvg from "@/assets/icons/svg/more.svg";
import { ContentPart, getMessageText, isMessageContainMedia } from "@/types/ask";
import { TFunction } from 'i18next';
import React from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import Loading from '../../ask/threeCircle';
import { ThemedText } from "../../ThemedText";
import MediaGrid from './MediaGrid';
interface MessageContentProps {
item: any;
isUser: boolean;
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: ContentPart }>>;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
t: TFunction;
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
}
const MessageContent = ({
item,
isUser,
setModalVisible,
setCancel,
cancel,
t,
setSelectedImages,
setModalDetailsVisible
}: MessageContentProps) => {
return (
<View className={`${isUser ? 'bg-bgPrimary' : 'bg-aiBubble'}`}>
<Text className={`text-base ${isUser ? 'text-[#4C320C]' : 'text-black'} leading-6 font-semibold`}>
{!isUser
? getMessageText(item) == "keepSearchIng"
? <Loading />
: getMessageText(item)
: getMessageText(item)
}
</Text>
{isMessageContainMedia(item) && (
<View className="relative">
{item.content instanceof Array && (() => {
const mediaItems = item.content.filter((media: ContentPart) => media.type !== 'text');
return (
<View className="mt-2">
<MediaGrid
mediaItems={mediaItems}
setModalVisible={setModalVisible}
setCancel={setCancel}
cancel={cancel}
t={t}
/>
</View>
);
})()}
{
(item.content instanceof Array && item.content.length > 3)
&& <TouchableOpacity className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full" onPress={() => {
setSelectedImages([])
setModalDetailsVisible({ visible: true, content: item.content });
}}>
<ThemedText className="!text-white font-semibold">{item.content.length}</ThemedText>
<View className="bg-white rounded-full p-2">
<MoreSvg />
</View>
</TouchableOpacity>
}
</View>
)}
</View>
);
};
export default MessageContent;

View File

@ -0,0 +1,50 @@
import React from 'react';
import { View, Text } from 'react-native';
import MessageBubble from './MessageBubble';
import { getMessageText } from "@/types/ask";
interface MessageRowProps {
item: any;
isUser: boolean;
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: any }>>;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
t: any;
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
}
const MessageRow = ({
item,
isUser,
setModalVisible,
setCancel,
cancel,
t,
setSelectedImages,
setModalDetailsVisible
}: MessageRowProps) => {
return (
<View className="w-full flex-row items-end">
<MessageBubble
item={item}
isUser={isUser}
setModalVisible={setModalVisible}
setCancel={setCancel}
cancel={cancel}
t={t}
setSelectedImages={setSelectedImages}
setModalDetailsVisible={setModalDetailsVisible}
/>
{
getMessageText(item) == "keepSearchIng"
&&
<Text className="text-[#d9d9d9]">
{t("ask:ask.think")}
</Text>
}
</View>
);
};
export default MessageRow;

View File

@ -0,0 +1,76 @@
import ChatSvg from "@/assets/icons/svg/chat.svg";
import { ContentPart, getMessageText, isMessageContainMedia, Message, User } from "@/types/ask";
import { TFunction } from "i18next";
import React from 'react';
import {
StyleSheet,
View
} from 'react-native';
import SelectModel from "../../ask/selectModel";
import SingleContentModel from "../../ask/singleContentModel";
import MessageRow from './MessageRow';
interface RenderMessageProps {
insets: { top: number };
item: Message;
sessionId: string;
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: ContentPart }>>;
modalVisible: { visible: boolean, data: ContentPart };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
modalDetailsVisible: { visible: boolean, content: any };
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[];
t: TFunction;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
}
const MessageItem = ({ setCancel, cancel = true, t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => {
const isUser = item.role === User;
return (
<View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
{!isUser && <ChatSvg width={36} height={36} />}
<View className="max-w-[90%] mb-[1rem] flex flex-col gap-2 ">
<MessageRow
item={item}
isUser={isUser}
setModalVisible={setModalVisible}
setCancel={setCancel}
cancel={cancel}
t={t}
setSelectedImages={setSelectedImages}
setModalDetailsVisible={setModalDetailsVisible}
/>
{/* {item.askAgain && item.askAgain.length > 0 && (
<View className={`mr-10`}>
{item.askAgain.map((suggestion, index, array) => (
<TouchableOpacity
key={suggestion.id}
className={`bg-yellow-50 rounded-xl px-4 py-2 border border-yellow-200 border-0 mb-2 ${index === array.length - 1 ? 'mb-0 rounded-b-3xl rounded-t-2xl' : 'rounded-2xl'}`}
>
<Text className="text-gray-700">{suggestion.text}</Text>
</TouchableOpacity>
))}
</View>
)} */}
{/* 单个图片弹窗 */}
<SingleContentModel modalVisible={modalVisible} setModalVisible={setModalVisible} />
{/* 全部图片详情弹窗 */}
<SelectModel
modalDetailsVisible={modalDetailsVisible}
setModalDetailsVisible={setModalDetailsVisible}
insets={insets}
setSelectedImages={setSelectedImages}
selectedImages={selectedImages}
t={t}
setCancel={setCancel}
cancel={cancel}
/>
</View>
</View>
);
};
export default MessageItem;

View File

@ -1,4 +1,3 @@
import { MaterialItem } from "./personal-info";
interface FileInfo { interface FileInfo {
id: string; id: string;
@ -34,22 +33,45 @@ export interface Video {
video: VideoInfo; video: VideoInfo;
video_clips: VideoClip[]; video_clips: VideoClip[];
} }
export interface Content { export interface ContentPart {
text: string; type: string;
image_material_infos?: MaterialItem[]; text?: string;
video_material_infos?: Video[]; caption?: string;
} url?: string;
export interface Message { id?: string;
content: Content;
role: 'User' | 'Assistant'; // 使用联合类型限制 role 的值
timestamp: string;
askAgain?: Array<{
id: string;
text: string;
}>;
} }
export interface Message {
id: string;
content: string | ContentPart[];
role: typeof User | typeof Assistant;
timestamp: string;
// askAgain?: Array<{
// id: string;
// text: string;
// }>;
}
export function getMessageText(message: Message) {
if (typeof message.content === 'string') {
return message.content;
} else {
return message.content.map((item) => item.text || '').join('');
}
}
export function isMessageContainMedia(message: Message) {
if (typeof message.content === 'string') {
return false;
} else {
return message.content.some((item) => item.type === 'image' || item.type === 'video');
}
}
export const User = 'user';
export const Assistant = 'assistant';
export interface Chat { export interface Chat {
created_at: string; created_at: string;
session_id: string; session_id: string;