feat: chat 交互

This commit is contained in:
jinyaqiu 2025-07-16 16:10:38 +08:00
parent c022e7f92f
commit 1c02968071
5 changed files with 429 additions and 239 deletions

View File

@ -6,107 +6,159 @@ import { ThemedText } from "@/components/ThemedText";
import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Animated,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AskScreen() {
const insets = useSafeAreaInsets();
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null);
// 用于控制是否显示问候页面
const [isHello, setIsHello] = useState(true);
// 获取对话id
const [conversationId, setConversationId] = useState<string | null>(null);
// 用户对话信息收集
const [userMessages, setUserMessages] = useState<Message[]>([]);
// 动画值
const fadeAnim = useRef(new Animated.Value(1)).current;
const fadeAnimChat = useRef(new Animated.Value(0)).current;
const createNewConversation = useCallback(async () => {
// TODO 用户未输入时,显示提示信息
setUserMessages([{
content: {
text: "请输入您的问题,寻找,请稍等..."
},
content: { text: "请输入您的问题,寻找,请稍等..." },
role: 'Assistant',
timestamp: new Date().toISOString()
}]);
const data = await fetchApi<string>("/chat/new", {
method: "POST",
});
const data = await fetchApi<string>("/chat/new", { method: "POST" });
setConversationId(data);
}, []);
// 获取路由参数
const { sessionId, newSession } = useLocalSearchParams<{
sessionId: string;
newSession: string;
}>();
// 添加自动滚动到底部的效果
// 处理滚动到底部
useEffect(() => {
if (scrollViewRef.current && !isHello) {
scrollViewRef.current.scrollToEnd({ animated: true });
}
}, [userMessages, isHello]);
// 处理路由参数
useEffect(() => {
if (sessionId) {
setConversationId(sessionId)
setIsHello(false)
setConversationId(sessionId);
setIsHello(false);
fetchApi<Message[]>(`/chats/${sessionId}/message-history`).then((res) => {
setUserMessages(res)
})
setUserMessages(res);
});
}
if (newSession) {
setIsHello(false)
createNewConversation()
// if (newSession) {
// setIsHello(false);
// createNewConversation();
// }
}, [sessionId]);
// 动画效果
useEffect(() => {
if (isHello) {
// 显示欢迎页,隐藏聊天页
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnimChat, {
toValue: 0,
duration: 300,
useNativeDriver: true,
})
]).start();
} else {
// 显示聊天页,隐藏欢迎页
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(fadeAnimChat, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
})
]).start();
}
}, [sessionId, newSession])
}, [isHello, fadeAnim, fadeAnimChat]);
return (
<View style={{ flex: 1, backgroundColor: 'white', paddingTop: insets.top }}>
{/* 导航栏 - 保持在顶部 */}
<View style={isHello ? "" : styles.navbar} className="relative w-full flex flex-row items-center justify-between pb-3 pt-[2rem]">
{/* 点击去memo list 页面 */}
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 导航栏 */}
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => {
router.replace('/memo-list');
}}
onPress={() => router.push('/memo-list')}
>
<ReturnArrow />
</TouchableOpacity>
<ThemedText className={`!text-textSecondary font-semibold text-3xl w-full text-center flex-1 ${isHello ? "opacity-0" : ""}`}>MemoWake</ThemedText>
<View />
<ThemedText style={styles.title}>MemoWake</ThemedText>
<View style={styles.placeholder} />
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
enabled={!isHello}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
onContentSizeChange={() => {
if (scrollViewRef.current && !isHello) {
scrollViewRef.current.scrollToEnd({ animated: true });
}
}}
>
{/* 内容区域 */}
<View className="flex-1">
{isHello ? <AskHello /> : <Chat userMessages={userMessages} sessionId={sessionId} />}
</View>
</ScrollView>
<View style={styles.contentContainer}>
{/* 欢迎页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnim,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'auto' : 'none',
zIndex: 1
}
]}
>
<AskHello />
</Animated.View>
{/* 功能区 - 放在 KeyboardAvoidingView 内但在 ScrollView 外 */}
<View className="w-full px-[1.5rem] mb-[2rem]">
<SendMessage setUserMessages={setUserMessages} setConversationId={setConversationId} setIsHello={setIsHello} conversationId={conversationId} />
{/* 聊天页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnimChat,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'none' : 'auto',
zIndex: 0
}
]}
>
<Chat userMessages={userMessages} sessionId={sessionId} />
</Animated.View>
</View>
{/* 输入框 */}
<View style={styles.inputContainer}>
<SendMessage
setIsHello={setIsHello}
setUserMessages={setUserMessages}
setConversationId={setConversationId}
conversationId={conversationId}
/>
</View>
</KeyboardAvoidingView>
</View>
@ -114,83 +166,66 @@ export default function AskScreen() {
}
const styles = StyleSheet.create({
navbar: {
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
backgroundColor: 'white',
zIndex: 10,
},
container: {
flex: 1,
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 60
},
navbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
backgroundColor: 'white',
// 使用 border 替代阴影
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
// 如果需要更柔和的边缘,可以添加一个微妙的阴影
elevation: 1, // Android
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 1,
},
hiddenNavbar: {
shadowOpacity: 0,
elevation: 0,
},
backButton: {
marginLeft: 16,
padding: 12
padding: 8,
marginRight: 8,
},
content: {
flex: 1,
padding: 20,
alignItems: 'center',
justifyContent: 'center',
},
description: {
fontSize: 16,
color: '#666',
title: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
marginBottom: 40,
paddingHorizontal: 20,
lineHeight: 24,
flex: 1,
},
chipsContainer: {
width: "100%",
flexDirection: 'row',
flexWrap: 'nowrap',
placeholder: {
width: 40,
},
// 更新 keyboardAvoidingView 和 contentContainer 样式
keyboardAvoidingView: {
flex: 1,
},
contentContainer: {
flex: 1,
justifyContent: 'center',
marginBottom: 40,
display: "flex",
alignItems: "center",
overflow: "scroll",
paddingBottom: 20,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF5E6',
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 20,
margin: 5,
},
chipText: {
marginLeft: 6,
color: '#FF9500',
fontSize: 14,
},
inputContainer: {
flexDirection: 'row',
padding: 16,
paddingBottom: 30,
absoluteView: {
position: 'absolute', // 保持绝对定位
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'white',
},
input: {
flex: 1,
borderColor: '#FF9500',
borderWidth: 1,
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 12,
fontSize: 16,
width: '100%', // 确保输入框宽度撑满
},
voiceButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
marginRight: 8, // 添加一点右边距
inputContainer: {
padding: 16,
paddingBottom: 24,
backgroundColor: 'white',
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
},
});

View File

@ -1,108 +1,229 @@
import IP from '@/assets/icons/svg/ip.svg';
import { registerBackgroundUploadTask, triggerManualUpload } from '@/components/file-upload/backgroundUploader';
import * as MediaLibrary from 'expo-media-library';
import { useRouter } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform, Text, TouchableOpacity, View } from 'react-native';
import ReturnArrow from "@/assets/icons/svg/returnArrow.svg";
import Chat from "@/components/ask/chat";
import AskHello from "@/components/ask/hello";
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 React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Animated,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
import MemoList from './memo-list';
export default function HomeScreen() {
const router = useRouter();
const { t } = useTranslation();
export default function AskScreen() {
const insets = useSafeAreaInsets();
const [isLoading, setIsLoading] = useState(true);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const [isHello, setIsHello] = useState(true);
const [conversationId, setConversationId] = useState<string | null>(null);
const [userMessages, setUserMessages] = useState<Message[]>([]);
useEffect(() => {
const checkAuthStatus = async () => {
try {
let token;
if (Platform.OS === 'web') {
token = localStorage.getItem('token') || '';
} else {
token = await SecureStore.getItemAsync('token') || '';
}
// 动画值
const fadeAnim = useRef(new Animated.Value(1)).current;
const fadeAnimChat = useRef(new Animated.Value(0)).current;
const loggedIn = !!token;
setIsLoggedIn(loggedIn);
if (loggedIn) {
// 已登录,请求必要的权限
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status === 'granted') {
await registerBackgroundUploadTask();
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
await triggerManualUpload(oneDayAgo, now);
}
router.replace('/ask');
}
} catch (error) {
console.error('检查登录状态出错:', error);
} finally {
setIsLoading(false);
}
};
checkAuthStatus();
const createNewConversation = useCallback(async () => {
setUserMessages([{
content: { text: "请输入您的问题,寻找,请稍等..." },
role: 'Assistant',
timestamp: new Date().toISOString()
}]);
const data = await fetchApi<string>("/chat/new", { method: "POST" });
setConversationId(data);
}, []);
if (isLoading) {
return (
<View className="flex-1 bg-bgPrimary justify-center items-center">
<Text className="text-white">...</Text>
</View>
);
}
const { sessionId, newSession } = useLocalSearchParams<{
sessionId: string;
newSession: string;
}>();
// 处理滚动到底部
useEffect(() => {
if (scrollViewRef.current && !isHello) {
scrollViewRef.current.scrollToEnd({ animated: true });
}
}, [userMessages, isHello]);
// 处理路由参数
useEffect(() => {
if (sessionId) {
setConversationId(sessionId);
setIsHello(false);
fetchApi<Message[]>(`/chats/${sessionId}/message-history`).then((res) => {
setUserMessages(res);
});
}
if (newSession) {
setIsHello(false);
createNewConversation();
}
}, [sessionId, newSession]);
// 动画效果
useEffect(() => {
if (isHello) {
// 显示欢迎页,隐藏聊天页
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnimChat, {
toValue: 0,
duration: 300,
useNativeDriver: true,
})
]).start();
} else {
// 显示聊天页,隐藏欢迎页
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(fadeAnimChat, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
})
]).start();
}
}, [isHello, fadeAnim, fadeAnimChat]);
return (
<View className="flex-1">
{
isLoggedIn ? <MemoList /> :
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem] " style={{ paddingTop: insets.top + 48 }}>
{/* 标题区域 */}
<View className="items-start mb-10 w-full px-5">
<Text className="text-white text-3xl font-bold mb-3 text-left">
{t('auth.welcomeAwaken.awaken', { ns: 'login' })}
{"\n"}
{t('auth.welcomeAwaken.your', { ns: 'login' })}
{"\n"}
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
</Text>
<Text className="text-white/85 text-base text-left">
{t('auth.welcomeAwaken.slogan', { ns: 'login' })}
</Text>
</View>
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 导航栏 */}
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.push('/memo-list')}
>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={styles.title}>MemoWake</ThemedText>
<View style={styles.placeholder} />
</View>
{/* Memo 形象区域 */}
<View className="items-center">
<IP />
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
enabled={!isHello}
>
<View style={styles.contentContainer}>
{/* 欢迎页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnim,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'auto' : 'none',
zIndex: 1
}
]}
>
<AskHello />
</Animated.View>
{/* 介绍文本 */}
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
{t('auth.welcomeAwaken.gallery', { ns: 'login' })}
{"\n"}
{t('auth.welcomeAwaken.back', { ns: 'login' })}
</Text>
{/* <MessagePush /> */}
{/* 唤醒按钮 */}
<TouchableOpacity
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"
onPress={async () => {
router.push('/login')
}}
activeOpacity={0.8}
>
<Text className="text-[#4C320C] font-bold text-lg">
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
</Text>
</TouchableOpacity>
</View>
}
{/* 聊天页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnimChat,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'none' : 'auto',
zIndex: 0
}
]}
>
<Chat userMessages={userMessages} sessionId={sessionId} />
</Animated.View>
</View>
{/* 输入框 */}
<View style={styles.inputContainer}>
<SendMessage
setIsHello={setIsHello}
setUserMessages={setUserMessages}
setConversationId={setConversationId}
conversationId={conversationId}
/>
</View>
</KeyboardAvoidingView>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
},
navbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
backgroundColor: 'white',
// 使用 border 替代阴影
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
// 如果需要更柔和的边缘,可以添加一个微妙的阴影
elevation: 1, // Android
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 1,
},
hiddenNavbar: {
shadowOpacity: 0,
elevation: 0,
},
backButton: {
padding: 8,
marginRight: 8,
},
title: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
flex: 1,
},
placeholder: {
width: 40,
},
keyboardAvoidingView: {
flex: 1,
},
contentContainer: {
flex: 1,
position: 'relative',
},
absoluteView: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'white', // 添加背景色
},
inputContainer: {
padding: 16,
paddingBottom: 24,
backgroundColor: 'white',
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
},
});

