diff --git a/src/components/Feedback/Feedback.jsx b/src/components/Feedback/Feedback.jsx index 7c0418920..ef03dacd3 100644 --- a/src/components/Feedback/Feedback.jsx +++ b/src/components/Feedback/Feedback.jsx @@ -19,9 +19,72 @@ import QuestionAnswerOutlinedIcon from "@mui/icons-material/QuestionAnswerOutlin import { i18nLoadNamespace } from "components/Shared/Languages/i18nLoadNamespace"; +const API_URL = process.env.REACT_APP_MY_WEB_HOOK_URL; + +const getFeedbackMessage = (email, message, messageType, archiveURL = null) => { + if (typeof message !== "string" || typeof messageType !== "string") + throw new Error("Invalid message type"); + + return { + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "" + messageType + "", + }, + }, + ...(email + ? [ + { + type: "section", + text: { + type: "mrkdwn", + text: "", + }, + }, + ] + : []), + { + type: "section", + text: { + type: "mrkdwn", + text: "" + message + "", + }, + ...(archiveURL // Conditionally add the accessory + ? { + accessory: { + type: "image", + image_url: archiveURL.trim(), + alt_text: "Problematic image", + }, + } + : {}), // Important: Return an empty object if archiveURL is not defined + }, + ], + }; +}; + +const sendToSlack = async (email, message, messageType, archiveURL = null) => { + const feedbackMessage = getFeedbackMessage( + email, + message, + messageType, + archiveURL, + ); + const response = await fetch(API_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(feedbackMessage), + }); + + if (!response.ok) throw response; + + return response; +}; + const Feedback = () => { const keyword = i18nLoadNamespace("components/FeedBack"); - const API_URL = process.env.REACT_APP_MY_WEB_HOOK_URL; const [isButtonHovered, setIsButtonHovered] = useState(false); const [displayCard, setDisplayCard] = useState(false); @@ -42,56 +105,6 @@ const Feedback = () => { const containerRef = React.useRef(null); - const getFeedbackMessage = (message, messageType) => { - if (typeof message !== "string" || typeof messageType !== "string") - throw new Error("Invalid message type"); - - return { - blocks: [ - { - type: "header", - text: { - type: "plain_text", - text: "" + messageType + "", - }, - }, - ...(email - ? [ - { - type: "section", - text: { - type: "mrkdwn", - text: "", - }, - }, - ] - : []), - { - type: "section", - text: { - type: "mrkdwn", - text: "" + message + "", - }, - }, - ], - }; - }; - - const sendToSlack = async (message, messageType) => { - const feedbackMessage = getFeedbackMessage(message, messageType); - //console.log(feedbackMessage); - //console.log(JSON.stringify(feedbackMessage)); - const response = await fetch(API_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(feedbackMessage), - }); - - if (!response.ok) throw response; - - return response; - }; - const validateEmail = (email) => { if (!email) return true; //allow to proceed if the email is empty const re = /\S+@\S+\.\S+/; //match string@string.string @@ -113,7 +126,7 @@ const Feedback = () => { setIsFeedbackSending(true); - await sendToSlack(message, messageType); + await sendToSlack(email, message, messageType); //console.log("submitted"); @@ -311,3 +324,4 @@ const Feedback = () => { }; export default Feedback; +export { getFeedbackMessage, sendToSlack }; // Named exports diff --git a/src/components/NavItems/Assistant/AssistantApiHandlers/useAssistantApi.jsx b/src/components/NavItems/Assistant/AssistantApiHandlers/useAssistantApi.jsx index 8a83c55db..7832c6ec0 100644 --- a/src/components/NavItems/Assistant/AssistantApiHandlers/useAssistantApi.jsx +++ b/src/components/NavItems/Assistant/AssistantApiHandlers/useAssistantApi.jsx @@ -2,6 +2,7 @@ import axios from "axios"; export default function assistantApiCalls() { const assistantEndpoint = process.env.REACT_APP_ASSISTANT_URL; + const chatbotEndpoint = process.env.REACT_APP_CHATBOT_URL; function handleAssistantError(errorResponse) { if (errorResponse.response) { @@ -22,6 +23,34 @@ export default function assistantApiCalls() { } } + const callChatbot = async ( + sessionID, + userInput = null, + email = null, + archiveURL = null, + tool = null, + result = null, + ) => { + let chatbotResponse; + try { + chatbotResponse = await axios.post(assistantEndpoint + "gcloud/chatbot", { + message: userInput, + sessionID: sessionID, + tool: tool, + result: result, + }); + } catch (error) { + handleAssistantError(error); + } + + if (chatbotResponse.data.status === "success") { + return chatbotResponse.data; + } else { + console.log("Chatbot error:", chatbotResponse); + throw new Error("assistant_error_server_error"); + } + }; + const callAssistantScraper = async (urlType, userInput) => { let scrapeResult; try { @@ -294,6 +323,7 @@ export default function assistantApiCalls() { }; return { + callChatbot, callAssistantScraper, callSourceCredibilityService, callNamedEntityService, diff --git a/src/components/NavItems/Assistant/AssistantIntroduction.jsx b/src/components/NavItems/Assistant/AssistantIntroduction.jsx index 5ef317098..8ae476a1c 100644 --- a/src/components/NavItems/Assistant/AssistantIntroduction.jsx +++ b/src/components/NavItems/Assistant/AssistantIntroduction.jsx @@ -26,6 +26,7 @@ import useMyStyles from "../../Shared/MaterialUiStyles/useMyStyles"; import { TransHtmlDoubleLineBreak, TransSupportedToolsLink, + TransSupportedUrlsLink, } from "./TransComponents"; const AssistantIntroduction = (props) => { diff --git a/src/components/NavItems/Assistant/AssistantScrapeResults/AssistantTextClassification.jsx b/src/components/NavItems/Assistant/AssistantScrapeResults/AssistantTextClassification.jsx index 16e91c818..307755c89 100644 --- a/src/components/NavItems/Assistant/AssistantScrapeResults/AssistantTextClassification.jsx +++ b/src/components/NavItems/Assistant/AssistantScrapeResults/AssistantTextClassification.jsx @@ -168,7 +168,10 @@ export default function AssistantTextClassification({ if (Object.keys(filteredCategories).length === 0) { filteredSentences = []; } - if (credibilitySignal === keyword("subjectivity_title") && Object.keys(filteredSentences).length === 0) { + if ( + credibilitySignal === keyword("subjectivity_title") && + Object.keys(filteredSentences).length === 0 + ) { filteredCategories = []; } diff --git a/src/components/NavItems/Assistant/ChatbotInterface.jsx b/src/components/NavItems/Assistant/ChatbotInterface.jsx new file mode 100644 index 000000000..fc07b5572 --- /dev/null +++ b/src/components/NavItems/Assistant/ChatbotInterface.jsx @@ -0,0 +1,191 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + +import RestartAltIcon from "@mui/icons-material/RestartAlt"; + +import { styled } from "@mui/system"; +import { i18nLoadNamespace } from "components/Shared/Languages/i18nLoadNamespace"; + +import { + clearChatbotMessages, + submitUserChatbotMessage, +} from "../../../redux/actions/tools/assistantActions"; + +const MessageBubble = styled(Box)(({ sent }) => ({ + maxWidth: "70%", + padding: "10px 15px", + borderRadius: sent ? "15px 15px 0 15px" : "15px 15px 15px 0", + backgroundColor: sent ? "#00926c" : "#fff", + color: sent ? "#fff" : "#000", + marginBottom: "10px", + alignSelf: sent ? "flex-end" : "flex-start", + boxShadow: "0 2px 4px rgba(0,0,0,0.1)", + transition: "transform 0.2s ease", +})); + +const ChatbotInterface = ({ tool, result }) => { + const dispatch = useDispatch(); + const keyword = i18nLoadNamespace("components/Shared/chatbot"); + const [formInput, setFormInput] = useState(""); + const archiveURL = useSelector( + (state) => state?.syntheticImageDetection?.duplicates?.archive_url ?? null, + ); + + const chatbotMessages = useSelector( + (state) => state.assistant.chatbotMessages, + ).filter((msg) => msg.message); // Access the entire state + const sessionID = useSelector((state) => state.assistant.chatbotSessionID); + const userEmail = useSelector((state) => state.userSession.user.email); + const [previousResult, setPreviousResult] = useState(null); + const [sendResult, setSendResult] = useState(true); + const chatbotLoading = useSelector((state) => state.assistant.chatbotLoading); + const messageWindow = useRef(null); + + if (result != previousResult) { + setPreviousResult(result); + setSendResult(true); + } + + const sendMessage = () => { + dispatch( + submitUserChatbotMessage( + sessionID, + formInput, + userEmail, + archiveURL, + sendResult ? tool : null, + sendResult ? result : null, + ), + ); + setFormInput(""); + setSendResult(false); + }; + + const resetChatbot = () => { + dispatch(clearChatbotMessages()); + // Clear the session history in the chatbot backend + dispatch(submitUserChatbotMessage(sessionID)); + setFormInput(""); + setSendResult(true); + setPreviousResult(result); + }; + + // Detect when the tab is closed and send a special request to the chatbot to clear the session history + useEffect(() => { + return () => { + window.addEventListener("beforeunload", function (e) { + dispatch(submitUserChatbotMessage(sessionID)); + if (sessionID) { + dispatch(submitUserChatbotMessage(sessionID)); + setSessionID(null); + } + }); + }; + }); + + useEffect(() => { + if (messageWindow.current) { + const lastChild = messageWindow.current.lastElementChild; + if (lastChild) { + lastChild.scrollIntoView({ behavior: "smooth", block: "end" }); + } + } + }, [chatbotLoading]); // The empty dependency array means this effect runs once after the initial render + + return ( +
+ + {/* Conversation */} + + {chatbotMessages.map((msg) => ( + + + {msg.message} + + + + ))} + {chatbotLoading ? ( + + + + ) : ( +
+ )} +
+ + {/* text box */} + + + setFormInput(e.target.value)} + data-testid="chatbot-input" + /> + + {/* submit button */} + + +
+
+ ); +}; + +export default ChatbotInterface; diff --git a/src/components/NavItems/tools/SyntheticImageDetection/index.jsx b/src/components/NavItems/tools/SyntheticImageDetection/index.jsx index 4db6c1567..87f61717c 100644 --- a/src/components/NavItems/tools/SyntheticImageDetection/index.jsx +++ b/src/components/NavItems/tools/SyntheticImageDetection/index.jsx @@ -5,8 +5,10 @@ import { useLocation } from "react-router-dom"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; +import CardHeader from "@mui/material/CardHeader"; import FormControlLabel from "@mui/material/FormControlLabel"; import FormGroup from "@mui/material/FormGroup"; +import Grid from "@mui/material/Grid"; import LinearProgress from "@mui/material/LinearProgress"; import Stack from "@mui/material/Stack"; import Switch from "@mui/material/Switch"; @@ -27,7 +29,9 @@ import { i18nLoadNamespace } from "components/Shared/Languages/i18nLoadNamespace import { setError } from "redux/reducers/errorReducer"; import HeaderTool from "../../../Shared/HeaderTool/HeaderTool"; +import useMyStyles from "../../../Shared/MaterialUiStyles/useMyStyles"; import StringFileUploadField from "../../../Shared/StringFileUploadField"; +import ChatbotInterface from "../../Assistant/ChatbotInterface"; import { syntheticImageDetectionAlgorithms } from "./SyntheticImageDetectionAlgorithms"; import SyntheticImageDetectionResults from "./syntheticImageDetectionResults"; @@ -35,7 +39,7 @@ const SyntheticImageDetection = () => { const location = useLocation(); const urlParams = new URLSearchParams(location.search); const urlParam = urlParams.get("url"); - + const classes = useMyStyles(); const keyword = i18nLoadNamespace( "components/NavItems/tools/SyntheticImageDetection", ); @@ -388,11 +392,62 @@ const SyntheticImageDetection = () => { - + + + + + + + + + + {keyword("synthetic_image_detection_link")} + + } + className={classes.headerUploadedImage} + /> + +
{ detailsPanelMessage === "synthetic_image_detection_additional_results_hide" ? setDetailsPanelMessage("synthetic_image_detection_additional_results") @@ -328,6 +333,12 @@ const SyntheticImageDetectionResults = ({ ); }; + const handleChatbotChange = () => { + chatbotPanelMessage === "synthetic_image_detection_hide_chatbot" + ? setChatbotPanelMessage("synthetic_image_detection_show_chatbot") + : setChatbotPanelMessage("synthetic_image_detection_hide_chatbot"); + }; + const [nddDetailsPanelMessage, setNddDetailsPanelMessage] = useState( "synthetic_image_detection_ndd_additional_results_hide", ); @@ -716,6 +727,27 @@ const SyntheticImageDetectionResults = ({ )} + + + + }> + {keyword(chatbotPanelMessage)} + + + + + + + + + {filteredNddRows && filteredNddRows.length > 0 && ( { }; }; +export const addChatbotMessage = (message, sent) => { + return { + type: "ADD_CHATBOT_MESSAGE", + payload: { + message: message, + sent: sent, + }, + }; +}; + +export const clearChatbotMessages = (message, sent) => { + return { + type: "CLEAR_CHATBOT_MESSAGES", + }; +}; + export const setScrapedData = ( text, lang, @@ -364,6 +380,27 @@ export const submitInputUrl = (inputUrl) => { }; }; +export const submitUserChatbotMessage = ( + sessionID, + message = null, + email = null, + archiveURL = null, + tool = null, + result = null, +) => { + return { + type: "SUBMIT_USER_CHATBOT_MESSAGE", + payload: { + message: message, + sessionID: sessionID, + email: email, + archiveURL: archiveURL, + tool: tool, + result: result, + }, + }; +}; + export const submitUpload = (contentType) => { return { type: "SUBMIT_UPLOAD", diff --git a/src/redux/reducers/assistantReducer.jsx b/src/redux/reducers/assistantReducer.jsx index cc3f89c61..8e61dbf43 100644 --- a/src/redux/reducers/assistantReducer.jsx +++ b/src/redux/reducers/assistantReducer.jsx @@ -1,3 +1,5 @@ +import { v4 as uuidv4 } from "uuid"; + const defaultState = { urlMode: false, imageVideoSelected: false, @@ -17,6 +19,10 @@ const defaultState = { processUrlType: null, inputUrlType: null, + chatbotMessages: [], + chatbotSessionID: uuidv4(), + chatbotLoading: false, + positiveSourceCred: null, cautionSourceCred: null, mixedSourceCred: null, @@ -119,7 +125,28 @@ const assistantReducer = (state = defaultState, action) => { case "SET_ASSURANCE_EXPANDED": case "SET_STATE_EXPANDED": return Object.assign({}, state, action.payload); - + case "ADD_CHATBOT_MESSAGE": { + const message = action.payload; + message.id = state.chatbotMessages.length + 1; + return { + ...state, + chatbotMessages: [...state.chatbotMessages, message], + chatbotLoading: message.sent == 1, + }; + } + case "SUBMIT_USER_CHATBOT_MESSAGE": { + return { + ...state, + chatbotLoading: true, + }; + } + case "CLEAR_CHATBOT_MESSAGES": { + return { + ...state, + chatbotMessages: [], + chatbotSessionID: uuidv4(), + }; + } case "CLEAN_STATE": return { ...state, @@ -127,6 +154,10 @@ const assistantReducer = (state = defaultState, action) => { imageVideoSelected: false, singleMediaPresent: null, + chatbotMessages: [], + chatbotSessionID: uuidv4(), + chatbotLoading: false, + inputUrl: null, errorKey: null, processUrl: null, diff --git a/src/redux/sagas/assistantSaga.jsx b/src/redux/sagas/assistantSaga.jsx index be7123b89..a153c89ab 100644 --- a/src/redux/sagas/assistantSaga.jsx +++ b/src/redux/sagas/assistantSaga.jsx @@ -19,10 +19,16 @@ import { takeLatest, } from "redux-saga/effects"; +import { + getFeedbackMessage, + sendToSlack, +} from "../../components/Feedback/Feedback"; import assistantApiCalls from "../../components/NavItems/Assistant/AssistantApiHandlers/useAssistantApi"; import DBKFApi from "../../components/NavItems/Assistant/AssistantApiHandlers/useDBKFApi"; import { + addChatbotMessage, cleanAssistantState, + clearChatbotMessages, setAssistantLoading, setDbkfImageMatchDetails, setDbkfTextMatchDetails, @@ -72,6 +78,10 @@ function* getMediaActionSaga() { ); } +function* getAssistantChatbotSaga() { + yield takeLatest("SUBMIT_USER_CHATBOT_MESSAGE", handleAssistantChatbotCall); +} + function* getAssistantScrapeSaga() { yield takeLatest("SUBMIT_INPUT_URL", handleAssistantScrapeCall); } @@ -707,6 +717,54 @@ function* handleNamedEntityCall(action) { } } +function* handleAssistantChatbotCall(action) { + const message = action.payload.message; + const email = action.payload.email; + const archiveURL = action.payload.archiveURL; + const sessionID = action.payload.sessionID; + const tool = action.payload.tool; + const result = action.payload.result; + + yield put(addChatbotMessage(message, 1)); + + try { + const chatbotResponse = yield call( + assistantApi.callChatbot, + sessionID, + message, + email, + archiveURL, + tool, + result, + ); + const suffix = "\n[Sent via the chatbot assistant]"; + + // UNCOMMENT BEFORE MERGING + // if (chatbotResponse.userMessageClasses.includes("BUG")) { + // sendToSlack(email, message + suffix, "BUG", archiveURL); + // } + // if (chatbotResponse.userMessageClasses.includes("FEATURE")) { + // sendToSlack(email, message + suffix, "FEATURE", archiveURL); + // } + // if (chatbotResponse.userMessageClasses.includes("IMPROVEMENT")) { + // sendToSlack(email, message + suffix, "IMPROVEMENT", archiveURL); + // } + + yield put(addChatbotMessage(chatbotResponse.message, 0)); + yield put(setAssistantLoading(false)); + } catch (error) { + console.log(error); + yield put( + addChatbotMessage( + "I'm sorry, something went wrong and I could not provide an answer. Please try again.", + 0, + ), + ); + yield put(setAssistantLoading(false)); + yield put(setErrorKey(error.message)); + } +} + function* handleAssistantScrapeCall(action) { let inputUrl = action.payload.inputUrl; @@ -1248,6 +1306,7 @@ export default function* assistantSaga() { fork(getMediaSimilaritySaga), fork(getMediaListSaga), fork(getNamedEntitySaga), + fork(getAssistantChatbotSaga), fork(getAssistantScrapeSaga), fork(getUploadSaga), fork(getNewsTopicSaga),