From 1254ce9ef674cb82e9a097c3d4f67dcd0a5198a7 Mon Sep 17 00:00:00 2001 From: Ed Spencer Date: Fri, 23 Aug 2024 16:36:41 -0400 Subject: [PATCH 1/2] ChatWrapper, tests and docs --- .changeset/forty-melons-roll.md | 5 + .changeset/funny-tomatoes-kick.md | 5 + .changeset/thick-moons-approve.md | 5 + jest.config.ts | 1 + package.json | 6 +- pnpm-lock.yaml | 16 ++ src/InformAIContext.tsx | 17 +- src/test/ChatBox.test.tsx | 54 +++++ src/test/ChatWrapper.test.tsx | 136 ++++++++++++ src/test/CurrentState.test.tsx | 82 +++++++ src/test/Messages.test.tsx | 9 + src/test/useInformAI.test.tsx | 343 ++++++++++++++++++++++++++++++ src/test/utils.test.tsx | 85 ++++++++ src/types.ts | 98 +++++++++ src/ui/ChatBox.tsx | 10 +- src/ui/ChatWrapper.tsx | 86 +++++++- src/ui/InformAI.tsx | 41 ++-- src/ui/Messages.tsx | 2 +- src/useInformAI.tsx | 2 +- src/utils.tsx | 4 +- 20 files changed, 965 insertions(+), 42 deletions(-) create mode 100644 .changeset/forty-melons-roll.md create mode 100644 .changeset/funny-tomatoes-kick.md create mode 100644 .changeset/thick-moons-approve.md create mode 100644 src/test/ChatBox.test.tsx create mode 100644 src/test/ChatWrapper.test.tsx create mode 100644 src/test/CurrentState.test.tsx create mode 100644 src/test/Messages.test.tsx create mode 100644 src/test/useInformAI.test.tsx create mode 100644 src/test/utils.test.tsx diff --git a/.changeset/forty-melons-roll.md b/.changeset/forty-melons-roll.md new file mode 100644 index 0000000..e1eed6a --- /dev/null +++ b/.changeset/forty-melons-roll.md @@ -0,0 +1,5 @@ +--- +"inform-ai": patch +--- + +More jest tests diff --git a/.changeset/funny-tomatoes-kick.md b/.changeset/funny-tomatoes-kick.md new file mode 100644 index 0000000..9c7d810 --- /dev/null +++ b/.changeset/funny-tomatoes-kick.md @@ -0,0 +1,5 @@ +--- +"inform-ai": minor +--- + +Built-in ChatWrapper component diff --git a/.changeset/thick-moons-approve.md b/.changeset/thick-moons-approve.md new file mode 100644 index 0000000..78e24b4 --- /dev/null +++ b/.changeset/thick-moons-approve.md @@ -0,0 +1,5 @@ +--- +"inform-ai": patch +--- + +Docs and tests diff --git a/jest.config.ts b/jest.config.ts index b1e45af..cacd815 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -8,6 +8,7 @@ const jestConfig: JestConfigWithTsJest = { // "^.+\\.(t|j)sx?$": ["@swc/jest"], }, moduleNameMapper: { + "^ai/rsc$": "/node_modules/ai/rsc/dist", "^@/(.*)$": "/$1", "\\.(css|less|sass|scss)$": "identity-obj-proxy", }, diff --git a/package.json b/package.json index 44e90d2..1f15ebf 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,13 @@ "@types/react": "npm:types-react@19.0.0-rc.1", "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", "@types/uuid": "^10.0.0", + "ai": "^3.3.6", "autoprefixer": "^10.4.20", "concurrently": "^8.2.2", "eslint": "^8.57.0", "eslint-plugin-react": "^7.35.0", "eslint-plugin-react-hooks": "^4.6.2", + "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "postcss": "^8.4.41", @@ -56,7 +58,6 @@ "@ai-sdk/openai": "^0.0.44", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.0.0", - "ai": "^3.3.6", "clsx": "^2.1.1", "nanoid": "^5.0.7", "react": "19.0.0-rc-cc1ec60d0d-20240607", @@ -65,5 +66,8 @@ "rollup-plugin-postcss": "^4.0.2", "uuid": "^10.0.0", "zod": "^3.23.8" + }, + "peerDependencies": { + "ai": "^3.3.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20cca2e..e3185aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: eslint-plugin-react-hooks: specifier: ^4.6.2 version: 4.6.2(eslint@8.57.0) + identity-obj-proxy: + specifier: ^3.0.0 + version: 3.0.0 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@12.20.55)(ts-node@10.9.2(@types/node@12.20.55)(typescript@5.5.4)) @@ -1756,6 +1759,9 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + harmony-reflect@1.6.2: + resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==} + has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -1825,6 +1831,10 @@ packages: peerDependencies: postcss: ^8.1.0 + identity-obj-proxy@3.0.0: + resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==} + engines: {node: '>=4'} + ignore@5.3.1: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} @@ -5667,6 +5677,8 @@ snapshots: graphemer@1.4.0: {} + harmony-reflect@1.6.2: {} + has-bigints@1.0.2: {} has-flag@3.0.0: {} @@ -5728,6 +5740,10 @@ snapshots: dependencies: postcss: 8.4.41 + identity-obj-proxy@3.0.0: + dependencies: + harmony-reflect: 1.6.2 + ignore@5.3.1: {} import-cwd@3.0.0: diff --git a/src/InformAIContext.tsx b/src/InformAIContext.tsx index 7839255..6d5c05a 100644 --- a/src/InformAIContext.tsx +++ b/src/InformAIContext.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useState, ReactNode } from "react"; +import { createContext, useContext, useState, ReactNode } from "react"; import { randomId } from "./utils"; import { StateMessage, EventMessage, Message, ComponentState, ComponentEvent, Conversation } from "./types"; @@ -40,7 +40,20 @@ interface InformAIProviderProps { } /** - * The internal implementation of the InformAIProvider component. + * The internal implementation of the InformAIProvider component. Sample usage: + * + * import { InformAIProvider } from 'inform-ai'; + * + * export default function MyComponent() { + * return ( + * + * {children} + * + * ); + * } + * + * Now within child React components you can use useInformAI() or to surface + * information about your components to the LLM. */ export const InformAIProvider = ({ children, onEvent }: InformAIProviderProps) => { const [messages, setMessages] = useState([]); diff --git a/src/test/ChatBox.test.tsx b/src/test/ChatBox.test.tsx new file mode 100644 index 0000000..1a19c51 --- /dev/null +++ b/src/test/ChatBox.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ChatBox } from "../ui/ChatBox"; + +describe("ChatBox", () => { + it("renders correctly", () => { + render( true} />); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("honors autoFocus", () => { + render( true} autoFocus={true} />); + expect(screen.getByRole("textbox")).toHaveFocus(); + }); + + it("honors placeholder", () => { + render( true} placeholder="test placeholder" />); + expect(screen.getByPlaceholderText("test placeholder")).toBeInTheDocument(); + }); + + it('clears the input if "onSubmit" returns true', async () => { + const onSubmit = jest.fn(async () => true); + + render(); + const input = screen.getByRole("textbox"); + const button = screen.getByRole("button"); + + fireEvent.change(input, { target: { value: "test" } }); + expect(input).toHaveValue("test"); + + fireEvent.click(button); + + await waitFor(() => { + expect(input).toHaveValue(""); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('does not clear the input if "onSubmit" returns false', async () => { + const onSubmit = jest.fn(async () => false); + + render(); + const input = screen.getByRole("textbox"); + const form = screen.getByRole("form"); + + fireEvent.change(input, { target: { value: "test" } }); + + expect(input).toHaveValue("test"); + + fireEvent.submit(form); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(input).toHaveValue("test"); + }); +}); diff --git a/src/test/ChatWrapper.test.tsx b/src/test/ChatWrapper.test.tsx new file mode 100644 index 0000000..1c0db14 --- /dev/null +++ b/src/test/ChatWrapper.test.tsx @@ -0,0 +1,136 @@ +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { ChatWrapper } from "../ui/ChatWrapper"; +import { InformAIProvider, useInformAIContext } from "../InformAIContext"; +import { useState } from "react"; +import { useInformAI } from "../useInformAI"; +import { FormattedMessage } from "../types"; + +describe("ChatWrapper", () => { + let currentMessages: any[]; + + const AppChatWrapper = ({ submitUserMessage = jest.fn() }: { submitUserMessage?: jest.Mock }) => { + const [messages, setMessages] = useState([]); + + currentMessages = messages; + + return ; + }; + + it("renders correctly", () => { + render( + + + + ); + + //just checks that the component renders the ChatBox + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("renders user messages correctly", async () => { + render( + + + + ); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "test message" }, + }); + fireEvent.submit(screen.getByRole("form")); + + await waitFor(() => screen.getByText("test message")); + }); + + describe("collecting messages to send to the LLM", () => { + let contextValues: ReturnType | undefined = undefined; + let mockSubmitUserMessage: jest.Mock; + + const AppComponent = () => { + const { addState } = useInformAI({ + name: "MyAppComponent", + props: { key: "value" }, + prompt: "MyAppComponent prompt", + }); + + contextValues = useInformAIContext(); + + const handleClick = () => { + addState({ props: { key: "newValue" } }); + }; + + return
clickable element
; + }; + + beforeEach(async () => { + mockSubmitUserMessage = jest.fn(async () => ({ + id: "response-id", + content: "response message", + role: "assistant", + })); + + render( + + + + + ); + + //after this we should have 2 state messages in the context + fireEvent.click(screen.getByText("clickable element")); + + expect(contextValues!.messages.length).toBe(2); + + //submit a message from the user + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "test message" }, + }); + + await act(async () => { + fireEvent.submit(screen.getByRole("form")); + }); + }); + + it("sends the correct deduped state and user messages to submitUserMessage", async () => { + //test that the correct messages were sent to submitUserMessage + expect(mockSubmitUserMessage).toHaveBeenCalledTimes(1); + const [submittedMessages] = mockSubmitUserMessage.mock.calls[0]; + + //the 2 state messages for the component should have been deduped into 1 (plus the user message) + expect(submittedMessages.length).toBe(2); + + const stateMessage = submittedMessages[0] as FormattedMessage; + expect(stateMessage.content).toContain('Component props: {"key":"newValue"}'); + expect(stateMessage.role).toEqual("system"); + expect(stateMessage.id.length).toEqual(10); + + const userMessage = submittedMessages[1] as FormattedMessage; + expect(userMessage.content).toEqual("test message"); + expect(userMessage.role).toEqual("user"); + expect(userMessage.id.length).toEqual(10); + }); + + it("ends up with the correct messages", async () => { + //we should end up with 3 messages - a state message, a user message, and an LLM response + expect(currentMessages.length).toBe(3); + + const stateMessage = currentMessages[0] as FormattedMessage; + expect(stateMessage.content).toContain('Component props: {"key":"newValue"}'); + expect(stateMessage.role).toEqual("system"); + expect(stateMessage.id.length).toEqual(10); + + const userMessage = currentMessages[1] as FormattedMessage; + expect(userMessage.role).toEqual("user"); + expect(userMessage.id.length).toEqual(10); + + const responseMessage = currentMessages[2] as FormattedMessage; + expect(responseMessage.role).toEqual("assistant"); + expect(responseMessage.content).toEqual("response message"); + expect(responseMessage.id).toEqual("response-id"); + }); + + it("clears the text input", async () => { + expect(screen.getByRole("textbox")).toHaveValue(""); + }); + }); +}); diff --git a/src/test/CurrentState.test.tsx b/src/test/CurrentState.test.tsx new file mode 100644 index 0000000..ba44e1b --- /dev/null +++ b/src/test/CurrentState.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, act, fireEvent, waitFor } from "@testing-library/react"; +import { CurrentState } from "../ui/CurrentState"; +import { InformAIProvider } from "../InformAIContext"; +import { useInformAI } from "../useInformAI"; + +describe("CurrentState", () => { + it("renders correctly", () => { + render( + + + + ); + expect(screen.getByText("Current InformAI State")).toBeInTheDocument(); + }); + + it("renders the current state", async () => { + const name = "TestComponentName"; + + const TestComponent = () => { + useInformAI({ + name, + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + return
test-component-id
; + }; + + render( + + + + + ); + await waitFor(() => { + expect(screen.getByText(name)).toBeInTheDocument(); + }); + }); + + it("will collapse and expand when clicking the h1", async () => { + const name = "TestComponentName"; + const prompt = "This is a test component"; + const componentId = "test-component-id"; + const props = { key: "value" }; + + const TestComponent = () => { + useInformAI({ + name, + componentId, + props, + prompt, + }); + + return
test-component-id
; + }; + + render( + + + + + ); + + //test that the row for the component is shown + await waitFor(() => { + expect(screen.getByText(name)).toBeInTheDocument(); + }); + + const h1 = screen.getByText("Current InformAI State"); + expect(h1).toBeInTheDocument(); + fireEvent.click(h1); + + //test that the row for the component is hidden + expect(screen.queryByText(name)).not.toBeInTheDocument(); + + //make sure we can expand again + fireEvent.click(h1); + expect(screen.getByText(name)).toBeInTheDocument(); + }); +}); diff --git a/src/test/Messages.test.tsx b/src/test/Messages.test.tsx new file mode 100644 index 0000000..2ae8810 --- /dev/null +++ b/src/test/Messages.test.tsx @@ -0,0 +1,9 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { Messages } from "../ui/Messages"; + +describe("Messages", () => { + it("renders correctly", () => { + render(); + // expect(screen.getByRole("list")).toBeInTheDocument(); + }); +}); diff --git a/src/test/useInformAI.test.tsx b/src/test/useInformAI.test.tsx new file mode 100644 index 0000000..5a4967a --- /dev/null +++ b/src/test/useInformAI.test.tsx @@ -0,0 +1,343 @@ +import { render, screen, act, fireEvent } from "@testing-library/react"; +import { useInformAI } from "../useInformAI"; +import { InformAIProvider, useInformAIContext, dedupeMessages } from "../InformAIContext"; +import { EventMessage, StateMessage } from "../types"; + +describe("useInformAI Hook", () => { + it('creates a unique "componentId" for each component if not passed', () => { + let contextValues: ReturnType | undefined = undefined; + + const TestComponent = () => { + useInformAI({ + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + + const messages = contextValues!.messages; + expect(messages[0].content.componentId!.length).toEqual(6); + }); + + it("honors custom componentIds", () => { + const componentId = "test-component-id"; + let contextValues: ReturnType | undefined = undefined; + + const TestComponent = () => { + useInformAI({ + componentId, + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + + const messages = contextValues!.messages; + expect(messages[0].content.componentId).toEqual(componentId); + }); + + it("adds a state message when contents are provided", () => { + let contextValues: ReturnType | undefined = undefined; + + const TestComponent = () => { + useInformAI({ + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + + const messages = contextValues!.messages; + expect(messages.length).toEqual(1); + + const message = messages[0] as StateMessage; + expect(message.content.name).toEqual("Test Component"); + expect(message.content.prompt).toEqual("This is a test component"); + expect(message.content.props).toEqual({ key: "value" }); + }); + + it("adds a message when contents are not provided", () => { + let contextValues: ReturnType | undefined = undefined; + + const TestComponent = () => { + useInformAI(); + + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + + const messages = contextValues!.messages; + expect(messages.length).toEqual(1); + }); + + it("generates an 8 character message id for each message", () => { + let contextValues: ReturnType | undefined = undefined; + + const TestComponent = () => { + useInformAI({ + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + + const messages = contextValues!.messages; + expect(messages[0].id).toBeDefined(); + expect(messages[0].id.length).toEqual(8); + }); + + describe("getting recent messages", () => { + it("returns an empty array if no messages have been added", () => { + let contextValues: ReturnType | undefined = undefined; + + const TestComponent = () => { + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + + expect(contextValues!.getRecentMessages()).toEqual([]); + }); + + it("returns the most recent messages", () => { + let contextValues: ReturnType | undefined = undefined; + + const TestComponent = () => { + useInformAI({ + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + + const messages = contextValues!.getRecentMessages(); + expect(messages.length).toEqual(1); + }); + }); + + describe("clearing messages", () => { + let contextValues: ReturnType | undefined = undefined; + + beforeEach(() => { + const TestComponent = () => { + useInformAI({ + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + }); + + it("clears all messages", () => { + act(() => { + contextValues!.clearRecentMessages(); + }); + + expect(contextValues!.messages.length).toEqual(0); + }); + }); + + describe("popping recent messages", () => { + it("sets the conversation lastSentAt to now", () => { + let contextValues: ReturnType | undefined = undefined; + + const TestComponent = () => { + useInformAI({ + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + return null; + }; + + render( + + + + ); + + const previousDate = contextValues!.conversation.lastSentAt; + + act(() => { + contextValues!.popRecentMessages(); + }); + + expect(+contextValues!.conversation.lastSentAt).toBeGreaterThanOrEqual(+previousDate); + }); + }); + + describe("Multiple state messages from the same component", () => { + let contextValues: ReturnType | undefined = undefined; + + beforeEach(() => { + const TestComponent = () => { + const { addState, addEvent } = useInformAI({ + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + + const handleClick = () => { + addEvent({ type: "click", data: { key: "value" } }); + addState({ props: { key: "newValue" } }); + }; + + return
test component
; + }; + + render( + + + + ); + + fireEvent.click(screen.getByText("test component")); + }); + + it("does not generate a new componentId", () => { + const messages = contextValues!.messages; + + expect(messages.length).toEqual(3); + expect(messages[0].content.componentId!.length).toEqual(6); + expect(messages[0].content.componentId).toEqual(messages[1].content.componentId); + }); + + it("dedupes correctly, returning only the latest state message", () => { + const deduped = dedupeMessages(contextValues!.messages); + expect(deduped.length).toEqual(2); + + const eventMessage = deduped[0] as EventMessage; + const stateMessage = deduped[1] as StateMessage; + expect(stateMessage.content.props).toEqual({ key: "newValue" }); + + //should leave event messages unmolested + expect(eventMessage.content.type).toEqual("click"); + expect(eventMessage.content.data).toEqual({ key: "value" }); + }); + }); + + describe("event messages", () => { + let contextValues: ReturnType | undefined = undefined; + + beforeEach(() => { + const TestComponent = () => { + const { addEvent } = useInformAI({ + name: "Test Component", + props: { + key: "value", + }, + prompt: "This is a test component", + }); + + contextValues = useInformAIContext(); + + const handleClick = () => { + addEvent({ type: "click", data: { key: "value" } }); + }; + + return
test component
; + }; + + render( + + + + ); + }); + + it("adds an event message", () => { + fireEvent.click(screen.getByText("test component")); + + expect(contextValues!.messages.length).toEqual(2); + + const eventMessage = contextValues!.messages[1] as EventMessage; + expect(eventMessage.content.type).toEqual("click"); + expect(eventMessage.content.data).toEqual({ key: "value" }); + }); + }); +}); diff --git a/src/test/utils.test.tsx b/src/test/utils.test.tsx new file mode 100644 index 0000000..ccfd3cb --- /dev/null +++ b/src/test/utils.test.tsx @@ -0,0 +1,85 @@ +import { EventMessage, Message, StateMessage, FormattedMessage } from "../types"; +import { randomId, mapComponentMessages } from "../utils"; + +describe("utils", () => { + it("generates a random identifier of a specified length", () => { + expect(randomId(6).length).toEqual(6); + }); + + describe("mapComponentMessages", () => { + let formattedMessages: FormattedMessage[]; + let stateMessage: StateMessage; + let eventMessage: EventMessage; + let componentMessages: Message[]; + + beforeEach(() => { + stateMessage = { + type: "state", + id: "message1", + createdAt: new Date(), + content: { + componentId: "test", + name: "test component name", + prompt: "test component prompt", + props: { + test: "test", + }, + }, + }; + + eventMessage = { + type: "event", + id: "message2", + createdAt: new Date(), + content: { + componentId: "test", + type: "test", + description: "test", + }, + }; + + componentMessages = [stateMessage, eventMessage]; + formattedMessages = mapComponentMessages(componentMessages); + }); + + it("maps an array of component messages to an array of formatted messages", () => { + expect(formattedMessages.length).toEqual(2); + }); + + it("creates a new id for each formatted message", () => { + expect(formattedMessages[0].id).not.toEqual(formattedMessages[1].id); + }); + + describe("mapped state messages", () => { + it("creates a new message id", () => { + expect(formattedMessages[0].id).not.toBeUndefined(); + }); + + it("exposes the type of event in the content", () => { + expect(formattedMessages[0].content).toContain("Component test has updated its state"); + }); + + it("exposes the name in the content", () => { + expect(formattedMessages[0].content).toContain("Component Name: test component name"); + }); + + it("exposes the props in the content", () => { + expect(formattedMessages[0].content).toContain('Component props: {"test":"test"}'); + }); + + it("exposes the prompt in the content", () => { + expect(formattedMessages[0].content).toContain("Component self-description: test component prompt"); + }); + }); + + describe("mapped event messages", () => { + it("creates a new message id", () => { + expect(formattedMessages[0].id).not.toBeUndefined(); + }); + + it("maps an event message to its formatted content", () => { + expect(formattedMessages[1].content).toEqual("Component test sent event test.\n Description was: test"); + }); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index feee427..3741ac9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,41 +1,139 @@ +/** + * Represents a conversation between a user and an AI. + */ export type Conversation = { + /** + * Unique identifier for the conversation. + */ id: string; + /** + * Timestamp when the conversation was started. + */ createdAt: Date; + /** + * Timestamp when the last message was sent in the conversation. + */ lastSentAt: Date; }; +/** + * Represents the state of a component. + */ export type ComponentState = { + /** + * Unique identifier for the component. + */ componentId?: string; + /** + * Display name for the component. + */ name?: string; + /** + * A short description of the component. + */ prompt?: string; + /** + * Additional properties of the component. + */ props?: { [key: string]: any; }; + /** + * If true, this component does not have any state. + */ noState?: boolean; }; +/** + * Represents an event that was sent by a component. + */ export type ComponentEvent = { + /** + * Unique identifier for the component that sent the event. + */ componentId: string; + /** + * Type of the event. + */ type: string; + /** + * Additional data associated with the event. + */ data?: any; + /** + * A short description of the event. + */ description?: string; }; +/** + * Represents an event that was sent by a component, but with an optional componentId. + */ export type OptionalComponentEvent = Omit & { componentId?: string }; +/** + * Base type for all messages sent between the user and the AI. + */ export type BaseMessage = { + /** + * Unique identifier for the message. + */ id: string; + /** + * Timestamp when the message was sent. + */ createdAt: Date; }; +/** + * Represents a message that contains the state of a component. + */ export type StateMessage = BaseMessage & { + /** + * Type of the message. + */ type: "state"; + /** + * The state of the component. + */ content: ComponentState; }; +/** + * Represents a message that contains an event sent by a component. + */ export type EventMessage = BaseMessage & { + /** + * Type of the message. + */ type: "event"; + /** + * The event sent by the component. + */ content: ComponentEvent; }; +/** + * Represents a message that can be sent between the user and the AI. + */ export type Message = StateMessage | EventMessage; + +/** + * Represents a formatted message that can be displayed to the user or sent to the AI. + * System messages like state and event messages are formatted into a string so that the LLM + * can understand the component contents. + */ +export type FormattedMessage = { + /** + * Unique identifier for the message. + */ + id: string; + /** + * The content of the message. + */ + content: string; + /** + * The role of the message sender (e.g. user, assistant). + */ + role: string; +}; diff --git a/src/ui/ChatBox.tsx b/src/ui/ChatBox.tsx index d338132..91b5ff0 100644 --- a/src/ui/ChatBox.tsx +++ b/src/ui/ChatBox.tsx @@ -18,18 +18,21 @@ export function ChatBox({ }) { return (
{ e.preventDefault(); + const messageEl = e.target.elements["message"]; + // Blur focus on mobile if (window.innerWidth < 600) { - e.target["message"]?.blur(); + messageEl?.blur(); } - const submitSuccess = await onSubmit(e.target["message"].value); + const submitSuccess = await onSubmit(messageEl?.value); if (submitSuccess) { - e.target["message"].value = ""; + messageEl.value = ""; } }} className="chatbox" @@ -41,7 +44,6 @@ export function ChatBox({ Promise; + messages?: any[]; + setMessages: (value: any[] | ((prevState: any[]) => any[])) => void; +} -export function ChatWrapper({ className, AI }: { className?: string; AI: any }) { - const { submitUserMessage } = useActions(); +/** + * A basic chat wrapper that provides a chat box and message list. + * Supports streaming LLM responses. + * Sample usage, using the Vercel AI SDK: + * + * import { ChatWrapper } from "inform-ai"; + * import { useActions, useUIState } from "ai/rsc"; + * import { AIProvider } from "./AI"; + * + * export function MyCustomChatWrapper() { + * const { submitUserMessage } = useActions(); + * const [messages, setMessages] = useUIState(); + * + * return ( + * + * ); + * } + * + * This assumes you have set up a Vercel AI Provider like this in a file called ./AI.tsx: + * + * "use server"; + * + * import { CoreMessage, generateId } from "ai"; + * import { createInformAI } from "inform-ai"; + * import { createAI } from "ai/rsc"; + * import { submitUserMessage } from "../actions/AI"; + * + * export type ClientMessage = CoreMessage & { + * id: string; + * }; + * + * export type AIState = { + * chatId: string; + * messages: ClientMessage[]; + * }; + * + * export type UIState = { + * id: string; + * role?: string; + * content: React.ReactNode; + * }[]; + * + * function submitUserMessage() { + * //your implementation to send message to the LLM via Vercel AI SDK + * } + * + * export const AIProvider = createAI({ + * actions: { + * submitUserMessage, + * }, + * initialUIState: [] as UIState, + * initialAIState: { chatId: generateId(), messages: [] } as AIState, + * }); + * + * + * @param {ChatWrapperProps} props - The properties of the ChatWrapper component. + * @param {string} props.className - An optional class name for the component. + * @param {function} props.submitUserMessage - A function to submit the user's message to the AI. + * @param {any[]} props.messages - The current messages in the chat history. + * @param {function} props.setMessages - A function to update the messages in the chat history. Can be async. + * @return {JSX.Element} The JSX element representing the chat wrapper. + */ +export function ChatWrapper({ className, submitUserMessage, messages = [], setMessages }: ChatWrapperProps) { const { popRecentMessages } = useInformAIContext(); - const [messages, setMessages] = useUIState(); async function onMessage(message: string) { const componentMessages = popRecentMessages(); @@ -22,7 +90,7 @@ export function ChatWrapper({ className, AI }: { className?: string; AI: any }) const newSystemMessages = mapComponentMessages(dedupeMessages(componentMessages)); //this is the new user message that will be sent to the AI - const newUserMessage = { id: generateId(), content: message, role: "user" }; + const newUserMessage: FormattedMessage = { id: randomId(10), content: message, role: "user" }; //the new user message UI that will be added to the chat history const newUserMessageUI = { ...newUserMessage, content: }; diff --git a/src/ui/InformAI.tsx b/src/ui/InformAI.tsx index 2b168ff..22ca07e 100644 --- a/src/ui/InformAI.tsx +++ b/src/ui/InformAI.tsx @@ -1,21 +1,3 @@ -/** - * Component for relaying information about a component to a LLM via InformAI. - * Sample Usage: - * - * import { InformAI } from "inform-ai"; - * - * //inside your component's template - * - * - * This is equivalent to calling the useInformAI hook in the component's code. The InformAI component can be used - * in either client side React components or React Server Components. - * - * @component - * @param {string} name - The name of the component. - * @param {string} prompt - The prompt for the InformAI service. - * @param {any} props - Additional props for the component. - * @returns {null} - */ "use client"; import { useInformAI } from "../useInformAI"; @@ -29,16 +11,31 @@ import { useInformAI } from "../useInformAI"; * //inside your component's template * * - * @param {string} name - The name of the component. - * @param {string} prompt - The prompt for the InformAI service. - * @param {any} props - Additional props for the component. + * This is equivalent to calling the useInformAI hook in the component's code. The InformAI component can be used + * in either client side React components or React Server Components. + * + * @param {string} name - The name of the component. Passed to the LLM so make it meaningful + * @param {string} prompt - The prompt to pass to the LLM describing the component + * @param {any} props - Props that will be sent to the LLM as part of the prompt. Must be JSON serializable + * @param {string} [componentId] - A unique identifier for the component (one will be generated if not provided) * @returns {null} */ -export function InformAI({ name, prompt, props }: { name: string; prompt: string; props: any }) { +export function InformAI({ + name, + prompt, + props, + componentId, +}: { + name: string; + prompt: string; + props: any; + componentId?: string; +}) { useInformAI({ name, prompt, props, + componentId, }); return null; diff --git a/src/ui/Messages.tsx b/src/ui/Messages.tsx index 2fb22cb..d5f9e7a 100644 --- a/src/ui/Messages.tsx +++ b/src/ui/Messages.tsx @@ -52,7 +52,7 @@ export function UserMessage({ message }: { message: string }) { } /** - * A component for rendering an assistant message. + * A component for rendering an assistant message. Supports streaming. * * @param {any} content - The content of the assistant message. * @return {JSX.Element} The rendered assistant message component. diff --git a/src/useInformAI.tsx b/src/useInformAI.tsx index b55628d..6450f4f 100644 --- a/src/useInformAI.tsx +++ b/src/useInformAI.tsx @@ -72,7 +72,7 @@ export interface UseInformAIHook { * @param {ComponentState} componentData - The data for the component. * @return {UseInformAIHook} An object with methods to add messages, events, and states to the Inform AI context. */ -export function useInformAI(componentData: ComponentState): UseInformAIHook { +export function useInformAI(componentData: ComponentState = {}): UseInformAIHook { const context = useInformAIContext(); const componentIdRef = useRef(componentData.componentId || randomId(6)); const componentId = componentIdRef.current; diff --git a/src/utils.tsx b/src/utils.tsx index a4cc3ae..c152f12 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -1,4 +1,4 @@ -import { EventMessage, Message, StateMessage } from "./types"; +import { EventMessage, Message, StateMessage, FormattedMessage } from "./types"; import { v4 as uuidv4 } from "uuid"; @@ -17,7 +17,7 @@ export function randomId(length = 8) { * @param componentMessages - The array of component messages to be mapped. * @returns An array of formatted messages. */ -export function mapComponentMessages(componentMessages: Message[]) { +export function mapComponentMessages(componentMessages: Message[]): FormattedMessage[] { return componentMessages.map((message) => { return { id: randomId(10), From 4b0532f24a206e4e9bdaa196b0de022ff85c2e20 Mon Sep 17 00:00:00 2001 From: Ed Spencer Date: Fri, 23 Aug 2024 16:37:51 -0400 Subject: [PATCH 2/2] fix pnpm-lock --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3185aa..ddc6573 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@testing-library/react': specifier: ^16.0.0 version: 16.0.0(@testing-library/dom@10.4.0)(react-dom@19.0.0-rc-cc1ec60d0d-20240607(react@19.0.0-rc-cc1ec60d0d-20240607))(react@19.0.0-rc-cc1ec60d0d-20240607)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) - ai: - specifier: ^3.3.6 - version: 3.3.6(react@19.0.0-rc-cc1ec60d0d-20240607)(sswr@2.1.0(svelte@4.2.18))(svelte@4.2.18)(vue@3.4.37(typescript@5.5.4))(zod@3.23.8) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -72,6 +69,9 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + ai: + specifier: ^3.3.6 + version: 3.3.6(react@19.0.0-rc-cc1ec60d0d-20240607)(sswr@2.1.0(svelte@4.2.18))(svelte@4.2.18)(vue@3.4.37(typescript@5.5.4))(zod@3.23.8) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.41)