View File

@ -1,28 +1,39 @@
import IP from "@/assets/icons/svg/ip.svg";
import { ThemedText } from "@/components/ThemedText";
import { useTranslation } from "react-i18next";
import { View } from 'react-native';
import { ScrollView, View } from 'react-native';
export default function AskHello() {
const { t } = useTranslation();
return (
<View className="flex-1 bg-white overflow-auto w-full">
{/* 内容区域 IP与介绍文本*/}
<View className="items-center flex-1">
<ThemedText className="text-3xl font-bold text-center">
{t('ask.hi', { ns: 'ask' })}
{"\n"}
{t('ask.iAmMemo', { ns: 'ask' })}
</ThemedText>
<View className="justify-center items-center"><IP /></View>
<ThemedText className="!text-textPrimary text-center -mt-[4rem]">
{t('ask.ready', { ns: 'ask' })}
{"\n"}
{t('ask.justAsk', { ns: 'ask' })}
</ThemedText>
</View>
<View className="flex-1 bg-white w-full">
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 16,
paddingBottom: 20
}}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
>
<View className="items-center">
<ThemedText className="text-3xl font-bold text-center">
{t('ask.hi', { ns: 'ask' })}
{"\n"}
{t('ask.iAmMemo', { ns: 'ask' })}
</ThemedText>
<View className="justify-center items-center my-4">
<IP />
</View>
<ThemedText className="!text-textPrimary text-center">
{t('ask.ready', { ns: 'ask' })}
{"\n"}
{t('ask.justAsk', { ns: 'ask' })}
</ThemedText>
</View>
</ScrollView>
</View>
);
}

