2025-08-06 10:47:05 +08:00

388 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Chat from "@/components/ask/chat";
import AskHello from "@/components/ask/hello";
import SendMessage from "@/components/ask/send";
import { fetchApi } from "@/lib/server-api-util";
import { getWebSocketErrorMessage, webSocketManager, WsMessage } from "@/lib/websocket-util";
import { Assistant, Message } from "@/types/ask";
import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from "react-i18next";
import {
Animated,
FlatList,
Keyboard,
KeyboardAvoidingView,
Platform,
StyleSheet,
View
} from 'react-native';
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { runOnJS } from 'react-native-reanimated';
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AskScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const chatListRef = useRef<FlatList>(null);
const [isHello, setIsHello] = useState(true);
const [conversationId, setConversationId] = useState<string | null>(null);
const [userMessages, setUserMessages] = useState<Message[]>([]);
const [selectedImages, setSelectedImages] = useState<string[]>([]);
const fadeAnim = useRef(new Animated.Value(1)).current;
const fadeAnimChat = useRef(new Animated.Value(0)).current;
const { t } = useTranslation();
const { sessionId, newSession, extra } = useLocalSearchParams<{
sessionId: string;
newSession: string;
extra: string;
}>();
// 创建一个可复用的滚动函数
const scrollToEnd = useCallback((animated = true) => {
if (chatListRef.current) {
setTimeout(() => chatListRef.current?.scrollToEnd({ animated }), 100);
}
}, []);
// 右滑
const gesture = Gesture.Pan()
.onEnd((event) => {
const { translationX } = event;
const threshold = 100; // 滑动阈值
if (translationX > threshold) {
// 从左向右滑动,跳转页面
runOnJS(router.replace)("/memo-list");
}
})
.minPointers(1)
.activeOffsetX([-20, 20]) // 扩大触发范围避免与ScrollView冲突
.failOffsetY([-10, 10]); // 限制Y轴的偏移避免垂直滚动时触发
useEffect(() => {
if (!isHello && userMessages.length > 0) {
scrollToEnd();
}
}, [userMessages, isHello, scrollToEnd]);
useEffect(() => {
const keyboardDidShowListener = Keyboard.addListener(
'keyboardDidShow',
(e) => {
setTimeout(() => {
if (!isHello) {
scrollToEnd();
}
}, 100);
}
);
const keyboardDidHideListener = Keyboard.addListener(
'keyboardDidHide',
() => {
setTimeout(() => {
if (!isHello) {
scrollToEnd(false);
}
}, 100);
}
);
return () => {
keyboardDidShowListener.remove();
keyboardDidHideListener.remove();
};
}, [isHello]);
useFocusEffect(
useCallback(() => {
webSocketManager.connect();
const handleChatStream = (message: WsMessage) => {
if (message.type === 'ChatStream') {
setUserMessages(prevMessages => {
const newMessages = [...prevMessages];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.role === Assistant) {
if (typeof lastMessage.content === 'string') {
if (lastMessage.content === 'keepSearchIng') {
// 第一次收到流式消息,替换占位符
lastMessage.content = message.chunk;
} else {
// 持续追加流式消息
lastMessage.content += message.chunk;
}
} else {
// 如果 content 是数组,则更新第一个 text 部分
const textPart = lastMessage.content.find(p => p.type === 'text');
if (textPart) {
textPart.text = (textPart.text || '') + message.chunk;
}
}
}
return newMessages;
});
}
};
const handleChatStreamEnd = (message: WsMessage) => {
if (message.type === 'ChatStreamEnd') {
setUserMessages(prevMessages => {
const newMessages = [...prevMessages];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.role === Assistant) {
// 使用最终消息替换流式消息,确保 message.message 存在
if (message.message) {
newMessages[newMessages.length - 1] = message.message as Message;
} else {
// 如果最终消息为空,则移除 'keepSearchIng' 占位符
return prevMessages.filter(m => !(typeof m.content === 'string' && m.content === 'keepSearchIng'));
}
}
return newMessages;
});
}
};
const handleError = (message: WsMessage) => {
if (message.type === 'Error') {
console.log(`WebSocket Error: ${message.code} - ${message.message}`);
// 可以在这里添加错误提示,例如替换最后一条消息为错误信息
setUserMessages(prev => {
// 创建新的数组和新的消息对象
return prev.map((msg, index) => {
if (index === prev.length - 1 &&
typeof msg.content === 'string' &&
msg.content === 'keepSearchIng') {
// 返回新的消息对象
return {
...msg,
content: getWebSocketErrorMessage(message.code, t)
};
}
return msg;
});
});
}
};
webSocketManager.subscribe('ChatStream', handleChatStream);
webSocketManager.subscribe('ChatStreamEnd', handleChatStreamEnd);
webSocketManager.subscribe('Error', handleError);
return () => {
webSocketManager.unsubscribe('ChatStream', handleChatStream);
webSocketManager.unsubscribe('ChatStreamEnd', handleChatStreamEnd);
webSocketManager.unsubscribe('Error', handleError);
// 可以在这里选择断开连接,或者保持连接以加快下次进入页面的速度
// webSocketManager.disconnect();
};
}, [])
);
useEffect(() => {
if (sessionId) {
setConversationId(sessionId);
setIsHello(false);
fetchApi<Message[]>(`/chats/${sessionId}/message-history`).then((res) => {
setUserMessages(res);
});
}
if (newSession) {
setIsHello(true);
setConversationId(null);
}
}, [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: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnimChat, {
toValue: 1,
duration: 300,
useNativeDriver: true,
})
]).start(() => {
setTimeout(() => {
if (!isHello) {
scrollToEnd(false);
}
}, 50);
});
}
}, [isHello, fadeAnim, fadeAnimChat]);
useEffect(() => {
if (!isHello) {
// 不再自动关闭键盘,让用户手动控制
// 这里可以添加其他需要在隐藏hello界面时执行的逻辑
scrollToEnd(false);
}
}, [isHello]);
// 组件卸载时清理动画
useEffect(() => {
return () => {
// 停止所有可能正在运行的动画
fadeAnim.stopAnimation();
fadeAnimChat.stopAnimation();
};
}, []);
useFocusEffect(
useCallback(() => {
if (!sessionId) {
setIsHello(true);
setUserMessages([]);
}
}, [sessionId])
);
return (
<GestureDetector gesture={gesture}>
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.contentContainer}>
{/* 欢迎页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnim,
pointerEvents: isHello ? 'auto' : 'none',
zIndex: 1
}
]}
>
<AskHello setUserMessages={setUserMessages} setConversationId={setConversationId} setIsHello={setIsHello} />
</Animated.View>
{/* 聊天页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnimChat,
pointerEvents: isHello ? 'none' : 'auto',
zIndex: 0
}
]}
>
<Chat
ref={chatListRef}
userMessages={userMessages}
sessionId={sessionId}
setSelectedImages={setSelectedImages}
selectedImages={selectedImages}
style={styles.chatContainer}
contentContainerStyle={styles.chatContentContainer}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollToEnd()}
/>
</Animated.View>
</View>
{/* 输入框区域 */}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}>
<View style={styles.inputContainer} key={conversationId}>
<SendMessage
setIsHello={setIsHello}
conversationId={conversationId}
setConversationId={setConversationId}
setUserMessages={setUserMessages}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
/>
</View>
</KeyboardAvoidingView>
</View >
</GestureDetector>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
navbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
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,
opacity: 0
},
backButton: {
padding: 8,
marginRight: 8,
},
title: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
flex: 1,
},
placeholder: {
width: 40,
},
contentContainer: {
flex: 1,
position: 'relative'
},
absoluteView: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'white',
},
chatContainer: {
flex: 1,
},
chatContentContainer: {
paddingBottom: 20,
},
inputContainer: {
padding: 16,
paddingBottom: 24,
backgroundColor: 'white',
// borderTopWidth: 1,
// borderTopColor: '#f0f0f0',
},
});