From 27e403cc51097107b7ea3ba02643b3f719938e9a Mon Sep 17 00:00:00 2001 From: baaalint Date: Fri, 6 Sep 2024 12:56:00 +0200 Subject: [PATCH 1/2] feat(chat): integrating chatbot with new API --- ui/src/App.tsx | 2 +- ui/src/atoms/index.ts | 22 +- ui/src/atoms/sessions.ts | 47 ++++ ui/src/atoms/{apiAtoms.ts => users.ts} | 29 ++- ui/src/components/feature/Chat.tsx | 45 ++-- ui/src/components/feature/ChatHistoryList.tsx | 78 ------- .../components/feature/ChatHistoryTable.tsx | 4 +- ui/src/components/feature/ChatMessage.tsx | 10 +- ui/src/components/feature/ChatSessionList.tsx | 204 ++++++++++++++++++ ui/src/components/feature/Chatbar.tsx | 95 ++++---- ui/src/components/feature/Layout.tsx | 10 +- ui/src/components/feature/Login/Login.tsx | 36 ++-- ui/src/components/feature/TypingText.tsx | 2 +- ui/src/components/feature/UsersTable.tsx | 2 +- ui/src/components/shared/Bubble.tsx | 7 +- ui/src/components/shared/Message.tsx | 51 ++--- ui/src/hooks/useAuth.ts | 4 +- ui/src/services/Api.ts | 86 +++++--- ui/src/shared/theme.ts | 3 +- ui/src/shared/{types.ts => types/index.ts} | 7 + ui/src/shared/types/session.ts | 27 +++ ui/src/shared/types/workflow.ts | 44 ++++ 22 files changed, 551 insertions(+), 264 deletions(-) create mode 100644 ui/src/atoms/sessions.ts rename ui/src/atoms/{apiAtoms.ts => users.ts} (68%) delete mode 100644 ui/src/components/feature/ChatHistoryList.tsx create mode 100644 ui/src/components/feature/ChatSessionList.tsx rename ui/src/shared/{types.ts => types/index.ts} (92%) create mode 100644 ui/src/shared/types/session.ts create mode 100644 ui/src/shared/types/workflow.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f1db0be..cbaa4e0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -34,7 +34,7 @@ function App() { element: }, { - path: '/admin/chat-histories', + path: '/admin/histories', element: }, diff --git a/ui/src/atoms/index.ts b/ui/src/atoms/index.ts index fe11216..d5e96e0 100644 --- a/ui/src/atoms/index.ts +++ b/ui/src/atoms/index.ts @@ -12,8 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Client from '@services/Api'; -import { ChatHistory, DataRow, User } from '@shared/types'; +import { ChatHistory, User } from '@shared/types'; import { atom } from 'jotai'; import { atomWithStorage } from "jotai/utils"; @@ -23,26 +22,13 @@ export const adminAtom = atomWithStorage('admin', localStorage.getItem('admin') export const modalAtom = atom(false); export const asyncAtom = atom(false); export const messagesAtom = atom([]); -export const conversationsAtom = atom([]); -export const userAtom = atomWithStorage('user', localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') as string) : null); +export const userWithTokenAtom = atomWithStorage('user', localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') as string) : null); +export const publicUserAtom = atom({}); export const usernameAtom = atom(''); -export const selectedUserAtom = atom({ username: '', admin: false, token: '' }); -export const comparisonUserAtom = atom({ username: '', admin: false, token: '' }); +export const isTypingAtom = atom(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any export const selectedRowAtom = atom({}); -export const usersAtom = atom[]>([]); -export const createUserAtom = atom( - null, - async (get, set, newUser: User) => { - try { - const createdUser = await Client.createUser(newUser); - set(usersAtom, (prev) => [...prev, createdUser]); - } catch (error) { - console.error(error); - } - } -); diff --git a/ui/src/atoms/sessions.ts b/ui/src/atoms/sessions.ts new file mode 100644 index 0000000..9cf82fd --- /dev/null +++ b/ui/src/atoms/sessions.ts @@ -0,0 +1,47 @@ +// Copyright 2024 Iguazio +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Client from '@services/Api'; +import { Session } from '@shared/types/session'; +import { atom } from 'jotai'; + +export const sessionsAtom = atom([]); + +export const sessionsLoadingAtom = atom(false); + +export const sessionsErrorAtom = atom(null); + + +export const sessionsWithFetchAtom = atom( + (get) => get(sessionsAtom), + async (_get, set, username) => { + set(sessionsLoadingAtom, true); + set(sessionsErrorAtom, null); + try { + const sessions = await Client.getSessions(username as string); + const sortedSessions = sessions.data.sort((a: Session, b: Session) => { + const dateA = new Date(a.created as string); + const dateB = new Date(b.created as string); + return dateA.getTime() - dateB.getTime(); + }); + set(sessionsAtom, sortedSessions); + } catch (error) { + set(sessionsErrorAtom, 'Failed to fetch sessions'); + } finally { + set(sessionsLoadingAtom, false); + } + } +); + +export const selectedSessionAtom = atom({ name: '', description: '', labels: {} }); diff --git a/ui/src/atoms/apiAtoms.ts b/ui/src/atoms/users.ts similarity index 68% rename from ui/src/atoms/apiAtoms.ts rename to ui/src/atoms/users.ts index 5dd66e3..ebafdbc 100644 --- a/ui/src/atoms/apiAtoms.ts +++ b/ui/src/atoms/users.ts @@ -17,18 +17,9 @@ import { User } from '@shared/types'; import { atom } from 'jotai'; export const usersAtom = atom([]); - export const usersLoadingAtom = atom(false); - export const usersErrorAtom = atom(null); -export const fetchUsersAtom = atom( - async (get) => { - get(usersLoadingAtom); - const users = await Client.getUsers(); - return users.data; - } -); export const usersWithFetchAtom = atom( (get) => get(usersAtom), @@ -45,3 +36,23 @@ export const usersWithFetchAtom = atom( } } ); + +export const publicUserAtom = atom({}); +export const userLoadingAtom = atom(false); +export const userErrorAtom = atom(null); + +export const userWithFetchAtom = atom( + (get) => get(publicUserAtom), + async (_get, set, username: string) => { + set(userLoadingAtom, true); + set(userErrorAtom, null); + try { + const user = await Client.getUser(username); + set(publicUserAtom, user.data); + } catch (error) { + set(userErrorAtom, 'Failed to fetch user'); + } finally { + set(userLoadingAtom, false); + } + } +); diff --git a/ui/src/components/feature/Chat.tsx b/ui/src/components/feature/Chat.tsx index 1583d91..a727776 100644 --- a/ui/src/components/feature/Chat.tsx +++ b/ui/src/components/feature/Chat.tsx @@ -17,35 +17,33 @@ import Bubble from '@components/shared/Bubble' import Message from '@components/shared/Message' import Client from '@services/Api' import { ChatHistory } from '@shared/types' -import { messagesAtom, sessionIdAtom } from 'atoms' +import { messagesAtom, sessionIdAtom, usernameAtom } from 'atoms' import { useAtom } from 'jotai' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' const Chat = () => { const [messages, setMessages] = useAtom(messagesAtom) - const [sessionId, setSessionId] = useAtom(sessionIdAtom) + const [sessionId] = useAtom(sessionIdAtom) + const [username] = useAtom(usernameAtom) useEffect(() => { async function fetchData() { - console.log('getting session:', sessionId) - if (!sessionId) { - setMessages([]) - return - } - const chatSession = await Client.getSession(sessionId) - console.log('session resp:', chatSession) - if (chatSession) { - setMessages(chatSession.history) - } else { - setMessages([]) - } + await Client.getSession(username, sessionId) } fetchData() - }, [sessionId, setMessages]) + }, [sessionId, setMessages, username]) + + const lastMessageRef = useRef(null) + + useEffect(() => { + if (lastMessageRef.current) { + lastMessageRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }) + } + }, [messages, setMessages]) return ( { height="calc(100vh - 92px)" > - {messages?.map((chatHistory, index) => ( + {messages?.map((message, index) => ( ))} + - + ) diff --git a/ui/src/components/feature/ChatHistoryList.tsx b/ui/src/components/feature/ChatHistoryList.tsx deleted file mode 100644 index 360ce15..0000000 --- a/ui/src/components/feature/ChatHistoryList.tsx +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2024 Iguazio -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { ChevronDownIcon, DeleteIcon, EditIcon, ExternalLinkIcon, RepeatIcon } from '@chakra-ui/icons' -import { Button, Flex, IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react' -import { colors } from '@shared/theme' -import { ChatHistory } from '@shared/types' -import { selectFunc } from '@shared/utils' -import { modalAtom, sessionIdAtom } from 'atoms' -import { useAtom } from 'jotai' -import { useLocation, useNavigate } from 'react-router-dom' - -type Props = { - history: ChatHistory[] - setNew(newChat: boolean): void -} - -const ChatHistoryList = (props: Props) => { - const [sessionId, setSessionId] = useAtom(sessionIdAtom) - const [modal, setModal] = useAtom(modalAtom) - const histories = props.history - - const { pathname } = useLocation() - const navigate = useNavigate() - - const selectChat = (sid: string) => { - console.log('MODAL: ', modal) - console.log('selected chat:', sid, sessionId) - if (sid === sessionId) { - return - } - selectFunc(sid) - props.setNew(false) - setSessionId(sid) - navigate(`/chat/${sid}`) - } - - return ( - - - {histories.map((history, index) => ( - - - - } variant="outline" /> - - }>Rename - }>Export to PDF - }>Archive - }>Delete - - - - ))} - - - ) -} - -export default ChatHistoryList diff --git a/ui/src/components/feature/ChatHistoryTable.tsx b/ui/src/components/feature/ChatHistoryTable.tsx index 9ef3a93..0e29ed4 100644 --- a/ui/src/components/feature/ChatHistoryTable.tsx +++ b/ui/src/components/feature/ChatHistoryTable.tsx @@ -78,7 +78,7 @@ const ChatHistoryTable = () => { }, { page: 'Chat Histories', - url: '/chat-histories' + url: '/histories' } ]} /> @@ -90,6 +90,8 @@ const ChatHistoryTable = () => { columns={columns as TableColumn>[]} contextActions={contextActions} onSelectedRowChange={e => setSelectedRows(e.selectedRows)} + toggleClearRows={false} + onOpenDrawer={() => {}} /> ) diff --git a/ui/src/components/feature/ChatMessage.tsx b/ui/src/components/feature/ChatMessage.tsx index a316b6b..7709509 100644 --- a/ui/src/components/feature/ChatMessage.tsx +++ b/ui/src/components/feature/ChatMessage.tsx @@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { isTypingAtom } from '@atoms/index' +import { useAtom } from 'jotai' import React from 'react' +import Markdown from 'react-markdown' import TypingText from './TypingText' interface ChatMessageProps { @@ -20,7 +23,12 @@ interface ChatMessageProps { } const ChatMessage: React.FC = ({ message }) => { - return + const [isTyping] = useAtom(isTypingAtom) + + if (isTyping) { + return + } + return {message} } export default ChatMessage diff --git a/ui/src/components/feature/ChatSessionList.tsx b/ui/src/components/feature/ChatSessionList.tsx new file mode 100644 index 0000000..5e9840d --- /dev/null +++ b/ui/src/components/feature/ChatSessionList.tsx @@ -0,0 +1,204 @@ +// Copyright 2024 Iguazio +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { selectedSessionAtom, sessionsAtom, sessionsWithFetchAtom } from '@atoms/sessions' +import { ChevronDownIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons' +import { + Button, + Flex, + IconButton, + Input, + Menu, + MenuButton, + MenuItem, + MenuList, + useColorMode, + useToast +} from '@chakra-ui/react' +import Client from '@services/Api' +import { colors } from '@shared/theme' +import { Session } from '@shared/types/session' +import { isTypingAtom, messagesAtom, sessionIdAtom, usernameAtom } from 'atoms' +import { useAtom } from 'jotai' +import { useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +type Props = { + setNew(newChat: boolean): void +} + +const ChatSessionList = (props: Props) => { + const [, setSessionId] = useAtom(sessionIdAtom) + const [sessions] = useAtom(sessionsAtom) + const [selectedSession, setSelectedSession] = useAtom(selectedSessionAtom) + const [, setMessages] = useAtom(messagesAtom) + const [, setIsTyping] = useAtom(isTypingAtom) + const [username] = useAtom(usernameAtom) + const { pathname } = useLocation() + const navigate = useNavigate() + const toast = useToast() + + const [isEditing, setIsEditing] = useState(false) + const [description, setDescription] = useState('') + const [, fetchSessions] = useAtom(sessionsWithFetchAtom) + const inputRef = useRef(null) + + const { colorMode } = useColorMode() + + useEffect(() => { + sessions.find(session => { + if (pathname.includes(session.uid as string)) { + setMessages(session.history!) + setDescription(session.description) + } + }) + }, [sessions, pathname, setMessages]) + + const selectChat = (session: Session) => { + props.setNew(false) + setIsTyping(false) + setMessages(session.history!) + navigate(`/chat/${session.uid}`) + } + + const deleteSession = async () => { + try { + await Client.deleteSession(username, selectedSession).then(res => { + if (!res.error) { + toast({ + title: 'Session deleted', + description: 'The selected session has been deleted successfully.', + status: 'success', + duration: 3000, + isClosable: true + }) + } else { + toast({ + title: 'Error while deleting session', + description: res.error, + status: 'error', + duration: 5000, + isClosable: true + }) + } + }) + } catch (error) { + console.error(error) + } + } + + const updateSession = async () => { + try { + await Client.updateSession(username, { ...selectedSession, description }) + setIsEditing(false) + await fetchSessions(username) + } catch (error) { + console.error('Error updating session:', error) + toast({ + title: 'Error updating session', + description: 'The session has not been updated.', + status: 'error', + duration: 3000, + isClosable: true + }) + } + } + + return ( + + + {sessions.map((session, index) => ( + + + + { + setIsEditing(false) + setSelectedSession(session) + setDescription(session.description) + }} + as={IconButton} + aria-label="Options" + icon={} + variant="outline" + /> + + setIsEditing(true)} icon={}> + Rename + + {/* }>Export to PDF + }>Archive */} + }> + Delete + + + + + ))} + + + ) +} + +export default ChatSessionList diff --git a/ui/src/components/feature/Chatbar.tsx b/ui/src/components/feature/Chatbar.tsx index 73e9dd9..974a736 100644 --- a/ui/src/components/feature/Chatbar.tsx +++ b/ui/src/components/feature/Chatbar.tsx @@ -12,73 +12,72 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box, Button, Flex, useColorMode } from '@chakra-ui/react' +import { selectedSessionAtom, sessionsWithFetchAtom } from '@atoms/sessions' +import { publicUserAtom, userWithFetchAtom } from '@atoms/users' +import { AddIcon } from '@chakra-ui/icons' +import { Button, Flex, useColorMode } from '@chakra-ui/react' import Client from '@services/Api' import { colors } from '@shared/theme' -import { ChatHistory } from '@shared/types' import { generateSessionId } from '@shared/utils' -import { conversationsAtom, sessionIdAtom, usernameAtom } from 'atoms' +import { sessionIdAtom, userWithTokenAtom, usernameAtom } from 'atoms' import { useAtom } from 'jotai' import { useEffect, useState } from 'react' -import ChatHistoryList from './ChatHistoryList' +import { useLocation, useNavigate } from 'react-router-dom' +import ChatSessionList from './ChatSessionList' const Chatbar = () => { - const [sessionId, setSessionId] = useAtom(sessionIdAtom) - const [username, setUsername] = useAtom(usernameAtom) - const [history, setHistory] = useAtom(conversationsAtom) - const [isNew, setIsNew] = useState(true) + const [, setSessionId] = useAtom(sessionIdAtom) + const [username] = useAtom(usernameAtom) + const [user] = useAtom(userWithTokenAtom) + const [, setIsNew] = useState(true) const { colorMode } = useColorMode() + const [publicUser] = useAtom(publicUserAtom) + const [, setSelectedSession] = useAtom(selectedSessionAtom) - const newChat = async () => { - const sid = generateSessionId() - setIsNew(true) - setSessionId(sid) - } + const [, fetchPublicUser] = useAtom(userWithFetchAtom) + const [, fetchSessions] = useAtom(sessionsWithFetchAtom) - const fetchSessions = async () => { - try { - const sessions = await Client.listSessions(username) - console.log(sessions) - if (isNew) { - if (sessions.length === 0 || sessions[0].name !== sessionId) { - sessions.unshift({ name: sessionId, description: '* New chat' }) - } - } - setHistory(sessions) - } catch (error) { - console.error('Failed to fetch sessions for:', username, error) - setHistory([{ name: sessionId, description: '* New chat', content: '', role: 'user', sources: [] }]) - } - } + const { pathname } = useLocation() + const navigate = useNavigate() useEffect(() => { - fetchSessions() - }, [sessionId]) + fetchSessions(user?.username) + fetchPublicUser(user?.username as string) + }, [fetchPublicUser, fetchSessions, user]) useEffect(() => { - async function updateUser() { - if (username) { - const sessions = await Client.listSessions(username, 'names', 1) - if (sessions.length > 0) { - setSessionId(sessions[0]) - setIsNew(false) - await fetchSessions() - } else { - await newChat() - } - } + if (pathname.includes('chat')) { + setSessionId(pathname.split('chat/')[1]) } - updateUser() - }, [username]) + }, [pathname]) + + const newChat = async () => { + try { + await Client.createSession(username, { + name: generateSessionId(), + description: '* New Chat', + labels: {}, + workflow_id: '1dfd7fc7c4024501850e3541abc3ed9f', + owner_id: publicUser.uid + }).then(res => { + setSessionId(res.data.uid) + setSelectedSession(res.data) + navigate(`/chat/${res.data.uid}`) + }) + await fetchSessions(username) + } catch (error) { + console.error('Failed to create session:', error) + } + } return ( - - - - + ) } diff --git a/ui/src/components/feature/Layout.tsx b/ui/src/components/feature/Layout.tsx index 6526261..929e4eb 100644 --- a/ui/src/components/feature/Layout.tsx +++ b/ui/src/components/feature/Layout.tsx @@ -14,7 +14,7 @@ import { Box, Flex, Menu, MenuItem } from '@chakra-ui/react' import useAuth from '@hooks/useAuth' -import { userAtom, usernameAtom } from 'atoms' +import { userWithTokenAtom, usernameAtom } from 'atoms' import { motion as m } from 'framer-motion' import { useAtom } from 'jotai' import React, { ReactNode } from 'react' @@ -28,8 +28,8 @@ type LayoutProps = { } const Layout: React.FC = ({ children }) => { - const [username, setUsername] = useAtom(usernameAtom) - const [user, setUser] = useAtom(userAtom) + const [username] = useAtom(usernameAtom) + const [user] = useAtom(userWithTokenAtom) const navigate = useNavigate() const { pathname } = useLocation() const { logout } = useAuth() @@ -51,12 +51,12 @@ const Layout: React.FC = ({ children }) => { - {pathname.includes('chat/') ? ( + {pathname.includes('chat') ? ( ) : ( navigate('/admin/users')}>Users - navigate('/admin/chat-histories')}>Chat Histories + navigate('/admin/histories')}>Chat Histories navigate('/admin/datasets')}>Datasets navigate('/admin/documents')}>Documents navigate('/admin/pipelines')}>Pipelines diff --git a/ui/src/components/feature/Login/Login.tsx b/ui/src/components/feature/Login/Login.tsx index c526d06..5b71e9d 100644 --- a/ui/src/components/feature/Login/Login.tsx +++ b/ui/src/components/feature/Login/Login.tsx @@ -13,12 +13,14 @@ // limitations under the License. import Logo from '@assets/mlrun.png' -import { adminAtom, conversationsAtom, userAtom, usernameAtom } from '@atoms/index' +import { adminAtom, publicUserAtom, usernameAtom } from '@atoms/index' +import { sessionsAtom, sessionsWithFetchAtom } from '@atoms/sessions' import { Box, Button, Flex, FormControl, FormLabel, Image, Input, Switch, useColorMode } from '@chakra-ui/react' import useAuth from '@hooks/useAuth' +import Client from '@services/Api' import { colors } from '@shared/theme' import { useAtom } from 'jotai' -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { useNavigate } from 'react-router-dom' const Login = () => { @@ -26,28 +28,36 @@ const Login = () => { const { colorMode } = useColorMode() const [username, setUsername] = useAtom(usernameAtom) const [admin, setAdmin] = useAtom(adminAtom) - const [chatHistory, setChatHistory] = useAtom(conversationsAtom) const [password, setPassword] = useState('') const [isLoading, setIsLoading] = useState(false) const { login } = useAuth() - const [user, setUser] = useAtom(userAtom) + const [, setPublicUser] = useAtom(publicUserAtom) + const [sessions] = useAtom(sessionsAtom) + const [, fetchSessions] = useAtom(sessionsWithFetchAtom) - useEffect(() => { - if (user) { - navigate('/chat') - } - }, [navigate]) - - const submitFunc = (event: React.MouseEvent | React.KeyboardEvent) => { + const submitFunc = async (event: React.MouseEvent | React.KeyboardEvent) => { event.preventDefault() setIsLoading(true) - setTimeout(() => { + setTimeout(async () => { setIsLoading(false) + setUsername(username) login(username, password, admin) + await Client.getUser(username).then(res => { + if (res) { + setPublicUser(res.data) + } + }) if (admin) { navigate('/admin/users') } else { - navigate(`/chat/${chatHistory.length ? chatHistory[0].name : ''}`) + navigate(`/chat`) + await fetchSessions(username).then(() => { + if (sessions.length) { + navigate(`/chat/${sessions[0].uid}`) + } else { + navigate(`/chat`) + } + }) } }, 1000) } diff --git a/ui/src/components/feature/TypingText.tsx b/ui/src/components/feature/TypingText.tsx index 3adf17e..6bdd99d 100644 --- a/ui/src/components/feature/TypingText.tsx +++ b/ui/src/components/feature/TypingText.tsx @@ -20,7 +20,7 @@ interface TypingTextProps { speed?: number // typing speed in ms per character } -const TypingText: React.FC = ({ text, speed = 16 }) => { +const TypingText: React.FC = ({ text, speed = 12 }) => { const [displayedText, setDisplayedText] = useState('') useEffect(() => { diff --git a/ui/src/components/feature/UsersTable.tsx b/ui/src/components/feature/UsersTable.tsx index a623e69..a806b9c 100644 --- a/ui/src/components/feature/UsersTable.tsx +++ b/ui/src/components/feature/UsersTable.tsx @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { usersAtom, usersWithFetchAtom } from '@atoms/apiAtoms' import { selectedRowAtom } from '@atoms/index' +import { usersAtom, usersWithFetchAtom } from '@atoms/users' import { AddIcon, DeleteIcon } from '@chakra-ui/icons' import { Button, diff --git a/ui/src/components/shared/Bubble.tsx b/ui/src/components/shared/Bubble.tsx index ac09b10..5d15767 100644 --- a/ui/src/components/shared/Bubble.tsx +++ b/ui/src/components/shared/Bubble.tsx @@ -13,7 +13,7 @@ // limitations under the License. import { ChatIcon, CopyIcon } from '@chakra-ui/icons' -import { Box, Flex, IconButton, useColorMode, useToast } from '@chakra-ui/react' +import { Flex, IconButton, Spinner, useColorMode, useToast } from '@chakra-ui/react' import ChatMessage from '@components/feature/ChatMessage' import { colors } from '@shared/theme' import { Source } from '@shared/types' @@ -29,12 +29,13 @@ type Props = { const Bubble = (props: Props) => { const { colorMode } = useColorMode() const toast = useToast() + return ( {props.bot == 'AI' ? ( - + {!props.content && } {!!props.content && } @@ -71,8 +72,6 @@ const Bubble = (props: Props) => { )} - - ) } diff --git a/ui/src/components/shared/Message.tsx b/ui/src/components/shared/Message.tsx index bbc3633..2de1252 100644 --- a/ui/src/components/shared/Message.tsx +++ b/ui/src/components/shared/Message.tsx @@ -15,32 +15,39 @@ import { ArrowUpIcon, AttachmentIcon } from '@chakra-ui/icons' import { Flex, IconButton, Input } from '@chakra-ui/react' import Client from '@services/Api' -import { ChatHistory } from '@shared/types' -import { sessionIdAtom, usernameAtom } from 'atoms' +import { messagesAtom, sessionIdAtom } from 'atoms' import { useAtom } from 'jotai' import { useState } from 'react' -type Props = { - setter: React.Dispatch> -} -const Message = ({ setter }: Props) => { + +const Message = () => { const [inputValue, setInputValue] = useState('') - const [sessionId, setSessionId] = useAtom(sessionIdAtom) - const [username, setUsername] = useAtom(usernameAtom) + const [sessionId] = useAtom(sessionIdAtom) + const [, setMessages] = useAtom(messagesAtom) const submitMessage = async () => { - setter(prevMessages => [...prevMessages, { role: 'Human', content: inputValue, sources: [] }]) + setMessages(prevMessages => { + const safeMessages = Array.isArray(prevMessages) ? prevMessages : [] + return [...safeMessages, { role: 'Human', content: inputValue, sources: [] }] + }) setInputValue('') - setTimeout(function () { - const lastBubble = document.getElementsByClassName('help-text').length - 1 - document.getElementsByClassName('help-text')[lastBubble].scrollIntoView(false) - }, 50) - setter(prevMessages => [...prevMessages, { role: 'AI', content: '...', sources: [] }]) - const result = await Client.submitQuery(sessionId, inputValue, username) - setter(prevMessages => [ - ...prevMessages.slice(0, -1), - { role: 'AI', content: result.answer, sources: result.sources } - ]) + setMessages(prevMessages => { + const safeMessages = Array.isArray(prevMessages) ? prevMessages : [] + return [...safeMessages, { role: 'AI', content: '', sources: [] }] + }) + + const result = await Client.inferWorkflow('default', '1dfd7fc7c4024501850e3541abc3ed9f', { + question: inputValue, + session_id: sessionId, + data_source: 'default' + }) + + console.log('RESULT', result) + + setMessages(prevMessages => { + const safeMessages = Array.isArray(prevMessages) ? prevMessages : [] + return [...safeMessages.slice(0, -1), { role: 'AI', content: result.data.data.answer, sources: result.sources }] + }) } const handleKeyPress = (e: React.KeyboardEvent) => { @@ -66,12 +73,6 @@ const Message = ({ setter }: Props) => { onChange={e => setInputValue(e.target.value)} onKeyDown={handleKeyPress} /> - {/*
{ - return (event.target as HTMLElement).classList.toggle('selected') - }} - >
*/} } onClick={handleClick} /> ) diff --git a/ui/src/hooks/useAuth.ts b/ui/src/hooks/useAuth.ts index 225ce41..11b61f3 100644 --- a/ui/src/hooks/useAuth.ts +++ b/ui/src/hooks/useAuth.ts @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { adminAtom, userAtom, usernameAtom } from '@atoms/index'; +import { adminAtom, userWithTokenAtom, usernameAtom } from '@atoms/index'; import { useAtom } from 'jotai'; import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -25,7 +25,7 @@ interface User { } const useAuth = () => { - const [user, setUser] = useAtom(userAtom); + const [user, setUser] = useAtom(userWithTokenAtom); const [, setUsername] = useAtom(usernameAtom); const [, setAdmin] = useAtom(adminAtom); const navigate = useNavigate() diff --git a/ui/src/services/Api.ts b/ui/src/services/Api.ts index 31730ec..4f5e696 100644 --- a/ui/src/services/Api.ts +++ b/ui/src/services/Api.ts @@ -13,6 +13,8 @@ // limitations under the License. import { User } from '@shared/types'; +import { Session } from '@shared/types/session'; +import { Query } from '@shared/types/workflow'; import axios, { AxiosResponse } from 'axios'; @@ -44,36 +46,12 @@ class ApiClient { return null; } - async listSessions(username?: string, mode?: string, last?: number) { + async getUsers() { try { - const response = await this.client.get('/sessions', { - params: { last: last, username: username, mode: mode || 'short' } - }) + const response = await this.client.get(`/users`) return this.handleResponse(response) } catch (error) { - return this.handleError(error as Error) - } - } - - async getSession(id?: string, username?: string) { - try { - const response = await this.client.get(`/session/${id || '$last'}`, { - headers: { 'x-username': username || 'guest' } - }) - return this.handleResponse(response) - } catch (error) { - return this.handleError(error as Error) - } - } - - async getUsers(username?: string) { - try { - const response = await this.client.get(`/users`, { - headers: { 'x-username': username || 'guest' } - }) - return this.handleResponse(response) - } catch (error) { - return this.handleError(error as Error) + this.handleError(error as Error) } } @@ -113,14 +91,56 @@ class ApiClient { } } - async submitQuery(id: string, question: string, username?: string) { + async getSessions(username?: string) { + try { + const response = await this.client.get(`/users/${username}/sessions`) + return this.handleResponse(response) + } catch (error) { + return this.handleError(error as Error) + } + } + + async getSession(username: string, id: string,) { + try { + const response = await this.client.get(`users/${username}/sessions/${id}`) + return this.handleResponse(response) + } catch (error) { + return this.handleError(error as Error) + } + } + + async createSession(username: string, session: Session) { + try { + const response = await this.client.post(`users/${username}/sessions`, session) + return this.handleResponse(response) + } catch (error) { + return this.handleError(error as Error) + } + } + + async updateSession(username: string, session: Session) { + try { + const response = await this.client.put(`/users/${username}/sessions/${session.name}`, session) + return this.handleResponse(response) + } catch (error) { + return this.handleError(error as Error) + } + } + + async deleteSession(username: string, session: Session) { + try { + const response = await this.client.delete(`/users/${username}/sessions/${session.uid}`) + return this.handleResponse(response) + } catch (error) { + return this.handleError(error as Error) + } + } + + async inferWorkflow(projectId: string, workflowId: string, query: Query) { try { const response = await this.client.post( - '/pipeline/default/run', - { session_id: id, question: question }, - { - headers: { 'x-username': username || 'guest' } - } + `/projects/${projectId}/workflows/${workflowId}/infer`, + query ) return this.handleResponse(response) } catch (error) { diff --git a/ui/src/shared/theme.ts b/ui/src/shared/theme.ts index 6cc98b8..c5d12f7 100644 --- a/ui/src/shared/theme.ts +++ b/ui/src/shared/theme.ts @@ -54,7 +54,8 @@ export const colors = { gray900: '#1A202C', mint: '#4CD5B1', - mintDark: '#177D7C' + mintLight: '#45cca9', + mintDark: '#369e83' } diff --git a/ui/src/shared/types.ts b/ui/src/shared/types/index.ts similarity index 92% rename from ui/src/shared/types.ts rename to ui/src/shared/types/index.ts index fbae21a..3c31c89 100644 --- a/ui/src/shared/types.ts +++ b/ui/src/shared/types/index.ts @@ -64,3 +64,10 @@ export type User = { full_name?: string; } + +export type APIResponse = { + //eslint-disable-next-line + data: any[] + success: boolean + error: string +} diff --git a/ui/src/shared/types/session.ts b/ui/src/shared/types/session.ts new file mode 100644 index 0000000..4132b13 --- /dev/null +++ b/ui/src/shared/types/session.ts @@ -0,0 +1,27 @@ +// Copyright 2024 Iguazio +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ChatHistory } from "." + + +export type Session = { + uid?: string + name: string + description: string + labels: { [key: string]: string } + owner_id?: string + workflow_id?: string + history?: ChatHistory[] + created?: string +} diff --git a/ui/src/shared/types/workflow.ts b/ui/src/shared/types/workflow.ts new file mode 100644 index 0000000..0ec9cbc --- /dev/null +++ b/ui/src/shared/types/workflow.ts @@ -0,0 +1,44 @@ +// Copyright 2024 Iguazio +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export type Workflow = { + name: string + uid: string + description: string + labels: { [key: string]: string } + owner_id: string + version: string + project_id: string + workflow_type: WorkflowType + deployment: string + workflow_function: string + configuration: { [key: string]: string } + graph: { [key: string]: string } +} + + +export enum WorkflowType { + INGESTION = 'ingestion', + APPLICATION = 'application', + DATA_PROCESSING = 'data-processing', + TRAINING = 'training', + EVALUATION = 'evaluation', + DEPLOYMENT = 'deployment' +} + +export type Query = { + question: string + session_id: string + data_source: string +} From 7f1bcb1c9b932b0add34219e5cc2fba86ae4ca49 Mon Sep 17 00:00:00 2001 From: baaalint Date: Fri, 6 Sep 2024 13:10:50 +0200 Subject: [PATCH 2/2] feat(chat): disable message send upon error --- ui/src/atoms/index.ts | 1 + ui/src/components/feature/ChatSessionList.tsx | 4 ++- ui/src/components/shared/Message.tsx | 29 +++++++++++++++---- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/ui/src/atoms/index.ts b/ui/src/atoms/index.ts index d5e96e0..8066cc4 100644 --- a/ui/src/atoms/index.ts +++ b/ui/src/atoms/index.ts @@ -26,6 +26,7 @@ export const userWithTokenAtom = atomWithStorage('user', localStora export const publicUserAtom = atom({}); export const usernameAtom = atom(''); export const isTypingAtom = atom(false); +export const canSendMessageAtom = atom(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any export const selectedRowAtom = atom({}); diff --git a/ui/src/components/feature/ChatSessionList.tsx b/ui/src/components/feature/ChatSessionList.tsx index 5e9840d..f80c41d 100644 --- a/ui/src/components/feature/ChatSessionList.tsx +++ b/ui/src/components/feature/ChatSessionList.tsx @@ -28,7 +28,7 @@ import { import Client from '@services/Api' import { colors } from '@shared/theme' import { Session } from '@shared/types/session' -import { isTypingAtom, messagesAtom, sessionIdAtom, usernameAtom } from 'atoms' +import { canSendMessageAtom, isTypingAtom, messagesAtom, sessionIdAtom, usernameAtom } from 'atoms' import { useAtom } from 'jotai' import { useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' @@ -51,6 +51,7 @@ const ChatSessionList = (props: Props) => { const [isEditing, setIsEditing] = useState(false) const [description, setDescription] = useState('') const [, fetchSessions] = useAtom(sessionsWithFetchAtom) + const [, setCanSendMessage] = useAtom(canSendMessageAtom) const inputRef = useRef(null) const { colorMode } = useColorMode() @@ -68,6 +69,7 @@ const ChatSessionList = (props: Props) => { props.setNew(false) setIsTyping(false) setMessages(session.history!) + setCanSendMessage(true) navigate(`/chat/${session.uid}`) } diff --git a/ui/src/components/shared/Message.tsx b/ui/src/components/shared/Message.tsx index 2de1252..7fb0768 100644 --- a/ui/src/components/shared/Message.tsx +++ b/ui/src/components/shared/Message.tsx @@ -13,18 +13,23 @@ // limitations under the License. import { ArrowUpIcon, AttachmentIcon } from '@chakra-ui/icons' -import { Flex, IconButton, Input } from '@chakra-ui/react' +import { Flex, IconButton, Input, useToast } from '@chakra-ui/react' import Client from '@services/Api' -import { messagesAtom, sessionIdAtom } from 'atoms' +import { canSendMessageAtom, messagesAtom, sessionIdAtom } from 'atoms' import { useAtom } from 'jotai' import { useState } from 'react' const Message = () => { const [inputValue, setInputValue] = useState('') + const [sessionId] = useAtom(sessionIdAtom) const [, setMessages] = useAtom(messagesAtom) + const [canSendMessage, setCanSendMessage] = useAtom(canSendMessageAtom) + + const toast = useToast() const submitMessage = async () => { + setCanSendMessage(false) setMessages(prevMessages => { const safeMessages = Array.isArray(prevMessages) ? prevMessages : [] return [...safeMessages, { role: 'Human', content: inputValue, sources: [] }] @@ -40,10 +45,22 @@ const Message = () => { question: inputValue, session_id: sessionId, data_source: 'default' + }).then(res => { + if (res.error) { + toast({ + title: 'An unexpected error occured', + description: res.error, + status: 'error', + duration: 5000, + isClosable: true + }) + setCanSendMessage(false) + return res + } + setCanSendMessage(true) + return res }) - console.log('RESULT', result) - setMessages(prevMessages => { const safeMessages = Array.isArray(prevMessages) ? prevMessages : [] return [...safeMessages.slice(0, -1), { role: 'AI', content: result.data.data.answer, sources: result.sources }] @@ -71,9 +88,9 @@ const Message = () => { placeholder="Send message..." value={inputValue} onChange={e => setInputValue(e.target.value)} - onKeyDown={handleKeyPress} + onKeyDown={e => canSendMessage && handleKeyPress(e)} /> - } onClick={handleClick} /> + } onClick={handleClick} /> ) }