diff --git a/.eslintrc.json b/.eslintrc.json index 8844c39a..c1872a8f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,11 +15,11 @@ } ], "rules": { - "sort-keys": "error", - "sort-imports": "error", + "sort-keys": 2, + "sort-imports": 2, "@typescript-eslint/no-var-requires": "warn", "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/ban-ts-comment": "warn" } } diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0064d088..f866ac5a 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,9 +1,10 @@ name: Playwright Tests on: - # push: - # branches: [] - pull_request: - branches: [main, deploy/staging] +# push: +# branches: [] +# pull_request: +# branches: [main, deploy/staging] + workflow_dispatch: jobs: test: timeout-minutes: 10 diff --git a/components/Chat/Chat.test.tsx b/components/Chat/Chat.test.tsx new file mode 100644 index 00000000..08182042 --- /dev/null +++ b/components/Chat/Chat.test.tsx @@ -0,0 +1,174 @@ +import { render, screen } from "@/test-utils"; + +import Chat from "@/components/Chat/Chat"; +import { SearchProvider } from "@/context/search-context"; +import mockRouter from "next-router-mock"; +import useChatSocket from "@/hooks/useChatSocket"; + +const mockSendMessage = jest.fn(); + +jest.mock("@/context/search-context", () => { + const actual = jest.requireActual("@/context/search-context"); + + return { + __esModule: true, + ...actual, + useSearchState: () => ({ + searchDispatch: jest.fn(), + searchState: { + activeTab: "stream", + aggregations: {}, + chat: { + answer: "", + documents: [], + end: "stop", + question: "", + }, + searchFixed: false, + }, + }), + }; +}); + +jest.mock("@/components/Chat/Response/Response", () => { + return function MockChatResponse(props: any) { + return ( +
+ Mock Chat Response +
+ ); + }; +}); + +// Mock the useChatSocket hook and provide a default mock +// implementation which can be overridden in individual tests +jest.mock("@/hooks/useChatSocket"); +(useChatSocket as jest.Mock).mockImplementation(() => ({ + authToken: "fake-token-1", + isConnected: false, + message: { answer: "fake-answer-1", end: "stop" }, + sendMessage: mockSendMessage, +})); + +describe("Chat component", () => { + it("renders default placeholder text when no search term is present", () => { + render(); + + const wrapper = screen.getByText( + "What can I help you find? Try searching for", + { + exact: false, + }, + ); + expect(wrapper).toBeInTheDocument(); + }); + + it("renders the chat response component when search term is present", () => { + mockRouter.setCurrentUrl("/search?q=tell+me+about+boats"); + + render( + + + , + ); + + const el = screen.getByTestId("mock-chat-response"); + expect(el).toBeInTheDocument(); + + const dataProps = el.getAttribute("data-props"); + expect(JSON.parse(dataProps!)).toEqual({ + isStreamingComplete: false, + searchTerm: "tell me about boats", + sourceDocuments: [], + streamedAnswer: "", + }); + }); + + it("sends a websocket message when the search term changes", () => { + const mockMessage = jest.fn(); + + (useChatSocket as jest.Mock).mockImplementation(() => ({ + authToken: "fake-token", + isConnected: true, + message: { answer: "fake-answer-1" }, + sendMessage: mockMessage, + })); + + mockRouter.setCurrentUrl("/search?q=boats"); + + render( + + + , + ); + + 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", () => { + mockRouter.setCurrentUrl("/search"); + render( + + + , + ); + + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it("displays an error message when the response hits the LLM token limit", () => { + (useChatSocket as jest.Mock).mockImplementation(() => ({ + authToken: "fake", + isConnected: true, + message: { + end: { + reason: "length", + ref: "fake", + }, + }, + sendMessage: mockSendMessage, + })); + + mockRouter.setCurrentUrl("/search?q=boats"); + + render( + + + , + ); + + const error = screen.getByText("The response has hit the LLM token limit."); + expect(error).toBeInTheDocument(); + }); + + it("displays an error message when the response times out", () => { + (useChatSocket as jest.Mock).mockImplementation(() => ({ + authToken: "fake", + isConnected: true, + message: { + end: { + reason: "timeout", + ref: "fake", + }, + }, + sendMessage: mockSendMessage, + })); + + mockRouter.setCurrentUrl("/search?q=boats"); + + render( + + + , + ); + + const error = screen.getByText("The response has timed out."); + expect(error).toBeInTheDocument(); + }); +}); diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx new file mode 100644 index 00000000..9ff8eeb2 --- /dev/null +++ b/components/Chat/Chat.tsx @@ -0,0 +1,169 @@ +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 ChatResponse from "@/components/Chat/Response/Response"; +import Container from "@/components/Shared/Container"; +import { Work } from "@nulib/dcapi-types"; +import { pluralize } from "@/lib/utils/count-helpers"; +import { prepareQuestion } from "@/lib/chat-helpers"; +import useChatSocket from "@/hooks/useChatSocket"; +import useQueryParams from "@/hooks/useQueryParams"; + +const Chat = ({ + totalResults, + viewResultsCallback, +}: { + totalResults?: number; + viewResultsCallback: () => void; +}) => { + const { searchTerm = "" } = useQueryParams(); + const { authToken, isConnected, message, sendMessage } = useChatSocket(); + + const [streamingError, setStreamingError] = useState(""); + + /** + * 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, documents } = chat; + + const [sourceDocuments, setSourceDocuments] = useState([]); + const [streamedAnswer, setStreamedAnswer] = useState(""); + + const isStreamingComplete = !!question && searchTerm === question; + + useEffect(() => { + if (!isStreamingComplete && isConnected && authToken && searchTerm) { + resetChat(); + const preparedQuestion = prepareQuestion(searchTerm, authToken); + sendMessage(preparedQuestion); + } + }, [authToken, isStreamingComplete, isConnected, searchTerm, sendMessage]); + + useEffect(() => { + if (!message) return; + + const updateSourceDocuments = () => { + setSourceDocuments(message.source_documents!); + }; + + const updateStreamedAnswer = () => { + setStreamedAnswer((prev) => prev + message.token); + }; + + const updateChat = () => { + searchDispatch({ + chat: { + answer: message.answer || "", + documents: sourceDocuments, + question: searchTerm || "", + ref: message.ref, + }, + type: "updateChat", + }); + }; + + if (message.source_documents) { + updateSourceDocuments(); + return; + } + + if (message.token) { + updateStreamedAnswer(); + return; + } + + if (message.end) { + switch (message.end.reason) { + case "length": + setStreamingError("The response has hit the LLM token limit."); + break; + case "timeout": + setStreamingError("The response has timed out."); + break; + case "eos_token": + setStreamingError("This should never happen."); + break; + default: + break; + } + } + + if (message.answer) { + updateChat(); + } + }, [message]); + + function handleNewQuestion() { + const input = document.getElementById("dc-search") as HTMLInputElement; + if (input) { + input.focus(); + input.value = ""; + } + } + + function resetChat() { + searchDispatch({ + chat: defaultState.chat, + type: "updateChat", + }); + setStreamedAnswer(""); + setSourceDocuments([]); + } + + if (!searchTerm) + return ( + + {AI_SEARCH_UNSUBMITTED} + + ); + + return ( + <> + + {streamingError && ( + + + {streamingError} + + + )} + {isStreamingComplete && ( + <> + + + + + + {AI_DISCLAIMER} + + + + )} + + ); +}; + +export default React.memo(Chat); diff --git a/components/Chat/Feedback/Feedback.tsx b/components/Chat/Feedback/Feedback.tsx new file mode 100644 index 00000000..234c3806 --- /dev/null +++ b/components/Chat/Feedback/Feedback.tsx @@ -0,0 +1,298 @@ +import { IconThumbsDown, IconThumbsUp } from "@/components/Shared/SVG/Icons"; +import { SyntheticEvent, useContext, useRef, useState } from "react"; + +import Announcement from "@/components/Shared/Announcement"; +import { Button } from "@nulib/design-system"; +import ChatFeedbackOptIn from "@/components/Chat/Feedback/OptIn"; +import ChatFeedbackOption from "@/components/Chat/Feedback/Option"; +import ChatFeedbackTextArea from "@/components/Chat/Feedback/TextArea"; +import Container from "@/components/Shared/Container"; +import { DC_URL } from "@/lib/constants/endpoints"; +import Icon from "@/components/Shared/Icon"; +import { UserContext } from "@/context/user-context"; +import { handleChatFeedbackRequest } from "@/lib/chat-helpers"; +import { styled } from "@/stitches.config"; +import { useSearchState } from "@/context/search-context"; + +type ChatFeedbackSentiment = "positive" | "negative" | ""; + +type ChatFeedbackFormPayload = { + sentiment: ChatFeedbackSentiment; + feedback: { + options: string[]; + text: string; + email: string; + }; + context: { + ref: string; + question: string; + answer: string; + source_documents: string[]; + }; +}; + +const defaultSubmittedState = { + completed: false, + sentiment: "", +}; + +const ChatFeedback = () => { + const formRef = useRef(null); + + const [isExpanded, setIsExpanded] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(defaultSubmittedState); + + const [isError, setIsError] = useState(false); + + const { + searchState: { + chat: { question, answer, documents, ref }, + }, + } = useSearchState(); + + const { user } = useContext(UserContext); + const userEmail = user?.email || ""; + + const initialPayload: ChatFeedbackFormPayload = { + sentiment: "", + feedback: { + options: [], + text: "", + email: "", + }, + context: { + ref, + question, + answer, + source_documents: documents.map(({ id }) => `${DC_URL}/items/${id}`), + }, + }; + + async function handleSubmit() { + const formData = formRef.current?.elements || []; + const payload = { ...initialPayload }; + + // Handle sentiment + if (isSubmitted.sentiment) { + payload.sentiment = isSubmitted.sentiment as ChatFeedbackSentiment; + } + + for (let i of formData) { + if (i instanceof HTMLInputElement) { + // Handle checkbox values + if (i.name === "email") { + payload.feedback.email = userEmail; + } else { + if (i.checked) { + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/labels + const labels = Array.from(i?.labels!); + const label = labels.map((l) => l.textContent).join(""); + + payload.feedback.options.push(label); + } + } + } + + // Handle textarea element + if (i instanceof HTMLTextAreaElement) { + payload.feedback.text = i.value || ""; + } + } + + setIsSubmitted({ ...isSubmitted, completed: true }); + + const response = await handleChatFeedbackRequest(payload); + response.err && handleError(); + } + + const handleSentimentSubmission = async ( + e: SyntheticEvent, + ) => { + const sentiment = e.currentTarget.value; + if (!sentiment) return; + + if (sentiment === "negative") setIsExpanded(true); + + setIsSubmitted({ + ...isSubmitted, + completed: sentiment === "positive", + sentiment, + }); + + const payload = { + ...initialPayload, + sentiment, + }; + + const response = await handleChatFeedbackRequest(payload); + response.err && handleError(); + }; + + function handleError() { + setIsError(true); + setIsSubmitted(defaultSubmittedState); + setIsExpanded(false); + } + + return ( + + + + Was this answer helpful? + + + + + + + + + + + + + + {isError && ( + + There was an error submitting the feedback form + + )} + + {isSubmitted.completed ? ( + + Thank you for your submission. + + ) : ( + + + + + + + + + + + + + )} + + + ); +}; + +/* eslint-disable sort-keys */ +const StyledChatFeedbackActivate = styled("div", { + margin: "0 0 $gr2 ", + display: "flex", + alignItems: "center", + fontSize: "$gr3", + gap: "$gr2", +}); + +const StyledChatFeedbackConfirmation = styled("div", { + fontSize: "$gr3", +}); + +const StyledChatFeedbackForm = styled("form", { + margin: "$gr3 0", + transition: "200ms all ease-in-out", + width: "61.8%", + + variants: { + isExpanded: { + true: { + opacity: "1", + height: "auto", + }, + false: { + opacity: "0", + height: "0", + }, + }, + }, +}); + +const StyledChatFeedback = styled("div", { + variants: { + isSubmitted: { + true: { + [`& ${StyledChatFeedbackActivate}`]: { + display: "none", + }, + }, + false: {}, + }, + }, +}); + +const StyledSentimentButton = styled("button", { + backgroundColor: "$purple10", + border: "none", + padding: 0, + height: "40px", + width: "40px", + display: "inline-flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + textAlign: "center", + borderRadius: "50%", + + "> span": { + height: "36px", + width: "36px", + }, + + "&:not([disabled])": { + cursor: "pointer", + + "> span": { + fill: "$purple60 !important", + }, + }, + + "&[data-is-selected=true]": { + "> span": { + fill: "$purple120", + }, + }, + + "&[data-is-selected=false]": { + "> span": { + fill: "$purple30", + }, + }, +}); + +export default ChatFeedback; diff --git a/components/Chat/Feedback/OptIn.test.tsx b/components/Chat/Feedback/OptIn.test.tsx new file mode 100644 index 00000000..706a0648 --- /dev/null +++ b/components/Chat/Feedback/OptIn.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; + +import ChatFeedbackOptIn from "@/components/Chat/Feedback/OptIn"; +import React from "react"; +import { UserContext } from "@/context/user-context"; + +const mockUserContextValue = { + user: { + email: "foo@bar.com", + isLoggedIn: true, + isReadingRoom: false, + name: "foo", + sub: "123", + }, +}; + +describe("ChatFeedbackOptIn", () => { + it("renders a checkbox input with the user email value", () => { + render( + + + , + ); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toHaveAttribute("value", "foo@bar.com"); + expect(checkbox).toBeInTheDocument(); + }); + + it("renders a label", () => { + render( + + + , + ); + const label = screen.getByText(/please follow up with me/i); + expect(label).toBeInTheDocument(); + }); +}); diff --git a/components/Chat/Feedback/OptIn.tsx b/components/Chat/Feedback/OptIn.tsx new file mode 100644 index 00000000..724a22e1 --- /dev/null +++ b/components/Chat/Feedback/OptIn.tsx @@ -0,0 +1,23 @@ +import { UserContext } from "@/context/user-context"; +import { styled } from "@/stitches.config"; +import { useContext } from "react"; + +const ChatFeedbackOptIn = () => { + const { user } = useContext(UserContext); + + return ( + + Please follow + up with me regarding this issue. + + ); +}; + +/* eslint-disable sort-keys */ +const StyledChatFeedbackOptIn = styled("label", { + display: "block", + margin: "$gr3 0", + fontSize: "$gr2", +}); + +export default ChatFeedbackOptIn; diff --git a/components/Chat/Feedback/Option.test.tsx b/components/Chat/Feedback/Option.test.tsx new file mode 100644 index 00000000..dcd0c3ea --- /dev/null +++ b/components/Chat/Feedback/Option.test.tsx @@ -0,0 +1,21 @@ +// test ChatFeedbackOption.tsx + +import { render, screen } from "@testing-library/react"; + +import ChatFeedbackOption from "@/components/Chat/Feedback/Option"; + +describe("ChatFeedbackOption", () => { + it("renders a checkbox input", () => { + render(); + const checkbox = screen.getByTestId("chat-feedback-option-test"); + expect(checkbox).toHaveAttribute("aria-checked", "false"); + expect(checkbox).toHaveAttribute("tabindex", "0"); + expect(checkbox).toBeInTheDocument(); + }); + + it("renders a label", () => { + render(); + const label = screen.getByText(/this is a test/i); + expect(label).toBeInTheDocument(); + }); +}); diff --git a/components/Chat/Feedback/Option.tsx b/components/Chat/Feedback/Option.tsx new file mode 100644 index 00000000..a5fb0ea3 --- /dev/null +++ b/components/Chat/Feedback/Option.tsx @@ -0,0 +1,88 @@ +import { useRef, useState } from "react"; + +import { styled } from "@/stitches.config"; + +const ChatFeedbackOption = ({ + name, + label, +}: { + name: string; + label: string; +}) => { + const [isChecked, setIsChecked] = useState(false); + const inputRef = useRef(null); + + function handleOnChange() { + setIsChecked(inputRef?.current?.checked ?? false); + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === " ") { + event.preventDefault(); + setIsChecked(!isChecked); + } + }; + + const handleClick = () => { + setIsChecked(!isChecked); + }; + + return ( + + + {label} + + ); +}; + +/* eslint-disable sort-keys */ +const StyledChatFeedbackOption = styled("label", { + display: "inline-flex", + alignItems: "center", + fontSize: "$gr2", + margin: "0 $gr1 $gr1 0", + borderRadius: "1rem", + cursor: "pointer", + transition: "$dcAll", + padding: "$gr1 $gr2", + gap: "3px", + + "&:hover": { + boxShadow: "3px 3px 8px #0002", + }, + + input: { + display: "none", + }, + + variants: { + isChecked: { + true: { + color: "$white", + border: "1px solid $black80", + backgroundColor: "$black80", + }, + false: { + color: "$black50", + border: "1px solid $black20", + backgroundColor: "$white", + }, + }, + }, +}); + +export default ChatFeedbackOption; diff --git a/components/Chat/Feedback/TextArea.tsx b/components/Chat/Feedback/TextArea.tsx new file mode 100644 index 00000000..36846755 --- /dev/null +++ b/components/Chat/Feedback/TextArea.tsx @@ -0,0 +1,27 @@ +import { styled } from "@/stitches.config"; + +const ChatFeedbackTextArea = () => { + return ( + + Add additional specific details (optional) +