2025-08-04 11:49:57 +08:00

316 lines
12 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.

'use client';
import SendSvg from '@/assets/icons/svg/send.svg';
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 { webSocketManager, WsMessage } from '@/lib/websocket-util';
import { Message } from '@/types/ask';
import { useTranslation } from 'react-i18next';
import { ThemedText } from '../ThemedText';
import { createNewConversation } from './utils';
interface Props {
setIsHello: Dispatch<SetStateAction<boolean>>,
conversationId: string | null,
setUserMessages: Dispatch<SetStateAction<Message[]>>;
setConversationId: (conversationId: string) => void,
selectedImages: string[];
setSelectedImages: Dispatch<SetStateAction<string[]>>;
}
const RENDER_INTERVAL = 50; // 渲染间隔,单位毫秒
export default function SendMessage(props: Props) {
const { setIsHello, conversationId, setUserMessages, setConversationId, selectedImages, setSelectedImages } = props;
const { t } = useTranslation()
// 用户询问
const [inputValue, setInputValue] = useState('');
// 添加一个ref来跟踪键盘状态
const isKeyboardVisible = useRef(false);
const chunkQueue = useRef<string[]>([]);
const renderInterval = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const handleChatStream = (message: WsMessage) => {
if (message.type !== 'ChatStream' || !message.chunk) return;
chunkQueue.current.push(message.chunk);
if (!renderInterval.current) {
renderInterval.current = setInterval(() => {
if (chunkQueue.current.length > 0) {
const textToRender = chunkQueue.current.join('');
chunkQueue.current = [];
setUserMessages(prevMessages => {
if (prevMessages.length === 0) return prevMessages;
const lastMessage = prevMessages[prevMessages.length - 1];
if (lastMessage.role !== 'assistant') return prevMessages;
const updatedContent = (lastMessage.content === 'keepSearchIng' ? '' : lastMessage.content) + textToRender;
const updatedLastMessage = { ...lastMessage, content: updatedContent };
return [...prevMessages.slice(0, -1), updatedLastMessage];
});
} else {
if (renderInterval.current) {
clearInterval(renderInterval.current);
renderInterval.current = null;
}
}
}, RENDER_INTERVAL);
}
};
const handleChatStreamEnd = (message: WsMessage) => {
if (message.type !== 'ChatStreamEnd') return;
// Stop the timer and process any remaining chunks
if (renderInterval.current) {
clearInterval(renderInterval.current);
renderInterval.current = null;
}
const remainingText = chunkQueue.current.join('');
chunkQueue.current = [];
setUserMessages(prevMessages => {
if (prevMessages.length === 0) return prevMessages;
const lastMessage = prevMessages[prevMessages.length - 1];
if (lastMessage.role !== 'assistant') return prevMessages;
// Apply remaining chunks from the queue
const contentWithQueue = (lastMessage.content === 'keepSearchIng' ? '' : lastMessage.content) + remainingText;
// Create the final updated message object
const updatedLastMessage = {
...lastMessage,
// Use the final message from ChatStreamEnd if available, otherwise use the content with queued text
content: message.message ? message.message.content : contentWithQueue,
timestamp: message.message ? message.message.timestamp : lastMessage.timestamp,
};
return [...prevMessages.slice(0, -1), updatedLastMessage];
});
};
const handleChatResponse = (message: WsMessage) => {
if (message.type === 'ChatResponse' && message.message) {
setUserMessages(prev => {
const newMessages = prev.filter(item => item.content !== 'keepSearchIng');
return [...newMessages, {
...(message.message as Message),
role: 'assistant',
}];
});
}
}
const typedHandleChatStream = handleChatStream as (message: WsMessage) => void;
const typedHandleChatStreamEnd = handleChatStreamEnd as (message: WsMessage) => void;
const typedHandleChatResponse = handleChatResponse as (message: WsMessage) => void;
webSocketManager.subscribe('ChatStream', typedHandleChatStream);
webSocketManager.subscribe('ChatStreamEnd', typedHandleChatStreamEnd);
webSocketManager.subscribe('ChatResponse', typedHandleChatResponse);
return () => {
webSocketManager.unsubscribe('ChatStream', typedHandleChatStream);
webSocketManager.unsubscribe('ChatStreamEnd', typedHandleChatStreamEnd);
webSocketManager.unsubscribe('ChatResponse', typedHandleChatResponse);
if (renderInterval.current) {
clearInterval(renderInterval.current);
}
};
}, [setUserMessages]);
useEffect(() => {
// 使用keyboardWillShow而不是keyboardDidShow这样可以在键盘完全显示前更新UI
const showSubscription = Keyboard.addListener('keyboardWillShow', () => {
isKeyboardVisible.current = true;
if (!conversationId) {
// 确保在下一个事件循环中更新状态,避免可能的渲染问题
requestAnimationFrame(() => {
setIsHello(false);
setUserMessages([
{
id: Math.random().toString(36).substring(2, 9),
content: t("ask:ask.introduction1"),
role: 'assistant',
timestamp: new Date().toISOString()
}
])
});
}
});
const hideSubscription = Keyboard.addListener('keyboardWillHide', () => {
isKeyboardVisible.current = false;
});
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, [conversationId, setIsHello, setUserMessages, t]);
// 发送询问
const handleSubmit = useCallback(async () => {
const text = inputValue.trim();
// 用户输入信息之后进行后续操作
if (text) {
// 将用户输入信息添加到消息列表中
setUserMessages(pre => ([...pre, {
id: Math.random().toString(36).substring(2, 9),
content: text,
role: 'user',
timestamp: new Date().toISOString()
},
{
id: Math.random().toString(36).substring(2, 9),
content: "keepSearchIng",
role: 'assistant',
timestamp: new Date().toISOString()
}
]));
let currentSessionId = conversationId;
// 如果没有对话ID先创建一个新对话
if (!currentSessionId) {
currentSessionId = await createNewConversation(text);
setConversationId(currentSessionId);
webSocketManager.send({
type: 'Chat',
session_id: currentSessionId,
message: text,
image_material_ids: selectedImages.length > 0 ? selectedImages : undefined,
});
setSelectedImages([]);
}
// 通过 WebSocket 发送消息
if (currentSessionId) {
webSocketManager.send({
type: 'Chat',
session_id: currentSessionId,
message: text,
image_material_ids: selectedImages.length > 0 ? selectedImages : undefined,
});
setSelectedImages([]);
} else {
console.error("无法获取 session_id消息发送失败。");
// 可以在这里处理错误,例如显示一个提示
setUserMessages(prev => prev.filter(item => item.content !== 'keepSearchIng'));
}
// 将输入框清空
setInputValue('');
// 只有在键盘可见时才关闭键盘
if (isKeyboardVisible.current) {
Keyboard.dismiss();
}
}
}, [inputValue, conversationId, selectedImages, createNewConversation, setConversationId, setSelectedImages, setUserMessages]);
const handleQuitly = (type: string) => {
setIsHello(false)
setUserMessages(pre => ([
...pre,
{
id: Math.random().toString(36).substring(2, 9),
content: 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..."
placeholderTextColor="#999"
value={inputValue}
onChangeText={(text: string) => {
setInputValue(text);
}}
onSubmitEditing={handleSubmit}
// 调起的键盘类型
returnKeyType="send"
/>
<TouchableOpacity
style={[styles.voiceButton, { bottom: -10 }]}
onPress={handleSubmit}
className="absolute right-2"
>
<View>
<SendSvg color={'white'} width={24} height={24} />
</View>
</TouchableOpacity>
</View>
</View>
);
}
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',
},
input: {
borderColor: '#FF9500',
borderWidth: 1,
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 13,
fontSize: 16,
width: '100%', // 确保输入框宽度撑满
paddingRight: 50
},
voiceButton: {
padding: 8,
borderRadius: 20,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
transform: [{ translateY: -12 }],
},
});