-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #370 from nulib/preview/chat-integration
Deploy preview/chat-integration branch to staging
- Loading branch information
Showing
89 changed files
with
3,972 additions
and
1,407 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div data-testid="mock-chat-response" data-props={JSON.stringify(props)}> | ||
Mock Chat Response | ||
</div> | ||
); | ||
}; | ||
}); | ||
|
||
// 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(<Chat />); | ||
|
||
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( | ||
<SearchProvider> | ||
<Chat /> | ||
</SearchProvider>, | ||
); | ||
|
||
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( | ||
<SearchProvider> | ||
<Chat /> | ||
</SearchProvider>, | ||
); | ||
|
||
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( | ||
<SearchProvider> | ||
<Chat /> | ||
</SearchProvider>, | ||
); | ||
|
||
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( | ||
<SearchProvider> | ||
<Chat /> | ||
</SearchProvider>, | ||
); | ||
|
||
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( | ||
<SearchProvider> | ||
<Chat /> | ||
</SearchProvider>, | ||
); | ||
|
||
const error = screen.getByText("The response has timed out."); | ||
expect(error).toBeInTheDocument(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Work[]>([]); | ||
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 ( | ||
<Container> | ||
<StyledUnsubmitted>{AI_SEARCH_UNSUBMITTED}</StyledUnsubmitted> | ||
</Container> | ||
); | ||
|
||
return ( | ||
<> | ||
<ChatResponse | ||
isStreamingComplete={isStreamingComplete} | ||
searchTerm={question || searchTerm} | ||
sourceDocuments={isStreamingComplete ? documents : sourceDocuments} | ||
streamedAnswer={isStreamingComplete ? answer : streamedAnswer} | ||
/> | ||
{streamingError && ( | ||
<Container> | ||
<Announcement css={{ marginTop: "1rem" }}> | ||
{streamingError} | ||
</Announcement> | ||
</Container> | ||
)} | ||
{isStreamingComplete && ( | ||
<> | ||
<Container> | ||
<StyledResponseActions> | ||
<Button isPrimary isLowercase onClick={viewResultsCallback}> | ||
View More Results | ||
</Button> | ||
<Button isLowercase onClick={handleNewQuestion}> | ||
Ask Another Question | ||
</Button> | ||
</StyledResponseActions> | ||
<StyledResponseDisclaimer>{AI_DISCLAIMER}</StyledResponseDisclaimer> | ||
</Container> | ||
<ChatFeedback /> | ||
</> | ||
)} | ||
</> | ||
); | ||
}; | ||
|
||
export default React.memo(Chat); |
Oops, something went wrong.