feat: ask下载交互

This commit is contained in:
jinyaqiu 2025-07-30 11:30:34 +08:00
parent 479eecdc95
commit 438876d7a6
18 changed files with 1112 additions and 421 deletions

View File

@ -12,7 +12,7 @@
"supportsTablet": true, "supportsTablet": true,
"infoPlist": { "infoPlist": {
"NSPhotoLibraryUsageDescription": "允许访问照片库以便模型使用您照片库中的素材进行视频创作”例如上传您参加音乐节的现场图生成一个音乐节体验Vlog", "NSPhotoLibraryUsageDescription": "允许访问照片库以便模型使用您照片库中的素材进行视频创作”例如上传您参加音乐节的现场图生成一个音乐节体验Vlog",
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册", "NSPhotoLibraryAddUsageDescription": "App需要访问相册来保存图片",
"NSLocationWhenInUseUsageDescription": "允许获取位置信息以便模型使用您的位置信息进行个性化创作”例如上传您去欧洲旅游的位置信息结合在当地拍摄的照片生成一个欧洲旅行攻略Vlog", "NSLocationWhenInUseUsageDescription": "允许获取位置信息以便模型使用您的位置信息进行个性化创作”例如上传您去欧洲旅游的位置信息结合在当地拍摄的照片生成一个欧洲旅行攻略Vlog",
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
"UIBackgroundModes": [ "UIBackgroundModes": [

View File

@ -5,8 +5,8 @@ import SendMessage from "@/components/ask/send";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { fetchApi } from "@/lib/server-api-util"; import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask"; import { Message } from "@/types/ask";
import { router, useLocalSearchParams } from "expo-router"; import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import { default as React, default as React, useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Animated, Animated,
FlatList, FlatList,
@ -116,7 +116,6 @@ export default function AskScreen() {
Animated.timing(fadeAnimChat, { Animated.timing(fadeAnimChat, {
toValue: 1, toValue: 1,
duration: 300, duration: 300,
duration: 300,
useNativeDriver: true, useNativeDriver: true,
}) })
]).start(() => { ]).start(() => {
@ -131,31 +130,20 @@ export default function AskScreen() {
useEffect(() => { useEffect(() => {
if (!isHello) { if (!isHello) {
const timer = setTimeout(() => { // 不再自动关闭键盘,让用户手动控制
scrollToEnd(false); // 这里可以添加其他需要在隐藏hello界面时执行的逻辑
}, 300); scrollToEnd(false);
return () => clearTimeout(timer);
} }
}, [isHello]); }, [isHello]);
useEffect(() => { useFocusEffect(
const timer = setTimeout(() => { useCallback(() => {
if (!isHello) { if (!sessionId) {
try { setIsHello(true);
if (TextInput.State?.currentlyFocusedInput) { setUserMessages([])
const input = TextInput.State.currentlyFocusedInput();
if (input) input.blur();
}
} catch (error) {
console.log('失去焦点失败:', error);
}
scrollToEnd(false);
} }
}, 200); }, [sessionId])
);
return () => clearTimeout(timer);
}, [isHello]);
return ( return (
<View style={[styles.container, { paddingTop: insets.top }]}> <View style={[styles.container, { paddingTop: insets.top }]}>
@ -194,7 +182,7 @@ export default function AskScreen() {
} }
]} ]}
> >
<AskHello /> <AskHello setUserMessages={setUserMessages} setConversationId={setConversationId} setIsHello={setIsHello} />
</Animated.View> </Animated.View>
{/* 聊天页面 */} {/* 聊天页面 */}
@ -299,7 +287,7 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
paddingBottom: 24, paddingBottom: 24,
backgroundColor: 'white', backgroundColor: 'white',
borderTopWidth: 1, // borderTopWidth: 1,
borderTopColor: '#f0f0f0', // borderTopColor: '#f0f0f0',
}, },
}); });

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-to-line-icon lucide-arrow-down-to-line"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></svg>

After

Width:  |  Height:  |  Size: 324 B

3
assets/icons/svg/sun.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 3V1.5M9 15V16.5M4.81066 4.81066L3.75 3.75M13.296 13.296L14.3567 14.3567M3 9H1.5M15 9H16.5M13.2964 4.81066L14.357 3.75M4.81103 13.296L3.75037 14.3567M9 12.75C6.92893 12.75 5.25 11.0711 5.25 9C5.25 6.92893 6.92893 5.25 9 5.25C11.0711 5.25 12.75 6.92893 12.75 9C12.75 11.0711 11.0711 12.75 9 12.75Z" stroke="#FFB645" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

View File

@ -0,0 +1,3 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 0.609375C3.35685 0.609375 0 3.96623 0 8.10938C0 12.2525 3.35685 15.6094 7.5 15.6094C11.6431 15.6094 15 12.2525 15 8.10938C15 3.96623 11.6431 0.609375 7.5 0.609375ZM9.91935 5.69002C10.4546 5.69002 10.8871 6.12248 10.8871 6.65776C10.8871 7.19304 10.4546 7.6255 9.91935 7.6255C9.38407 7.6255 8.95161 7.19304 8.95161 6.65776C8.95161 6.12248 9.38407 5.69002 9.91935 5.69002ZM5.08065 5.69002C5.61593 5.69002 6.04839 6.12248 6.04839 6.65776C6.04839 7.19304 5.61593 7.6255 5.08065 7.6255C4.54536 7.6255 4.1129 7.19304 4.1129 6.65776C4.1129 6.12248 4.54536 5.69002 5.08065 5.69002ZM10.9718 10.8372C10.1099 11.8715 8.84577 12.4642 7.5 12.4642C6.15423 12.4642 4.89012 11.8715 4.02823 10.8372C3.61694 10.3443 4.36089 9.72732 4.77218 10.2172C5.4496 11.0307 6.44153 11.4934 7.5 11.4934C8.55847 11.4934 9.5504 11.0277 10.2278 10.2172C10.6331 9.72732 11.38 10.3443 10.9718 10.8372Z" fill="#E2793F"/>
</svg>

