From 0acfcc0d08fa47f13acb65b1379b25c714854e57 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Wed, 24 Sep 2025 11:49:57 -0500 Subject: [PATCH] Updated the FE to be able to display warnings and images --- .../llm-fe/components/AsyncChat/AsyncChat.tsx | 625 ++++++++++-------- .../ConversationDetailCard.tsx | 152 +++-- .../src/llm-fe/contexts/WebSocketContext.js | 198 +++--- 3 files changed, 549 insertions(+), 426 deletions(-) diff --git a/llm-fe/src/llm-fe/components/AsyncChat/AsyncChat.tsx b/llm-fe/src/llm-fe/components/AsyncChat/AsyncChat.tsx index 4d8567f..859dcb3 100644 --- a/llm-fe/src/llm-fe/components/AsyncChat/AsyncChat.tsx +++ b/llm-fe/src/llm-fe/components/AsyncChat/AsyncChat.tsx @@ -1,291 +1,360 @@ -import { Box, Button, InputAdornment, TextField, Typography } from '@mui/material'; -import React, {useRef, useContext, useEffect, useState } from 'react'; -import { Conversation, ConversationPrompt, ConversationPromptType, ConversationType } from '../../data'; -import { axiosInstance } from '../../../axiosApi'; -import { AxiosResponse } from 'axios'; -import ConversationDetailCard from '../ConversationDetailCard/ConversationDetailCard'; -import { ErrorMessage, Field, Form, Formik } from 'formik'; -import { WebSocketContext } from '../../contexts/WebSocketContext'; -import { AccountContext } from '../../contexts/AccountContext'; -import * as Yup from 'yup'; -import { styled } from '@mui/material/styles'; -import { AttachFile, Send } from '@mui/icons-material'; -import Markdown from 'markdown-to-jsx'; +import { + Box, + Button, + InputAdornment, + TextField, + Typography, +} from "@mui/material"; +import React, { useRef, useContext, useEffect, useState } from "react"; +import { + Conversation, + ConversationPrompt, + ConversationPromptType, + ConversationType, +} from "../../data"; +import { axiosInstance } from "../../../axiosApi"; +import { AxiosResponse } from "axios"; +import ConversationDetailCard from "../ConversationDetailCard/ConversationDetailCard"; +import { ErrorMessage, Field, Form, Formik } from "formik"; +import { WebSocketContext } from "../../contexts/WebSocketContext"; +import { AccountContext } from "../../contexts/AccountContext"; +import * as Yup from "yup"; +import { styled } from "@mui/material/styles"; +import { AttachFile, Send } from "@mui/icons-material"; +import Markdown from "markdown-to-jsx"; -type RenderMessageProps= { - response: string - index: number +type RenderMessageProps = { + response: string; + index: number; }; type AsyncChatProps = { - selectedConversation: number | undefined - conversationTitle: string - conversations: Conversation[] - setConversations: React.Dispatch> - setSelectedConversation: React.Dispatch> - drawerWidth: number; -} + selectedConversation: number | undefined; + conversationTitle: string; + conversations: Conversation[]; + setConversations: React.Dispatch>; + setSelectedConversation: React.Dispatch< + React.SetStateAction + >; + drawerWidth: number; +}; const validationSchema = Yup.object().shape({ - prompt: Yup.string().min(1, "Need to have at least one character").required("This is requried") -} -) + prompt: Yup.string() + .min(1, "Need to have at least one character") + .required("This is requried"), +}); -const VisuallyHiddenInput = styled('input')({ - clip: 'rect(0 0 0 0)', - clipPath: 'inset(50%)', - height: 1, - overflow: 'hidden', - position: 'absolute', - bottom: 0, - left: 0, - whiteSpace: 'nowrap', - width: 1, - }); +const VisuallyHiddenInput = styled("input")({ + clip: "rect(0 0 0 0)", + clipPath: "inset(50%)", + height: 1, + overflow: "hidden", + position: "absolute", + bottom: 0, + left: 0, + whiteSpace: "nowrap", + width: 1, +}); -const AsyncChat = ({selectedConversation, conversationTitle, conversations, setConversations, setSelectedConversation, drawerWidth}: AsyncChatProps): JSX.Element => { +const AsyncChat = ({ + selectedConversation, + conversationTitle, + conversations, + setConversations, + setSelectedConversation, + drawerWidth, +}: AsyncChatProps): JSX.Element => { + const messageEndRef = useRef(null); - - const messageEndRef = useRef(null); - - const [conversationDetails, setConversationDetails] = useState([]) - const [disableInput, setDisableInput] = useState(false); + const [conversationDetails, setConversationDetails] = useState< + ConversationPrompt[] + >([]); + const [disableInput, setDisableInput] = useState(false); - const [subscribe, unsubscribe, socket, sendMessage] = useContext(WebSocketContext) - const { account, setAccount } = useContext(AccountContext) - const messageRef = useRef('') - const messageResponsePart = useRef(0); - const conversationRef = useRef(conversationDetails) - const [stateMessage, setStateMessage] = useState('') - const selectedConversationRef = useRef(undefined) - - useEffect(() => { - /* register a consistent channel name for identifing this chat messages */ - const channelName = `ACCOUNT_ID_${account?.email}` + const [subscribe, unsubscribe, socket, sendMessage] = + useContext(WebSocketContext); + const { account, setAccount } = useContext(AccountContext); + const messageRef = useRef(""); + const messageResponsePart = useRef(0); + const conversationRef = useRef(conversationDetails); + const [stateMessage, setStateMessage] = useState(""); + const selectedConversationRef = useRef(undefined); - /* subscribe to channel and register callback */ - subscribe(channelName, (message: string) => { - /* when a message is received just add it to the UI */ - - if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){ - messageResponsePart.current = 0 - - conversationRef.current.pop() - - //handleAssistantPrompt({prompt: messageRef.current}) - setConversationDetails([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})]) - messageRef.current = '' - setStateMessage('') + useEffect(() => { + /* register a consistent channel name for identifing this chat messages */ + const channelName = `ACCOUNT_ID_${account?.email}`; + + /* subscribe to channel and register callback */ + subscribe(channelName, (message: string) => { + /* when a message is received just add it to the UI */ + + if (message === "END_OF_THE_STREAM_ENDER_GAME_42") { + messageResponsePart.current = 0; + + conversationRef.current.pop(); + + //handleAssistantPrompt({prompt: messageRef.current}) + setConversationDetails([ + ...conversationRef.current, + new ConversationPrompt({ + message: `${messageRef.current}`, + user_created: false, + }), + ]); + messageRef.current = ""; + setStateMessage(""); + } else if (message === "START_OF_THE_STREAM_ENDER_GAME_42") { + messageResponsePart.current = 2; + } else if (message === "CONVERSATION_ID") { + messageResponsePart.current = 1; + } else { + if (messageResponsePart.current === 1) { + // this has to do with the conversation id + if (!selectedConversation) { + //console.log("we have a new conversation") + setSelectedConversation(Number(message)); + } + } else if (messageResponsePart.current === 2) { + let contentToAdd = message; + try { + const parsedMessage = JSON.parse(message); + if ( + parsedMessage && + typeof parsedMessage === "object" && + parsedMessage.type + ) { + switch (parsedMessage.type) { + case "text": + contentToAdd = parsedMessage.content; + break; + case "plot": + contentToAdd = ``; + break; + case "error": + contentToAdd = ``; + break; + default: + // contentToAdd is already `message` + break; + } } - else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){ - messageResponsePart.current = 2 + } catch (e) { + // Not a JSON object, treat as raw string. + // contentToAdd is already `message`. + } - }else if (message === 'CONVERSATION_ID'){ - messageResponsePart.current = 1 - }else{ - if (messageResponsePart.current === 1){ - // this has to do with the conversation id - if(!selectedConversation){ - //console.log("we have a new conversation") - setSelectedConversation(Number(message)) - } - } - else if (messageResponsePart.current === 2){ - messageRef.current += message - setStateMessage(messageRef.current) - - } - } - - }) - - return () => { - /* unsubscribe from channel during cleanup */ - unsubscribe(channelName) + messageRef.current += contentToAdd; + setStateMessage(messageRef.current); } - }, [account, subscribe, unsubscribe]) - - - async function GetConversationDetails(){ - if(selectedConversation){ - - try{ - //console.log('GetConversationDetails') - //setPromptProcessing(true) - selectedConversationRef.current = selectedConversation; - const {data, }: AxiosResponse = await axiosInstance.get(`conversation_details?conversation_id=${selectedConversation}`) - - const tempConversations: ConversationPrompt[] = data.map((item) => new ConversationPrompt({ - message: item.message, - user_created: item.user_created, - created_timestamp: item.created_timestamp - })) - conversationRef.current = tempConversations - setConversationDetails(tempConversations) - - }finally{ - //setPromptProcessing(false) - - } - - } - } - - useEffect(() => { - GetConversationDetails(); - }, [selectedConversation]) - - - // Function to render each message - - const renderMessage = ({response, index}: RenderMessageProps) => ( -
-

{response}

-
- ); - - type PromptValues = { - prompt: string; - file: Blob | null; - fileType: string | null; + } + }); + return () => { + /* unsubscribe from channel during cleanup */ + unsubscribe(channelName); }; + }, [account, subscribe, unsubscribe]); - const handlePromptSubmit = async ({prompt, file, fileType}: PromptValues, {resetForm}: any): Promise => { - - // send the prompt to be saved - console.log(fileType) - try{ - const tempConversations: ConversationPrompt[] = [...conversationDetails, new ConversationPrompt({message: prompt, user_created:true}), new ConversationPrompt({message: '', user_created:false})] - conversationRef.current = tempConversations - setConversationDetails(tempConversations) - // TODO: add the file here - console.log(`Sending message. ${prompt} ${selectedConversation} ${fileType}`) - sendMessage(prompt, selectedConversation, file, fileType) - resetForm(); - - - }catch(e){ - console.log(`error ${e}`) - // TODO: make this user friendly - } + async function GetConversationDetails() { + if (selectedConversation) { + try { + //console.log('GetConversationDetails') + //setPromptProcessing(true) + selectedConversationRef.current = selectedConversation; + const { data }: AxiosResponse = + await axiosInstance.get( + `conversation_details?conversation_id=${selectedConversation}`, + ); + + const tempConversations: ConversationPrompt[] = data.map( + (item) => + new ConversationPrompt({ + message: item.message, + user_created: item.user_created, + created_timestamp: item.created_timestamp, + }), + ); + conversationRef.current = tempConversations; + setConversationDetails(tempConversations); + } finally { + //setPromptProcessing(false) + } } + } + useEffect(() => { + GetConversationDetails(); + }, [selectedConversation]); - return( - ( +
+

{response}

+
+ ); + + type PromptValues = { + prompt: string; + file: Blob | null; + fileType: string | null; + }; + + const handlePromptSubmit = async ( + { prompt, file, fileType }: PromptValues, + { resetForm }: any, + ): Promise => { + // send the prompt to be saved + console.log(fileType); + try { + const tempConversations: ConversationPrompt[] = [ + ...conversationDetails, + new ConversationPrompt({ message: prompt, user_created: true }), + new ConversationPrompt({ message: "", user_created: false }), + ]; + conversationRef.current = tempConversations; + setConversationDetails(tempConversations); + // TODO: add the file here + console.log( + `Sending message. ${prompt} ${selectedConversation} ${fileType}`, + ); + sendMessage(prompt, selectedConversation, file, fileType); + resetForm(); + } catch (e) { + console.log(`error ${e}`); + // TODO: make this user friendly + } + }; + + return ( + +
+
+
+ + {conversationTitle} + +
+
+
+ + {selectedConversation ? ( + conversationDetails.map((convo_detail) => + convo_detail.message.length > 0 ? ( + + ) : ( + + ), + ) + ) : ( + + Either select a previous conversation on start a new one. + + )} +
+ +
+
+ +
+ -
-
- -
- - {conversationTitle} - -
-
-
- - {selectedConversation ? - conversationDetails.map((convo_detail) => - convo_detail.message.length >0 ? - : - ) - : - Either select a previous conversation on start a new one. - } -
- - - - -
- -
- -
- ( +
+
+
+ } + size={"small"} + role={undefined} + tabIndex={-1} + margin={"dense"} + variant={"outlined"} + InputProps={{ + endAdornment: ( + + + + + ), }} - onSubmit={handlePromptSubmit} - validationSchema={validationSchema} - > - {(formik) => - -
-
- } - size={"small"} - role={undefined} - tabIndex={-1} - - - margin={"dense"} - variant={"outlined"} - InputProps={{ - endAdornment: ( - - - - - - ) - }} - > - -
- {/*
+ > +
+ {/*
*/} -
- - } +
+ + )} + +
+ + ); +}; -
-
- - - - - - - ) -} - -export default AsyncChat \ No newline at end of file +export default AsyncChat; diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx index 113227b..6b19b60 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx @@ -1,13 +1,7 @@ -import { useContext, useEffect, useRef, useState } from "react" -import { WebSocketContext } from "../../contexts/WebSocketContext" -import { AccountContext } from "../../contexts/AccountContext" -import Markdown from "markdown-to-jsx" -import { Card, CardContent, CircularProgress } from "@mui/material" -import MDTypography from "../../ui-kit/components/MDTypography" -import styled from 'styled-components'; -// import { CodeBlock } from "react-code-blocks" -// import CustomCodeBlock from "../CustomPreBlock/CustomPreBlock" -// import CustomPreBlock from "../CustomPreBlock/CustomPreBlock" +import React from "react"; +import Markdown from "markdown-to-jsx"; +import { Card, CardContent, CircularProgress } from "@mui/material"; +import styled from "styled-components"; const StyleDiv = styled.div` background-color: #f0f0f0; @@ -26,44 +20,112 @@ const StyleDiv = styled.div` `; type ConversationDetailCardProps = { - message: string - user_created: boolean + message: string; + user_created: boolean; +}; -} +// Custom component for rendering plots +const MyPlot = ({ format, image }: { format: string; image: string }) => { + const imageSrc = `data:image/${format};base64,${image}`; + return ( + plot + ); +}; -const ConversationDetailCard = ({message, user_created}: ConversationDetailCardProps): JSX.Element => { - const type = user_created ? 'info' : 'dark' - if(user_created){ +// Custom component for rendering errors +const MyError = ({ content }: { content: string }) => { + return ( + Error: {content} + ); +}; - } - const text_align = user_created ? 'right' : 'left' - if (message.length === 0){ - return( - - - - - - ) - }else{ - const newMessage: string = message.replace("```", "\n```\n"); - - return ( +const ConversationDetailCard = ({ + message, + user_created, +}: ConversationDetailCardProps): JSX.Element => { + const text_align = user_created ? "right" : "left"; + if (message.length === 0) { + return ( + + + + + + ); + } else { + let contentToAdd = message; + console.log(contentToAdd); + try { + const parsedMessage = JSON.parse(message); + if ( + parsedMessage && + typeof parsedMessage === "object" && + parsedMessage.type + ) { + switch (parsedMessage.type) { + case "text": + contentToAdd = parsedMessage.content; - - - - {newMessage} - - - ) + break; + case "plot": + contentToAdd = ``; + break; + case "error": + contentToAdd = ``; + break; + default: + // contentToAdd is already `message` - } - -} + break; + } + } + } catch {} + console.log(contentToAdd); -export default ConversationDetailCard; \ No newline at end of file + return ( + + + + {contentToAdd} + + + + ); + } +}; + +export default ConversationDetailCard; diff --git a/llm-fe/src/llm-fe/contexts/WebSocketContext.js b/llm-fe/src/llm-fe/contexts/WebSocketContext.js index e316846..e9c4a76 100644 --- a/llm-fe/src/llm-fe/contexts/WebSocketContext.js +++ b/llm-fe/src/llm-fe/contexts/WebSocketContext.js @@ -1,113 +1,111 @@ -import { AccountContext } from "./AccountContext" +import { AccountContext } from "./AccountContext"; import { AuthContext } from "./AuthContext"; -const { useEffect, createContext, useRef, useState, useContext } = require("react") +const { + useEffect, + createContext, + useRef, + useState, + useContext, +} = require("react"); - -const WebSocketContext = createContext() +const WebSocketContext = createContext(); function WebSocketProvider({ children }) { - const { authenticated, loading } = useContext(AuthContext); - const ws = useRef(null) - const [socket, setSocket] = useState(null) - const channels = useRef({}) // maps each channel to the callback - const { account, setAccount } = useContext(AccountContext) - const [currentChannel, setCurrentChannel] = useState('') - /* called from a component that registers a callback for a channel */ - const subscribe = (channel, callback) => { - //console.log(`Subbing to ${channel}`) - setCurrentChannel(channel) - channels.current[channel] = callback - } - /* remove callback */ - const unsubscribe = (channel) => { - delete channels.current[channel] + const { authenticated, loading } = useContext(AuthContext); + const ws = useRef(null); + const [socket, setSocket] = useState(null); + const channels = useRef({}); // maps each channel to the callback + const { account, setAccount } = useContext(AccountContext); + const [currentChannel, setCurrentChannel] = useState(""); + /* called from a component that registers a callback for a channel */ + const subscribe = (channel, callback) => { + //console.log(`Subbing to ${channel}`) + setCurrentChannel(channel); + channels.current[channel] = callback; + }; + /* remove callback */ + const unsubscribe = (channel) => { + delete channels.current[channel]; + }; + + const sendMessage = (message, conversation_id, file, fileType, modelName) => { + if (socket && socket.readyState === WebSocket.OPEN) { + if (file) { + const reader = new FileReader(); + reader.onload = () => { + const base64File = reader.result?.toString().split(",")[1]; + if (base64File) { + const data = { + message: message, + conversation_id: conversation_id, + email: account?.email, + file: base64File, + fileType: fileType, + modelName: modelName, + }; + socket.send(JSON.stringify(data)); + } + }; + reader.readAsDataURL(file); + } else { + const data = { + message: message, + conversation_id: conversation_id, + email: account?.email, + file: null, + fileType: null, + modelName: modelName, + }; + + socket.send(JSON.stringify(data)); + } + //socket.send(`${conversation_id} | ${message}`) + } else { + console.log("Error sending message. WebSocket is not open"); } + }; + useEffect(() => { + /* WS initialization and cleanup */ + if (account) { + ws.current = new WebSocket(`ws://localhost:8011/ws/chat_again/`); + //ws.current = new WebSocket('wss://chatbackend.aimloperations.com/ws/chat_again/') + //ws.current = process.env.REACT_APP_BACKEND_WS_API_BASE_URL; - const sendMessage = (message, conversation_id, file, fileType, modelName) => { - if (socket && socket.readyState === WebSocket.OPEN){ - - if (file){ + ws.current.onopen = () => { + setSocket(ws.current); + }; + ws.current.onclose = () => {}; + ws.current.onmessage = (message) => { + const data = message.data; + // lookup for an existing chat in which this message belongs + // if no chat is subscribed send message to generic channel + const chatChannel = Object.entries(channels.current)[0][0]; - - const reader = new FileReader(); - reader.onload = () => { - const base64File = reader.result?.toString().split(',')[1]; - if (base64File){ - const data = { - message: message, - conversation_id: conversation_id, - email: account?.email, - file: base64File, - fileType: fileType, - modelName: modelName, - } - socket.send(JSON.stringify(data)) - - } - } - reader.readAsDataURL(file) - }else{ - const data = { - message: message, - conversation_id: conversation_id, - email: account?.email, - file: null, - fileType: null, - modelName: modelName, - } - - socket.send(JSON.stringify(data)) - } - //socket.send(`${conversation_id} | ${message}`) - - }else{ - console.log('Error sending message. WebSocket is not open') + if (channels.current[chatChannel]) { + /* in chat component the subscribed channel is `MESSAGE_CREATE_${id}` */ + channels.current[chatChannel](data); + } else { + /* in notifications wrapper the subscribed channel is `MESSAGE_CREATE` */ + console.log("Error"); + // channels.current[type]?.(data) } - + }; + return () => { + ws.current.close(); + }; } - useEffect(() => { - /* WS initialization and cleanup */ - if (account){ - - - //ws.current = new WebSocket(`ws://127.0.0.1:8011/ws/chat_again/`); - ws.current = new WebSocket('wss://chatbackend.aimloperations.com/ws/chat_again/') - //ws.current = process.env.REACT_APP_BACKEND_WS_API_BASE_URL; + }, [account]); - ws.current.onopen = () => { setSocket(ws.current); } - ws.current.onclose = () => { } - ws.current.onmessage = (message) => { - const data = message.data - // lookup for an existing chat in which this message belongs - // if no chat is subscribed send message to generic channel - const chatChannel = Object.entries(channels.current)[0][0] - - if (channels.current[chatChannel]) { - /* in chat component the subscribed channel is `MESSAGE_CREATE_${id}` */ - channels.current[chatChannel](data) - } else { - /* in notifications wrapper the subscribed channel is `MESSAGE_CREATE` */ - console.log('Error') - // channels.current[type]?.(data) - } - } - return () => { ws.current.close() } - - } - - - }, [account]) - - - - /* WS provider dom */ - /* subscribe and unsubscribe are the only required prop for the context */ - return ( - - {children} - - ) + /* WS provider dom */ + /* subscribe and unsubscribe are the only required prop for the context */ + return ( + + {children} + + ); } -export { WebSocketContext, WebSocketProvider } \ No newline at end of file +export { WebSocketContext, WebSocketProvider };