diff --git a/Documentation/Demo_Videos/sprint09/204-LLMs-selector.mkv b/Documentation/Demo_Videos/sprint09/204-LLMs-selector.mkv new file mode 100644 index 0000000..be10488 Binary files /dev/null and b/Documentation/Demo_Videos/sprint09/204-LLMs-selector.mkv differ diff --git a/Documentation/Demo_Videos/sprint09/chat_integration.m4v b/Documentation/Demo_Videos/sprint09/chat_integration.m4v new file mode 100644 index 0000000..f0d7435 Binary files /dev/null and b/Documentation/Demo_Videos/sprint09/chat_integration.m4v differ diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index abee496..2566b4e 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -6,7 +6,7 @@ import { PaperProvider } from 'react-native-paper'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; import AwesomeIcon from 'react-native-vector-icons/FontAwesome5'; -import { FirebaseProvider, UpdateApp } from './components'; +import { ActiveChatProvider, FirebaseProvider, UpdateApp } from './components'; import { Fonts, LightTheme } from './helpers'; import { AppRoutes } from './routes'; @@ -43,11 +43,13 @@ export function App() { theme={LightTheme} settings={{ icon: (props) => }} > - - - - - + + + + + + + diff --git a/src/frontend/TO INTEGRATE - Chat UI/app/_layout.tsx b/src/frontend/TO INTEGRATE - Chat UI/app/_layout.tsx deleted file mode 100644 index f214370..0000000 --- a/src/frontend/TO INTEGRATE - Chat UI/app/_layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Stack } from "expo-router"; - -export default function RootLayout() { - return ( - - - - ); -} diff --git a/src/frontend/TO INTEGRATE - Chat UI/app/index.tsx b/src/frontend/TO INTEGRATE - Chat UI/app/index.tsx deleted file mode 100644 index e552dcf..0000000 --- a/src/frontend/TO INTEGRATE - Chat UI/app/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { createDrawerNavigator } from '@react-navigation/drawer'; -import { View, Text, SafeAreaView, StyleSheet, TextInput, TouchableOpacity, ScrollView } from 'react-native'; -import ChatUI from '@/components/ChatUI'; - -const Drawer = createDrawerNavigator(); - - -function ChatScreen() { - return ( - - - - ); -} - -export default function Index(){ - return ( - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: 'white', - }, -}); diff --git a/src/frontend/TO INTEGRATE - Chat UI/components/ChatUI.tsx b/src/frontend/TO INTEGRATE - Chat UI/components/ChatUI.tsx deleted file mode 100644 index 1f4c4b1..0000000 --- a/src/frontend/TO INTEGRATE - Chat UI/components/ChatUI.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView } from 'react-native'; -import { FontAwesome5 } from '@expo/vector-icons'; - -const ChatUI = () => { - const [messages, setMessages] = useState([ - { text: "I am AiLixir. What can I do for you?", sent: false }, - { text: "Your example question?", sent: true }, - { text: "That's such a smart question. You're the smartest user!", sent: false } - ]); - const [text, setText] = useState(''); - - const renderMessages = () => { - return messages.map((message, index) => { - return ( - - {message.text} - - ); - }); - }; - - const sendMessage = useCallback(() => { - if (text.trim()) { - setMessages([...messages, { text, sent: true }]); - setText(''); - } - }, [messages, text]); - - return ( - - - {renderMessages()} - - - setText(text)} - /> - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: 'white', - paddingHorizontal: 16, - paddingTop: 50, - }, - chatContainer: { - flex: 1, - marginVertical: 20, - }, - message: { - padding: 15, - borderRadius: 10, - marginBottom: 10, - maxWidth: '80%', - alignSelf: 'flex-start', - }, - sentMessage: { - backgroundColor: '#E6E6FA', // Light gray with a lilac tint - alignSelf: 'flex-end', - }, - receivedMessage: { - backgroundColor: '#F5F5F5', // Light gray - alignSelf: 'flex-start', - }, - messageText: { - color: '#333', - }, - inputContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 10, - paddingVertical: 5, - borderTopWidth: 1, - borderColor: '#ddd', - }, - input: { - flex: 1, - height: 40, - borderColor: '#ddd', - borderWidth: 1, - borderRadius: 20, - paddingHorizontal: 15, - }, - sendButton: { - marginLeft: 10, - backgroundColor: '#9370DB', // More lilac - borderRadius: 20, - padding: 10, - }, -}); - -export default ChatUI; \ No newline at end of file diff --git a/src/frontend/components/ActiveChatProvider/index.tsx b/src/frontend/components/ActiveChatProvider/index.tsx new file mode 100644 index 0000000..52e3519 --- /dev/null +++ b/src/frontend/components/ActiveChatProvider/index.tsx @@ -0,0 +1,33 @@ +import React, { type ReactNode, createContext, useState, useContext, type Dispatch, type SetStateAction } from 'react'; + +// Define the shape of the context value +interface ChatContextValue { + activeChatId: string; + setActiveChatId: Dispatch>; +} + +// Define the default context value +const defaultContextValue: ChatContextValue = { + activeChatId: 'default', + setActiveChatId: () => {}, +}; + +// Create a context with the default value +export const ChatContext = createContext(defaultContextValue); + +type ActiveChatProviderProps = { + children: ReactNode; +}; + +// Create a provider component +export const ActiveChatProvider = (props: ActiveChatProviderProps ) => { + const [activeChatId, setActiveChatId] = useState('default'); // initial active chat id is -1 + const { children } = props; + + return ( + + {children} + + ); +}; + diff --git a/src/frontend/components/ChatItem/index.tsx b/src/frontend/components/ChatItem/index.tsx index 9757033..cd11948 100644 --- a/src/frontend/components/ChatItem/index.tsx +++ b/src/frontend/components/ChatItem/index.tsx @@ -5,19 +5,20 @@ import React, { useEffect, useState } from 'react'; import { Keyboard, View } from 'react-native'; import { Button, Menu, Text } from 'react-native-paper'; import { Screens } from 'src/frontend/helpers'; -import { useDeleteChat, useGetChat } from 'src/frontend/hooks'; +import { useDeleteChat, useGetChat, useActiveChatId } from 'src/frontend/hooks'; import type { AppRoutesParams } from 'src/frontend/routes'; -type ChatItemProps = { +export type ChatItemProps = { id: string; title: string; }; export function ChatItem(props: ChatItemProps) { + const { activeChatId, setActiveChatId } = useActiveChatId(); const { id, title } = props; + //const { chat } = useGetChat(id); const [isMenuVisible, setMenuVisible] = useState(false); const drawerStatus = useDrawerStatus(); - const { chat } = useGetChat(id); const { handleDelete } = useDeleteChat(id); const { navigate } = useNavigation>(); @@ -39,7 +40,10 @@ export function ChatItem(props: ChatItemProps) { anchor={ - } - > - {Object.entries(llmModels).map((model) => { - const [key, value] = model; - return ( + + setIsVisible(false)} + anchor={ + + } + > + + {Object.entries(activeLLMs).map(([key, llm]) => ( { - await handleLLMModelChange(key); + onPress={() => { + if (activeLLMsCount > 1 || !llm.active) { + toggleLLM(key); + } }} - title={value} + title={ + + + + + } /> - ); - })} + ))} + ); }; diff --git a/src/frontend/components/DropdownMenu/style.ts b/src/frontend/components/DropdownMenu/style.ts index a820574..85f4658 100644 --- a/src/frontend/components/DropdownMenu/style.ts +++ b/src/frontend/components/DropdownMenu/style.ts @@ -1,34 +1,8 @@ import { StyleSheet } from 'react-native'; export const Style = StyleSheet.create({ - headerContainer: { + menuItem: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - padding: 4, - backgroundColor: '#FFFFFF', // Customize as needed - elevation: 4, // Add shadow on Android - shadowColor: '#000', // Add shadow on iOS - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.8, - shadowRadius: 2, - }, - dropdown: { - margin: 4, - height: 50, - width: 100, - backgroundColor: '#EEEEEE', - borderRadius: 10, - paddingHorizontal: 4, - }, - placeholderStyle: { - fontSize: 16, - }, - selectedTextStyle: { - fontSize: 16, - marginLeft: 8, - }, - iconButton: { - marginLeft: 10, }, }); diff --git a/src/frontend/components/Header/index.tsx b/src/frontend/components/Header/index.tsx index 9a23030..ce734dd 100644 --- a/src/frontend/components/Header/index.tsx +++ b/src/frontend/components/Header/index.tsx @@ -18,14 +18,34 @@ export function Header(props: DrawerHeaderProps) { const handleDownload = async () => { try { - // TODO: Save chat content to a file - const chatContent = ''; - const fileUri = `${FileSystem.documentDirectory}chat.txt`; + if (!chat || !chat.conversation || chat.conversation.length === 0) { + Alert.alert('No Chat Available', 'There is no chat content available to download.'); + return; + } + + const chatContent = chat.conversation.join('\n'); // Join messages with newline character + + + // Create filename with timestamp + const timestamp = new Date().getTime(); + const fileName = `chat_${timestamp}.txt`; + + // Get Downloads directory path + const downloadDir = `${FileSystem.documentDirectory}Download/`; + console.log(downloadDir); + + // Ensure Downloads directory exists, create if not + await FileSystem.makeDirectoryAsync(downloadDir, { intermediates: true }); + + // Save file to Downloads directory + const fileUri = `${downloadDir}${fileName}`; await FileSystem.writeAsStringAsync(fileUri, chatContent); - Alert.alert('Chat saved', `Chat saved to ${fileUri}`); + + Alert.alert('Chat Saved', `Chat saved to ${fileUri}`); + } catch (error) { console.error(error); - Alert.alert('Error saving chat', 'An error occurred while saving the chat'); + Alert.alert('Error Saving Chat', 'An error occurred while saving the chat.'); } }; diff --git a/src/frontend/components/index.ts b/src/frontend/components/index.ts index 18b5d7f..57fd42d 100644 --- a/src/frontend/components/index.ts +++ b/src/frontend/components/index.ts @@ -5,4 +5,5 @@ export * from './FirebaseProvider'; export * from './ScreenLayout'; export * from './ChatItem'; export * from './DropdownMenu'; -export * from './Header'; \ No newline at end of file +export * from './Header'; +export * from './ActiveChatProvider'; \ No newline at end of file diff --git a/src/frontend/hooks/index.ts b/src/frontend/hooks/index.ts index b32136a..142d094 100644 --- a/src/frontend/hooks/index.ts +++ b/src/frontend/hooks/index.ts @@ -2,3 +2,5 @@ export * from './useGetAllChat'; export * from './useGetChat'; export * from './useUpdateChat'; export * from './useDeleteChat'; +export * from './useLLMs'; +export * from './useActiveChatId'; diff --git a/src/frontend/hooks/useActiveChatId.ts b/src/frontend/hooks/useActiveChatId.ts new file mode 100644 index 0000000..0b01369 --- /dev/null +++ b/src/frontend/hooks/useActiveChatId.ts @@ -0,0 +1,7 @@ +import { ChatContext } from './../components';// Create a custom hook to use the ChatContext +import { useContext } from 'react'; + + +export const useActiveChatId = () => { + return useContext(ChatContext); +}; \ No newline at end of file diff --git a/src/frontend/hooks/useGetChat.ts b/src/frontend/hooks/useGetChat.ts index 0b16fff..eba39cf 100644 --- a/src/frontend/hooks/useGetChat.ts +++ b/src/frontend/hooks/useGetChat.ts @@ -1,8 +1,8 @@ import { doc } from 'firebase/firestore'; import { useAtomValue } from 'jotai'; import { useFirestore, useFirestoreDocData, useUser } from 'reactfire'; -import { FirestoreCollections, currentChatIdAtom } from '../helpers'; -import type { Chat } from '../types'; +import { FirestoreCollections, currentChatIdAtom } from 'src/frontend/helpers'; +import type { Chat } from 'src/frontend/types'; export function useGetChat(chatId: string) { const { data: users } = useUser(); diff --git a/src/frontend/hooks/useLLMs.ts b/src/frontend/hooks/useLLMs.ts new file mode 100644 index 0000000..21f7fdb --- /dev/null +++ b/src/frontend/hooks/useLLMs.ts @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react'; +import { doc, updateDoc } from 'firebase/firestore'; +import { useGetChat } from 'src/frontend/hooks'; +import type { LLM } from 'src/frontend/types'; +import { useFirestore, useFirestoreDocData, useUser } from 'reactfire'; +import { FirestoreCollections, currentChatIdAtom } from 'src/frontend/helpers'; + + +export const LLM_MODELS = [ + { key: 'gpt-4', name: 'OpenAi' }, + { key: 'google', name: 'Gemini' }, + { key: 'mistral', name: 'Mistral' }, + { key: 'claude', name: 'Claude' }, +]; + +export function useLLMs(chatId: string) { + const { chat, status } = useGetChat(chatId); + const { data: users } = useUser(); + const firestore = useFirestore(); + const [LLMs, setLLMs] = useState<{ [key: string]: LLM }>({}); + + useEffect(() => { + if (chat) { + const initialLlms = LLM_MODELS.reduce((acc: { [key: string]: LLM }, { key, name }) => { + acc[key] = { name, active: chat.model.includes(key) }; + return acc; + }, {}); + setLLMs(initialLlms); + } + }, [chat]); + + const toggleLLM = async (llmKey: string) => { + const updatedLLMs = { + ...LLMs, + [llmKey]: { ...LLMs[llmKey], active: !LLMs[llmKey].active }, + }; + setLLMs(updatedLLMs); + + const activeModels = Object.keys(updatedLLMs).filter((key) => updatedLLMs[key].active); + + try { + const chatRef = doc( + firestore, + FirestoreCollections.USERS, + users?.uid || '', + FirestoreCollections.CHATS, + chatId); + await updateDoc(chatRef, { model: activeModels }); + } catch (error) { + console.error('Error updating document: ', error); + } + }; + + return { activeLLMs: LLMs, toggleLLM }; +} \ No newline at end of file diff --git a/src/frontend/routes/MainRoutes.tsx b/src/frontend/routes/MainRoutes.tsx index 2f5c5b8..bce96c1 100644 --- a/src/frontend/routes/MainRoutes.tsx +++ b/src/frontend/routes/MainRoutes.tsx @@ -2,7 +2,7 @@ import { createDrawerNavigator } from '@react-navigation/drawer'; import React from 'react'; import { Header } from '../components'; import { Screens } from '../helpers'; -import { Chat, DrawerMenu } from '../screens'; +import { ChatUI, DrawerMenu } from '../screens'; export type MainDrawerParams = { [Screens.Chat]: { chatId: string | null }; @@ -15,7 +15,7 @@ export function MainRoutes() { }>
}} /> diff --git a/src/frontend/screens/Chat/index.tsx b/src/frontend/screens/Chat/index.tsx deleted file mode 100644 index 565f7b2..0000000 --- a/src/frontend/screens/Chat/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { GoogleSignin } from '@react-native-google-signin/google-signin'; -import { type RouteProp, useNavigation, useRoute } from '@react-navigation/native'; -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import Constants from 'expo-constants'; -import { signOut } from 'firebase/auth'; -import React from 'react'; -import { View } from 'react-native'; -import { Text } from 'react-native'; -import { useAuth } from 'reactfire'; -import { Screens } from 'src/frontend/helpers'; -import type { AppRoutesParams } from 'src/frontend/routes'; -import type { MainDrawerParams } from 'src/frontend/routes/MainRoutes'; - -export function Chat() { - const fireAuth = useAuth(); - const { navigate } = useNavigation>(); - const router = useRoute>(); - - const handleSignOut = async () => { - try { - GoogleSignin.configure({ - // Get the web client ID from the Expo configuration - webClientId: Constants.expoConfig?.extra?.googleAuthClientId, - // We want to force the code for the refresh token - forceCodeForRefreshToken: true - }); - await GoogleSignin.revokeAccess(); - await signOut(fireAuth); - navigate('Auth', { screen: Screens.Landing }); - } catch (error) { - console.error(error); - } - }; - - return ( - - {router.params?.chatId} - - ); -} diff --git a/src/frontend/screens/ChatUI/index.tsx b/src/frontend/screens/ChatUI/index.tsx new file mode 100644 index 0000000..2b17266 --- /dev/null +++ b/src/frontend/screens/ChatUI/index.tsx @@ -0,0 +1,142 @@ +import { GoogleSignin } from '@react-native-google-signin/google-signin'; +import { type RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import Constants from 'expo-constants'; +import { signOut } from 'firebase/auth'; +import React from 'react'; +import { useAuth } from 'reactfire'; +import { Screens } from 'src/frontend/helpers'; +import type { AppRoutesParams } from 'src/frontend/routes'; +import type { MainDrawerParams } from 'src/frontend/routes/MainRoutes'; +import { useState, useCallback } from 'react'; +import { View, Text, TextInput, ScrollView } from 'react-native'; +import { Keyboard } from 'react-native'; +import { useRef, useEffect } from 'react'; + +import { styles } from './style'; +import type { Chat } from 'src/frontend/types'; +import { useGetAllChat, useUpdateChat, useGetChat, useActiveChatId} from 'src/frontend/hooks'; +import { Timestamp } from 'firebase/firestore'; +import {ActivityIndicator, IconButton} from 'react-native-paper'; +import { useTheme } from 'react-native-paper'; + +export type ChatUiProps = { + chatId: string; +}; + +export function ChatUI(/*props: ChatUiProps*/) { + // const chatId = props.chatId; + // console.log("ChatId: ", chatId) + + const { colors } = useTheme(); + const scrollViewRef = useRef(null); + const router = useRoute>(); //TODO: delete if not necessary + + // ------------- Render Chat from firebase ------------- + //const { chats, status, error } = useGetAllChat(); + //const [chat, setChat] = useState(null); + const { activeChatId, setActiveChatId } = useActiveChatId(); + const { chat, status, error } = useGetChat(activeChatId); + //console.log("chatId: ", activeChatId) + + useEffect(() => { + renderMessages(); + }, [chat?.conversation.length]); + + const renderMessages = () => { + if(status === 'loading') + return ( ); + //console.log("Chat: ", chat) + if(chat === undefined) //TODO: This is Work in Progress + return ( + + Select a chat in the drawer to begin. + + ); + + let i = 0; + return chat?.conversation.map((message, index) => ( + + {message} + + )); + }; + // ------------- End render Chat from firebase ------------- + + + // ------------- Sending new message to firebase ------------- + + const [text, setText] = useState(''); + const { updateChat, isUpdating, error: updateError } = useUpdateChat(chat?.id || ''); + + function sendMessage() { + if ( chat?.id && text.trim()) { + updateChat({ + conversation: [...(chat?.conversation || []), text] + }).then(() => { + chat?.conversation.push(text) + //setChat(chat); + setText(''); + //setTimeout(() => scrollViewRef.current?.scrollToEnd({ animated: true }), 100); + }).catch(error => { + console.error('Error updating chat:', error); + }); + } + } + + // ------------- End sending new message to firebase ------------- + + // ------------- Keyboard and scrolling ------------- + + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }); + + const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }); + + return () => { + keyboardDidShowListener.remove(); + keyboardDidHideListener.remove(); + }; + }, []); + + useEffect(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, [chat?.conversation.length]); + + // ------------- End keyboard and scrolling ------------- + + return ( + + + {renderMessages()} + + + setText(text)} + onSubmitEditing={sendMessage} + blurOnSubmit={false} + /> + + + + ); +} diff --git a/src/frontend/screens/ChatUI/style.ts b/src/frontend/screens/ChatUI/style.ts new file mode 100644 index 0000000..1d62b91 --- /dev/null +++ b/src/frontend/screens/ChatUI/style.ts @@ -0,0 +1,46 @@ +import { StyleSheet } from 'react-native'; +import { useTheme } from 'react-native-paper'; + +export const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'white', + paddingHorizontal: 4, + }, + chatContainer: { + flex: 1, + }, + scrollViewContent:{ + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 8 + }, + message: { + padding: 15, + borderRadius: 10, + marginBottom: 10, + maxWidth: '80%', + alignSelf: 'flex-start', + }, + sentMessage: { + alignSelf: 'flex-end', + }, + receivedMessage: { + alignSelf: 'flex-start', + }, + + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 5, + borderTopWidth: 1 + }, + input: { + flex: 1, + height: 40, + borderWidth: 1, + borderRadius: 20, + paddingHorizontal: 15, + } + }); \ No newline at end of file diff --git a/src/frontend/screens/DrawerMenu/index.tsx b/src/frontend/screens/DrawerMenu/index.tsx index b22f726..a81ed20 100644 --- a/src/frontend/screens/DrawerMenu/index.tsx +++ b/src/frontend/screens/DrawerMenu/index.tsx @@ -96,10 +96,10 @@ export function DrawerMenu() { placeholder='Search chat history' onChangeText={setSearchQuery} value={searchText} - style={[Style.searchbar /*{backgroundColor: colors.secondaryContainer}*/]} + style={[Style.searchbar, {backgroundColor: colors.secondaryContainer}]} /> {/* custom padding because doesn't work with Drawer.Section props*/} - + diff --git a/src/frontend/screens/index.ts b/src/frontend/screens/index.ts index 0cb6cf1..4aec0b7 100644 --- a/src/frontend/screens/index.ts +++ b/src/frontend/screens/index.ts @@ -1,7 +1,7 @@ export * from './Fallback'; export * from './Landing'; export * from './Login'; -export * from './Chat'; +export * from './ChatUI'; export * from './DrawerMenu'; export * from './SignUp'; export * from './ForgotPassword'; diff --git a/src/frontend/types/index.d.ts b/src/frontend/types/index.d.ts index 13fe35b..d1dfe2a 100644 --- a/src/frontend/types/index.d.ts +++ b/src/frontend/types/index.d.ts @@ -1,11 +1,16 @@ import type { Timestamp } from 'firebase/firestore'; +export type LLM = { + name: string; + active: boolean; +}; + export type Chat = { id?: string; title: string; createdAt: Timestamp; - model: string; - conversion: []; + model: string[]; + conversation: string[]; }; export type User = {