After

Width:  |  Height:  |  Size: 1001 B

View File

@ -1,26 +1,25 @@
import CancelSvg from '@/assets/icons/svg/cancel.svg';
import ChatSvg from "@/assets/icons/svg/chat.svg"; import ChatSvg from "@/assets/icons/svg/chat.svg";
import FolderSvg from "@/assets/icons/svg/folder.svg"; import DownloadSvg from '@/assets/icons/svg/download.svg';
import MoreSvg from "@/assets/icons/svg/more.svg"; import MoreSvg from "@/assets/icons/svg/more.svg";
import ReturnArrow from "@/assets/icons/svg/returnArrow.svg";
import YesSvg from "@/assets/icons/svg/yes.svg";
import { Message, Video } from "@/types/ask"; import { Message, Video } from "@/types/ask";
import { MaterialItem } from "@/types/personal-info"; import { MaterialItem } from "@/types/personal-info";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import React from 'react'; import React from 'react';
import { import {
FlatList,
Image, Image,
Modal,
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import ContextMenu from "../gusture/contextMenu";
import { ThemedText } from "../ThemedText"; import { ThemedText } from "../ThemedText";
import SelectModel from "./selectModel";
import SingleContentModel from "./singleContentModel";
import TypewriterText from "./typewriterText"; import TypewriterText from "./typewriterText";
import { mergeArrays } from "./utils"; import { mergeArrays, saveMediaToGallery } from "./utils";
import VideoPlayer from "./VideoPlayer";
interface RenderMessageProps { interface RenderMessageProps {
insets: { top: number }; insets: { top: number };
@ -28,18 +27,17 @@ interface RenderMessageProps {
sessionId: string; sessionId: string;
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>; setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
modalVisible: { visible: boolean, data: Video | MaterialItem }; modalVisible: { visible: boolean, data: Video | MaterialItem };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<boolean>>; setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
modalDetailsVisible: boolean; modalDetailsVisible: { visible: boolean, content: any };
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>; setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[]; selectedImages: string[];
t: TFunction; t: TFunction;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
} }
const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => { const MessageItem = ({ setCancel, cancel = true, t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => {
const isUser = item.role === 'User'; const isUser = item.role === 'User';
const isVideo = (data: Video | MaterialItem): data is Video => {
return 'video' in data;
};
return ( return (
<View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}> <View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
@ -76,23 +74,58 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible
marginBottom: 8, marginBottom: 8,
}} }}
> >
<Image <ContextMenu
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }} items={[
style={{ {
width: '100%', svg: <DownloadSvg width={20} height={20} />,
height: '100%', label: "保存",
borderRadius: 12, 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: "取消",
onPress: () => console.log('取消'),
textStyle: { color: 'red' }
}
]}
menuStyle={{
backgroundColor: 'white',
borderRadius: 8,
padding: 8,
minWidth: 150,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
}} }}
resizeMode="cover" >
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')} <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> </Pressable>
))} ))}
</View> </View>
{ {
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3 ((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={() => { && <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={() => {
setModalDetailsVisible(true); 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> <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"> <View className="bg-white rounded-full p-2">
@ -116,152 +149,19 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible
))} ))}
</View> </View>
)} */} )} */}
<Modal {/* 单个图片弹窗 */}
animationType="fade" <SingleContentModel modalVisible={modalVisible} setModalVisible={setModalVisible} />
transparent={true} {/* 全部图片详情弹窗 */}
visible={modalVisible.visible} <SelectModel
onRequestClose={() => { modalDetailsVisible={modalDetailsVisible}
setModalVisible({ visible: false, data: {} as Video | MaterialItem }); setModalDetailsVisible={setModalDetailsVisible}
}}> insets={insets}
<View style={styles.centeredView}> setSelectedImages={setSelectedImages}
<TouchableOpacity selectedImages={selectedImages}
style={styles.background} t={t}
onPress={() => { setCancel={setCancel}
setModalVisible({ visible: false, data: {} as Video | MaterialItem }) cancel={cancel}
}} />
/>
<TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}>
{isVideo(modalVisible.data) ? (
// 视频播放器
<TouchableOpacity
activeOpacity={1}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: "60%",
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
>
<VideoPlayer
videoUrl={modalVisible.data.video.file_info.url}
style={{
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center',
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
/>
</TouchableOpacity>
) : (
// 图片预览
<TouchableOpacity
activeOpacity={1}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
style={styles.imageContainer}
>
<Image
source={{ uri: modalVisible.data.preview_file_info?.url }}
style={styles.fullWidthImage}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
</Modal>
<Modal
animationType="fade"
visible={modalDetailsVisible}
transparent={false}
statusBarTranslucent={true}
onRequestClose={() => {
setModalDetailsVisible(false);
}}
>
<View style={[detailsStyles.container, { paddingTop: insets?.top }]}>
<View style={detailsStyles.header}>
<TouchableOpacity onPress={() => setModalDetailsVisible(false)}>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
<FolderSvg />
</View>
<View style={{ overflow: 'scroll', height: "100%" }}>
<FlatList
data={mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])}
numColumns={3}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={detailsStyles.flatListContent}
initialNumToRender={12}
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={50}
windowSize={10}
removeClippedSubviews={true}
renderItem={({ item }) => {
return (
<TouchableOpacity
style={detailsStyles.gridItemContainer}
key={item.id}
>
<View style={detailsStyles.gridItem}>
<ThemedText style={detailsStyles.imageNumber}>
{selectedImages?.map((image, index) => {
if (image === item.id || image === item.video?.id) {
return index + 1
}
})}
</ThemedText>
<Image
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
style={detailsStyles.image}
onError={(error) => console.log('Image load error:', error.nativeEvent.error)}
onLoad={() => console.log('Image loaded successfully')}
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
<TouchableOpacity
style={[detailsStyles.circleMarker, selectedImages.includes(item?.id || item?.video?.id) ? detailsStyles.circleMarkerSelected : ""]}
onPress={() => {
setSelectedImages((prev) => {
if (prev.includes(item?.id || item?.video?.id)) {
return prev.filter((id) => id !== (item.id || item?.video?.id));
} else {
return [...prev, item.id || item.video?.id];
}
});
}}
>
{selectedImages.includes(item?.id || item?.video?.id) ? <YesSvg width={16} height={16} /> : ""}
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}}
/>
</View>
<View style={detailsStyles.footer}>
<TouchableOpacity
style={detailsStyles.continueButton}
onPress={async () => {
// 如果用户没有选择 则为选择全部
if (selectedImages?.length < 0) {
setSelectedImages(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.map((item) => {
return item.id || item.video?.id
}))
}
setModalDetailsVisible(false)
}}
activeOpacity={0.8}
>
<Text style={detailsStyles.continueButtonText}>
{t('ask.continueAsking', { ns: 'ask' })}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View> </View>
</View> </View>
); );
@ -352,123 +252,3 @@ const styles = StyleSheet.create({
}, },
}); });
const detailsStyles = StyleSheet.create({
gridItemContainer: {
flex: 1, // 使用 flex 布局使项目平均分配空间
maxWidth: '33.33%', // 每行最多4个项目
aspectRatio: 1, // 保持1:1的宽高比
},
flatListContent: {
paddingBottom: 100, // 为底部按钮留出更多空间
paddingHorizontal: 8, // 添加水平内边距
paddingTop: 8,
},
headerText: {
fontSize: 20,
fontWeight: 'bold',
color: "#4C320C"
},
container: {
flex: 1,
padding: 0,
margin: 0,
backgroundColor: '#fff',
width: '100%',
height: '100%',
position: 'relative',
},
imageNumber: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
position: 'absolute',
top: 10,
left: 10,
zIndex: 10, // 确保数字显示在图片上方
},
imageNumberText: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
},
numberText: {
position: 'absolute',
top: 10,
left: 10,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(0, 122, 255, 0.9)', // 使用半透明蓝色背景
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
gridItem: {
flex: 1, // 填充父容器
overflow: 'hidden',
backgroundColor: '#f5f5f5',
borderWidth: 1,
borderColor: '#eee',
height: '100%', // 确保高度填满容器
position: 'relative',
},
image: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
circleMarker: {
position: 'absolute',
top: 10,
right: 10,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: '#fff',
},
circleMarkerSelected: {
backgroundColor: '#FFB645',
},
markerText: {
fontSize: 16,
fontWeight: 'bold',
color: '#000',
},
footer: {
position: 'absolute',
bottom: 20,
left: 0,
right: 0,
paddingHorizontal: 16,
zIndex: 10,
paddingVertical: 10,
},
continueButton: {
backgroundColor: '#E2793F',
borderRadius: 32,
padding: 16,
alignItems: 'center',
width: '100%',
zIndex: 10,
},
continueButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
}
});

