From 465f89296499f9e14b903c1511934914a6a1ccef Mon Sep 17 00:00:00 2001 From: Mat Jordan Date: Wed, 29 Jan 2025 15:56:57 -0500 Subject: [PATCH] Handle conversations chat UI. --- components/Chat/Chat.test.tsx | 13 - components/Chat/Chat.tsx | 199 +++++----------- components/Chat/Conversation.test.tsx | 16 ++ components/Chat/Conversation.tsx | 225 ++++++++++++++++++ components/Chat/Feedback/Feedback.tsx | 23 +- components/Chat/Response/Images.tsx | 29 +-- .../Chat/Response/Interstitial.styled.tsx | 35 +-- components/Chat/Response/Interstitial.tsx | 21 +- components/Chat/Response/Markdown.tsx | 28 ++- components/Chat/Response/Options.tsx | 11 + components/Chat/Response/Response.styled.tsx | 84 +++++-- components/Chat/Response/Response.tsx | 56 +++-- components/Search/Search.tsx | 11 +- components/Shared/SVG/Icons.tsx | 16 ++ hooks/useChatSocket.ts | 1 + pages/search.tsx | 27 +-- 16 files changed, 514 insertions(+), 281 deletions(-) create mode 100644 components/Chat/Conversation.test.tsx create mode 100644 components/Chat/Conversation.tsx create mode 100644 components/Chat/Response/Options.tsx diff --git a/components/Chat/Chat.test.tsx b/components/Chat/Chat.test.tsx index 44964a48..c1d8728d 100644 --- a/components/Chat/Chat.test.tsx +++ b/components/Chat/Chat.test.tsx @@ -80,11 +80,6 @@ describe("Chat component", () => { const dataProps = el.getAttribute("data-props"); const dataPropsObj = JSON.parse(dataProps!); expect(dataPropsObj.question).toEqual("tell me about boats"); - expect(dataPropsObj.isStreamingComplete).toEqual(false); - expect(dataPropsObj.message).toEqual({ - answer: "fake-answer-1", - end: "stop", - }); expect(typeof dataPropsObj.conversationRef).toBe("string"); expect(uuidRegex.test(dataPropsObj.conversationRef)).toBe(true); }); @@ -106,14 +101,6 @@ describe("Chat component", () => { , ); - - expect(mockMessage).toHaveBeenCalledWith( - expect.objectContaining({ - auth: "fake-token", - message: "chat", - question: "boats", - }), - ); }); it("doesn't send a websocket message if the search term is empty", () => { diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx index 0666c194..d075f830 100644 --- a/components/Chat/Chat.tsx +++ b/components/Chat/Chat.tsx @@ -1,93 +1,54 @@ -import { AI_DISCLAIMER, AI_SEARCH_UNSUBMITTED } from "@/lib/constants/common"; import React, { useEffect, useState } from "react"; -import { - StyledResponseActions, - StyledResponseDisclaimer, - StyledUnsubmitted, -} from "@/components/Chat/Response/Response.styled"; -import { defaultState, useSearchState } from "@/context/search-context"; -import Announcement from "@/components/Shared/Announcement"; -import { Button } from "@nulib/design-system"; -import ChatFeedback from "@/components/Chat/Feedback/Feedback"; +import { AI_SEARCH_UNSUBMITTED } from "@/lib/constants/common"; +import ChatConversation from "./Conversation"; import ChatResponse from "@/components/Chat/Response/Response"; import Container from "@/components/Shared/Container"; -import { prepareQuestion } from "@/lib/chat-helpers"; -import useChatSocket from "@/hooks/useChatSocket"; +import { StyledUnsubmitted } from "./Response/Response.styled"; +import { styled } from "@/stitches.config"; import useQueryParams from "@/hooks/useQueryParams"; import { v4 as uuidv4 } from "uuid"; -const Chat = ({ - viewResultsCallback, -}: { - viewResultsCallback?: () => void; -}) => { - const { searchTerm = "" } = useQueryParams(); - const { authToken, isConnected, message, sendMessage } = useChatSocket(); - const [conversationRef, setConversationRef] = useState(); - - const [streamingError, setStreamingError] = useState(""); +interface Conversation { + question: string; + answer: string; +} - /** - * get the`chat` state and dispatch function from the search context - * for persisting the chat state when search screen tabs are switched - */ - const { - searchState: { chat }, - searchDispatch, - } = useSearchState(); - const { question, answer } = chat; +const Chat = () => { + const { searchTerm } = useQueryParams(); - const [isStreamingComplete, setIsStreamingComplete] = useState(false); + const initialConversation = { + question: searchTerm, + answer: "", + }; - useEffect(() => { - if ( - !isStreamingComplete && - isConnected && - authToken && - searchTerm && - conversationRef - ) { - resetChat(); - const preparedQuestion = prepareQuestion( - searchTerm, - authToken, - conversationRef, - ); - sendMessage(preparedQuestion); - } - }, [ - authToken, - isStreamingComplete, - isConnected, - searchTerm, - conversationRef, - sendMessage, + const [conversationRef, setConversationRef] = useState(); + const [conversation, setConversation] = useState([ + initialConversation, ]); + const [isStreaming, setIsStreaming] = useState(false); useEffect(() => { - setIsStreamingComplete(false); - setConversationRef(uuidv4()); + const conversationRef = uuidv4(); + setIsStreaming(true); + setConversationRef(conversationRef); + setConversation([initialConversation]); }, [searchTerm]); - useEffect(() => { - if (!message || !conversationRef) return; - }, [message]); - - function handleNewQuestion() { - const input = document.getElementById("dc-search") as HTMLInputElement; - if (input) { - input.focus(); - input.value = ""; - } - } + const handleConversationCallback = (value: string) => { + setIsStreaming(true); + setConversation([ + ...conversation, + { + question: value, + answer: "", + }, + ]); + }; - function resetChat() { - searchDispatch({ - chat: defaultState.chat, - type: "updateChat", - }); - } + const handleResponseCallback = (content: any) => { + setIsStreaming(false); + }; if (!searchTerm) return ( @@ -96,68 +57,36 @@ const Chat = ({ ); - const handleResponseCallback = (content: any) => { - if (!conversationRef) return; - - setIsStreamingComplete(true); - searchDispatch({ - chat: { - // content here is now a react element - // once continued conversations ar e in place - // see note below for question refactor - answer: content, - - // documents should be eventually removed as - // they are now integrated into content - // doing so will require some careful refactoring - // as the documents are used in feedback form - documents: [], - - // question should become an entry[] with - // entry[n].question and entry[n].content - question: searchTerm || "", - - ref: conversationRef, - }, - type: "updateChat", - }); - }; - return ( - <> - - {streamingError && ( - - - {streamingError} - - - )} - {isStreamingComplete && ( - <> - - - - - - {AI_DISCLAIMER} - - - - )} - + + + {conversation + .filter((entry) => entry.question) + .map((entry, index) => { + return ( + + ); + })} + + + ); }; -export default React.memo(Chat); +const StyledChat = styled("section", { + padding: "$gr5 0", +}); + +export default Chat; diff --git a/components/Chat/Conversation.test.tsx b/components/Chat/Conversation.test.tsx new file mode 100644 index 00000000..93ca82ec --- /dev/null +++ b/components/Chat/Conversation.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@/test-utils"; + +import ChatConversation from "./Conversation"; + +describe("Conversaton component", () => { + const handleConversationCallback = jest.fn(); + + it("renders a chat conversation", () => { + render( + , + ); + + const wrapper = screen.getByTestId("chat-conversation"); + expect(wrapper).toBeInTheDocument(); + }); +}); diff --git a/components/Chat/Conversation.tsx b/components/Chat/Conversation.tsx new file mode 100644 index 00000000..67a4d107 --- /dev/null +++ b/components/Chat/Conversation.tsx @@ -0,0 +1,225 @@ +import { IconArrowForward, IconRefresh, IconReply } from "../Shared/SVG/Icons"; + +import { styled } from "@/stitches.config"; +import { transform } from "next/dist/build/swc"; +import { useRef } from "react"; +import { useRouter } from "next/router"; + +const textareaPlaceholder = "Ask a followup question..."; + +interface ChatConversationProps { + conversationCallback: (message: string) => void; + isStreaming?: boolean; +} + +const ChatConversation: React.FC = ({ + conversationCallback, + isStreaming, +}) => { + const router = useRouter(); + const textareaRef = useRef(null); + const formRef = useRef(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + submitConversationCallback(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submitConversationCallback(); + } + }; + + const submitConversationCallback = () => { + if (isStreaming) return; + + const value = textareaRef.current?.value; + if (value) conversationCallback(value); + + /* Clear the textarea and unfocus it */ + textareaRef.current!.value = ""; + textareaRef.current!.blur(); + }; + + const handleFocus = () => { + const isFocused = String(textareaRef.current === document.activeElement); + formRef.current!.dataset.isFocused = isFocused; + }; + + const handleClearConversation = () => { + const textarea = document.getElementById( + "dc-search", + ) as HTMLTextAreaElement; + if (textarea) { + textarea.value = ""; + textarea.innerText = ""; + textarea.focus(); + } + + router.push({ + pathname: "/search", + }); + }; + + return ( + +
+ + +
+ + Start new conversation + + +
+ ); +}; + +const StyledResetButton = styled("button", { + border: "none", + backgroundColor: "$white", + display: "inline-flex", + justifyContent: "center", + alignItems: "center", + gap: "$gr1", + fontFamily: "$northwesternSansRegular", + fontSize: "$gr3", + color: "$purple", + padding: "$gr3", + borderRadius: "3px", + cursor: "pointer", + transition: "$dcAll", + textDecoration: "underline", + textDecorationThickness: "min(2px,max(1px,.05em))", + textUnderlineOffset: "calc(.05em + 2px)", + textDecorationColor: "$purple10", + margin: "0 auto", + + svg: { + fill: "transparent", + stroke: "$purple", + strokeWidth: "48px", + height: "1.25em", + width: "1.25em", + transform: "rotate(45deg) scaleX(-1)", + }, +}); + +const StyledChatConversation = styled("div", { + display: "flex", + flexDirection: "column", + margin: "$gr5 0 $gr4", + gap: "$gr3", + + form: { + position: "relative", + transition: "$dcAll", + borderRadius: "3px", + flexWrap: "wrap", + overflow: "hidden", + flexGrow: 1, + zIndex: 0, + height: "62px", + + ["&[data-is-focused=true]"]: { + backgroundColor: "$white !important", + boxShadow: "3px 3px 11px #0001", + outline: "2px solid $purple60", + + button: { + backgroundColor: "$purple", + color: "$white", + }, + }, + + ["&[data-is-focused=false]"]: { + backgroundColor: "#f0f0f0", + boxShadow: "none", + outline: "2px solid transparent", + + textarea: { + color: "$black50", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, + }, + + textarea: { + width: "100%", + height: "100%", + padding: "15px $gr3", + border: "none", + resize: "none", + backgroundColor: "$gray6", + fontSize: "$gr3", + lineHeight: "147%", + zIndex: "1", + fontFamily: "$northwesternSansRegular", + overflow: "hidden", + outline: "none", + transition: "$dcAll", + boxSizing: "border-box", + + "&::placeholder": { + overflow: "hidden", + color: "$black50", + textOverflow: "ellipsis", + }, + }, + + button: { + position: "absolute", + bottom: "$gr2", + right: "$gr2", + height: "38px", + borderRadius: "3px", + background: "$purple", + border: "none", + color: "$white", + display: "flex", + alignItems: "center", + justifyContent: "center", + transition: "$dcAll", + cursor: "pointer", + fontSize: "$gr2", + padding: "0 $gr2", + gap: "$gr2", + fontFamily: "$northwesternSansRegular", + + "&:hover, &:focus": { + backgroundColor: "$purple120", + color: "$white", + }, + + svg: { + width: "1rem", + height: "1rem", + fill: "transparent", + stroke: "$white", + // transform: "rotate(-90deg)", + + path: { + strokeWidth: "32px", + }, + }, + + "&:disabled": { + backgroundColor: "$black20", + color: "$white", + }, + }, + }, +}); + +export default ChatConversation; diff --git a/components/Chat/Feedback/Feedback.tsx b/components/Chat/Feedback/Feedback.tsx index 234c3806..79ad74de 100644 --- a/components/Chat/Feedback/Feedback.tsx +++ b/components/Chat/Feedback/Feedback.tsx @@ -216,14 +216,16 @@ const StyledChatFeedbackActivate = styled("div", { margin: "0 0 $gr2 ", display: "flex", alignItems: "center", - fontSize: "$gr3", - gap: "$gr2", -}); + borderTop: "1px solid $gray6", + padding: "$gr3 0", -const StyledChatFeedbackConfirmation = styled("div", { - fontSize: "$gr3", + "> span": { + marginRight: "$gr2", + }, }); +const StyledChatFeedbackConfirmation = styled("div", {}); + const StyledChatFeedbackForm = styled("form", { margin: "$gr3 0", transition: "200ms all ease-in-out", @@ -244,6 +246,9 @@ const StyledChatFeedbackForm = styled("form", { }); const StyledChatFeedback = styled("div", { + fontSize: "$gr2", + color: "$black50", + variants: { isSubmitted: { true: { @@ -257,7 +262,7 @@ const StyledChatFeedback = styled("div", { }); const StyledSentimentButton = styled("button", { - backgroundColor: "$purple10", + backgroundColor: "transparent", border: "none", padding: 0, height: "40px", @@ -270,8 +275,8 @@ const StyledSentimentButton = styled("button", { borderRadius: "50%", "> span": { - height: "36px", - width: "36px", + height: "32px", + width: "32px", }, "&:not([disabled])": { @@ -290,7 +295,7 @@ const StyledSentimentButton = styled("button", { "&[data-is-selected=false]": { "> span": { - fill: "$purple30", + fill: "$black20", }, }, }); diff --git a/components/Chat/Response/Images.tsx b/components/Chat/Response/Images.tsx index c8779887..bde6c7c0 100644 --- a/components/Chat/Response/Images.tsx +++ b/components/Chat/Response/Images.tsx @@ -1,38 +1,13 @@ -import { useEffect, useState } from "react"; - import GridItem from "@/components/Grid/Item"; import { StyledImages } from "@/components/Chat/Response/Response.styled"; import { Work } from "@nulib/dcapi-types"; const INITIAL_MAX_ITEMS = 5; -const ResponseImages = ({ - isStreamingComplete, - works, -}: { - isStreamingComplete: boolean; - works: Work[]; -}) => { - const [nextIndex, setNextIndex] = useState(0); - - useEffect(() => { - if (isStreamingComplete) { - setNextIndex(works.length); - return; - } - - if (nextIndex < works.length && nextIndex < INITIAL_MAX_ITEMS) { - const timer = setTimeout(() => { - setNextIndex(nextIndex + 1); - }, 100); - - return () => clearTimeout(timer); - } - }, [isStreamingComplete, nextIndex, works.length]); - +const ResponseImages = ({ works }: { works: Work[] }) => { return ( - {works.slice(0, nextIndex).map((document: Work) => ( + {works.slice(0, INITIAL_MAX_ITEMS).map((document: Work) => ( ))} diff --git a/components/Chat/Response/Interstitial.styled.tsx b/components/Chat/Response/Interstitial.styled.tsx index acde30d4..7de6524e 100644 --- a/components/Chat/Response/Interstitial.styled.tsx +++ b/components/Chat/Response/Interstitial.styled.tsx @@ -9,16 +9,11 @@ const gradientAnimation = keyframes({ const StyledInterstitialIcon = styled("div", { display: "flex", - width: "1.5rem", - height: "1.5rem", + width: "1rem", + height: "1rem", alignItems: "center", justifyContent: "center", borderRadius: "50%", - background: - "linear-gradient(73deg, $purple120 0%, $purple 38.2%, $brightBlueB 61.8%)", - backgroundSize: "250%", - backgroundPosition: "61.8%", - animation: `${gradientAnimation} 5s infinite alternate`, transition: "$dcAll", content: "", @@ -34,21 +29,31 @@ const StyledInterstitialIcon = styled("div", { }, svg: { - fill: "$white", - width: "0.85rem", - height: "0.85rem", + fill: "$purple", + width: "1rem", + height: "1rem", }, }); const StyledInterstitial = styled("div", { - color: "$black", - fontFamily: "$northwesternSansBold", - fontSize: "$gr4", - display: "flex", + fontFamily: "$northwesternSansRegular", + fontWeight: "400", + fontSize: "$gr3", + display: "inline-flex", alignItems: "center", gap: "$gr2", + marginBottom: "$gr1", + width: "fit-content", + color: "$purple60", + borderRadius: "1em", + paddingRight: "$gr2", + backgroundSize: "250%", + backgroundPosition: "61.8%", + animation: `${gradientAnimation} 5s infinite alternate`, - em: { + strong: { + fontFamily: "$northwesternSansBold", + fontWeight: "400", color: "$purple", }, }); diff --git a/components/Chat/Response/Interstitial.tsx b/components/Chat/Response/Interstitial.tsx index da0447e7..d6b82b96 100644 --- a/components/Chat/Response/Interstitial.tsx +++ b/components/Chat/Response/Interstitial.tsx @@ -1,9 +1,9 @@ +import { IconSearch, IconSparkles } from "@/components/Shared/SVG/Icons"; import { StyledInterstitial, StyledInterstitialIcon, } from "@/components/Chat/Response/Interstitial.styled"; -import { IconSearch } from "@/components/Shared/SVG/Icons"; import React from "react"; import { ToolStartMessage } from "@/types/components/chat"; @@ -20,19 +20,22 @@ const ResponseInterstitial: React.FC = ({ switch (tool) { case "aggregate": text = ( - <> - Aggregating {input.agg_field} by {input.term_field} {input.term} - + ); break; case "discover_fields": - text = <>Discovering fields; + text = ; break; case "search": text = ( - <> - Searching for {input.query} - + ); break; default: @@ -42,7 +45,7 @@ const ResponseInterstitial: React.FC = ({ return ( - + diff --git a/components/Chat/Response/Markdown.tsx b/components/Chat/Response/Markdown.tsx index dc25f524..a01256d5 100644 --- a/components/Chat/Response/Markdown.tsx +++ b/components/Chat/Response/Markdown.tsx @@ -2,10 +2,34 @@ import React from "react"; import { StyledResponseMarkdown } from "@/components/Chat/Response/Response.styled"; import useMarkdown from "@nulib/use-markdown"; +/** + * Add a wrapper around HTML table elements to allow + * for horizontal scrolling in responsive viewports + */ +function addTableWrapper(html: string) { + let parsedHtml = html; + + const tableRegex = /([\s\S]*?)<\/table>/g; + const tableMatch = html.match(tableRegex); + + if (tableMatch) { + tableMatch.forEach((table) => { + const tableWrapped = `
${table}
`; + parsedHtml = html.replace(table, tableWrapped); + }); + } + + return parsedHtml; +} + const ResponseMarkdown = ({ content }: { content: string }) => { - const { jsx } = useMarkdown(content); + const { html } = useMarkdown(content); + + const parsedHtml = addTableWrapper(html); - return {jsx}; + return ( + + ); }; export default ResponseMarkdown; diff --git a/components/Chat/Response/Options.tsx b/components/Chat/Response/Options.tsx new file mode 100644 index 00000000..9c0aea95 --- /dev/null +++ b/components/Chat/Response/Options.tsx @@ -0,0 +1,11 @@ +import ChatFeedback from "../Feedback/Feedback"; + +const ResponseOptions = () => { + return ( + <> + + + ); +}; + +export default ResponseOptions; diff --git a/components/Chat/Response/Response.styled.tsx b/components/Chat/Response/Response.styled.tsx index 1083c9e9..e3147e5f 100644 --- a/components/Chat/Response/Response.styled.tsx +++ b/components/Chat/Response/Response.styled.tsx @@ -8,13 +8,19 @@ const CursorKeyframes = keyframes({ }, }); -const StyledResponse = styled("section", { +const StyledResponse = styled("article", { display: "flex", position: "relative", flexDirection: "column", gap: "$gr3", zIndex: "0", - minHeight: "50vh", + marginBottom: "$gr5", + + "> div": { + display: "flex", + flexDirection: "column", + gap: "$gr3", + }, "h1, h2, h3, h4, h5, h6, strong": { fontFamily: "$northwesternSansBold", @@ -31,10 +37,6 @@ const StyledResponseAside = styled("aside", {}); const StyledResponseContent = styled("div", {}); -const StyledResponseWrapper = styled("div", { - padding: "0", -}); - const StyledImages = styled("div", { display: "grid", gap: "$gr4", @@ -56,10 +58,6 @@ const StyledImages = styled("div", { figure: { padding: "0", - "> div": { - boxShadow: "5px 5px 13px rgba(0, 0, 0, 0.25)", - }, - figcaption: { "span:first-of-type": { textOverflow: "ellipsis", @@ -73,26 +71,38 @@ const StyledImages = styled("div", { }, }); -const StyledQuestion = styled("h3", { +const StyledQuestion = styled("header", { fontFamily: "$northwesternSansBold", fontWeight: "400", - fontSize: "$gr6", - letterSpacing: "-0.012em", + fontSize: "$gr3", lineHeight: "1.35em", - margin: "0 0 $gr4", - padding: "0", - color: "$black", + padding: "$gr2 $gr3", + margin: "0", + color: "$purple120", + alignSelf: "flex-end", + borderRadius: "1rem", + backgroundColor: "$purple10", }); -const StyledResponseMarkdown = styled("article", { +const StyledResponseMarkdown = styled("div", { fontSize: "$gr3", lineHeight: "1.47em", - overflow: "hidden", - p: { + ".table-wrapper": { + overflowX: "auto", + width: "100%", + "-webkit-overflow-scrolling": "touch", + margin: "$gr4 0", + }, + + "p, li": { lineHeight: "inherit", }, + li: { + marginBottom: "$gr1", + }, + "h1, h2, h3, h4, h5, h6, strong": { fontWeight: "400", fontFamily: "$northwesternSansBold", @@ -106,6 +116,36 @@ const StyledResponseMarkdown = styled("article", { textDecorationColor: "$purple10", }, + table: { + width: "100%", + borderCollapse: "collapse", + borderSpacing: "0", + marginBottom: "$gr4", + borderLeft: "1px solid $gray6", + borderRight: "1px solid $gray6", + borderTop: "1px solid $gray6", + margin: "0", + + th: { + padding: "$gr2", + textAlign: "left", + fontWeight: "400", + fontFamily: "$northwesternSansBold", + borderBottom: "1px solid $gray6", + }, + + td: { + padding: "$gr2", + borderBottom: "1px solid $gray6", + }, + }, + + img: { + maxWidth: "$gr7", + maxHeight: "$gr7", + borderRadius: "3px", + }, + "span.markdown-cursor": { position: "relative", marginLeft: "$gr1", @@ -131,12 +171,13 @@ const StyledResponseActions = styled("div", { const StyledUnsubmitted = styled("p", { color: "$black50", fontSize: "$gr3", - fontFamily: "$northwesternSansLight", + fontFamily: "$northwesternSansRegular", textAlign: "center", width: "61.8%", maxWidth: "61.8%", margin: "0 auto", - padding: "$gr4 0", + padding: "$gr5 0", + minHeight: "38.2vh", }); const StyledResponseDisclaimer = styled("p", { @@ -152,7 +193,6 @@ export { StyledResponseAside, StyledResponseContent, StyledResponseDisclaimer, - StyledResponseWrapper, StyledImages, StyledQuestion, StyledResponseMarkdown, diff --git a/components/Chat/Response/Response.tsx b/components/Chat/Response/Response.tsx index 11454628..7ef4c09d 100644 --- a/components/Chat/Response/Response.tsx +++ b/components/Chat/Response/Response.tsx @@ -2,7 +2,6 @@ import React, { use, useEffect, useState } from "react"; import { StyledQuestion, StyledResponse, - StyledResponseWrapper, } from "@/components/Chat/Response/Response.styled"; import BouncingLoader from "@/components/Shared/BouncingLoader"; @@ -10,27 +9,46 @@ import Container from "@/components/Shared/Container"; import ResponseImages from "@/components/Chat/Response/Images"; import ResponseInterstitial from "@/components/Chat/Response/Interstitial"; import ResponseMarkdown from "@/components/Chat/Response/Markdown"; -import { StreamingMessage } from "@/types/components/chat"; +import ResponseOptions from "./Options"; +import { prepareQuestion } from "@/lib/chat-helpers"; +import useChatSocket from "@/hooks/useChatSocket"; interface ChatResponseProps { conversationRef?: string; - isStreamingComplete: boolean; - message?: StreamingMessage; question: string; - responseCallback?: (renderedMessage: any) => void; + responseCallback?: (response: any) => void; } const ChatResponse: React.FC = ({ conversationRef, - isStreamingComplete, - message, question, responseCallback, }) => { + const { authToken, isConnected, message, sendMessage } = useChatSocket(); + + useEffect(() => { + console.log(`trying to send message`); + if (isConnected && authToken && question && conversationRef) { + const preparedQuestion = prepareQuestion( + question, + authToken, + conversationRef, + ); + sendMessage(preparedQuestion); + } + }, [isConnected, authToken, question, conversationRef]); + const [renderedMessage, setRenderedMessage] = useState(); const [streamedMessage, setStreamedMessage] = useState(""); + const [isStreamingComplete, setIsStreamingComplete] = useState(false); useEffect(() => { + setIsStreamingComplete(false); + }, [conversationRef, question]); + + useEffect(() => { + console.log(`message`, message); + if (!message || message.ref !== conversationRef) return; const { type } = message; @@ -66,10 +84,7 @@ const ChatResponse: React.FC = ({ setRenderedMessage((prev) => ( <> {prev} - + )); } @@ -92,6 +107,7 @@ const ChatResponse: React.FC = ({ * to store this response. */ if (type === "final_message") { + setIsStreamingComplete(true); if (responseCallback) responseCallback(renderedMessage); } }, [message]); @@ -110,16 +126,14 @@ const ChatResponse: React.FC = ({ } return ( - - - - {question} - {renderedMessage} - {streamedMessage && } - {!isStreamingComplete && } - - - + + {question} +
+ {renderedMessage} + {streamedMessage && } + {isStreamingComplete ? : } +
+
); }; diff --git a/components/Search/Search.tsx b/components/Search/Search.tsx index 77d5fe2f..c3da1ff6 100644 --- a/components/Search/Search.tsx +++ b/components/Search/Search.tsx @@ -27,13 +27,15 @@ const Search: React.FC = ({ isSearchActive }) => { const router = useRouter(); const { urlFacets } = useQueryParams(); + const { q } = router.query; + const { isChecked } = useGenerativeAISearchToggle(); const searchRef = useRef(null); const formRef = useRef(null); const [isLoaded, setIsLoaded] = useState(false); - const [searchValue, setSearchValue] = useState(""); + const [searchValue, setSearchValue] = useState(q as string); const [searchFocus, setSearchFocus] = useState(false); const appendSearchJumpTo = isCollectionPage(router?.pathname); @@ -88,12 +90,13 @@ const Search: React.FC = ({ isSearchActive }) => { useEffect(() => setIsLoaded(true), []); useEffect(() => { - if (router) { - const { q } = router.query; + if (q) { if (q && searchRef.current) searchRef.current.value = q as string; setSearchValue(q as string); + } else { + setSearchValue(""); } - }, [router]); + }, [q]); useEffect(() => { !searchFocus && !searchValue ? isSearchActive(false) : isSearchActive(true); diff --git a/components/Shared/SVG/Icons.tsx b/components/Shared/SVG/Icons.tsx index 0df72d2e..896e2f51 100644 --- a/components/Shared/SVG/Icons.tsx +++ b/components/Shared/SVG/Icons.tsx @@ -136,6 +136,20 @@ const IconMenu: React.FC = () => ( ); +const IconRefresh: React.FC = () => ( + + + + +); + +const IconReply: React.FC = () => ( + + + + +); + const IconReturnDownBack: React.FC = () => ( Return Down Back @@ -241,6 +255,8 @@ export { IconInfo, IconLock, IconMenu, + IconRefresh, + IconReply, IconReturnDownBack, IconSearch, IconSocialFacebook, diff --git a/hooks/useChatSocket.ts b/hooks/useChatSocket.ts index 91457db0..9f18447a 100644 --- a/hooks/useChatSocket.ts +++ b/hooks/useChatSocket.ts @@ -62,6 +62,7 @@ const useChatSocket = () => { const sendMessage = useCallback((data: object) => { if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + console.log("Sending message", data); socketRef.current.send(JSON.stringify(data)); } }, []); diff --git a/pages/search.tsx b/pages/search.tsx index b27958b7..1171def5 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -12,8 +12,6 @@ import { DC_API_SEARCH_URL } from "@/lib/constants/endpoints"; import { HEAD_META } from "@/lib/constants/head-meta"; import Head from "next/head"; import Heading from "@/components/Heading/Heading"; -import Icon from "@/components/Shared/Icon"; -import { IconSparkles } from "@/components/Shared/SVG/Icons"; import Layout from "@/components/layout"; import { PRODUCTION_URL } from "@/lib/constants/endpoints"; import { SEARCH_RESULTS_PER_PAGE } from "@/lib/constants/common"; @@ -195,10 +193,6 @@ const SearchPage: NextPage = () => { }); } - function handleViewResultsCallback() { - setActiveTab("results"); - } - return ( <> {/* Google Structured Data via JSON-LD */} @@ -233,28 +227,13 @@ const SearchPage: NextPage = () => { onValueChange={(value) => setActiveTab(value as ActiveTab)} > - - - - - AI Response - - - {Number.isInteger(totalResults) ? ( - "View More Results" - ) : ( - - )} - - - } + tabs={<>} // placeholder for back tab activeTab={activeTab} renderTabList={showStreamedResponse} /> + - +