-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: AI Chat UI #25
Merged
Merged
feat: AI Chat UI #25
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
b2b9a9d
add composeRefs, useInterval
ChristopherChudzicki 04f4fa0
add ScrollSnap
ChristopherChudzicki 96d69b1
start AI chat
ChristopherChudzicki cd4eacd
add some classes
ChristopherChudzicki 5c43aab
add some tests
ChristopherChudzicki 794698f
add aria-live region for screenreaders
ChristopherChudzicki 1ac93f9
add ai export
ChristopherChudzicki aa938b0
export SrAnnouncer, VisuallyHidden, too
ChristopherChudzicki a3924d2
better comment
ChristopherChudzicki b5e31a8
fix lint
ChristopherChudzicki 6acab6c
a few more comments
ChristopherChudzicki cd8fc91
lint + fix storybook ci bug
ChristopherChudzicki File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There isn't a great way with storybook docs to embed Typescript interfaces (e.g., for complex props). A direct link to the types in github seems best compromise for now.
related: