diff --git a/app.json b/app.json index 426908f..e201da3 100644 --- a/app.json +++ b/app.json @@ -12,7 +12,7 @@ "supportsTablet": true, "infoPlist": { "NSPhotoLibraryUsageDescription": "允许访问照片库,以便模型使用您照片库中的素材进行视频创作”(例如:上传您参加音乐节的现场图,生成一个音乐节体验Vlog", - "NSPhotoLibraryAddUsageDescription": "需要保存图片到相册", + "NSPhotoLibraryAddUsageDescription": "App需要访问相册来保存图片", "NSLocationWhenInUseUsageDescription": "允许获取位置信息,以便模型使用您的位置信息进行个性化创作”(例如:上传您去欧洲旅游的位置信息,结合在当地拍摄的照片,生成一个欧洲旅行攻略Vlog)", "ITSAppUsesNonExemptEncryption": false, "UIBackgroundModes": [ diff --git a/app/(tabs)/ask.tsx b/app/(tabs)/ask.tsx index eac825b..035bbfa 100644 --- a/app/(tabs)/ask.tsx +++ b/app/(tabs)/ask.tsx @@ -5,8 +5,8 @@ import SendMessage from "@/components/ask/send"; import { ThemedText } from "@/components/ThemedText"; import { fetchApi } from "@/lib/server-api-util"; import { Message } from "@/types/ask"; -import { router, useLocalSearchParams } from "expo-router"; -import { default as React, default as React, useCallback, useEffect, useRef, useState } from 'react'; +import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Animated, FlatList, @@ -116,7 +116,6 @@ export default function AskScreen() { Animated.timing(fadeAnimChat, { toValue: 1, duration: 300, - duration: 300, useNativeDriver: true, }) ]).start(() => { @@ -131,31 +130,20 @@ export default function AskScreen() { useEffect(() => { if (!isHello) { - const timer = setTimeout(() => { - scrollToEnd(false); - }, 300); - return () => clearTimeout(timer); + // 不再自动关闭键盘,让用户手动控制 + // 这里可以添加其他需要在隐藏hello界面时执行的逻辑 + scrollToEnd(false); } }, [isHello]); - useEffect(() => { - const timer = setTimeout(() => { - if (!isHello) { - try { - if (TextInput.State?.currentlyFocusedInput) { - const input = TextInput.State.currentlyFocusedInput(); - if (input) input.blur(); - } - } catch (error) { - console.log('失去焦点失败:', error); - } - - scrollToEnd(false); + useFocusEffect( + useCallback(() => { + if (!sessionId) { + setIsHello(true); + setUserMessages([]) } - }, 200); - - return () => clearTimeout(timer); - }, [isHello]); + }, [sessionId]) + ); return ( @@ -194,7 +182,7 @@ export default function AskScreen() { } ]} > - + {/* 聊天页面 */} @@ -299,7 +287,7 @@ const styles = StyleSheet.create({ padding: 16, paddingBottom: 24, backgroundColor: 'white', - borderTopWidth: 1, - borderTopColor: '#f0f0f0', + // borderTopWidth: 1, + // borderTopColor: '#f0f0f0', }, }); \ No newline at end of file diff --git a/assets/icons/svg/cancel.svg b/assets/icons/svg/cancel.svg new file mode 100644 index 0000000..982309c --- /dev/null +++ b/assets/icons/svg/cancel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/svg/download.svg b/assets/icons/svg/download.svg new file mode 100644 index 0000000..c79fa79 --- /dev/null +++ b/assets/icons/svg/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/svg/sun.svg b/assets/icons/svg/sun.svg new file mode 100644 index 0000000..ad68ee6 --- /dev/null +++ b/assets/icons/svg/sun.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/video.svg b/assets/icons/svg/video.svg new file mode 100644 index 0000000..e5a979c --- /dev/null +++ b/assets/icons/svg/video.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/ask/aiChat.tsx b/components/ask/aiChat.tsx index b0ff2fd..1163829 100644 --- a/components/ask/aiChat.tsx +++ b/components/ask/aiChat.tsx @@ -1,26 +1,25 @@ +import CancelSvg from '@/assets/icons/svg/cancel.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 ReturnArrow from "@/assets/icons/svg/returnArrow.svg"; -import YesSvg from "@/assets/icons/svg/yes.svg"; import { Message, Video } from "@/types/ask"; import { MaterialItem } from "@/types/personal-info"; import { TFunction } from "i18next"; import React from 'react'; import { - FlatList, Image, - Modal, 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 TypewriterText from "./typewriterText"; -import { mergeArrays } from "./utils"; -import VideoPlayer from "./VideoPlayer"; +import { mergeArrays, saveMediaToGallery } from "./utils"; interface RenderMessageProps { insets: { top: number }; @@ -28,18 +27,17 @@ interface RenderMessageProps { sessionId: string; setModalVisible: React.Dispatch>; modalVisible: { visible: boolean, data: Video | MaterialItem }; - setModalDetailsVisible: React.Dispatch>; - modalDetailsVisible: boolean; + setModalDetailsVisible: React.Dispatch>; + modalDetailsVisible: { visible: boolean, content: any }; setSelectedImages: React.Dispatch>; selectedImages: string[]; t: TFunction; + setCancel: React.Dispatch>; + 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 isVideo = (data: Video | MaterialItem): data is Video => { - return 'video' in data; - }; return ( @@ -76,23 +74,60 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible marginBottom: 8, }} > - , + 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: , + 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, }} - resizeMode="cover" - loadingIndicatorSource={require('@/assets/images/png/placeholder.png')} - /> + > + + ))} { ((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3 && { - setModalDetailsVisible(true); + setSelectedImages([]) + setModalDetailsVisible({ visible: true, content: item.content }); }}> {((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0))} @@ -116,152 +151,19 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible ))} )} */} - { - setModalVisible({ visible: false, data: {} as Video | MaterialItem }); - }}> - - { - setModalVisible({ visible: false, data: {} as Video | MaterialItem }) - }} - /> - setModalVisible({ visible: false, data: {} as Video | MaterialItem })}> - {isVideo(modalVisible.data) ? ( - // 视频播放器 - setModalVisible({ visible: false, data: {} as Video | MaterialItem })} - > - setModalVisible({ visible: false, data: {} as Video | MaterialItem })} - /> - - ) : ( - // 图片预览 - setModalVisible({ visible: false, data: {} as Video | MaterialItem })} - style={styles.imageContainer} - > - - - )} - - - - { - setModalDetailsVisible(false); - }} - > - - - setModalDetailsVisible(false)}> - - - {t('ask.selectPhoto', { ns: 'ask' })} - - - - item.id} - showsVerticalScrollIndicator={false} - contentContainerStyle={detailsStyles.flatListContent} - initialNumToRender={12} - maxToRenderPerBatch={12} - updateCellsBatchingPeriod={50} - windowSize={10} - removeClippedSubviews={true} - renderItem={({ item }) => { - return ( - - - - {selectedImages?.map((image, index) => { - if (image === item.id || image === item.video?.id) { - return index + 1 - } - })} - - console.log('Image load error:', error.nativeEvent.error)} - onLoad={() => console.log('Image loaded successfully')} - loadingIndicatorSource={require('@/assets/images/png/placeholder.png')} - /> - { - 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) ? : ""} - - - - ); - }} - /> - - - { - // 如果用户没有选择 则为选择全部 - 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} - > - - {t('ask.continueAsking', { ns: 'ask' })} - - - - - + {/* 单个图片弹窗 */} + + {/* 全部图片详情弹窗 */} + ); @@ -298,7 +200,7 @@ const styles = StyleSheet.create({ }, background: { ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.5)', // 添加半透明黑色背景 + backgroundColor: 'rgba(0, 0, 0, 0.5)', }, centeredView: { flex: 1, @@ -352,123 +254,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', - } -}); \ No newline at end of file diff --git a/components/ask/chat.tsx b/components/ask/chat.tsx index 9a65dc5..69f42d1 100644 --- a/components/ask/chat.tsx +++ b/components/ask/chat.tsx @@ -5,7 +5,8 @@ import { useTranslation } from 'react-i18next'; import { FlatList, FlatListProps, - SafeAreaView + SafeAreaView, + View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import MessageItem from './aiChat'; @@ -26,10 +27,15 @@ function ChatComponent( const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem }); const { t } = useTranslation(); 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(false); + const [modalDetailsVisible, setModalDetailsVisible] = useState<{ visible: boolean, content: any }>({ visible: false, content: [] }); return ( @@ -37,7 +43,14 @@ function ChatComponent( ref={ref} data={userMessages} 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 ( + + {MessageItem({ setCancel, cancel, t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })} + + ); + }} contentContainerStyle={contentContainerStyle} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" diff --git a/components/ask/hello.tsx b/components/ask/hello.tsx index e47aea1..474a37e 100644 --- a/components/ask/hello.tsx +++ b/components/ask/hello.tsx @@ -1,17 +1,55 @@ import IP from "@/assets/icons/svg/ip.svg"; import { ThemedText } from "@/components/ThemedText"; +import { Message } from "@/types/ask"; +import { Dispatch, SetStateAction } from "react"; 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>; + setConversationId: Dispatch>; + setIsHello: Dispatch>; +} +export default function AskHello({ setUserMessages, setConversationId, setIsHello }: AskHelloProps) { 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 ( - + - + {t('ask.ready', { ns: 'ask' })} {"\n"} {t('ask.justAsk', { ns: 'ask' })} + + { + handleCase(t('ask:ask.case1')); + }}> + + {t('ask:ask.case1')} + + + { + handleCase(t('ask:ask.case2')); + }}> + + {t('ask:ask.case2')} + + + { + handleCase(t('ask:ask.case3')); + }}> + + {t('ask:ask.case3')} + + + ); -} \ No newline at end of file +} + + +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' + } +}) \ No newline at end of file diff --git a/components/ask/selectModel.tsx b/components/ask/selectModel.tsx new file mode 100644 index 0000000..5ae244a --- /dev/null +++ b/components/ask/selectModel.tsx @@ -0,0 +1,289 @@ +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>; + insets: { top: number }; + setSelectedImages: React.Dispatch>; + selectedImages: string[]; + t: TFunction; + cancel: boolean; + setCancel: React.Dispatch>; +} +const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t, cancel, setCancel }: SelectModelProps) => { + + return ( + { + setModalDetailsVisible({ visible: false, content: [] }); + }} + > + + + setModalDetailsVisible({ visible: false, content: [] })}> + + + {t('ask.selectPhoto', { ns: 'ask' })} + + + + 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 ( + + + + {isSelected && ( + + {selectedImages.indexOf(itemId) + 1} + + )} + , + label: t("ask:ask.save"), + onPress: () => { + const imageUrl = item?.file_info?.url || item.video?.file_info?.url; + if (imageUrl) { + saveMediaToGallery(imageUrl, t); + } + }, + textStyle: { color: '#4C320C' } + }, + { + svg: , + 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, + }} + > + console.log('Image load error:', error.nativeEvent.error)} + onLoad={() => console.log('Image loaded successfully')} + loadingIndicatorSource={require('@/assets/images/png/placeholder.png')} + /> + + + { + setSelectedImages(prev => + isSelected + ? prev.filter(id => id !== itemId) + : [...prev, itemId] + ); + }} + activeOpacity={0.8} + > + {isSelected && } + + + + ); + }} + /> + + + { + // 如果用户没有选择 则为选择全部 + 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} + > + + {t('ask.continueAsking', { ns: 'ask' })} + + + + + + ) +} + + +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 \ No newline at end of file diff --git a/components/ask/send.tsx b/components/ask/send.tsx index ab9793e..08543cd 100644 --- a/components/ask/send.tsx +++ b/components/ask/send.tsx @@ -1,16 +1,21 @@ 'use client'; 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 { Keyboard, + ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; -import { fetchApi } from '@/lib/server-api-util'; import { Message } from '@/types/ask'; +import { useTranslation } from 'react-i18next'; +import { ThemedText } from '../ThemedText'; +import { createNewConversation, getConversation } from './utils'; interface Props { setIsHello: Dispatch>, @@ -23,38 +28,48 @@ interface Props { export default function SendMessage(props: Props) { const { setIsHello, conversationId, setUserMessages, setConversationId, selectedImages, setSelectedImages } = props; + const { t } = useTranslation() + // 用户询问 const [inputValue, setInputValue] = useState(''); - // 创建新对话并获取消息 - const createNewConversation = useCallback(async (user_text: string) => { - const data = await fetchApi("/chat/new", { - method: "POST", - }); - setConversationId(data); - await getConversation({ session_id: data, user_text, material_ids: [] }); - }, []); + // 添加一个ref来跟踪键盘状态 + const isKeyboardVisible = useRef(false); - // 获取对话信息 - 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(`/chat`, { - method: "POST", - body: JSON.stringify({ - session_id, - user_text, - material_ids - }) + useEffect(() => { + // 使用keyboardWillShow而不是keyboardDidShow,这样可以在键盘完全显示前更新UI + const showSubscription = Keyboard.addListener('keyboardWillShow', () => { + isKeyboardVisible.current = true; + if (!conversationId) { + // 确保在下一个事件循环中更新状态,避免可能的渲染问题 + requestAnimationFrame(() => { + setIsHello(false); + setUserMessages([ + { + content: { + text: t("ask:ask.introduction1") + }, + 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 text = inputValue; + const handleSubmit = useCallback(async () => { + const text = inputValue.trim(); // 用户输入信息之后进行后续操作 if (text) { // 将用户输入信息添加到消息列表中 @@ -75,48 +90,73 @@ export default function SendMessage(props: Props) { ])); // 如果没有对话ID,创建新对话并获取消息,否则直接获取消息 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 { - getConversation({ + const response = await getConversation({ session_id: conversationId, user_text: text, material_ids: selectedImages }); + setSelectedImages([]); + setUserMessages((prev: Message[]) => { + const newMessages = [...(prev || [])]; + if (response) { + newMessages.push(response); + } + return newMessages.filter((item: Message) => + item?.content?.text !== '正在寻找,请稍等...' + ); + }); } // 将输入框清空 setInputValue(''); - // 关闭键盘 - 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 { - - } + // 只有在键盘可见时才关闭键盘 + if (isKeyboardVisible.current) { + Keyboard.dismiss(); } - ); + } + }, [inputValue, conversationId, selectedImages, createNewConversation, getConversation]); - return () => { - keyboardWillShowListener.remove(); - }; - }, [conversationId]); + const handleQuitly = (type: string) => { + setIsHello(false) + setUserMessages(pre => ([ + ...pre, + { + content: { + text: type === "search" + ? t("ask:ask.introduction2") + : t("ask:ask.introduction3") + }, + role: 'Assistant', + timestamp: new Date().toISOString() + } + ])) + }; return ( + + handleQuitly('search')}> + + {t("ask:ask.search")} + handleQuitly('video')}> + + {t("ask:ask.video")} + + @@ -144,6 +184,17 @@ export default function SendMessage(props: Props) { } const styles = StyleSheet.create({ + button: { + paddingHorizontal: 8, + paddingVertical: 4, + margin: 5, + borderRadius: 25, + alignItems: 'center', + borderWidth: 2, + display: 'flex', + flexDirection: 'row', + gap: 5 + }, container: { justifyContent: 'center', backgroundColor: '#transparent', diff --git a/components/ask/singleContentModel.tsx b/components/ask/singleContentModel.tsx new file mode 100644 index 0000000..4140ae7 --- /dev/null +++ b/components/ask/singleContentModel.tsx @@ -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>; +} +const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentModelProps) => { + const isVideo = (data: Video | MaterialItem): data is Video => { + return 'video' in data; + }; + + return ( + { + setModalVisible({ visible: false, data: {} as Video | MaterialItem }); + }}> + + { + setModalVisible({ visible: false, data: {} as Video | MaterialItem }) + }} + /> + setModalVisible({ visible: false, data: {} as Video | MaterialItem })}> + {isVideo(modalVisible.data) ? ( + // 视频播放器 + setModalVisible({ visible: false, data: {} as Video | MaterialItem })} + > + setModalVisible({ visible: false, data: {} as Video | MaterialItem })} + /> + + ) : ( + // 图片预览 + setModalVisible({ visible: false, data: {} as Video | MaterialItem })} + style={styles.imageContainer} + > + + + )} + + + + ) +} + + + +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 \ No newline at end of file diff --git a/components/ask/typewriterText.tsx b/components/ask/typewriterText.tsx index b1289f3..f0994d5 100644 --- a/components/ask/typewriterText.tsx +++ b/components/ask/typewriterText.tsx @@ -10,7 +10,7 @@ interface TypewriterTextProps { const TypewriterText: React.FC = ({ text, - speed = 150, + speed = 100, loop = false, delay = 2000, }) => { diff --git a/components/ask/utils.ts b/components/ask/utils.ts index 81a838f..79f046a 100644 --- a/components/ask/utils.ts +++ b/components/ask/utils.ts @@ -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[]) => { const result: any[] = []; @@ -8,3 +16,114 @@ export const mergeArrays = (arr1: any[], arr2: any[]) => { } return result; }; + + +// 创建新对话并获取消息 +export const createNewConversation = useCallback(async (user_text: string) => { + const data = await fetchApi("/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 => { + // 获取对话信息必须要有对话id + if (!session_id) return undefined; + + try { + const response = await fetchApi(`/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(t("ask:ask.mediaAuth"), t("ask:ask.mediaAuthDesc")); + 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( + t("ask:ask.saveSuccess"), + isVideo ? t("ask:ask.videoSave") : t("ask:ask.imgSave") + ); + return true; + } catch (error) { + console.log('保存失败:', error); + Alert.alert( + t("ask:ask.saveError"), + error instanceof Error ? error.message : t("ask:ask.saveError") + ); + return false; + } finally { + // 清理临时文件 + try { + if (fileUri) { + await FileSystem.deleteAsync(fileUri, { idempotent: true }).catch(console.warn); + } + } catch (cleanupError) { + console.log('清理临时文件时出错:', cleanupError); + } + } +}; \ No newline at end of file diff --git a/components/download/qrCode.tsx b/components/download/qrCode.tsx index 36d2531..74e435e 100644 --- a/components/download/qrCode.tsx +++ b/components/download/qrCode.tsx @@ -1,9 +1,9 @@ +import i18n from '@/i18n'; +import { PermissionService } from '@/lib/PermissionService'; import * as Haptics from 'expo-haptics'; import * as MediaLibrary from 'expo-media-library'; import React, { useRef } from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; -import i18n from '@/i18n'; -import { PermissionService } from '@/lib/PermissionService'; import QRCode from 'react-native-qrcode-svg'; import { captureRef } from 'react-native-view-shot'; diff --git a/components/gusture/contextMenu.tsx b/components/gusture/contextMenu.tsx new file mode 100644 index 0000000..01280b3 --- /dev/null +++ b/components/gusture/contextMenu.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, 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; +} + +interface ContextMenuProps { + children: React.ReactNode; + items: MenuItem[]; + menuStyle?: StyleProp; + menuItemStyle?: StyleProp; + menuTextStyle?: StyleProp; + dividerStyle?: StyleProp; + onOpen?: () => void; + onClose?: () => void; + longPressDuration?: number; + activeOpacity?: number; + cancel?: boolean; +} + +const ContextMenu: React.FC = ({ + 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(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 ( + <> + + + + {children} + + + + + + + + + + screenWidth / 2 ? menuPosition.x - 150 : menuPosition.x, + screenWidth - 160 + ), + }, + menuStyle, + ]} + onStartShouldSetResponder={() => true} + > + {items.map((item, index) => ( + + handleItemPress(item.onPress)} + activeOpacity={activeOpacity} + > + {item.svg} + + {item.label} + + + {index < items.length - 1 && ( + + )} + + ))} + + + + ); +}; + +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; \ No newline at end of file diff --git a/i18n/locales/en/ask.json b/i18n/locales/en/ask.json index 2d7159c..cff67f7 100644 --- a/i18n/locales/en/ask.json +++ b/i18n/locales/en/ask.json @@ -1,18 +1,35 @@ { - "ask": { - "hi": "Hi,", - "iAmMemo": "I'm Memo!", - "ready": "Ready to wake up your memories?", - "justAsk": "Just ask MeMo, let me bring them back to life!", - "selectPhoto": "Select Photo", - "continueAsking": "Continue Asking", - "unNamed": "UnNamed", - "noMessage": "No Message", - "memoList": "Memo List", - "noChat": "No Chat", - "loading": "Loading...", - "refresh": "Refresh", - "error": "have some error", - "issue": "have some issue" - } + "ask": { + "hi": "Hi,", + "iAmMemo": "I'm Memo!", + "ready": "Ready to wake up your memories?", + "justAsk": "Just ask MeMo, let me bring them back to life!", + "selectPhoto": "Select Photo", + "continueAsking": "Continue Asking", + "unNamed": "UnNamed", + "noMessage": "No Message", + "memoList": "Memo List", + "noChat": "No Chat", + "loading": "Loading...", + "refresh": "Refresh", + "error": "have some error", + "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" + } } \ No newline at end of file diff --git a/i18n/locales/zh/ask.json b/i18n/locales/zh/ask.json index bc9a814..5f46082 100644 --- a/i18n/locales/zh/ask.json +++ b/i18n/locales/zh/ask.json @@ -13,6 +13,23 @@ "loading": "加载中...", "refresh": "刷新", "error": "出错了", - "issue": "发生了一些问题" + "issue": "发生了一些问题", + "case1": "找去年我家宝宝/宠物的素材片段", + "case2": "找去年吃过的美食", + "case3": "找近期旅游的素材", + "mediaAuth": "需要相册权限", + "mediaAuthDesc": "请允许应用访问相册以保存媒体文件", + "saveSuccess": "保存成功", + "imgSave": "图片已保存到相册", + "videoSave": "视频已保存到相册", + "saveError": "保存失败", + "saveErrorDesc": "保存媒体文件时出错,请重试", + "save": "保存", + "cancel": "取消", + "introduction1": "想打开记忆盲盒吗?描述你的回忆,我来帮你找回照片、生成影片或解锁隐藏彩蛋✨", + "introduction2": "想找合适的图片?试试这样搜更精准:\n\n• 明确主题:比如'秋日森林'、'极简风书桌'、'复古海报设计'\n\n• 加上细节:想找特定风格?试试'水彩风猫咪'、'赛博朋克城市夜景';需要特定用途?比如'无版权风景图'、'可商用图标'\n\n• 描述场景:比如'阳光透过树叶的光斑'、'雨天咖啡馆窗外'\n\n输入这些关键词,说不定就能找到你想要的画面啦~", + "introduction3": "想让你的视频内容更吸睛、更有故事感吗?不妨试试从搜索图片入手吧!\n\n你可以先确定视频的主题——是治愈系的自然风景,还是复古风的城市街景,或是充满活力的生活瞬间?然后根据主题去搜索相关的图片,比如想做'春日限定'主题,就搜'樱花飘落''草地野餐''嫩芽初绽'之类的画面。\n\n这些图片能帮你快速理清视频的画面脉络,甚至能激发新的创意——比如一张老照片里的复古物件,或许能延伸出一段关于时光的故事;一组星空图片,说不定能串联成关于梦想与远方的叙事。把这些图片按你的想法串联起来,配上合适的音乐和文案,一段有温度的视频就诞生啦,试试看吧~", + "search": "检索素材", + "video": "创作视频" } } \ No newline at end of file diff --git a/provider.tsx b/provider.tsx index 7960a54..5e20661 100644 --- a/provider.tsx +++ b/provider.tsx @@ -1,6 +1,9 @@ import { I18nextProvider } from "react-i18next"; 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 { Provider as ReduxProvider } from "react-redux"; import { AuthProvider } from "./contexts/auth-context"; @@ -70,24 +73,24 @@ const toastConfig: ToastConfig = { export function Provider({ children }: { children: React.ReactNode }) { return ( - - - - - - {children} - - - - - - + + + + + + {children} + + + + + + ); }