-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
6b501c2
commit 8375c8a
Showing
28 changed files
with
1,857 additions
and
227 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
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,2 @@ | ||
export { AiChat } from "./components/AiChat/AiChat" | ||
export type { AiChatProps } from "./components/AiChat/AiChat" |
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,25 @@ | ||
import { Meta, Title, Primary, Controls, Stories } from "@storybook/blocks" | ||
|
||
import * as AiChat from "./AiChat.stories" | ||
import { gitLink } from "../../story-utils" | ||
|
||
<Meta of={AiChat} /> | ||
|
||
<Title /> | ||
|
||
Exported from `smoot-design/ai`, the AiChat component is a chat interface | ||
for use with AI services. It can be used with text-streaming or JSON APIs. | ||
|
||
This demo shows the AiChat component with a simple text-streaming API. | ||
|
||
<Primary /> | ||
|
||
## Inputs | ||
|
||
The component accepts the following inputs (props): | ||
|
||
<Controls /> | ||
|
||
See <a href={gitLink("src/components/AiChat/types.ts")}>AiChat/types.ts</a> for all Typescript interface definitions. | ||
|
||
<Stories includePrimary={false} /> |
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,87 @@ | ||
import * as React from "react" | ||
import type { Meta, StoryObj } from "@storybook/react" | ||
import { AiChat } from "./AiChat" | ||
import type { AiChatProps } from "./types" | ||
import { mockJson, mockStreaming } from "./story-utils" | ||
import styled from "@emotion/styled" | ||
|
||
const TEST_API_STREAMING = "http://localhost:4567/streaming" | ||
const TEST_API_JSON = "http://localhost:4567/json" | ||
|
||
const INITIAL_MESSAGES: AiChatProps["initialMessages"] = [ | ||
{ | ||
content: "Hi! What are you interested in learning about?", | ||
role: "assistant", | ||
}, | ||
] | ||
|
||
const STARTERS = [ | ||
{ content: "I'm interested in quantum computing" }, | ||
{ content: "I want to understand global warming. " }, | ||
{ content: "I am curious about AI applications for business" }, | ||
] | ||
|
||
const Container = styled.div({ | ||
width: "100%", | ||
height: "350px", | ||
}) | ||
|
||
const meta: Meta<typeof AiChat> = { | ||
title: "smoot-design/AiChat", | ||
component: AiChat, | ||
render: (args) => <AiChat {...args} />, | ||
decorators: (Story) => { | ||
return ( | ||
<Container> | ||
<Story /> | ||
</Container> | ||
) | ||
}, | ||
args: { | ||
initialMessages: INITIAL_MESSAGES, | ||
requestOpts: { apiUrl: TEST_API_STREAMING }, | ||
conversationStarters: STARTERS, | ||
}, | ||
argTypes: { | ||
conversationStarters: { | ||
control: { type: "object", disable: true }, | ||
}, | ||
initialMessages: { | ||
control: { type: "object", disable: true }, | ||
}, | ||
requestOpts: { | ||
control: { type: "object", disable: true }, | ||
table: { readonly: true }, // See above | ||
}, | ||
}, | ||
beforeEach: () => { | ||
const originalFetch = window.fetch | ||
window.fetch = (url, opts) => { | ||
if (url === TEST_API_STREAMING) { | ||
return mockStreaming() | ||
} else if (url === TEST_API_JSON) { | ||
return mockJson() | ||
} | ||
return originalFetch(url, opts) | ||
} | ||
}, | ||
} | ||
|
||
export default meta | ||
|
||
type Story = StoryObj<typeof AiChat> | ||
|
||
export const StreamingResponses: Story = {} | ||
|
||
/** | ||
* Here `AiChat` is used with a non-streaming JSON API. The JSON is converted | ||
* to text via `parseContent`. | ||
*/ | ||
export const JsonResponses: Story = { | ||
args: { | ||
requestOpts: { apiUrl: TEST_API_JSON }, | ||
parseContent: (content: unknown) => { | ||
return JSON.parse(content as string).message | ||
}, | ||
}, | ||
} |
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,162 @@ | ||
// This was giving false positives | ||
/* eslint-disable testing-library/await-async-utils */ | ||
import { render, screen, waitFor } from "@testing-library/react" | ||
import user from "@testing-library/user-event" | ||
import { AiChat } from "./AiChat" | ||
import { ThemeProvider } from "../ThemeProvider/ThemeProvider" | ||
import * as React from "react" | ||
import { AiChatProps } from "./types" | ||
import { faker } from "@faker-js/faker/locale/en" | ||
|
||
const counter = jest.fn() // use jest.fn as counter because it resets on each test | ||
const mockFetch = jest.mocked( | ||
jest.fn(() => { | ||
const count = counter.mock.calls.length | ||
counter() | ||
return Promise.resolve( | ||
new Response(`AI Response ${count}`, { | ||
headers: { | ||
"Content-Type": "application/json", | ||
}, | ||
}), | ||
) | ||
}) as typeof fetch, | ||
) | ||
window.fetch = mockFetch | ||
jest.mock("react-markdown", () => { | ||
return { | ||
__esModule: true, | ||
default: ({ children }: { children: string }) => <div>{children}</div>, | ||
} | ||
}) | ||
|
||
const getMessages = (): HTMLElement[] => { | ||
return Array.from(document.querySelectorAll(".MitAiChat--message")) | ||
} | ||
const getConversationStarters = (): HTMLElement[] => { | ||
return Array.from( | ||
document.querySelectorAll("button.MitAiChat--conversationStarter"), | ||
) | ||
} | ||
const whenCount = async <T,>(cb: () => T[], count: number) => { | ||
return await waitFor(() => { | ||
const result = cb() | ||
expect(result).toHaveLength(count) | ||
return result | ||
}) | ||
} | ||
|
||
describe("AiChat", () => { | ||
const setup = (props: Partial<AiChatProps> = {}) => { | ||
const initialMessages: AiChatProps["initialMessages"] = [ | ||
{ role: "assistant", content: faker.lorem.sentence() }, | ||
] | ||
const conversationStarters: AiChatProps["conversationStarters"] = [ | ||
{ content: faker.lorem.sentence() }, | ||
{ content: faker.lorem.sentence() }, | ||
] | ||
render( | ||
<AiChat | ||
initialMessages={initialMessages} | ||
conversationStarters={conversationStarters} | ||
requestOpts={{ apiUrl: "http://localhost:4567/test" }} | ||
{...props} | ||
/>, | ||
{ wrapper: ThemeProvider }, | ||
) | ||
|
||
return { initialMessages, conversationStarters } | ||
} | ||
|
||
test("Clicking conversation starters and sending chats", async () => { | ||
const { initialMessages, conversationStarters } = setup() | ||
|
||
const scrollBy = jest.spyOn(HTMLElement.prototype, "scrollBy") | ||
|
||
const initialMessageEls = getMessages() | ||
expect(initialMessageEls.length).toBe(1) | ||
expect(initialMessageEls[0]).toHaveTextContent(initialMessages[0].content) | ||
|
||
const starterEls = getConversationStarters() | ||
expect(starterEls.length).toBe(2) | ||
expect(starterEls[0]).toHaveTextContent(conversationStarters[0].content) | ||
expect(starterEls[1]).toHaveTextContent(conversationStarters[1].content) | ||
|
||
const chosen = faker.helpers.arrayElement([0, 1]) | ||
|
||
await user.click(starterEls[chosen]) | ||
expect(scrollBy).toHaveBeenCalled() | ||
scrollBy.mockReset() | ||
|
||
const messageEls = await whenCount(getMessages, 3) | ||
|
||
expect(messageEls[0]).toHaveTextContent(initialMessages[0].content) | ||
expect(messageEls[1]).toHaveTextContent( | ||
conversationStarters[chosen].content, | ||
) | ||
expect(messageEls[2]).toHaveTextContent("AI Response 0") | ||
|
||
await user.click(screen.getByPlaceholderText("Type a message...")) | ||
await user.paste("User message") | ||
await user.click(screen.getByRole("button", { name: "Send" })) | ||
expect(scrollBy).toHaveBeenCalled() | ||
|
||
const afterSending = await whenCount(getMessages, 5) | ||
expect(afterSending[3]).toHaveTextContent("User message") | ||
expect(afterSending[4]).toHaveTextContent("AI Response 1") | ||
}) | ||
|
||
test("transformBody is called before sending requests", async () => { | ||
const fakeBody = { message: faker.lorem.sentence() } | ||
const apiUrl = faker.internet.url() | ||
const transformBody = jest.fn(() => fakeBody) | ||
const { initialMessages } = setup({ | ||
requestOpts: { apiUrl, transformBody }, | ||
}) | ||
|
||
await user.click(screen.getByPlaceholderText("Type a message...")) | ||
await user.paste("User message") | ||
await user.click(screen.getByRole("button", { name: "Send" })) | ||
|
||
expect(transformBody).toHaveBeenCalledWith([ | ||
expect.objectContaining(initialMessages[0]), | ||
expect.objectContaining({ content: "User message", role: "user" }), | ||
]) | ||
expect(mockFetch).toHaveBeenCalledTimes(1) | ||
expect(mockFetch).toHaveBeenCalledWith( | ||
apiUrl, | ||
expect.objectContaining({ | ||
body: JSON.stringify(fakeBody), | ||
}), | ||
) | ||
}) | ||
|
||
test("parseContent is called on the API-received message content", async () => { | ||
const fakeBody = { message: faker.lorem.sentence() } | ||
const apiUrl = faker.internet.url() | ||
const transformBody = jest.fn(() => fakeBody) | ||
const { initialMessages, conversationStarters } = setup({ | ||
requestOpts: { apiUrl, transformBody }, | ||
parseContent: jest.fn((content) => `Parsed: ${content}`), | ||
}) | ||
|
||
await user.click(getConversationStarters()[0]) | ||
|
||
await whenCount(getMessages, initialMessages.length + 2) | ||
|
||
await user.click(screen.getByPlaceholderText("Type a message...")) | ||
await user.paste("User message") | ||
await user.click(screen.getByRole("button", { name: "Send" })) | ||
|
||
await whenCount(getMessages, initialMessages.length + 4) | ||
|
||
const messagesTexts = getMessages().map((el) => el.textContent) | ||
expect(messagesTexts).toEqual([ | ||
initialMessages[0].content, | ||
conversationStarters[0].content, | ||
"Parsed: AI Response 0", | ||
"User message", | ||
"Parsed: AI Response 1", | ||
]) | ||
}) | ||
}) |
Oops, something went wrong.