Skip to content

Commit 8375c8a

Browse files
feat: AI Chat UI (#25)
1 parent 6b501c2 commit 8375c8a

28 files changed

+1857
-227
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ module.exports = {
4646
"**/*.test.tsx",
4747
"**/src/setupJest.ts",
4848
"**/jest-setup.ts",
49+
"**/jsdom-extended.ts",
4950
"**/test-utils/**",
5051
"**/test-utils/**",
5152
"**/webpack.config.js",

.storybook/main.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { StorybookConfig } from "@storybook/react-webpack5"
22
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin"
3+
import { exec as execCb } from "child_process"
4+
import { promisify } from "util"
5+
6+
const exec = promisify(execCb)
7+
const getGitSha = async (): Promise<string> => {
8+
const { stdout } = await exec("git rev-parse HEAD")
9+
return stdout.trim()
10+
}
311

412
const config: StorybookConfig = {
513
stories: ["../src/**/*.mdx", "../src/**/*.stories.tsx"],
@@ -26,6 +34,11 @@ const config: StorybookConfig = {
2634
typescript: {
2735
reactDocgen: "react-docgen-typescript",
2836
},
37+
env: async () => {
38+
return {
39+
STORYBOOK_GIT_SHA: await getGitSha(),
40+
}
41+
},
2942
}
3043

3144
export default config

jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const config: Config.InitialOptions = {
99
"jest-watch-typeahead/testname",
1010
],
1111
setupFilesAfterEnv: ["./jest-setup.ts"],
12-
testEnvironment: "jsdom",
12+
testEnvironment: "<rootDir>/jsdom-extended.ts",
1313
transform: {
1414
"^.+\\.(t|j)sx?$": "@swc/jest",
1515
},

package.json

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@
3939
"default": "./dist/esm/index.js"
4040
}
4141
},
42+
"./ai": {
43+
"import": {
44+
"types": "./dist/esm/ai.d.ts",
45+
"default": "./dist/esm/ai.js"
46+
},
47+
"require": {
48+
"types": "./dist/cjs/ai.d.ts",
49+
"default": "./dist/cjs/ai.js"
50+
},
51+
"default": {
52+
"types": "./dist/esm/ai.d.ts",
53+
"default": "./dist/esm/ai.js"
54+
}
55+
},
4256
"./type-augmentation": {
4357
"import": "./dist/type-augmentation/index.d.ts",
4458
"require": "./dist/type-augmentation/index.d.ts",
@@ -55,27 +69,30 @@
5569
"@mui/system": "^6.1.6",
5670
"@remixicon/react": "^4.2.0",
5771
"@types/jest": "^29.5.14",
72+
"ai": "^4.0.13",
5873
"classnames": "^2.5.1",
5974
"lodash": "^4.17.21",
60-
"material-ui-popup-state": "^5.1.0",
61-
"tiny-invariant": "^1.3.1"
75+
"react-markdown": "^9.0.1",
76+
"tiny-invariant": "^1.3.1",
77+
"zod": "^3.23.8"
6278
},
6379
"devDependencies": {
6480
"@chromatic-com/storybook": "^1.9.0",
6581
"@faker-js/faker": "^9.0.0",
66-
"@storybook/addon-actions": "^8.4.2",
67-
"@storybook/addon-essentials": "^8.4.2",
68-
"@storybook/addon-interactions": "^8.4.2",
69-
"@storybook/addon-links": "^8.4.2",
70-
"@storybook/addon-onboarding": "^8.4.2",
82+
"@jest/environment": "^29.7.0",
83+
"@storybook/addon-actions": "^8.4.7",
84+
"@storybook/addon-essentials": "^8.4.7",
85+
"@storybook/addon-interactions": "^8.4.7",
86+
"@storybook/addon-links": "^8.4.7",
87+
"@storybook/addon-onboarding": "^8.4.7",
7188
"@storybook/addon-webpack5-compiler-swc": "^1.0.5",
72-
"@storybook/blocks": "^8.4.2",
73-
"@storybook/nextjs": "^8.4.2",
74-
"@storybook/preview-api": "^8.4.2",
75-
"@storybook/react": "^8.4.2",
76-
"@storybook/react-webpack5": "^8.4.2",
77-
"@storybook/test": "^8.4.2",
78-
"@storybook/types": "^8.4.2",
89+
"@storybook/blocks": "^8.4.7",
90+
"@storybook/nextjs": "^8.4.7",
91+
"@storybook/preview-api": "^8.4.7",
92+
"@storybook/react": "^8.4.7",
93+
"@storybook/react-webpack5": "^8.4.7",
94+
"@storybook/test": "^8.4.7",
95+
"@storybook/types": "^8.4.7",
7996
"@swc/jest": "^0.2.37",
8097
"@testing-library/jest-dom": "^6.6.3",
8198
"@testing-library/react": "^16.0.1",
@@ -99,7 +116,7 @@
99116
"jest": "^29.7.0",
100117
"jest-environment-jsdom": "^29.5.0",
101118
"jest-extended": "^4.0.2",
102-
"jest-fail-on-console": "^3.2.0",
119+
"jest-fail-on-console": "^3.3.1",
103120
"jest-watch-typeahead": "^2.2.2",
104121
"next": "^15.0.2",
105122
"prettier": "^3.3.3",

src/ai.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { AiChat } from "./components/AiChat/AiChat"
2+
export type { AiChatProps } from "./components/AiChat/AiChat"

src/components/AiChat/AiChat.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Meta, Title, Primary, Controls, Stories } from "@storybook/blocks"
2+
3+
import * as AiChat from "./AiChat.stories"
4+
import { gitLink } from "../../story-utils"
5+
6+
<Meta of={AiChat} />
7+
8+
<Title />
9+
10+
Exported from `smoot-design/ai`, the AiChat component is a chat interface
11+
for use with AI services. It can be used with text-streaming or JSON APIs.
12+
13+
This demo shows the AiChat component with a simple text-streaming API.
14+
15+
<Primary />
16+
17+
## Inputs
18+
19+
The component accepts the following inputs (props):
20+
21+
<Controls />
22+
23+
See <a href={gitLink("src/components/AiChat/types.ts")}>AiChat/types.ts</a> for all Typescript interface definitions.
24+
25+
<Stories includePrimary={false} />
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as React from "react"
2+
import type { Meta, StoryObj } from "@storybook/react"
3+
import { AiChat } from "./AiChat"
4+
import type { AiChatProps } from "./types"
5+
import { mockJson, mockStreaming } from "./story-utils"
6+
import styled from "@emotion/styled"
7+
8+
const TEST_API_STREAMING = "http://localhost:4567/streaming"
9+
const TEST_API_JSON = "http://localhost:4567/json"
10+
11+
const INITIAL_MESSAGES: AiChatProps["initialMessages"] = [
12+
{
13+
content: "Hi! What are you interested in learning about?",
14+
role: "assistant",
15+
},
16+
]
17+
18+
const STARTERS = [
19+
{ content: "I'm interested in quantum computing" },
20+
{ content: "I want to understand global warming. " },
21+
{ content: "I am curious about AI applications for business" },
22+
]
23+
24+
const Container = styled.div({
25+
width: "100%",
26+
height: "350px",
27+
})
28+
29+
const meta: Meta<typeof AiChat> = {
30+
title: "smoot-design/AiChat",
31+
component: AiChat,
32+
render: (args) => <AiChat {...args} />,
33+
decorators: (Story) => {
34+
return (
35+
<Container>
36+
<Story />
37+
</Container>
38+
)
39+
},
40+
args: {
41+
initialMessages: INITIAL_MESSAGES,
42+
requestOpts: { apiUrl: TEST_API_STREAMING },
43+
conversationStarters: STARTERS,
44+
},
45+
argTypes: {
46+
conversationStarters: {
47+
control: { type: "object", disable: true },
48+
},
49+
initialMessages: {
50+
control: { type: "object", disable: true },
51+
},
52+
requestOpts: {
53+
control: { type: "object", disable: true },
54+
table: { readonly: true }, // See above
55+
},
56+
},
57+
beforeEach: () => {
58+
const originalFetch = window.fetch
59+
window.fetch = (url, opts) => {
60+
if (url === TEST_API_STREAMING) {
61+
return mockStreaming()
62+
} else if (url === TEST_API_JSON) {
63+
return mockJson()
64+
}
65+
return originalFetch(url, opts)
66+
}
67+
},
68+
}
69+
70+
export default meta
71+
72+
type Story = StoryObj<typeof AiChat>
73+
74+
export const StreamingResponses: Story = {}
75+
76+
/**
77+
* Here `AiChat` is used with a non-streaming JSON API. The JSON is converted
78+
* to text via `parseContent`.
79+
*/
80+
export const JsonResponses: Story = {
81+
args: {
82+
requestOpts: { apiUrl: TEST_API_JSON },
83+
parseContent: (content: unknown) => {
84+
return JSON.parse(content as string).message
85+
},
86+
},
87+
}

src/components/AiChat/AiChat.test.tsx

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// This was giving false positives
2+
/* eslint-disable testing-library/await-async-utils */
3+
import { render, screen, waitFor } from "@testing-library/react"
4+
import user from "@testing-library/user-event"
5+
import { AiChat } from "./AiChat"
6+
import { ThemeProvider } from "../ThemeProvider/ThemeProvider"
7+
import * as React from "react"
8+
import { AiChatProps } from "./types"
9+
import { faker } from "@faker-js/faker/locale/en"
10+
11+
const counter = jest.fn() // use jest.fn as counter because it resets on each test
12+
const mockFetch = jest.mocked(
13+
jest.fn(() => {
14+
const count = counter.mock.calls.length
15+
counter()
16+
return Promise.resolve(
17+
new Response(`AI Response ${count}`, {
18+
headers: {
19+
"Content-Type": "application/json",
20+
},
21+
}),
22+
)
23+
}) as typeof fetch,
24+
)
25+
window.fetch = mockFetch
26+
jest.mock("react-markdown", () => {
27+
return {
28+
__esModule: true,
29+
default: ({ children }: { children: string }) => <div>{children}</div>,
30+
}
31+
})
32+
33+
const getMessages = (): HTMLElement[] => {
34+
return Array.from(document.querySelectorAll(".MitAiChat--message"))
35+
}
36+
const getConversationStarters = (): HTMLElement[] => {
37+
return Array.from(
38+
document.querySelectorAll("button.MitAiChat--conversationStarter"),
39+
)
40+
}
41+
const whenCount = async <T,>(cb: () => T[], count: number) => {
42+
return await waitFor(() => {
43+
const result = cb()
44+
expect(result).toHaveLength(count)
45+
return result
46+
})
47+
}
48+
49+
describe("AiChat", () => {
50+
const setup = (props: Partial<AiChatProps> = {}) => {
51+
const initialMessages: AiChatProps["initialMessages"] = [
52+
{ role: "assistant", content: faker.lorem.sentence() },
53+
]
54+
const conversationStarters: AiChatProps["conversationStarters"] = [
55+
{ content: faker.lorem.sentence() },
56+
{ content: faker.lorem.sentence() },
57+
]
58+
render(
59+
<AiChat
60+
initialMessages={initialMessages}
61+
conversationStarters={conversationStarters}
62+
requestOpts={{ apiUrl: "http://localhost:4567/test" }}
63+
{...props}
64+
/>,
65+
{ wrapper: ThemeProvider },
66+
)
67+
68+
return { initialMessages, conversationStarters }
69+
}
70+
71+
test("Clicking conversation starters and sending chats", async () => {
72+
const { initialMessages, conversationStarters } = setup()
73+
74+
const scrollBy = jest.spyOn(HTMLElement.prototype, "scrollBy")
75+
76+
const initialMessageEls = getMessages()
77+
expect(initialMessageEls.length).toBe(1)
78+
expect(initialMessageEls[0]).toHaveTextContent(initialMessages[0].content)
79+
80+
const starterEls = getConversationStarters()
81+
expect(starterEls.length).toBe(2)
82+
expect(starterEls[0]).toHaveTextContent(conversationStarters[0].content)
83+
expect(starterEls[1]).toHaveTextContent(conversationStarters[1].content)
84+
85+
const chosen = faker.helpers.arrayElement([0, 1])
86+
87+
await user.click(starterEls[chosen])
88+
expect(scrollBy).toHaveBeenCalled()
89+
scrollBy.mockReset()
90+
91+
const messageEls = await whenCount(getMessages, 3)
92+
93+
expect(messageEls[0]).toHaveTextContent(initialMessages[0].content)
94+
expect(messageEls[1]).toHaveTextContent(
95+
conversationStarters[chosen].content,
96+
)
97+
expect(messageEls[2]).toHaveTextContent("AI Response 0")
98+
99+
await user.click(screen.getByPlaceholderText("Type a message..."))
100+
await user.paste("User message")
101+
await user.click(screen.getByRole("button", { name: "Send" }))
102+
expect(scrollBy).toHaveBeenCalled()
103+
104+
const afterSending = await whenCount(getMessages, 5)
105+
expect(afterSending[3]).toHaveTextContent("User message")
106+
expect(afterSending[4]).toHaveTextContent("AI Response 1")
107+
})
108+
109+
test("transformBody is called before sending requests", async () => {
110+
const fakeBody = { message: faker.lorem.sentence() }
111+
const apiUrl = faker.internet.url()
112+
const transformBody = jest.fn(() => fakeBody)
113+
const { initialMessages } = setup({
114+
requestOpts: { apiUrl, transformBody },
115+
})
116+
117+
await user.click(screen.getByPlaceholderText("Type a message..."))
118+
await user.paste("User message")
119+
await user.click(screen.getByRole("button", { name: "Send" }))
120+
121+
expect(transformBody).toHaveBeenCalledWith([
122+
expect.objectContaining(initialMessages[0]),
123+
expect.objectContaining({ content: "User message", role: "user" }),
124+
])
125+
expect(mockFetch).toHaveBeenCalledTimes(1)
126+
expect(mockFetch).toHaveBeenCalledWith(
127+
apiUrl,
128+
expect.objectContaining({
129+
body: JSON.stringify(fakeBody),
130+
}),
131+
)
132+
})
133+
134+
test("parseContent is called on the API-received message content", async () => {
135+
const fakeBody = { message: faker.lorem.sentence() }
136+
const apiUrl = faker.internet.url()
137+
const transformBody = jest.fn(() => fakeBody)
138+
const { initialMessages, conversationStarters } = setup({
139+
requestOpts: { apiUrl, transformBody },
140+
parseContent: jest.fn((content) => `Parsed: ${content}`),
141+
})
142+
143+
await user.click(getConversationStarters()[0])
144+
145+
await whenCount(getMessages, initialMessages.length + 2)
146+
147+
await user.click(screen.getByPlaceholderText("Type a message..."))
148+
await user.paste("User message")
149+
await user.click(screen.getByRole("button", { name: "Send" }))
150+
151+
await whenCount(getMessages, initialMessages.length + 4)
152+
153+
const messagesTexts = getMessages().map((el) => el.textContent)
154+
expect(messagesTexts).toEqual([
155+
initialMessages[0].content,
156+
conversationStarters[0].content,
157+
"Parsed: AI Response 0",
158+
"User message",
159+
"Parsed: AI Response 1",
160+
])
161+
})
162+
})

0 commit comments

Comments
 (0)