feat: ask下载交互 #18
2
app.json
2
app.json
@ -12,7 +12,7 @@
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSPhotoLibraryUsageDescription": "允许访问照片库,以便模型使用您照片库中的素材进行视频创作”(例如:上传您参加音乐节的现场图,生成一个音乐节体验Vlog",
|
||||
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册",
|
||||
"NSPhotoLibraryAddUsageDescription": "App需要访问相册来保存图片",
|
||||
"NSLocationWhenInUseUsageDescription": "允许获取位置信息,以便模型使用您的位置信息进行个性化创作”(例如:上传您去欧洲旅游的位置信息,结合在当地拍摄的照片,生成一个欧洲旅行攻略Vlog)",
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"UIBackgroundModes": [
|
||||
|
||||
@ -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 (
|
||||
<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>
|
||||
|
||||
{/* 聊天页面 */}
|
||||
@ -299,7 +287,7 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
paddingBottom: 24,
|
||||
backgroundColor: 'white',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f0f0f0',
|
||||
// borderTopWidth: 1,
|
||||
// borderTopColor: '#f0f0f0',
|
||||
},
|
||||
});
|
||||
1
assets/icons/svg/cancel.svg
Normal file
1
assets/icons/svg/cancel.svg
Normal 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 |
1
assets/icons/svg/download.svg
Normal file
1
assets/icons/svg/download.svg
Normal 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
3
assets/icons/svg/sun.svg
Normal 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 |
3
assets/icons/svg/video.svg
Normal file
3
assets/icons/svg/video.svg
Normal 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 |
@ -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<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
|
||||
modalVisible: { visible: boolean, data: Video | MaterialItem };
|
||||
setModalDetailsVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
modalDetailsVisible: boolean;
|
||||
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
|
||||
modalDetailsVisible: { visible: boolean, content: any };
|
||||
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
selectedImages: string[];
|
||||
t: TFunction;
|
||||
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
cancel: boolean;
|
||||
}
|
||||
|
||||
const MessageItem = ({ 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 (
|
||||
<View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
@ -76,23 +74,60 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 12,
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
svg: <DownloadSvg width={20} height={20} />,
|
||||
label: t("ask:ask.save"),
|
||||
onPress: () => {
|
||||
const imageUrl = image?.preview_file_info?.url || image.video?.preview_file_info?.url;
|
||||
if (imageUrl) {
|
||||
saveMediaToGallery(imageUrl, t);
|
||||
}
|
||||
},
|
||||
textStyle: { color: '#4C320C' }
|
||||
},
|
||||
{
|
||||
svg: <CancelSvg width={20} height={20} color='red' />,
|
||||
label: t("ask:ask.cancel"),
|
||||
onPress: () => {
|
||||
setCancel(true);
|
||||
},
|
||||
textStyle: { color: 'red' }
|
||||
}
|
||||
]}
|
||||
cancel={cancel}
|
||||
menuStyle={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
minWidth: 150,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
}}
|
||||
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>
|
||||
))}
|
||||
</View>
|
||||
{
|
||||
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3
|
||||
&& <TouchableOpacity className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full" onPress={() => {
|
||||
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>
|
||||
<View className="bg-white rounded-full p-2">
|
||||
@ -116,152 +151,19 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible
|
||||
))}
|
||||
</View>
|
||||
)} */}
|
||||
<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>
|
||||
<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>
|
||||
{/* 单个图片弹窗 */}
|
||||
<SingleContentModel modalVisible={modalVisible} setModalVisible={setModalVisible} />
|
||||
{/* 全部图片详情弹窗 */}
|
||||
<SelectModel
|
||||
modalDetailsVisible={modalDetailsVisible}
|
||||
setModalDetailsVisible={setModalDetailsVisible}
|
||||
insets={insets}
|
||||
setSelectedImages={setSelectedImages}
|
||||
selectedImages={selectedImages}
|
||||
t={t}
|
||||
setCancel={setCancel}
|
||||
cancel={cancel}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@ -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',
|
||||
}
|
||||
});
|
||||
@ -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<boolean>(false);
|
||||
const [modalDetailsVisible, setModalDetailsVisible] = useState<{ visible: boolean, content: any }>({ visible: false, content: [] });
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
@ -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 (
|
||||
<View style={itemStyle}>
|
||||
{MessageItem({ setCancel, cancel, t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
|
||||
@ -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<SetStateAction<Message[]>>;
|
||||
setConversationId: Dispatch<SetStateAction<string | null>>;
|
||||
setIsHello: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
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 (
|
||||
<View className="flex-1 bg-white w-full">
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20
|
||||
}}
|
||||
@ -24,16 +62,58 @@ export default function AskHello() {
|
||||
{"\n"}
|
||||
{t('ask.iAmMemo', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
<View className="justify-center items-center my-4">
|
||||
<View className="-mt-10">
|
||||
<IP />
|
||||
</View>
|
||||
<ThemedText className="!text-textPrimary text-center">
|
||||
<ThemedText className="!text-textPrimary text-center -mt-20">
|
||||
{t('ask.ready', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.justAsk', { ns: 'ask' })}
|
||||
</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>
|
||||
</ScrollView>
|
||||
</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'
|
||||
}
|
||||
})
|
||||
289
components/ask/selectModel.tsx
Normal file
289
components/ask/selectModel.tsx
Normal file
@ -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<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: 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: <CancelSvg width={20} height={20} color='red' />,
|
||||
label: t("ask:ask.cancel"),
|
||||
onPress: () => setCancel(true),
|
||||
textStyle: { color: 'red' }
|
||||
}
|
||||
]}
|
||||
cancel={cancel}
|
||||
menuStyle={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
minWidth: 150,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: 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
|
||||
@ -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<SetStateAction<boolean>>,
|
||||
@ -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<string>("/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<Message>(`/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 (
|
||||
<View style={styles.container}>
|
||||
<View className="relative w-full">
|
||||
<ScrollView horizontal={true}>
|
||||
<TouchableOpacity style={[styles.button, { borderColor: '#FFB645' }]} onPress={() => handleQuitly('search')}>
|
||||
<SunSvg width={18} height={18} />
|
||||
<ThemedText>{t("ask:ask.search")}</ThemedText>
|
||||
</TouchableOpacity><TouchableOpacity style={[styles.button, { borderColor: '#E2793F' }]} onPress={() => handleQuitly('video')}>
|
||||
<VideoSvg width={18} height={18} />
|
||||
<ThemedText>{t("ask:ask.video")}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ask MeMo Anything..."
|
||||
@ -132,7 +172,7 @@ export default function SendMessage(props: Props) {
|
||||
<TouchableOpacity
|
||||
style={styles.voiceButton}
|
||||
onPress={handleSubmit}
|
||||
className={`absolute right-0 top-1/2 -translate-y-1/2 `} // 使用绝对定位将按钮放在输入框内右侧
|
||||
className={`absolute right-0 bottom-0`} // 使用绝对定位将按钮放在输入框内右侧
|
||||
>
|
||||
<View style={{ transform: [{ rotate: '330deg' }] }}>
|
||||
<SendSvg color={'white'} width={24} height={24} />
|
||||
@ -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',
|
||||
|
||||
159
components/ask/singleContentModel.tsx
Normal file
159
components/ask/singleContentModel.tsx
Normal 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
|
||||
@ -10,7 +10,7 @@ interface TypewriterTextProps {
|
||||
|
||||
const TypewriterText: React.FC<TypewriterTextProps> = ({
|
||||
text,
|
||||
speed = 150,
|
||||
speed = 100,
|
||||
loop = false,
|
||||
delay = 2000,
|
||||
}) => {
|
||||
|
||||
@ -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<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(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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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';
|
||||
|
||||
|
||||
180
components/gusture/contextMenu.tsx
Normal file
180
components/gusture/contextMenu.tsx
Normal file
@ -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<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;
|
||||
cancel?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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": "创作视频"
|
||||
}
|
||||
}
|
||||
41
provider.tsx
41
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 (
|
||||
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<LanguageProvider>
|
||||
<ReduxProvider store={store}>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<Toast
|
||||
config={toastConfig}
|
||||
position={Platform.OS === 'web' ? 'top' : 'bottom'}
|
||||
topOffset={Platform.OS === 'web' ? 20 : undefined}
|
||||
bottomOffset={Platform.OS === 'web' ? undefined : 40}
|
||||
visibilityTime={3000}
|
||||
autoHide
|
||||
/>
|
||||
</AuthProvider>
|
||||
</ReduxProvider>
|
||||
</LanguageProvider>
|
||||
</I18nextProvider>
|
||||
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<LanguageProvider>
|
||||
<ReduxProvider store={store}>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<Toast
|
||||
config={toastConfig}
|
||||
position={Platform.OS === 'web' ? 'top' : 'bottom'}
|
||||
topOffset={Platform.OS === 'web' ? 20 : undefined}
|
||||
bottomOffset={Platform.OS === 'web' ? undefined : 40}
|
||||
visibilityTime={3000}
|
||||
autoHide
|
||||
/>
|
||||
</AuthProvider>
|
||||
</ReduxProvider>
|
||||
</LanguageProvider>
|
||||
</I18nextProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user