View File

@ -1,7 +1,8 @@
'use client';
import VoiceSvg from '@/assets/icons/svg/vioce.svg';
import React, { Dispatch, SetStateAction, useCallback, useState } from 'react';
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import {
Keyboard,
StyleSheet,
TextInput,
TouchableOpacity,
@ -12,7 +13,7 @@ import { fetchApi } from '@/lib/server-api-util';
import { Message } from '@/types/ask';
interface Props {
setIsHello: (isHello: boolean) => void,
setIsHello: Dispatch<SetStateAction<boolean>>,
conversationId: string | null,
setUserMessages: Dispatch<SetStateAction<Message[]>>;
setConversationId: (conversationId: string) => void,
@ -70,7 +71,6 @@ export default function SendMessage(props: Props) {
// 如果没有对话ID创建新对话并获取消息否则直接获取消息
if (!conversationId) {
createNewConversation(text);
setIsHello(false);
} else {
getConversation({
session_id: conversationId,
@ -81,6 +81,26 @@ export default function SendMessage(props: Props) {
setInputValue('');
}
}
useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener(
'keyboardWillShow',
() => {
console.log('Keyboard will show');
setIsHello(false);
setUserMessages([{
content: {
text: "快来寻找你的记忆吧。。。"
},
role: 'Assistant',
timestamp: new Date().toISOString()
}])
}
);
return () => {
keyboardWillShowListener.remove();
};
}, []);
return (
<View style={styles.container}>

View File

@ -2,10 +2,13 @@ import NavbarSvg from "@/assets/icons/svg/navbar.svg";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import React from 'react';
import { Platform, TouchableOpacity, View } from 'react-native';
import { Dimensions, Platform, TouchableOpacity, View } from 'react-native';
import { Circle, Ellipse, G, Mask, Path, Rect, Svg } from 'react-native-svg';
const AskNavbar = () => {
// 获取设备尺寸
const { width } = Dimensions.get('window');
return (
<View className="absolute bottom-0 left-0 right-0 bg-white" style={{
shadowColor: '#000',
@ -27,7 +30,7 @@ const AskNavbar = () => {
params: { newSession: "true" }
});
}}
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : '-mt-[5rem] ml-[0.8rem]'}`}
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : width <= 375 ? '-mt-[5rem] ml-[2rem]' : '-mt-[5rem] ml-[0.8rem]'}`}
>
<View style={{
shadowColor: '#FFB645',