View File

@ -5,7 +5,8 @@ import { useTranslation } from 'react-i18next';
import { import {
FlatList, FlatList,
FlatListProps, FlatListProps,
SafeAreaView SafeAreaView,
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 './aiChat';
@ -26,10 +27,15 @@ function ChatComponent(
const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem }); const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem });
const { t } = useTranslation(); const { t } = useTranslation();
const keyExtractor = useCallback((item: Message) => `${item.role}-${item.timestamp}`, []); const keyExtractor = useCallback((item: Message) => `${item.role}-${item.timestamp}`, []);
// 取消展示右键菜单
const [cancel, setCancel] = useState(true);
const contentContainerStyle = useMemo(() => ({
padding: 16,
flexGrow: 1,
paddingTop: 0,
}), []);
const contentContainerStyle = useMemo(() => ({ padding: 16, flexGrow: 1 }), []); const [modalDetailsVisible, setModalDetailsVisible] = useState<{ visible: boolean, content: any }>({ visible: false, content: [] });
const [modalDetailsVisible, setModalDetailsVisible] = useState<boolean>(false);
return ( return (
<SafeAreaView style={{ flex: 1 }}> <SafeAreaView style={{ flex: 1 }}>
@ -37,7 +43,14 @@ function ChatComponent(
ref={ref} ref={ref}
data={userMessages} data={userMessages}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={({ item }) => MessageItem({ t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })} renderItem={({ item, index }) => {
const itemStyle = index === 0 ? { marginTop: 16, marginHorizontal: 16 } : { marginHorizontal: 16 };
return (
<View style={itemStyle}>
{MessageItem({ setCancel, cancel, t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
</View>
);
}}
contentContainerStyle={contentContainerStyle} contentContainerStyle={contentContainerStyle}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"

View File

@ -1,17 +1,55 @@
import IP from "@/assets/icons/svg/ip.svg"; import IP from "@/assets/icons/svg/ip.svg";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { Message } from "@/types/ask";
import { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from 'react-native'; import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { createNewConversation, getConversation } from "./utils";
export default function AskHello() { interface AskHelloProps {
setUserMessages: Dispatch<SetStateAction<Message[]>>;
setConversationId: Dispatch<SetStateAction<string | null>>;
setIsHello: Dispatch<SetStateAction<boolean>>;
}
export default function AskHello({ setUserMessages, setConversationId, setIsHello }: AskHelloProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleCase = async (text: string) => {
setIsHello(false)
setUserMessages([
{
content: {
text: text
},
role: 'User',
timestamp: new Date().toISOString()
},
{
content: {
text: "正在寻找,请稍等..."
},
role: 'Assistant',
timestamp: new Date().toISOString()
}
]);
const data = await createNewConversation(text);
setConversationId(data);
const response = await getConversation({ session_id: data, user_text: text, material_ids: [] });
setUserMessages((prev: Message[]) => {
const newMessages = [...(prev || [])];
if (response) {
newMessages.push(response);
}
return newMessages.filter((item: Message) =>
item?.content?.text !== '正在寻找,请稍等...'
);
});
}
return ( return (
<View className="flex-1 bg-white w-full"> <View className="flex-1 bg-white w-full">
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
flexGrow: 1, flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 20 paddingBottom: 20
}} }}
@ -24,16 +62,58 @@ export default function AskHello() {
{"\n"} {"\n"}
{t('ask.iAmMemo', { ns: 'ask' })} {t('ask.iAmMemo', { ns: 'ask' })}
</ThemedText> </ThemedText>
<View className="justify-center items-center my-4"> <View className="-mt-10">
<IP /> <IP />
</View> </View>
<ThemedText className="!text-textPrimary text-center"> <ThemedText className="!text-textPrimary text-center -mt-20">
{t('ask.ready', { ns: 'ask' })} {t('ask.ready', { ns: 'ask' })}
{"\n"} {"\n"}
{t('ask.justAsk', { ns: 'ask' })} {t('ask.justAsk', { ns: 'ask' })}
</ThemedText> </ThemedText>
<View style={styles.caseContainer}>
<TouchableOpacity onPress={() => {
handleCase(t('ask:ask.case1'));
}}>
<ThemedText style={styles.case}>
{t('ask:ask.case1')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
handleCase(t('ask:ask.case2'));
}}>
<ThemedText style={styles.case}>
{t('ask:ask.case2')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
handleCase(t('ask:ask.case3'));
}}>
<ThemedText style={styles.case}>
{t('ask:ask.case3')}
</ThemedText>
</TouchableOpacity>
</View>
</View> </View>
</ScrollView> </ScrollView>
</View> </View>
); );
} }
const styles = StyleSheet.create({
caseContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 8,
width: '100%',
marginTop: 16
},
case: {
borderWidth: 2,
borderColor: "#FFB645",
borderRadius: 24,
paddingHorizontal: 8,
width: 'auto'
}
})

View File

@ -0,0 +1,288 @@
import CancelSvg from '@/assets/icons/svg/cancel.svg';
import DownloadSvg from '@/assets/icons/svg/download.svg';
import FolderSvg from "@/assets/icons/svg/folder.svg";
import ReturnArrow from "@/assets/icons/svg/returnArrow.svg";
import YesSvg from "@/assets/icons/svg/yes.svg";
import { TFunction } from "i18next";
import React from "react";
import { FlatList, Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native";
import ContextMenu from "../gusture/contextMenu";
import { ThemedText } from "../ThemedText";
import { mergeArrays, saveMediaToGallery } from "./utils";
interface SelectModelProps {
modalDetailsVisible: { visible: boolean, content: any };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
insets: { top: number };
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[];
t: TFunction;
cancel: boolean;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
}
const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t, cancel, setCancel }: SelectModelProps) => {
return (
<Modal
animationType="fade"
visible={modalDetailsVisible.visible}
transparent={false}
statusBarTranslucent={true}
onRequestClose={() => {
setModalDetailsVisible({ visible: false, content: [] });
}}
>
<View style={[detailsStyles.container, { paddingTop: insets?.top }]}>
<View style={detailsStyles.header}>
<TouchableOpacity onPress={() => setModalDetailsVisible({ visible: false, content: [] })}>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
<FolderSvg />
</View>
<View style={{ flex: 1 }}>
<FlatList
data={mergeArrays(modalDetailsVisible?.content?.image_material_infos || [], modalDetailsVisible?.content?.video_material_infos || [])}
numColumns={3}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={detailsStyles.gridContainer}
initialNumToRender={12}
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={50}
windowSize={10}
removeClippedSubviews={true}
renderItem={({ item }) => {
const itemId = item?.id || item?.video?.id;
const isSelected = selectedImages.includes(itemId);
const toggleSelection = () => {
setSelectedImages(prev =>
isSelected
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
};
return (
<View style={detailsStyles.gridItemContainer} key={itemId}>
<View style={detailsStyles.gridItem}>
{isSelected && (
<ThemedText style={detailsStyles.imageNumber}>
{selectedImages.indexOf(itemId) + 1}
</ThemedText>
)}
<ContextMenu
items={[
{
svg: <DownloadSvg width={20} height={20} />,
label: "保存",
onPress: () => {
const imageUrl = item?.file_info?.url || item.video?.file_info?.url;
if (imageUrl) {
saveMediaToGallery(imageUrl, t);
}
},
textStyle: { color: '#4C320C' }
},
{
svg: <CancelSvg width={20} height={20} color='red' />,
label: "取消",
onPress: () => console.log('取消'),
textStyle: { color: 'red' }
}
]}
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: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
style={detailsStyles.image}
onError={(error) => console.log('Image load error:', error.nativeEvent.error)}
onLoad={() => console.log('Image loaded successfully')}
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
</ContextMenu>
<TouchableOpacity
style={[
detailsStyles.circleMarker,
isSelected && detailsStyles.circleMarkerSelected
]}
onPress={() => {
setSelectedImages(prev =>
isSelected
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
}}
activeOpacity={0.8}
>
{isSelected && <YesSvg width={16} height={16} />}
</TouchableOpacity>
</View>
</View>
);
}}
/>
</View>
<View style={detailsStyles.footer}>
<TouchableOpacity
style={detailsStyles.continueButton}
onPress={async () => {
// 如果用户没有选择 则为选择全部
if (selectedImages?.length < 0) {
setSelectedImages(mergeArrays(modalDetailsVisible?.content?.image_material_infos || [], modalDetailsVisible?.content?.video_material_infos || [])?.map((item) => {
return item.id || item.video?.id
}))
}
setModalDetailsVisible({ visible: false, content: [] })
}}
activeOpacity={0.8}
>
<ThemedText style={detailsStyles.continueButtonText}>
{t('ask.continueAsking', { ns: 'ask' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</Modal >
)
}
const detailsStyles = StyleSheet.create({
gridContainer: {
flex: 1,
paddingHorizontal: 8,
paddingTop: 8,
},
gridItemContainer: {
width: '33.33%',
aspectRatio: 1,
padding: 1,
},
flatListContent: {
paddingBottom: 100,
paddingHorizontal: 8,
paddingTop: 8,
},
headerText: {
fontSize: 20,
fontWeight: 'bold',
color: "#4C320C"
},
container: {
flex: 1,
padding: 0,
margin: 0,
backgroundColor: '#fff',
width: '100%',
height: '100%',
position: 'relative',
},
imageNumber: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
position: 'absolute',
top: 10,
left: 10,
zIndex: 10,
},
imageNumberText: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
},
numberText: {
position: 'absolute',
top: 10,
left: 10,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(0, 122, 255, 0.9)',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
gridItem: {
flex: 1,
backgroundColor: '#f5f5f5',
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
},
image: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
circleMarker: {
position: 'absolute',
top: 10,
right: 10,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#fff',
},
circleMarkerSelected: {
backgroundColor: '#FFB645',
},
markerText: {
fontSize: 16,
fontWeight: 'bold',
color: '#000',
},
footer: {
position: 'absolute',
bottom: 20,
left: 0,
right: 0,
paddingHorizontal: 16,
zIndex: 10,
paddingVertical: 10,
},
continueButton: {
backgroundColor: '#E2793F',
borderRadius: 32,
padding: 16,
alignItems: 'center',
width: '100%',
zIndex: 10,
},
continueButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
}
});
export default SelectModel

View File

@ -1,16 +1,21 @@
'use client'; 'use client';
import SendSvg from '@/assets/icons/svg/send.svg'; import SendSvg from '@/assets/icons/svg/send.svg';
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; import SunSvg from '@/assets/icons/svg/sun.svg';
import VideoSvg from '@/assets/icons/svg/video.svg';
import React, { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
import { import {
EventSubscription,
Keyboard, Keyboard,
ScrollView,
StyleSheet, StyleSheet,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import { fetchApi } from '@/lib/server-api-util';
import { Message } from '@/types/ask'; import { Message } from '@/types/ask';
import { ThemedText } from '../ThemedText';
import { createNewConversation, getConversation } from './utils';
interface Props { interface Props {
setIsHello: Dispatch<SetStateAction<boolean>>, setIsHello: Dispatch<SetStateAction<boolean>>,
@ -26,35 +31,48 @@ export default function SendMessage(props: Props) {
// 用户询问 // 用户询问
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
// 创建新对话并获取消息
const createNewConversation = useCallback(async (user_text: string) => {
const data = await fetchApi<string>("/chat/new", {
method: "POST",
});
setConversationId(data);
await getConversation({ session_id: data, user_text, material_ids: [] });
}, []);
// 获取对话信息
const getConversation = useCallback(async ({ session_id, user_text, material_ids }: { session_id: string, user_text: string, material_ids: string[] }) => {
// 获取对话信息必须要有对话id
if (!session_id) return;
const response = await fetchApi<Message>(`/chat`, { // 添加一个ref来跟踪键盘状态
method: "POST", const keyboardDidShowListener = useRef<EventSubscription | null>(null);
body: JSON.stringify({ const keyboardDidHideListener = useRef<EventSubscription | null>(null);
session_id, const isKeyboardVisible = useRef(false);
user_text,
material_ids useEffect(() => {
}) // 使用keyboardWillShow而不是keyboardDidShow这样可以在键盘完全显示前更新UI
const showSubscription = Keyboard.addListener('keyboardWillShow', () => {
isKeyboardVisible.current = true;
if (!conversationId) {
// 确保在下一个事件循环中更新状态,避免可能的渲染问题
requestAnimationFrame(() => {
setIsHello(false);
setUserMessages([
{
content: {
text: '想打开记忆盲盒吗?描述你的回忆,我来帮你找回照片、生成影片或解锁隐藏彩蛋✨'
},
role: 'Assistant',
timestamp: new Date().toISOString()
}
])
});
}
}); });
setSelectedImages([]);
setUserMessages((prev: Message[]) => [...prev, response]?.filter((item: Message) => item.content.text !== '正在寻找,请稍等...')); const hideSubscription = Keyboard.addListener('keyboardWillHide', () => {
}, []); isKeyboardVisible.current = false;
});
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, [conversationId]);
// 发送询问 // 发送询问
const handleSubmit = () => { const handleSubmit = useCallback(async () => {
const text = inputValue; const text = inputValue.trim();
// 用户输入信息之后进行后续操作 // 用户输入信息之后进行后续操作
if (text) { if (text) {
// 将用户输入信息添加到消息列表中 // 将用户输入信息添加到消息列表中
@ -75,48 +93,73 @@ export default function SendMessage(props: Props) {
])); ]));
// 如果没有对话ID创建新对话并获取消息否则直接获取消息 // 如果没有对话ID创建新对话并获取消息否则直接获取消息
if (!conversationId) { if (!conversationId) {
createNewConversation(text); const data = await createNewConversation(text);
setConversationId(data);
const response = await getConversation({ session_id: data, user_text: text, material_ids: [] });
setSelectedImages([]);
setUserMessages((prev: Message[]) => {
const newMessages = [...(prev || [])];
if (response) {
newMessages.push(response);
}
return newMessages.filter((item: Message) =>
item?.content?.text !== '正在寻找,请稍等...'
);
});
} else { } else {
getConversation({ const response = await getConversation({
session_id: conversationId, session_id: conversationId,
user_text: text, user_text: text,
material_ids: selectedImages material_ids: selectedImages
}); });
setSelectedImages([]);
setUserMessages((prev: Message[]) => {
const newMessages = [...(prev || [])];
if (response) {
newMessages.push(response);
}
return newMessages.filter((item: Message) =>
item?.content?.text !== '正在寻找,请稍等...'
);
});
} }
// 将输入框清空 // 将输入框清空
setInputValue(''); setInputValue('');
// 关闭键盘 // 只有在键盘可见时才关闭键盘
Keyboard.dismiss(); if (isKeyboardVisible.current) {
} Keyboard.dismiss();
}
useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener(
'keyboardWillShow',
() => {
if (!conversationId) {
console.log('Keyboard will show');
setIsHello(false);
setUserMessages([{
content: {
text: "快来寻找你的记忆吧。。。"
},
role: 'Assistant',
timestamp: new Date().toISOString()
}])
} else {
}
} }
); }
}, [inputValue, conversationId, selectedImages, createNewConversation, getConversation]);
return () => { const handleQuitly = (type: string) => {
keyboardWillShowListener.remove(); setIsHello(false)
}; setUserMessages(pre => ([
}, [conversationId]); ...pre,
{
content: {
text: type === "search"
? '想找合适的图片?试试这样搜更精准:\n\n• 明确主题:比如"秋日森林""极简风书桌""复古海报设计"\n\n• 加上细节:想找特定风格?试试"水彩风猫咪""赛博朋克城市夜景";需要特定用途?比如"无版权风景图""可商用图标"\n\n• 描述场景:比如"阳光透过树叶的光斑""雨天咖啡馆窗外"\n\n输入这些关键词说不定就能找到你想要的画面啦'
: '想让你的视频内容更吸睛、更有故事感吗?不妨试试从搜索图片入手吧!\n\n你可以先确定视频的主题——是治愈系的自然风景还是复古风的城市街景或是充满活力的生活瞬间然后根据主题去搜索相关的图片比如想做"春日限定"主题,就搜"樱花飘落""草地野餐""嫩芽初绽"之类的画面。\n\n这些图片能帮你快速理清视频的画面脉络甚至能激发新的创意——比如一张老照片里的复古物件或许能延伸出一段关于时光的故事一组星空图片说不定能串联成关于梦想与远方的叙事。把这些图片按你的想法串联起来配上合适的音乐和文案一段有温度的视频就诞生啦试试看吧'
},
role: 'Assistant',
timestamp: new Date().toISOString()
}
]))
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View className="relative w-full"> <View className="relative w-full">
<ScrollView horizontal={true}>
<TouchableOpacity style={[styles.button, { borderColor: '#FFB645' }]} onPress={() => handleQuitly('search')}>
<SunSvg width={18} height={18} />
<ThemedText></ThemedText>
</TouchableOpacity><TouchableOpacity style={[styles.button, { borderColor: '#E2793F' }]} onPress={() => handleQuitly('video')}>
<VideoSvg width={18} height={18} />
<ThemedText></ThemedText>
</TouchableOpacity>
</ScrollView>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Ask MeMo Anything..." placeholder="Ask MeMo Anything..."
@ -132,7 +175,7 @@ export default function SendMessage(props: Props) {
<TouchableOpacity <TouchableOpacity
style={styles.voiceButton} style={styles.voiceButton}
onPress={handleSubmit} onPress={handleSubmit}
className={`absolute right-0 top-1/2 -translate-y-1/2 `} // 使用绝对定位将按钮放在输入框内右侧 className={`absolute right-0 bottom-0`} // 使用绝对定位将按钮放在输入框内右侧
> >
<View style={{ transform: [{ rotate: '330deg' }] }}> <View style={{ transform: [{ rotate: '330deg' }] }}>
<SendSvg color={'white'} width={24} height={24} /> <SendSvg color={'white'} width={24} height={24} />
@ -144,6 +187,17 @@ export default function SendMessage(props: Props) {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
button: {
paddingHorizontal: 8,
paddingVertical: 4,
margin: 5,
borderRadius: 25,
alignItems: 'center',
borderWidth: 2,
display: 'flex',
flexDirection: 'row',
gap: 5
},
container: { container: {
justifyContent: 'center', justifyContent: 'center',
backgroundColor: '#transparent', backgroundColor: '#transparent',

View File

@ -0,0 +1,159 @@
import { Video } from "@/types/ask";
import { MaterialItem } from "@/types/personal-info";
import { Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native";
import VideoPlayer from "./VideoPlayer";
interface SingleContentModelProps {
modalVisible: { visible: boolean, data: Video | MaterialItem };
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
}
const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentModelProps) => {
const isVideo = (data: Video | MaterialItem): data is Video => {
return 'video' in data;
};
return (
<Modal
animationType="fade"
transparent={true}
visible={modalVisible.visible}
onRequestClose={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem });
}}>
<View style={styles.centeredView}>
<TouchableOpacity
style={styles.background}
onPress={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem })
}}
/>
<TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}>
{isVideo(modalVisible.data) ? (
// 视频播放器
<TouchableOpacity
activeOpacity={1}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: "60%",
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
>
<VideoPlayer
videoUrl={modalVisible.data.video.file_info.url}
style={{
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center',
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
/>
</TouchableOpacity>
) : (
// 图片预览
<TouchableOpacity
activeOpacity={1}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
style={styles.imageContainer}
>
<Image
source={{ uri: modalVisible.data.preview_file_info?.url }}
style={styles.fullWidthImage}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
</Modal>
)
}
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: '20%',
},
aiBubble: {
alignSelf: 'flex-start',
backgroundColor: '#fff',
marginRight: '20%',
borderWidth: 1,
borderColor: '#e5e5ea',
},
userText: {
color: '#4C320C',
fontSize: 16,
},
aiText: {
color: '#000',
fontSize: 16,
},
});
export default SingleContentModel

View File

@ -1,3 +1,11 @@
import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask";
import * as FileSystem from 'expo-file-system';
import * as MediaLibrary from 'expo-media-library';
import { TFunction } from "i18next";
import { useCallback } from "react";
import { Alert } from 'react-native';
// 实现一个函数,从两个数组中轮流插入新数组 // 实现一个函数,从两个数组中轮流插入新数组
export const mergeArrays = (arr1: any[], arr2: any[]) => { export const mergeArrays = (arr1: any[], arr2: any[]) => {
const result: any[] = []; const result: any[] = [];
@ -8,3 +16,114 @@ export const mergeArrays = (arr1: any[], arr2: any[]) => {
} }
return result; return result;
}; };
// 创建新对话并获取消息
export const createNewConversation = useCallback(async (user_text: string) => {
const data = await fetchApi<string>("/chat/new", {
method: "POST",
});
return data
}, []);
// 获取对话信息
export const getConversation = async ({
session_id,
user_text,
material_ids
}: {
session_id: string,
user_text: string,
material_ids: string[]
}): Promise<Message | undefined> => {
// 获取对话信息必须要有对话id
if (!session_id) return undefined;
try {
const response = await fetchApi<Message>(`/chat`, {
method: "POST",
body: JSON.stringify({
session_id,
user_text,
material_ids
})
});
return response;
} catch (error) {
// console.error('Error in getConversation:', error);
return undefined;
}
};
// 图片 视频 保存到本地
export const saveMediaToGallery = async (mediaUrl: string, t: TFunction) => {
// 声明 fileUri 变量以便在 finally 块中使用
let fileUri: string | null = null;
try {
// 首先请求权限
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('需要相册权限', '请允许应用访问相册以保存媒体文件');
return false;
}
// 获取文件扩展名
const fileExtension = mediaUrl.split('.').pop()?.toLowerCase() || 'mp4';
const isVideo = ['mp4', 'mov', 'avi', 'mkv'].includes(fileExtension);
const fileName = `temp_${Date.now()}.${fileExtension}`;
fileUri = `${FileSystem.documentDirectory}${fileName}`;
// 下载文件
console.log('开始下载文件:', mediaUrl);
const downloadResumable = FileSystem.createDownloadResumable(
mediaUrl,
fileUri,
{},
(downloadProgress) => {
const progress = downloadProgress.totalBytesWritten / (downloadProgress.totalBytesExpectedToWrite || 1);
console.log(`下载进度: ${Math.round(progress * 100)}%`);
}
);
const downloadResult = await downloadResumable.downloadAsync();
if (!downloadResult) {
throw new Error('下载失败: 下载被取消或发生错误');
}
const { uri } = downloadResult;
console.log('文件下载完成,准备保存到相册:', uri);
// 保存到相册
const asset = await MediaLibrary.createAssetAsync(uri);
await MediaLibrary.createAlbumAsync(
'Memowake',
asset,
false
);
Alert.alert(
'保存成功',
isVideo ? '视频已保存到相册' : '图片已保存到相册'
);
return true;
} catch (error) {
console.error('保存失败:', error);
Alert.alert(
'保存失败',
error instanceof Error ? error.message : '保存媒体文件时出错,请重试'
);
return false;
} finally {
// 清理临时文件
try {
if (fileUri) {
await FileSystem.deleteAsync(fileUri, { idempotent: true }).catch(console.warn);
}
} catch (cleanupError) {
console.warn('清理临时文件时出错:', cleanupError);
}
}
};

