Skip to content

Commit

Permalink
Merge pull request #370 from nulib/preview/chat-integration
Browse files Browse the repository at this point in the history
Deploy preview/chat-integration branch to staging
  • Loading branch information
kdid authored Aug 27, 2024
2 parents 1ccd535 + 42a55e0 commit 0bf6d6a
Show file tree
Hide file tree
Showing 89 changed files with 3,972 additions and 1,407 deletions.
6 changes: 3 additions & 3 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
9 changes: 5 additions & 4 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
174 changes: 174 additions & 0 deletions components/Chat/Chat.test.tsx
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();
});
});
169 changes: 169 additions & 0 deletions components/Chat/Chat.tsx
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);
Loading

0 comments on commit 0bf6d6a

Please sign in to comment.