View File

@ -1,9 +1,9 @@
import i18n from '@/i18n';
import { PermissionService } from '@/lib/PermissionService';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { StyleSheet, TouchableOpacity, View } from 'react-native';
import i18n from '@/i18n';
import { PermissionService } from '@/lib/PermissionService';
import QRCode from 'react-native-qrcode-svg'; import QRCode from 'react-native-qrcode-svg';
import { captureRef } from 'react-native-view-shot'; import { captureRef } from 'react-native-view-shot';

View File

@ -0,0 +1,179 @@
import React, { useRef, useState } from 'react';
import {
Dimensions,
Modal,
StyleProp,
StyleSheet,
Text,
TextStyle,
TouchableOpacity,
TouchableWithoutFeedback,
View,
ViewStyle
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
interface MenuItem {
label: string;
svg?: React.ReactNode;
onPress: () => void;
textStyle?: StyleProp<TextStyle>;
}
interface ContextMenuProps {
children: React.ReactNode;
items: MenuItem[];
menuStyle?: StyleProp<ViewStyle>;
menuItemStyle?: StyleProp<ViewStyle>;
menuTextStyle?: StyleProp<TextStyle>;
dividerStyle?: StyleProp<ViewStyle>;
onOpen?: () => void;
onClose?: () => void;
longPressDuration?: number;
activeOpacity?: number;
}
const ContextMenu: React.FC<ContextMenuProps> = ({
children,
items,
menuStyle,
menuItemStyle,
menuTextStyle,
dividerStyle,
cancel,
onOpen,
onClose,
longPressDuration = 500,
activeOpacity = 0.8,
}) => {
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const containerRef = useRef<View>(null);
const showMenu = (x: number, y: number) => {
setMenuPosition({ x, y });
setMenuVisible(true);
onOpen?.();
};
const hideMenu = () => {
setMenuVisible(false);
onClose?.();
};
const handleItemPress = (onPress: () => void) => {
onPress();
hideMenu();
};
const gesture = Gesture.LongPress()
.minDuration(longPressDuration)
.onStart((e) => {
const absoluteX = e.absoluteX;
const absoluteY = e.absoluteY;
runOnJS(showMenu)(absoluteX, absoluteY);
});
useEffect(() => {
setMenuVisible(!cancel);
}, [cancel])
return (
<>
<View ref={containerRef} collapsable={false} style={{ flex: 1 }}>
<GestureDetector gesture={gesture}>
<View style={{ flex: 1 }}>
{children}
</View>
</GestureDetector>
</View>
<Modal
visible={menuVisible}
transparent
animationType="fade"
onRequestClose={hideMenu}
>
<TouchableWithoutFeedback onPress={hideMenu}>
<View style={styles.modalOverlay} />
</TouchableWithoutFeedback>
<View
style={[
styles.menu,
{
position: 'absolute',
top: Math.min(
menuPosition.y,
screenHeight - 300
),
left: Math.min(
menuPosition.x > screenWidth / 2 ? menuPosition.x - 150 : menuPosition.x,
screenWidth - 160
),
},
menuStyle,
]}
onStartShouldSetResponder={() => true}
>
{items.map((item, index) => (
<React.Fragment key={item.label}>
<TouchableOpacity
style={[styles.menuItem, menuItemStyle]}
onPress={() => handleItemPress(item.onPress)}
activeOpacity={activeOpacity}
>
{item.svg}
<Text style={[styles.menuText, menuTextStyle, item.textStyle]}>
{item.label}
</Text>
</TouchableOpacity>
{index < items.length - 1 && (
<View style={[styles.divider, dividerStyle]} />
)}
</React.Fragment>
))}
</View>
</Modal>
</>
);
};
const styles = StyleSheet.create({
modalOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
menu: {
backgroundColor: 'white',
borderRadius: 8,
minWidth: 100,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 6,
elevation: 10,
zIndex: 1000,
},
menuItem: {
paddingVertical: 12,
paddingHorizontal: 16,
minWidth: 100,
flexDirection: 'row',
gap: 4,
alignItems: 'center'
},
menuText: {
fontSize: 16,
color: '#333',
},
divider: {
height: 1,
backgroundColor: '#f0f0f0',
marginHorizontal: 8,
},
});
export default ContextMenu;

View File

@ -1,18 +1,35 @@
{ {
"ask": { "ask": {
"hi": "Hi,", "hi": "Hi,",
"iAmMemo": "I'm Memo!", "iAmMemo": "I'm Memo!",
"ready": "Ready to wake up your memories?", "ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!", "justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo", "selectPhoto": "Select Photo",
"continueAsking": "Continue Asking", "continueAsking": "Continue Asking",
"unNamed": "UnNamed", "unNamed": "UnNamed",
"noMessage": "No Message", "noMessage": "No Message",
"memoList": "Memo List", "memoList": "Memo List",
"noChat": "No Chat", "noChat": "No Chat",
"loading": "Loading...", "loading": "Loading...",
"refresh": "Refresh", "refresh": "Refresh",
"error": "have some error", "error": "have some error",
"issue": "have some issue" "issue": "have some issue",
} "case1": "Find last year's baby/pet material",
"case2": "Find last year's food",
"case3": "Find recent travel material",
"mediaAuth": "need album permission",
"mediaAuthDesc": "allow app to access album to save media files",
"saveSuccess": "save success",
"imgSave": "image saved to album",
"videoSave": "video saved to album",
"saveError": "save failed",
"saveErrorDesc": "save media files error, please try again",
"save": "save",
"cancel": "cancel",
"introduction1": "Ready to open the memory time capsule? Describe your memory, and I'll help you find photos, create videos, or unlock hidden Easter eggs ✨",
"introduction2": "Looking for the perfect image? Try these search tips for better results:\n\n• Be specific: Try 'autumn forest', 'minimalist desk', or 'vintage poster design'\n\n• Add details: For specific styles, try 'watercolor cat' or 'cyberpunk city nightscape'; for specific uses, try 'royalty-free landscape' or 'commercial use icons'\n\n• Describe the scene: Try 'sunlight through leaves' or 'rainy day coffee shop window'\n\nEnter these keywords, and you might just find the perfect shot!",
"introduction3": "Want to make your videos more engaging and story-driven? Start with an image search!\n\nFirst, decide on your video's theme—whether it's healing natural landscapes, retro cityscapes, or vibrant life moments. Then search for related images. For example, if your theme is 'Spring Limited,' search for 'cherry blossoms falling,' 'picnic in the grass,' or 'first buds of spring.'\n\nThese images can help you visualize your video's flow and even spark new ideas—like how an old photo of a vintage object might inspire a story about time, or how a series of starry sky images could connect into a narrative about dreams and distant places. String these images together with the right music and captions, and you've got yourself a heartwarming video. Give it a try!",
"search": "Search Assets",
"video": "Create Video"
}
} }

View File

@ -13,6 +13,9 @@
"loading": "加载中...", "loading": "加载中...",
"refresh": "刷新", "refresh": "刷新",
"error": "出错了", "error": "出错了",
"issue": "发生了一些问题" "issue": "发生了一些问题",
"case1": "找去年我家宝宝/宠物的素材片段",
"case2": "找去年吃过的美食",
"case3": "找近期旅游的素材"
} }
} }

View File

@ -1,6 +1,9 @@
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import 'react-native-gesture-handler';
import { GestureHandlerRootView } from "react-native-gesture-handler";
import 'react-native-reanimated';
import Toast, { BaseToast, ErrorToast, ToastConfig } from 'react-native-toast-message'; import Toast, { BaseToast, ErrorToast, ToastConfig } from 'react-native-toast-message';
import { Provider as ReduxProvider } from "react-redux"; import { Provider as ReduxProvider } from "react-redux";
import { AuthProvider } from "./contexts/auth-context"; import { AuthProvider } from "./contexts/auth-context";
@ -70,24 +73,24 @@ const toastConfig: ToastConfig = {
export function Provider({ children }: { children: React.ReactNode }) { export function Provider({ children }: { children: React.ReactNode }) {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<LanguageProvider> <LanguageProvider>
<ReduxProvider store={store}> <ReduxProvider store={store}>
<AuthProvider> <AuthProvider>
{children} {children}
<Toast <Toast
config={toastConfig} config={toastConfig}
position={Platform.OS === 'web' ? 'top' : 'bottom'} position={Platform.OS === 'web' ? 'top' : 'bottom'}
topOffset={Platform.OS === 'web' ? 20 : undefined} topOffset={Platform.OS === 'web' ? 20 : undefined}
bottomOffset={Platform.OS === 'web' ? undefined : 40} bottomOffset={Platform.OS === 'web' ? undefined : 40}
visibilityTime={3000} visibilityTime={3000}
autoHide autoHide
/> />
</AuthProvider> </AuthProvider>
</ReduxProvider> </ReduxProvider>
</LanguageProvider> </LanguageProvider>
</I18nextProvider> </I18nextProvider>
</GestureHandlerRootView>
); );
} }