├── .changeset ├── README.md └── config.json ├── .eslintrc.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── docs ├── current-state-example-2.png ├── current-state-example.png ├── inform-ai-chat-example.png └── magic-square.png ├── jest.config.ts ├── jest.setup.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── rollup.config.mjs ├── src ├── InformAIContext.tsx ├── createInformAI.tsx ├── index.ts ├── test │ ├── ChatBox.test.tsx │ ├── ChatWrapper.test.tsx │ ├── CurrentState.test.tsx │ ├── InformAIContext.test.tsx │ ├── Messages.test.tsx │ ├── useInformAI.test.tsx │ └── utils.test.tsx ├── types.ts ├── ui │ ├── ChatBox.tsx │ ├── ChatWrapper.tsx │ ├── CurrentState.tsx │ ├── InformAI.tsx │ ├── Messages.tsx │ ├── index.ts │ └── main.css ├── useInformAI.tsx ├── useStreamableContent.tsx └── utils.tsx ├── tailwind.config.js └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2022": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:react-hooks/recommended"], 7 | "parserOptions": { 8 | "ecmaFeatures": { 9 | "jsx": true 10 | }, 11 | "ecmaVersion": "latest", 12 | "sourceType": "module" 13 | }, 14 | "plugins": ["react", "react-hooks"], 15 | "rules": { 16 | "react/jsx-uses-react": "error", 17 | "react/jsx-uses-vars": "error" 18 | }, 19 | "settings": { 20 | "react": { 21 | "version": "detect" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Test and Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' # Runs on all branches 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x, 22.x] 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - uses: pnpm/action-setup@v4 25 | with: 26 | version: 9.7.0 27 | 28 | - name: Set up Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: "pnpm" 33 | 34 | - name: Install dependencies 35 | run: pnpm install 36 | 37 | - name: Run tests 38 | run: npx jest 39 | 40 | release: 41 | name: Release 42 | runs-on: ubuntu-latest 43 | needs: test 44 | if: github.ref == 'refs/heads/main' # Only run this job on the main branch 45 | steps: 46 | - name: Checkout Repo 47 | uses: actions/checkout@v4 48 | 49 | - uses: pnpm/action-setup@v4 50 | with: 51 | version: 9.7.0 52 | 53 | - name: Setup Node.js 54 | uses: actions/setup-node@v4 55 | with: 56 | node-version: "20" 57 | cache: "pnpm" 58 | registry-url: "https://registry.npmjs.org" 59 | 60 | - name: Install dependencies 61 | run: pnpm install 62 | 63 | - name: Create Release Pull Request or Publish to npm 64 | id: changesets 65 | uses: changesets/action@v1 66 | with: 67 | publish: npm run ci:publish 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 71 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js 2 | node_modules/ 3 | 4 | # Logs 5 | logs/ 6 | *.log 7 | npm-debug.log* 8 | pnpm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Dependency directories 13 | jspm_packages/ 14 | 15 | # Optional npm cache directory 16 | .npm/ 17 | 18 | # Optional eslint cache 19 | .eslintcache 20 | 21 | # Optional REPL history 22 | .node_repl_history 23 | 24 | # Environment variables 25 | .env 26 | .env.test 27 | .env.production 28 | 29 | # Dist directory 30 | dist/ 31 | 32 | # Build directories 33 | build/ 34 | .tmp/ 35 | out/ 36 | 37 | # Coverage directory used by tools like istanbul or cypress 38 | coverage/ 39 | 40 | # Temporary files 41 | tmp/ 42 | temp/ 43 | *.tmp 44 | *.temp 45 | 46 | # IDE files 47 | .vscode/ 48 | .idea/ 49 | .DS_Store 50 | 51 | # macOS specific files 52 | ._* 53 | .Spotlight-V100 54 | .Trashes 55 | 56 | # Windows specific files 57 | Thumbs.db 58 | Desktop.ini 59 | 60 | # Optional lock files 61 | package-lock.json 62 | yarn.lock 63 | .pnpm-lock.yaml 64 | 65 | # TypeScript build artifacts 66 | *.tsbuildinfo 67 | 68 | # Lint and formatting results 69 | stylelint.cache 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # inform-ai 2 | 3 | ## 0.5.4 4 | 5 | ### Patch Changes 6 | 7 | - 57f065e: Fix AI SDK dep again 8 | 9 | ## 0.5.3 10 | 11 | ### Patch Changes 12 | 13 | - 35fac59: Fixed Vercel AI SDK version 14 | 15 | ## 0.5.2 16 | 17 | ### Patch Changes 18 | 19 | - 1014721: Fixed CJS output 20 | 21 | ## 0.5.1 22 | 23 | ### Patch Changes 24 | 25 | - bc1e6e9: Fix scrolling overflow on Messages UI component 26 | 27 | ## 0.5.0 28 | 29 | ### Minor Changes 30 | 31 | - 8b87064: Allow customization of placeholder and send button text 32 | 33 | ## 0.4.1 34 | 35 | ### Patch Changes 36 | 37 | - fc1246a: README improvements 38 | 39 | ## 0.4.0 40 | 41 | ### Minor Changes 42 | 43 | - b7c17d0: Adopt MIT license 44 | 45 | ### Patch Changes 46 | 47 | - b7c17d0: Better README 48 | 49 | ## 0.3.2 50 | 51 | ### Patch Changes 52 | 53 | - c2bb195: Delete vestigial example dir 54 | 55 | ## 0.3.1 56 | 57 | ### Patch Changes 58 | 59 | - 7ce83dd: Rearrange dependencies, devDependencies and peerDependencies 60 | 61 | ## 0.3.0 62 | 63 | ### Minor Changes 64 | 65 | - 1254ce9: Built-in ChatWrapper component 66 | 67 | ### Patch Changes 68 | 69 | - 1254ce9: More jest tests 70 | - 1254ce9: Docs and tests 71 | 72 | ## 0.2.9 73 | 74 | ### Patch Changes 75 | 76 | - a172371: CI changes 77 | 78 | ## 0.2.8 79 | 80 | ### Patch Changes 81 | 82 | - 5e92ebf: Refactor randomId into utils 83 | - 8e819a3: Fix JsonView CSS import 84 | - 8e819a3: Create shorter IDs for components and messages 85 | 86 | ## 0.2.7 87 | 88 | ### Patch Changes 89 | 90 | - 5f744ee: Fix CSS build 91 | 92 | ## 0.2.6 93 | 94 | ### Patch Changes 95 | 96 | - 1dff333: CSS fixes 97 | 98 | ## 0.2.5 99 | 100 | ### Patch Changes 101 | 102 | - ffe5ae6: Use nanoid for generating ids 103 | - 59ade55: Simplify InformAIProvider 104 | - ffe5ae6: Add some tests 105 | 106 | ## 0.2.4 107 | 108 | ### Patch Changes 109 | 110 | - d40bed0: Allow ChatBox placeholder customization 111 | - d40bed0: nicer chevron for CurrentState 112 | - d40bed0: Show no-messages message 113 | - d40bed0: Allow ChatBox to be autoFocus or not 114 | 115 | ## 0.2.3 116 | 117 | ### Patch Changes 118 | 119 | - 0f987fb: Allow Messages to accept a className 120 | - 324abd8: Better-looking collapsed state for CurrentState 121 | 122 | ## 0.2.2 123 | 124 | ### Patch Changes 125 | 126 | - 0b50a6c: Allow CurrentState to be collapsed 127 | - 0b50a6c: Refactored mapComponentState and related functions into separate file 128 | - 64e9e3e: Remove .npmignore, whitelist files in package.json 129 | - 0b50a6c: Expanded README, swapped streamMulti for streamUI 130 | - 1b0aaee: Migrate to tailwind apply statements rather than inline classNames 131 | 132 | ## 0.2.1 133 | 134 | ### Patch Changes 135 | 136 | - 4e5de0f: Docs, make createInformAI have optional argument 137 | 138 | ## 0.2.0 139 | 140 | ### Minor Changes 141 | 142 | - d0c9d64: Added the InformAI component 143 | 144 | ### Patch Changes 145 | 146 | - a749126: Add better JSON view 147 | - 7dcb831: Fix leading zero on last sent in CurrentState 148 | 149 | ## 0.1.0 150 | 151 | ### Minor Changes 152 | 153 | - 53e9a35: Add noState option, return assigned componentId 154 | 155 | ## 0.0.4 156 | 157 | ### Patch Changes 158 | 159 | - c69d2f9: Added tailwind and postfix, incorporated into rollup build 160 | 161 | ## 0.0.3 162 | 163 | ### Patch Changes 164 | 165 | - f22bdb9: Add package scripts 166 | 167 | ## 0.0.2 168 | 169 | ### Patch Changes 170 | 171 | - 9a51acc: Github CI 172 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright 2004 Ed Spencer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InformAI - Context-Aware AI Integration for React Apps 2 | 3 | **InformAI** is a tool that enables seamless integration of context-aware AI into any React application. It's designed to work effortlessly with the [Vercel AI SDK](https://sdk.vercel.ai/docs/introduction), but is also compatible with other AI SDK providers. 4 | 5 | ### Key Features: 6 | 7 | - **Contextual AI Integration**: Easily expose the state of your React components to an LLM (Large Language Model) or other AI, providing valuable context with minimal effort. 8 | - **Event Publishing**: Allow your components to publish events, like user interactions, in an LLM-optimized format. 9 | - **Flexible Usage**: Works well with both client-side React components and React Server Components. Though it excels with Next.js and React Server Components, these are not required. 10 | 11 | InformAI doesn't directly send data to your LLM but simplifies integration with tools like the Vercel AI SDK, making it easy to incorporate AI into your app. 12 | 13 | If ChatGPT and other LLMs can read and write text, and Vercel AI SDK adds the ability to write UI by streaming React components as part of an LLM response, InformAI fills in the missing piece by allowing the LLM to read your UI as well as write to it: 14 | 15 | ![Where InformAI fits](/docs/magic-square.png) 16 | 17 | ## Installation 18 | 19 | Install the NPM package: 20 | 21 | ```sh 22 | npm install inform-ai 23 | ``` 24 | 25 | Include the stylesheet if you plan to use the included UI components (or don't, if you want to use them but customize their appearance): 26 | 27 | ```tsx 28 | import "inform-ai/dist/main.css"; 29 | ``` 30 | 31 | ## Installing the Provider 32 | 33 | InformAI can be used via either the `` Component or the `useInformAI` hook. Either way, you need to wrap any components using InformAI inside an `InformAIProvider`: 34 | 35 | ```tsx 36 | import { InformAIProvider } from "inform-ai"; 37 | 38 | //somewhere in your layout.tsx or similar: 39 | {children}; 40 | ``` 41 | 42 | ## Exposing Component state 43 | 44 | Now, within any React component that will be rendered inside that `InformAIProvider`, you can insert a `` node: 45 | 46 | ```tsx 47 | import { InformAI } from "inform-ai"; 48 | 49 | const prompt = "Shows the life history of a person, including their name, title and age"; 50 | 51 | export function Bio({ name, title, age }) { 52 | return ( 53 |
54 | 55 | //... rest of the component here 56 |
57 | ); 58 | } 59 | ``` 60 | 61 | Adding the `` tag to our component we were able to tell the LLM 3 things about our component: 62 | 63 | - **name** - a meaningful name for this specific component instance 64 | - **props** - any props we want to pass to the LLM (must be JSON-serializable) 65 | - **prompt** - a string to help the LLM understand what the component does 66 | 67 | ### useInformAI 68 | 69 | An alternative to the `` component is to use the `useInformAI` hook. `useInformAI` is a little more versatile than ``. Here's a slightly simplified example taken from the [backups table from lansaver](https://github.com/edspencer/lansaver/blob/main/components/backup/table.tsx), showing how to use `useInformAI` instead of ``: 70 | 71 | ```tsx 72 | import { useInformAI } from "inform-ai"; 73 | 74 | const prompt = 75 | "This table displays a list of backups taken for various devices. The data will be provided to you in JSON format"; 76 | 77 | export function BackupsTable({ 78 | name = "Backups Table", 79 | backups, 80 | showDevice = false, 81 | }: { 82 | name?: string; 83 | backups: BackupWithDevice[]; 84 | showDevice?: boolean; 85 | }) { 86 | useInformAI({ 87 | name, 88 | prompt, 89 | props: { 90 | backups, 91 | }, 92 | }); 93 | 94 | if (condensed) { 95 | return ; 96 | } 97 | 98 | return //your table implementation
; 99 | } 100 | ``` 101 | 102 | It was useful to use the hook in this case as we render a different table if `condensed` is set to true, but we wanted to surface the same information either way to InformAI, so by using 'useInformAI' we didn't need to maintain 2 duplicate copies of an `` tag in our 2 table components. 103 | 104 | ## Exposing Component events 105 | 106 | Another possibility that is unlocked by using `useInformAI` is telling the LLM about component events like clicks or other user interactions: 107 | 108 | Here's an example of a different Table component, which can render arbitrary data and exposes `click` events when the user clicks on a table cell: 109 | 110 | ```tsx 111 | const defaultPrompt = `This component is a table that displays data in a tabular format. 112 | It takes two props: data and colHeaders. The data prop is an array of objects, where 113 | each object represents a row in the table. The colHeaders prop is an optional 114 | array of strings that represent the column headers of the table. 115 | If the colHeaders prop is not provided, the component will use the 116 | keys of the first object in the data array as the column headers. 117 | The component will render the table with the provided data and column headers.`; 118 | 119 | export function Table({ data, colHeaders, name = "Table", informPrompt = defaultPrompt, header }: TableProps) { 120 | const { addEvent } = useInformAI({ 121 | name, 122 | prompt: informPrompt, 123 | props: { 124 | data, 125 | colHeaders, 126 | }, 127 | }); 128 | 129 | //adds a new hint to the AI 130 | const cellClicked = (e: React.MouseEvent) => { 131 | addEvent({ 132 | type: "user-click", 133 | description: "User clicked on a cell with data: " + (e.target as HTMLElement).innerHTML, 134 | }); 135 | }; 136 | 137 | return ( 138 |
139 | {header} 140 | 141 | 142 | 143 | {colHeaders 144 | ? colHeaders.map((header, index) => {header.label}) 145 | : Object.keys(data[0]).map((header, index) => {header})} 146 | 147 | 148 | 149 | {data.map((row, rowIndex) => ( 150 | 151 | {Object.values(row).map((cell, cellIndex) => ( 152 | 153 | {cell as ReactNode} 154 | 155 | ))} 156 | 157 | ))} 158 | 159 |
160 |
161 | ); 162 | } 163 | ``` 164 | 165 | The `type` and `description` we pass can be any strings we like. 166 | 167 | ## Viewing Current State 168 | 169 | Under the covers, InformAI collects together all of the component state and event messages that are published by `` and `useInformAI`. While in development, it's useful to be able to see what InformAI is aware of, and what will be sent with the next user message to the LLM. 170 | 171 | InformAI ships with a small React component called `` which can be rendered anywhere inside your component tree, and will show you all of the component states and events that InformAI has collected. 172 | 173 | Drop this into your layout.tsx like so: 174 | 175 | ```tsx 176 | import "inform-ai/dist/main.css"; 177 | import "./globals.css"; 178 | 179 | //optionally include the CurrentState component for easier InformAI debugging 180 | import { InformAIProvider, CurrentState } from "inform-ai"; 181 | 182 | export default function RootLayout({ 183 | children, 184 | }: Readonly<{ 185 | children: React.ReactNode; 186 | }>) { 187 | return ( 188 | 189 | 190 | 191 | {children} 192 | 193 | 194 | 195 | 196 | ); 197 | } 198 | ``` 199 | 200 | `` accepts a `className` so you can style.position it however you like (this example has it pinned top right). It will collapse/expand when you click the component heading if it's getting in the way. 201 | 202 | ![CurrentState component](/docs/current-state-example.png) 203 | 204 | `` is intended to help understand/debug in development, and is not something you'd likely ship to your users. Each time a component registers a state or event update, a row is added to CurrentState with the ability to dig down into a JSON view of all of the information. 205 | 206 | ## Adding a Chatbot 207 | 208 | How you add your Chatbot UI is completely up to you. InformAI works well alongside the Vercel AI SDK (`npm install ai`), and provides a couple of rudimentary chatbot UI components out of the box that use Vercel AI SDK. 209 | 210 | Here's how you can use that to create your own simple `ChatBot` component using the Vercel AI SDK and InformAI: 211 | 212 | ```tsx 213 | "use client"; 214 | 215 | import { ChatWrapper } from "inform-ai"; 216 | import { useActions, useUIState } from "ai/rsc"; 217 | 218 | export function ChatBot() { 219 | const { submitUserMessage } = useActions(); 220 | const [messages, setMessages] = useUIState(); 221 | 222 | return ; 223 | } 224 | ``` 225 | 226 | InformAI exposes `ChatBox` and `Messages` components, along with a `ChatWrapper` that just combines them both into an easy package. `ChatBox` is a fairly simple form with a text input and a button to submit the user's message, and `Messages` just renders the conversation between the user and the LLM assistant. 227 | 228 | Because the Vercel AI SDK is awesome, `Messages` can handle streaming LLM responses as well as streaming React Server Components (if you're using nextjs or similar). Here's an example of a conversation using `ChatWrapper`: 229 | 230 | ![Example Chat on Schedules page](/docs/inform-ai-chat-example.png) 231 | 232 | You're highly encouraged to check out the [ChatWrapper source](/src/ui/ChatWrapper.tsx) as well as that for [ChatBox](/src/ui/ChatBox.tsx) and [Messages](/src/ui/Messages.tsx) - they're all pretty straightforward components so you can use all, some or none of them in your app. 233 | 234 | ### Vercel AI backend for this example 235 | 236 | To get that `ChatBot` component to work, we actually need 2 more things: 237 | 238 | - A Vercel `` in our React tree 239 | - A `submitUserMessage` function 240 | 241 | We can define those both in a single file, something like this: 242 | 243 | ```tsx 244 | "use server"; 245 | 246 | import { CoreMessage, generateId } from "ai"; 247 | import { createAI } from "ai/rsc"; 248 | import { AssistantMessage } from "inform-ai"; 249 | 250 | export type ClientMessage = CoreMessage & { 251 | id: string; 252 | }; 253 | 254 | export type AIState = { 255 | chatId: string; 256 | messages: ClientMessage[]; 257 | }; 258 | 259 | export type UIState = { 260 | id: string; 261 | role?: string; 262 | content: React.ReactNode; 263 | }[]; 264 | 265 | export async function submitUserMessage(messages: ClientMessage[]) { 266 | const aiState = getMutableAIState(); 267 | 268 | //add the new messages to the AI State so the user can refresh and not lose the context 269 | aiState.update({ 270 | ...aiState.get(), 271 | messages: [...aiState.get().messages, ...messages], 272 | }); 273 | 274 | //set up our streaming LLM response using Vercel AI SDK 275 | const result = await streamUI({ 276 | model: openai("gpt-4o-2024-08-06"), 277 | system: "You are a helpful assistant who blah blah blah", 278 | messages: aiState.get().messages, 279 | text: ({ content, done }) => { 280 | if (done) { 281 | //save the LLM's response to our AIState 282 | aiState.done({ 283 | ...aiState.get(), 284 | messages: [...aiState.get().messages, { role: "assistant", content }], 285 | }); 286 | } 287 | 288 | //AssistantMessage is a simple, styled component that supports streaming text/UI responses 289 | return ; 290 | }, 291 | }); 292 | 293 | return { 294 | id: generateId(), 295 | content: result.value, 296 | }; 297 | } 298 | 299 | export const AIProvider = createAI({ 300 | actions: { 301 | submitUserMessage, 302 | }, 303 | initialUIState: [] as UIState, 304 | initialAIState: { chatId: generateId(), messages: [] } as AIState, 305 | }); 306 | ``` 307 | 308 | This gives us our `submitUserMessage` and `` exports. All you need to do now is add the `` into your React component tree, just like we did with ``, and everything should Just Work. The `useActions()` hook we used in our `ChatBot.tsx` will be able to pull out our `submitUserMessage` function and pass it to `ChatWrapper`, which will then call it when the user enters and sends a message. 309 | 310 | The AIState management we do there is to keep a running context of the conversation so far - see the [Vercel AI SDK AIState docs](https://sdk.vercel.ai/examples/next-app/state-management/ai-ui-states) if you're not familiar with that pattern. 311 | 312 | The `text` prop we passed to `streamUI` is doing 2 things - rendering a pretty `` bubble for the streaming LLM response, and saving the finished LLM response into the AIState history when the LLM has finished its answer. This allows the LLM to see the whole conversation when the user sends follow-up messages, without the client needing to send the entire conversation each time. 313 | 314 | ### Tips & Tricks 315 | 316 | #### Page-level integration 317 | 318 | The fastest way to add InformAI to your app is by doing so at the page level. Below is an example from the [lansaver application](https://github.com/edspencer/lansaver), which is a nextjs app that backs up configurations for network devices like firewalls and managed switches ([see the full SchedulePage component here](https://github.com/edspencer/lansaver/blob/main/app/schedules/%5Bid%5D/page.tsx)). 319 | 320 | This is a React Server Component, rendered on the server. It imports the `` React component, defines a `prompt` string to help the LLM understand what this component does, and then renders `` with a meaningful component `name`, the `prompt`, and an arbitrary `props` object, which is passed to the LLM in addition to the name and prompt: 321 | 322 | ```tsx 323 | import { InformAI } from "inform-ai"; 324 | 325 | const prompt = `A page that shows the details of a schedule. It should show the schedule's configuration, the devices in the schedule, and recent jobs for the schedule. It should also have buttons to run the schedule, edit the schedule, and delete the schedule.`; 326 | 327 | export default async function SchedulePage({ params: { id } }: { params: { id: string } }) { 328 | const schedule = await getSchedule(parseInt(id, 10)); 329 | 330 | if (!schedule) { 331 | return notFound(); 332 | } 333 | 334 | const devices = await getScheduleDevices(schedule.id); 335 | const jobs = await recentJobs(schedule.id); 336 | 337 | return ( 338 |
339 | 340 |
341 | Schedule Details 342 |
343 | 344 | 345 | 346 | 347 |
348 |
349 |
350 | 351 | 352 |
353 | Recent Jobs 354 | 355 | {jobs.length ? : null} 356 | {/* */} 357 |
358 | ); 359 | } 360 | ``` 361 | 362 | In this case we passed the `schedule` (a row from the database), `devices` (an array of device database rows) and `jobs` (an array of recent backup jobs) to the LLM, but we could have passed anything into `props`, so long as it is serializable into JSON. Next time the user sends the LLM a message, it will also receive all of the context we just exposed to it about this page, so can answer questions about what the user is looking at. 363 | 364 | When possible, it is usually better to use InformAI at the component level rather than the page level to take advantage of React's composability, but it's really up to you. 365 | 366 | #### Streaming UI using Tools 367 | 368 | We can extend our use of streamUI and other functions like it by providing tools definitions for the LLM to choose from. The streamUI() function and its UI streaming capabilities are 100% Vercel AI SDK functionality and not InformAI itself, but InformAI plays well with it and supports streaming UI instead of/in addition to streaming text responses from the LLM: 369 | 370 | ```tsx 371 | //inside our submitUserMessage function 372 | const result = await streamUI({ 373 | model: openai("gpt-4o-2024-08-06"), 374 | system: "You are a helpful assistant who blah blah blah", 375 | messages: aiState.get().messages, 376 | text: ({ content, done }) => { 377 | if (done) { 378 | //save the LLM's response to our AIState 379 | aiState.done({ 380 | ...aiState.get(), 381 | messages: [...aiState.get().messages, { role: "assistant", content }], 382 | }); 383 | } 384 | 385 | return ; 386 | }, 387 | tools: { 388 | redirect: RedirectTool, 389 | backupsTable: BackupsTableTool, 390 | }, 391 | }); 392 | ``` 393 | 394 | In the code snippet above we defined 2 tools that the LLM can execute if it thinks it makes sense to do so. If the tool has a `generate` function, it can render arbitrary React components that will be streamed to the browser. Tools can be defined inline but they're easier to read, test and swap in/out when extracted into their own files (tool-calling LLMs like those from OpenAI are still not great at picking the right tool when given too many options). 395 | 396 | Here's a real-world example of a tool definition used in the [LANsaver](https://github.com/edspencer/lansaver) project ([see the full tool source](https://github.com/edspencer/lansaver/blob/main/app/tools/BackupsTable.tsx)). Most of this file is just textual description telling the LLM what the tool is and how to use it. The important part of the tool is the `generate` function: 397 | 398 | ```tsx 399 | import { z } from "zod"; 400 | import { BackupsTable } from "@/components/backup/table"; 401 | import { getDeviceBackups, getDeviceByHostname } from "@/models/device"; 402 | import { getPaginatedBackups } from "@/models/backup"; 403 | import { Spinner } from "@/components/common/spinner"; 404 | 405 | type BackupsTableToolParameters = { 406 | condensed?: boolean; 407 | showDevice?: boolean; 408 | deviceId?: number; 409 | deviceName?: string; 410 | perPage?: number; 411 | name?: string; 412 | }; 413 | 414 | const BackupsTableTool = { 415 | //tells the LLM what this tool is and when to use it 416 | description: 417 | "Renders a table of backups. Optionally, you can show the device column and condense the table. If the user requests to see all backups, do not pass in a deviceId.", 418 | 419 | //tells the LLM how to invoke the tool, what the arguments are and which are optional 420 | parameters: z.object({ 421 | condensed: z 422 | .boolean() 423 | .optional() 424 | .describe("Set to true to condense the table and hide some of the extraneous columns"), 425 | showDevice: z.boolean().optional().describe("Set to true to show the device column"), 426 | deviceId: z 427 | .number() 428 | .optional() 429 | .describe("The ID of the device to show backups for (do not set to show all backups)"), 430 | deviceName: z 431 | .string() 432 | .optional() 433 | .describe( 434 | "The name of the device to show backups for. Pass this if the user asks for backups for a device by name. The tool will perform a fuzzy search for this device" 435 | ), 436 | perPage: z.number().optional().describe("The number of backups to show per page (defaults to 5)"), 437 | name: z.string().optional().describe("A name to give to this table. For example, 'Recent Backups for Device X'"), 438 | }), 439 | 440 | //if the LLM decides to call this tool, generate will be called with the arguments the LLM decided to use 441 | //this function can yield a temporary piece of UI, like a spinner, and then return the permanent UI when ready 442 | generate: async function* (config: BackupsTableToolParameters) { 443 | const { condensed, showDevice, deviceId, deviceName, perPage = 5, name } = config; 444 | 445 | let backups; 446 | 447 | yield ; 448 | 449 | if (deviceName) { 450 | // Perform a fuzzy search for the device 451 | const device = await getDeviceByHostname(deviceName); 452 | 453 | if (device) { 454 | backups = await getDeviceBackups(device.id, { take: perPage }); 455 | } 456 | } else if (deviceId) { 457 | backups = await getDeviceBackups(deviceId, { take: perPage }); 458 | } 459 | 460 | if (!backups) { 461 | backups = (await getPaginatedBackups({ includeDevice: true, page: 1, perPage })).backups; 462 | } 463 | 464 | return ; 465 | }, 466 | }; 467 | 468 | export default BackupsTableTool; 469 | ``` 470 | 471 | Note that this is all just vanilla Vercel AI SDK functionality, and you can read more about it [in their docs](https://sdk.vercel.ai/examples/next-app/interface/route-components). Basically, though, this function `yield`s a Spinner component while it is loading the data for the real component it will show, then does some basic fuzzy searching, then finally returns the `` component, which will be streamed to the UI. 472 | 473 | The [Render Interface During Tool Call](https://sdk.vercel.ai/examples/next-app/tools/render-interface-during-tool-call) documentation in the Vercel AI SDK is a good thing to read if you're not familiar with what that can do already. 474 | 475 | ### What the LLM sees 476 | 477 | Here's an example of a quick conversation with the LLM after just integrating InformAI into the `SchedulePage` component that we showed above. `SchedulePage` is just a Next JS page (therefore a React component) that shows some simple details about a backup schedule for devices on a network: 478 | 479 | ![Example Chat on Schedules page](/docs/inform-ai-chat-example.png) 480 | 481 | By just adding the `` tag to our `SchedulePage`, we were able to have this conversation with the LLM about its contents, without having to do any other integration. But because we also defined our `FirewallsTableTool` tool, the LLM knew how to stream our `` component back instead of a text response. 482 | 483 | Because `` also uses InformAI, via the `useInformAI` hook, the LLM also sees all of the information in the React component that it just streamed back, so it was able to answer questions about that too ("did all of those complete successfully?"). 484 | 485 | As our project was using the bundled `` component while we had this conversation, we could easily see what the state sent to the LLM looks like directly in our UI: 486 | 487 | ![CurrentState after this exchange](/docs/current-state-example-2.png) 488 | 489 | Here you can see that 4 `state` messages were published to InformAI - the first 2 were for the `SchedulePage` (which has name=`Schedule Detail Page`), and 2 for the freshly-streamed `` that the LLM sent back. Expanding the last message there, we can see that the LLM gave the streamed component a sensible name based on the user's request, and also has the `prompt` and `props` that we supply it in ``. 490 | 491 | The 'Last Sent' message at the bottom tells us that all of the messages above were already sent to the LLM, as they were popped off the stack using `popRecentMessages` in our `` component. `ChatWrapper` also did some deduping and conversion of the messages into an LLM-friendly format. If we add `console.log(aiState.get().messages)` to our `submitUserMessage` function we will see something like this: 492 | 493 | ``` 494 | [ 495 | { 496 | id: '492b4wc', 497 | content: 'Component adc51d has updated its state\n' + 498 | 'Component Name: Schedule Detail Page\n' + 499 | "Component self-description: A page that shows the details of a schedule. It should show the schedule's configuration, the devices in the schedule, and recent jobs for the schedule. It should also have buttons to run the schedule, edit the schedule, and delete the schedule.\n" + 500 | 'Component props: {"schedule":{"id":6,"disabled":false,"cron":"0 0 * * *","name":"Daily","devices":"21,19,83","createdAt":"2024-06-14T19:55:29.825Z","updatedAt":"2024-08-13T21:17:43.936Z"},"devices":[{"id":21,"type":"tplink","hostname":"masterclosetswitch.local","credentials":"fdbec89f1c1bb41b0e55d60f46092da3:7391a495c95411ebc5f087c4b8f5bcfb2b903d370cedd6a819a9e69b15f03999b9fbc4378a5254751ddb038bfec87facd5b642d0aa28b48b9ecf675b0deceb28","config":"{}","createdAt":"2024-06-14T19:55:29.824Z","updatedAt":"2024-06-14T19:55:29.824Z"},{"id":19,"type":"opnsense","hostname":"firewall.local","credentials":"dd503eaafa2acdae999023a63735b7b8:9af028d461a8b3aea27c6edc013d64e98d33476d8614bdd0ad1cab42601a2517c01cc0342b6946fee8bb5a31ceaa26a659b37051da1584ba163360f9465997154ff7f9344ff5726683fe6183e6e7054f622aeeaaa2402dc416e5ae6edea6cb34ff9d80720bb9942d2ccb90015821f8fa103ec0f116bcc3532b2ff285dad80ec56c90503996b094daf52b5775b224b137a8ba0dc13d29e2e4c37d244ff10bda30bc7ed892390efc3e3ac19dd0845e7cb0e6b3cd88c2f126d2f8d9b7191f85f72f","config":"{}","createdAt":"2024-06-14T19:55:29.823Z","updatedAt":"2024-06-16T16:06:39.091Z"},{"id":83,"type":"hass","hostname":"13232pct.duckdns.org","credentials":"6628e50a7bd550741dd1963ef98bfb67:107376648f66355e19787eb82036ea506a9cae6152ed98f1f1739640d2a930f30c54683c9bc3eaebd49043434afeed16b7928ba31b44048477b40d68f2a1638d83a9e1aaf83f015ffc53ed5114eb77fd90e06cfe3f52f804d9433056b985a0f00358e42d04733440e7c3c245a926266e3f5d1232022850baa970e38d8a33b032e1ccdadc563574420447cacb8498dbb637dfdfa19272cf226df112730cd8e4282e09ce99c30e0854c7ca5144426ad8f7f349fcffea7da3f7970c3ad5af9b33023ad7f057ad4144817f9df0e4c69e1466","config":"{\\"port\\":\\"3000\\"}","createdAt":"2024-07-16T17:13:33.455Z","updatedAt":"2024-07-16T17:13:33.455Z"}],"jobs":[{"id":24,"createdAt":"2024-08-13T21:17:54.387Z","updatedAt":"2024-08-13T21:18:31.499Z","startedAt":"2024-08-13T21:17:54.400Z","finishedAt":"2024-08-13T21:18:31.496Z","status":"completed","scheduleId":6,"_count":{"backups":3}},{"id":23,"createdAt":"2024-08-13T21:15:46.991Z","updatedAt":"2024-08-13T21:15:47.571Z","startedAt":"2024-08-13T21:15:47.004Z","finishedAt":"2024-08-13T21:15:47.570Z","status":"completed","scheduleId":6,"_count":{"backups":2}},{"id":22,"createdAt":"2024-08-13T21:09:42.083Z","updatedAt":"2024-08-13T21:09:42.083Z","startedAt":null,"finishedAt":null,"status":"pending","scheduleId":6,"_count":{"backups":1}},{"id":18,"createdAt":"2024-07-15T15:37:38.955Z","updatedAt":"2024-07-15T15:38:14.366Z","startedAt":"2024-07-15T15:37:38.967Z","finishedAt":"2024-07-15T15:38:14.365Z","status":"completed","scheduleId":6,"_count":{"backups":2}},{"id":17,"createdAt":"2024-07-15T15:36:30.814Z","updatedAt":"2024-07-15T15:37:06.483Z","startedAt":"2024-07-15T15:36:30.828Z","finishedAt":"2024-07-15T15:37:06.482Z","status":"completed","scheduleId":6,"_count":{"backups":2}}]}', 501 | role: 'system' 502 | }, 503 | { 504 | id: 'QfhU2Z2', 505 | content: 'how many devices does this schedule back up?', 506 | role: 'user' 507 | }, 508 | { role: 'assistant', content: 'This schedule backs up 3 devices.' }, 509 | { 510 | id: '3Rytlyw', 511 | content: 'Component 2b5e1c has updated its state\n' + 512 | 'Component Name: 3 Most Recent Backups for firewall.local\n' + 513 | 'Component self-description: This table displays a list of backups taken for various devices. The data will be provided to you in JSON format\n' + 514 | 'Component props: {"backups":[{"id":65,"jobId":24,"deviceId":19,"createdAt":"2024-08-13T21:17:54.391Z","updatedAt":"2024-08-13T21:17:54.626Z","status":"completed","bytes":57117},{"id":63,"jobId":23,"deviceId":19,"createdAt":"2024-08-13T21:15:46.996Z","updatedAt":"2024-08-13T21:15:47.571Z","status":"completed","bytes":57117},{"id":61,"jobId":22,"deviceId":19,"createdAt":"2024-08-13T21:09:42.091Z","updatedAt":"2024-08-13T21:09:42.091Z","status":"pending","bytes":null}]}', 515 | role: 'system' 516 | }, 517 | { 518 | id: 'Min6j9V', 519 | content: 'did all of those complete successfully?', 520 | role: 'user' 521 | }, 522 | { 523 | role: 'assistant', 524 | content: 'Out of the three most recent backups for the device "firewall.local," two backups completed successfully, and one is still pending:\n' + 525 | '\n' + 526 | '1. Backup ID 65: Completed\n' + 527 | '2. Backup ID 63: Completed\n' + 528 | '3. Backup ID 61: Pending' 529 | } 530 | ] 531 | ``` 532 | 533 | It's a little dense to the human eye, but here we can see the first message is from the `system` role, and is a string representation of the content that we supplied to `` in our `SchedulePage` React component. After that we see our user message, followed by another `system` message that InformAI injected for us because the `` was streamed in as a response and published data to InformAI. 534 | 535 | The internal messages stored by InformAI are converted into LLM-friendly strings via the [mapComponentMessages](https://github.com/edspencer/inform-ai/blob/main/src/utils.tsx) function, but it's easy to swap that out for any function you like. The default `mapComponentMessages` function just delegates to a function that looks like this: 536 | 537 | ```tsx 538 | export function mapStateToContent(state: StateMessage) { 539 | const content = []; 540 | 541 | const { name, componentId, prompt, props } = state.content; 542 | 543 | content.push(`Component ${componentId} has updated its state`); 544 | 545 | if (name) { 546 | content.push(`Component Name: ${name}`); 547 | } 548 | 549 | if (prompt) { 550 | content.push(`Component self-description: ${prompt}`); 551 | } 552 | 553 | if (props) { 554 | content.push(`Component props: ${JSON.stringify(props)}`); 555 | } 556 | 557 | return content.join("\n"); 558 | } 559 | ``` 560 | -------------------------------------------------------------------------------- /docs/current-state-example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edspencer/inform-ai/8f36699a9d71c257109b6cb885c9f5146878bbc8/docs/current-state-example-2.png -------------------------------------------------------------------------------- /docs/current-state-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edspencer/inform-ai/8f36699a9d71c257109b6cb885c9f5146878bbc8/docs/current-state-example.png -------------------------------------------------------------------------------- /docs/inform-ai-chat-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edspencer/inform-ai/8f36699a9d71c257109b6cb885c9f5146878bbc8/docs/inform-ai-chat-example.png -------------------------------------------------------------------------------- /docs/magic-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edspencer/inform-ai/8f36699a9d71c257109b6cb885c9f5146878bbc8/docs/magic-square.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from "ts-jest"; 2 | 3 | const jestConfig: JestConfigWithTsJest = { 4 | preset: "ts-jest", 5 | testEnvironment: "jsdom", 6 | moduleNameMapper: { 7 | "^ai/rsc$": "/node_modules/ai/rsc/dist", 8 | "^@/(.*)$": "/$1", 9 | "\\.(css|less|sass|scss)$": "identity-obj-proxy", 10 | }, 11 | modulePaths: [""], 12 | transformIgnorePatterns: ["/node_modules/(?!react18-json-view)"], 13 | testPathIgnorePatterns: ["/node_modules/", "/dist/"], 14 | 15 | setupFilesAfterEnv: ["/jest.setup.ts"], 16 | }; 17 | 18 | export default jestConfig; 19 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/jest-globals"; 2 | import "@testing-library/jest-dom"; 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inform-ai", 3 | "version": "0.5.4", 4 | "description": "A collection of hooks and utilities to easily add contextual AI to React applications", 5 | "main": "./dist/index.cjs.js", 6 | "module": "./dist/index.js", 7 | "types": "./dist/types/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs.js" 12 | } 13 | }, 14 | "scripts": { 15 | "test": "npx jest", 16 | "build": "tsc && rollup -c", 17 | "build:watch": "concurrently \"tsc --watch\" \"rollup -c --watch\"", 18 | "ci:version": "changeset version", 19 | "ci:publish": "tsc && rollup -c && changeset publish" 20 | }, 21 | "keywords": [ 22 | "ai", 23 | "react" 24 | ], 25 | "files": [ 26 | "/dist/**/*" 27 | ], 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/edspencer/inform-ai.git" 31 | }, 32 | "author": "Ed Spencer", 33 | "license": "MIT", 34 | "devDependencies": { 35 | "@changesets/cli": "^2.27.7", 36 | "@jest/globals": "^29.7.0", 37 | "@rollup/plugin-commonjs": "^26.0.1", 38 | "@rollup/plugin-node-resolve": "^15.2.3", 39 | "@testing-library/dom": "^10.4.0", 40 | "@testing-library/jest-dom": "^6.4.8", 41 | "@testing-library/react": "^16.0.0", 42 | "@types/jest": "^29.5.12", 43 | "@types/react": "^18.3.4", 44 | "@types/react-dom": "^18.3.0", 45 | "@types/uuid": "^10.0.0", 46 | "ai": ">=3.3.17", 47 | "autoprefixer": "^10.4.20", 48 | "concurrently": "^8.2.2", 49 | "eslint": "^8.57.0", 50 | "eslint-plugin-react": "^7.35.0", 51 | "eslint-plugin-react-hooks": "^4.6.2", 52 | "identity-obj-proxy": "^3.0.0", 53 | "jest": "^29.7.0", 54 | "jest-environment-jsdom": "^29.7.0", 55 | "postcss": "^8.4.41", 56 | "react": "^18 || ^19", 57 | "react-dom": "^18 || ^19", 58 | "rollup": "^4.20.0", 59 | "rollup-plugin-peer-deps-external": "^2.2.4", 60 | "rollup-plugin-postcss": "^4.0.2", 61 | "rollup-plugin-typescript2": "^0.36.0", 62 | "tailwindcss": "^3.4.9", 63 | "ts-jest": "^29.2.4", 64 | "ts-node": "^10.9.2", 65 | "typescript": "^5.5.4" 66 | }, 67 | "dependencies": { 68 | "clsx": "^2.1.1", 69 | "react18-json-view": "^0.2.8", 70 | "uuid": "^10.0.0" 71 | }, 72 | "peerDependencies": { 73 | "ai": ">=3.3.17", 74 | "react": "^18 || ^19", 75 | "react-dom": "^18 || ^19" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "tailwindcss/nesting": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 4 | import postcss from "rollup-plugin-postcss"; 5 | 6 | export default [ 7 | { 8 | input: "src/ui/main.css", 9 | output: [{ file: "dist/main.css", format: "es" }], 10 | plugins: [ 11 | postcss({ 12 | extract: true, 13 | minimize: true, 14 | }), 15 | ], 16 | }, 17 | // ESM Build 18 | { 19 | input: "src/index.ts", 20 | output: { 21 | file: "dist/index.js", 22 | format: "esm", 23 | sourcemap: true, 24 | }, 25 | plugins: [peerDepsExternal(), typescript({ useTsconfigDeclarationDir: true }), commonjs()], 26 | watch: { 27 | include: "src/**", 28 | }, 29 | }, 30 | // CommonJS Build 31 | { 32 | input: "src/index.ts", 33 | output: { 34 | file: "dist/index.cjs.js", 35 | format: "cjs", 36 | sourcemap: true, 37 | }, 38 | plugins: [peerDepsExternal(), typescript({ useTsconfigDeclarationDir: true }), commonjs()], 39 | watch: { 40 | include: "src/**", 41 | }, 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /src/InformAIContext.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createContext, useContext, useState, ReactNode } from "react"; 4 | import { randomId } from "./utils"; 5 | 6 | import { StateMessage, EventMessage, Message, ComponentState, ComponentEvent, Conversation } from "./types"; 7 | 8 | /** 9 | * Defines the shape of the InformAIContext. 10 | */ 11 | export interface InformAIContextType { 12 | messages: Message[]; 13 | conversation: Conversation; 14 | addMessage: (message: Message) => void; 15 | addState: (state: ComponentState) => void; 16 | addEvent: (event: ComponentEvent) => void; 17 | addStateMessage: (state: StateMessage) => void; 18 | addEventMessage: (event: EventMessage) => void; 19 | getEvents?: () => Message[]; 20 | getState: (componentId: string) => ComponentState | undefined; 21 | updateState: (componentId: string, updates: object) => void; 22 | onEvent?: (event: any) => void; 23 | getMessagesSince: (since: Date) => Message[]; 24 | popRecentMessages: (since?: Date) => Message[]; 25 | clearRecentMessages: (since?: Date) => void; 26 | getRecentMessages: () => Message[]; 27 | } 28 | 29 | /** 30 | * The InformAIContext that provides access to messages and conversation state. 31 | */ 32 | export const InformAIContext = createContext(undefined); 33 | 34 | /** 35 | * Props for the InformAIProvider component. 36 | */ 37 | interface InformAIProviderProps { 38 | children?: ReactNode; 39 | onEvent?: (event: any) => void; 40 | } 41 | 42 | /** 43 | * The internal implementation of the InformAIProvider component. Sample usage: 44 | * 45 | * import { InformAIProvider } from 'inform-ai'; 46 | * 47 | * export default function MyComponent() { 48 | * return ( 49 | * 50 | * {children} 51 | * 52 | * ); 53 | * } 54 | * 55 | * Now within child React components you can use useInformAI() or to surface 56 | * information about your components to the LLM. 57 | */ 58 | export const InformAIProvider = ({ children, onEvent }: InformAIProviderProps) => { 59 | const [messages, setMessages] = useState([]); 60 | const [conversation, setConversation] = useState({ 61 | id: randomId(8), 62 | createdAt: new Date(), 63 | lastSentAt: new Date(), 64 | }); 65 | 66 | /** 67 | * Retrieves the state of a component with the specified componentId. 68 | * @param componentId The ID of the component. 69 | * @returns The state of the component, if found; otherwise, undefined. 70 | */ 71 | function getState(componentId: string) { 72 | return messages 73 | .reverse() 74 | .filter((message) => message.type === "state") 75 | .map((message) => message as StateMessage) 76 | .find((message) => message.content.componentId === componentId)?.content; 77 | } 78 | 79 | /** 80 | * Adds a message to the list of messages. 81 | * @param message The message to add. 82 | */ 83 | function addMessage(message: Message) { 84 | setMessages((prevMessages) => [...prevMessages, message]); 85 | } 86 | 87 | /** 88 | * Adds a state message to the list of messages. 89 | * @param state The state message to add. 90 | */ 91 | function addState(state: ComponentState) { 92 | addMessage({ 93 | id: randomId(8), 94 | createdAt: new Date(), 95 | type: "state", 96 | content: state, 97 | }); 98 | } 99 | 100 | /** 101 | * Updates the state of a component with the specified componentId. 102 | * @param componentId The ID of the component. 103 | * @param updates The updates to apply to the state. 104 | */ 105 | function updateState(componentId: string, updates: object) { 106 | const mostRecentState = getState(componentId); 107 | 108 | if (mostRecentState) { 109 | return addState({ ...mostRecentState, ...updates }); 110 | } else { 111 | return addState(updates as ComponentState); 112 | } 113 | } 114 | 115 | /** 116 | * Clears the recent messages up to the specified date. 117 | * @param since The date to clear messages from. If not specified, clears all recent messages. 118 | */ 119 | function clearRecentMessages(since?: Date) { 120 | const cutoff = since || conversation.lastSentAt; 121 | setMessages((prevMessages) => prevMessages.filter((message) => message.createdAt < cutoff)); 122 | } 123 | 124 | /** 125 | * Retrieves and removes the recent messages up to the specified date. 126 | * @param since The date to retrieve messages from. If not specified, retrieves all recent messages. 127 | * @returns The recent messages. 128 | */ 129 | function popRecentMessages(since?: Date) { 130 | const cutoff = since || conversation.lastSentAt; 131 | const recentMessages = messages.filter((message) => message.createdAt > cutoff); 132 | 133 | setConversation((prevConversation) => ({ 134 | ...prevConversation, 135 | lastSentAt: new Date(), 136 | })); 137 | 138 | return recentMessages; 139 | } 140 | 141 | /** 142 | * Adds an event message to the list of messages. 143 | * @param event The event message to add. 144 | */ 145 | function addEvent(event: ComponentEvent) { 146 | addMessage({ 147 | id: randomId(8), 148 | createdAt: new Date(), 149 | type: "event", 150 | content: event, 151 | }); 152 | } 153 | 154 | /** 155 | * Adds a state message to the list of messages. 156 | * @param state The state message to add. 157 | */ 158 | function addStateMessage(state: StateMessage) { 159 | addMessage(state); 160 | } 161 | 162 | /** 163 | * Adds an event message to the list of messages and triggers the onEvent callback. 164 | * @param event The event message to add. 165 | */ 166 | function addEventMessage(event: EventMessage) { 167 | addMessage(event); 168 | 169 | if (onEvent) { 170 | onEvent(event); 171 | } 172 | } 173 | 174 | /** 175 | * Retrieves the messages since the specified date. 176 | * @param since The date to retrieve messages from. 177 | * @returns The messages since the specified date. 178 | */ 179 | function getMessagesSince(since: Date) { 180 | return messages.filter((message) => message.createdAt >= since); 181 | } 182 | 183 | /** 184 | * Retrieves the recent messages. 185 | * @returns The recent messages. 186 | */ 187 | function getRecentMessages() { 188 | return getMessagesSince(conversation.lastSentAt); 189 | } 190 | 191 | return ( 192 | 210 | {children} 211 | 212 | ); 213 | }; 214 | 215 | /** 216 | * Removes duplicate state messages from the list of messages, keeping only the most recent. 217 | * Keeps all the event messages intact. 218 | * @param messages The list of messages. 219 | * @returns The deduplicated list of messages. 220 | */ 221 | export function dedupeMessages(messages: Message[]) { 222 | const seen = new Set(); 223 | return messages 224 | .reverse() 225 | .filter((message) => { 226 | if (message.type === "state") { 227 | const { componentId } = message.content; 228 | 229 | if (componentId) { 230 | if (seen.has(componentId)) { 231 | return false; 232 | } 233 | seen.add(componentId); 234 | } 235 | } 236 | 237 | return true; 238 | }) 239 | .reverse(); 240 | } 241 | 242 | /** 243 | * Custom hook for accessing the InformAIContext. 244 | * @returns The InformAIContext. 245 | * @throws An error if used outside of an InformAIProvider. 246 | */ 247 | export const useInformAIContext = () => { 248 | const context = useContext(InformAIContext); 249 | if (!context) { 250 | throw new Error("useInformAIContext must be used within an InformAIProvider"); 251 | } 252 | return context; 253 | }; 254 | -------------------------------------------------------------------------------- /src/createInformAI.tsx: -------------------------------------------------------------------------------- 1 | import { InformAIProvider } from "./InformAIContext"; 2 | 3 | type InformAIProvider = (props: { 4 | onEvent?: (message: string) => void; 5 | children: React.ReactNode; 6 | }) => React.ReactElement; 7 | 8 | export function createInformAI({ onEvent }: { onEvent?: (event: any) => void } = {}) { 9 | const InformAI: InformAIProvider = (props) => { 10 | return {props.children}; 11 | }; 12 | 13 | return InformAI; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./createInformAI"; 2 | export * from "./ui"; 3 | export * from "./InformAIContext"; 4 | export * from "./types"; 5 | export * from "./utils"; 6 | export * from "./useInformAI"; 7 | -------------------------------------------------------------------------------- /src/test/ChatBox.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent, waitFor } from "@testing-library/react"; 2 | import { ChatBox } from "../ui/ChatBox"; 3 | 4 | describe("ChatBox", () => { 5 | it("renders correctly", () => { 6 | render( true} />); 7 | expect(screen.getByRole("textbox")).toBeInTheDocument(); 8 | }); 9 | 10 | it("honors autoFocus", () => { 11 | render( true} autoFocus={true} />); 12 | expect(screen.getByRole("textbox")).toHaveFocus(); 13 | }); 14 | 15 | it("honors placeholder", () => { 16 | render( true} placeholder="test placeholder" />); 17 | expect(screen.getByPlaceholderText("test placeholder")).toBeInTheDocument(); 18 | }); 19 | 20 | it('clears the input if "onSubmit" returns true', async () => { 21 | const onSubmit = jest.fn(async () => true); 22 | 23 | render(); 24 | const input = screen.getByRole("textbox"); 25 | const button = screen.getByRole("button"); 26 | 27 | fireEvent.change(input, { target: { value: "test" } }); 28 | expect(input).toHaveValue("test"); 29 | 30 | fireEvent.click(button); 31 | 32 | await waitFor(() => { 33 | expect(input).toHaveValue(""); 34 | }); 35 | expect(onSubmit).toHaveBeenCalledTimes(1); 36 | }); 37 | 38 | it('does not clear the input if "onSubmit" returns false', async () => { 39 | const onSubmit = jest.fn(async () => false); 40 | 41 | render(); 42 | const input = screen.getByRole("textbox"); 43 | const form = screen.getByRole("form"); 44 | 45 | fireEvent.change(input, { target: { value: "test" } }); 46 | 47 | expect(input).toHaveValue("test"); 48 | 49 | fireEvent.submit(form); 50 | 51 | expect(onSubmit).toHaveBeenCalledTimes(1); 52 | expect(input).toHaveValue("test"); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/test/ChatWrapper.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; 2 | import { ChatWrapper } from "../ui/ChatWrapper"; 3 | import { InformAIProvider, useInformAIContext } from "../InformAIContext"; 4 | import { useState } from "react"; 5 | import { useInformAI } from "../useInformAI"; 6 | import { FormattedMessage } from "../types"; 7 | 8 | describe("ChatWrapper", () => { 9 | let currentMessages: any[]; 10 | 11 | const AppChatWrapper = ({ submitUserMessage = jest.fn() }: { submitUserMessage?: jest.Mock }) => { 12 | const [messages, setMessages] = useState([]); 13 | 14 | currentMessages = messages; 15 | 16 | return ; 17 | }; 18 | 19 | it("renders correctly", () => { 20 | render( 21 | 22 | 23 | 24 | ); 25 | 26 | //just checks that the component renders the ChatBox 27 | expect(screen.getByRole("textbox")).toBeInTheDocument(); 28 | }); 29 | 30 | it("renders user messages correctly", async () => { 31 | render( 32 | 33 | 34 | 35 | ); 36 | 37 | fireEvent.change(screen.getByRole("textbox"), { 38 | target: { value: "test message" }, 39 | }); 40 | fireEvent.submit(screen.getByRole("form")); 41 | 42 | await waitFor(() => screen.getByText("test message")); 43 | }); 44 | 45 | describe("collecting messages to send to the LLM", () => { 46 | let contextValues: ReturnType | undefined = undefined; 47 | let mockSubmitUserMessage: jest.Mock; 48 | 49 | const AppComponent = () => { 50 | const { addState } = useInformAI({ 51 | name: "MyAppComponent", 52 | props: { key: "value" }, 53 | prompt: "MyAppComponent prompt", 54 | }); 55 | 56 | contextValues = useInformAIContext(); 57 | 58 | const handleClick = () => { 59 | addState({ props: { key: "newValue" } }); 60 | }; 61 | 62 | return
clickable element
; 63 | }; 64 | 65 | beforeEach(async () => { 66 | mockSubmitUserMessage = jest.fn(async () => ({ 67 | id: "response-id", 68 | content: "response message", 69 | role: "assistant", 70 | })); 71 | 72 | render( 73 | 74 | 75 | 76 | 77 | ); 78 | 79 | //after this we should have 2 state messages in the context 80 | fireEvent.click(screen.getByText("clickable element")); 81 | 82 | expect(contextValues!.messages.length).toBe(2); 83 | 84 | //submit a message from the user 85 | fireEvent.change(screen.getByRole("textbox"), { 86 | target: { value: "test message" }, 87 | }); 88 | 89 | await act(async () => { 90 | fireEvent.submit(screen.getByRole("form")); 91 | }); 92 | }); 93 | 94 | it("sends the correct deduped state and user messages to submitUserMessage", async () => { 95 | //test that the correct messages were sent to submitUserMessage 96 | expect(mockSubmitUserMessage).toHaveBeenCalledTimes(1); 97 | const [submittedMessages] = mockSubmitUserMessage.mock.calls[0]; 98 | 99 | //the 2 state messages for the component should have been deduped into 1 (plus the user message) 100 | expect(submittedMessages.length).toBe(2); 101 | 102 | const stateMessage = submittedMessages[0] as FormattedMessage; 103 | expect(stateMessage.content).toContain('Component props: {"key":"newValue"}'); 104 | expect(stateMessage.role).toEqual("system"); 105 | expect(stateMessage.id.length).toEqual(10); 106 | 107 | const userMessage = submittedMessages[1] as FormattedMessage; 108 | expect(userMessage.content).toEqual("test message"); 109 | expect(userMessage.role).toEqual("user"); 110 | expect(userMessage.id.length).toEqual(10); 111 | }); 112 | 113 | it("ends up with the correct messages", async () => { 114 | //we should end up with 3 messages - a state message, a user message, and an LLM response 115 | expect(currentMessages.length).toBe(3); 116 | 117 | const stateMessage = currentMessages[0] as FormattedMessage; 118 | expect(stateMessage.content).toContain('Component props: {"key":"newValue"}'); 119 | expect(stateMessage.role).toEqual("system"); 120 | expect(stateMessage.id.length).toEqual(10); 121 | 122 | const userMessage = currentMessages[1] as FormattedMessage; 123 | expect(userMessage.role).toEqual("user"); 124 | expect(userMessage.id.length).toEqual(10); 125 | 126 | const responseMessage = currentMessages[2] as FormattedMessage; 127 | expect(responseMessage.role).toEqual("assistant"); 128 | expect(responseMessage.content).toEqual("response message"); 129 | expect(responseMessage.id).toEqual("response-id"); 130 | }); 131 | 132 | it("clears the text input", async () => { 133 | expect(screen.getByRole("textbox")).toHaveValue(""); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/test/CurrentState.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act, fireEvent, waitFor } from "@testing-library/react"; 2 | import { CurrentState } from "../ui/CurrentState"; 3 | import { InformAIProvider } from "../InformAIContext"; 4 | import { useInformAI } from "../useInformAI"; 5 | 6 | describe("CurrentState", () => { 7 | it("renders correctly", () => { 8 | render( 9 | 10 | 11 | 12 | ); 13 | expect(screen.getByText("Current InformAI State")).toBeInTheDocument(); 14 | }); 15 | 16 | it("renders the current state", async () => { 17 | const name = "TestComponentName"; 18 | 19 | const TestComponent = () => { 20 | useInformAI({ 21 | name, 22 | props: { 23 | key: "value", 24 | }, 25 | prompt: "This is a test component", 26 | }); 27 | 28 | return
test-component-id
; 29 | }; 30 | 31 | render( 32 | 33 | 34 | 35 | 36 | ); 37 | await waitFor(() => { 38 | expect(screen.getByText(name)).toBeInTheDocument(); 39 | }); 40 | }); 41 | 42 | it("will collapse and expand when clicking the h1", async () => { 43 | const name = "TestComponentName"; 44 | const prompt = "This is a test component"; 45 | const componentId = "test-component-id"; 46 | const props = { key: "value" }; 47 | 48 | const TestComponent = () => { 49 | useInformAI({ 50 | name, 51 | componentId, 52 | props, 53 | prompt, 54 | }); 55 | 56 | return
test-component-id
; 57 | }; 58 | 59 | render( 60 | 61 | 62 | 63 | 64 | ); 65 | 66 | //test that the row for the component is shown 67 | await waitFor(() => { 68 | expect(screen.getByText(name)).toBeInTheDocument(); 69 | }); 70 | 71 | const h1 = screen.getByText("Current InformAI State"); 72 | expect(h1).toBeInTheDocument(); 73 | fireEvent.click(h1); 74 | 75 | //test that the row for the component is hidden 76 | expect(screen.queryByText(name)).not.toBeInTheDocument(); 77 | 78 | //make sure we can expand again 79 | fireEvent.click(h1); 80 | expect(screen.getByText(name)).toBeInTheDocument(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/test/InformAIContext.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from "@testing-library/react"; 2 | import { InformAIContextType, InformAIProvider, useInformAIContext } from "../InformAIContext"; 3 | import { ComponentState, ComponentEvent } from "../types"; 4 | 5 | describe("InformAIProvider", () => { 6 | it("renders children correctly", () => { 7 | render( 8 | 9 |
Test Child
10 |
11 | ); 12 | expect(screen.getByText("Test Child")).toBeInTheDocument(); 13 | }); 14 | 15 | it("provides the correct default context values", () => { 16 | let contextValues: InformAIContextType | undefined = undefined; 17 | const TestComponent = () => { 18 | contextValues = useInformAIContext(); 19 | return null; 20 | }; 21 | 22 | render( 23 | 24 | 25 | 26 | ); 27 | 28 | expect(contextValues!.messages).toEqual([]); 29 | expect(contextValues!.conversation.id).toBeDefined(); 30 | expect(contextValues!.conversation.createdAt).toBeInstanceOf(Date); 31 | }); 32 | 33 | it("adds a state message and retrieves it by componentId", () => { 34 | const testComponentId = "test-component-id"; 35 | const testState: ComponentState = { componentId: testComponentId, props: { key: "value" } }; 36 | 37 | let contextValues: InformAIContextType; 38 | const TestComponent = () => { 39 | contextValues = useInformAIContext(); 40 | return null; 41 | }; 42 | 43 | render( 44 | 45 | 46 | 47 | ); 48 | 49 | act(() => { 50 | contextValues!.addState(testState); 51 | }); 52 | 53 | const retrievedState = contextValues!.getState(testComponentId); 54 | expect(retrievedState).toEqual(testState); 55 | }); 56 | 57 | it("updates the state of an existing component", () => { 58 | const testComponentId = "test-component-id"; 59 | const initialState: ComponentState = { componentId: testComponentId, props: { key: "initial" } }; 60 | const updatedState = { props: { key: "updated" } }; 61 | 62 | let contextValues: InformAIContextType; 63 | const TestComponent = () => { 64 | contextValues = useInformAIContext(); 65 | return null; 66 | }; 67 | 68 | render( 69 | 70 | 71 | 72 | ); 73 | 74 | act(() => { 75 | contextValues.addState(initialState); 76 | }); 77 | 78 | act(() => { 79 | contextValues.updateState(testComponentId, updatedState); 80 | }); 81 | 82 | const retrievedState = contextValues!.getState(testComponentId); 83 | expect(retrievedState).toEqual({ componentId: testComponentId, ...updatedState }); 84 | }); 85 | 86 | it("adds and retrieves event messages", () => { 87 | const testEvent: ComponentEvent = { componentId: "test-event", type: "clicked" }; 88 | 89 | let contextValues; 90 | const TestComponent = () => { 91 | contextValues = useInformAIContext(); 92 | return null; 93 | }; 94 | 95 | render( 96 | 97 | 98 | 99 | ); 100 | 101 | act(() => { 102 | contextValues!.addEvent(testEvent); 103 | }); 104 | 105 | const recentMessages = contextValues!.getRecentMessages(); 106 | 107 | expect(recentMessages.length).toBe(1); 108 | expect(recentMessages[0].content).toEqual(testEvent); 109 | }); 110 | 111 | it("clears recent messages correctly", () => { 112 | const testEvent: ComponentEvent = { componentId: "test-event", type: "clicked" }; 113 | 114 | let contextValues: InformAIContextType; 115 | const TestComponent = () => { 116 | contextValues = useInformAIContext(); 117 | return null; 118 | }; 119 | 120 | render( 121 | 122 | 123 | 124 | ); 125 | 126 | act(() => { 127 | contextValues!.addEvent(testEvent); 128 | }); 129 | 130 | act(() => { 131 | contextValues!.clearRecentMessages(); 132 | }); 133 | 134 | const recentMessages = contextValues!.getRecentMessages(); 135 | expect(recentMessages.length).toBe(0); 136 | }); 137 | 138 | it("throws error when useInformAIContext is used outside of provider", () => { 139 | const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); 140 | 141 | const TestComponent = () => { 142 | useInformAIContext(); 143 | return null; 144 | }; 145 | 146 | expect(() => render()).toThrow("useInformAIContext must be used within an InformAIProvider"); 147 | 148 | consoleError.mockRestore(); // Restore the original console.error behavior 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/test/Messages.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent, waitFor } from "@testing-library/react"; 2 | import { Messages } from "../ui/Messages"; 3 | 4 | describe("Messages", () => { 5 | it("renders correctly", () => { 6 | render(); 7 | // expect(screen.getByRole("list")).toBeInTheDocument(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/test/useInformAI.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act, fireEvent } from "@testing-library/react"; 2 | import { useInformAI } from "../useInformAI"; 3 | import { InformAIProvider, useInformAIContext, dedupeMessages } from "../InformAIContext"; 4 | import { EventMessage, StateMessage } from "../types"; 5 | 6 | describe("useInformAI Hook", () => { 7 | it('creates a unique "componentId" for each component if not passed', () => { 8 | let contextValues: ReturnType | undefined = undefined; 9 | 10 | const TestComponent = () => { 11 | useInformAI({ 12 | name: "Test Component", 13 | props: { 14 | key: "value", 15 | }, 16 | prompt: "This is a test component", 17 | }); 18 | 19 | contextValues = useInformAIContext(); 20 | return null; 21 | }; 22 | 23 | render( 24 | 25 | 26 | 27 | ); 28 | 29 | const messages = contextValues!.messages; 30 | expect(messages[0].content.componentId!.length).toEqual(6); 31 | }); 32 | 33 | it("honors custom componentIds", () => { 34 | const componentId = "test-component-id"; 35 | let contextValues: ReturnType | undefined = undefined; 36 | 37 | const TestComponent = () => { 38 | useInformAI({ 39 | componentId, 40 | name: "Test Component", 41 | props: { 42 | key: "value", 43 | }, 44 | prompt: "This is a test component", 45 | }); 46 | 47 | contextValues = useInformAIContext(); 48 | return null; 49 | }; 50 | 51 | render( 52 | 53 | 54 | 55 | ); 56 | 57 | const messages = contextValues!.messages; 58 | expect(messages[0].content.componentId).toEqual(componentId); 59 | }); 60 | 61 | it("adds a state message when contents are provided", () => { 62 | let contextValues: ReturnType | undefined = undefined; 63 | 64 | const TestComponent = () => { 65 | useInformAI({ 66 | name: "Test Component", 67 | props: { 68 | key: "value", 69 | }, 70 | prompt: "This is a test component", 71 | }); 72 | 73 | contextValues = useInformAIContext(); 74 | return null; 75 | }; 76 | 77 | render( 78 | 79 | 80 | 81 | ); 82 | 83 | const messages = contextValues!.messages; 84 | expect(messages.length).toEqual(1); 85 | 86 | const message = messages[0] as StateMessage; 87 | expect(message.content.name).toEqual("Test Component"); 88 | expect(message.content.prompt).toEqual("This is a test component"); 89 | expect(message.content.props).toEqual({ key: "value" }); 90 | }); 91 | 92 | it("adds a message when contents are not provided", () => { 93 | let contextValues: ReturnType | undefined = undefined; 94 | 95 | const TestComponent = () => { 96 | useInformAI(); 97 | 98 | contextValues = useInformAIContext(); 99 | return null; 100 | }; 101 | 102 | render( 103 | 104 | 105 | 106 | ); 107 | 108 | const messages = contextValues!.messages; 109 | expect(messages.length).toEqual(1); 110 | }); 111 | 112 | it("generates an 8 character message id for each message", () => { 113 | let contextValues: ReturnType | undefined = undefined; 114 | 115 | const TestComponent = () => { 116 | useInformAI({ 117 | name: "Test Component", 118 | props: { 119 | key: "value", 120 | }, 121 | prompt: "This is a test component", 122 | }); 123 | 124 | contextValues = useInformAIContext(); 125 | return null; 126 | }; 127 | 128 | render( 129 | 130 | 131 | 132 | ); 133 | 134 | const messages = contextValues!.messages; 135 | expect(messages[0].id).toBeDefined(); 136 | expect(messages[0].id.length).toEqual(8); 137 | }); 138 | 139 | describe("getting recent messages", () => { 140 | it("returns an empty array if no messages have been added", () => { 141 | let contextValues: ReturnType | undefined = undefined; 142 | 143 | const TestComponent = () => { 144 | contextValues = useInformAIContext(); 145 | return null; 146 | }; 147 | 148 | render( 149 | 150 | 151 | 152 | ); 153 | 154 | expect(contextValues!.getRecentMessages()).toEqual([]); 155 | }); 156 | 157 | it("returns the most recent messages", () => { 158 | let contextValues: ReturnType | undefined = undefined; 159 | 160 | const TestComponent = () => { 161 | useInformAI({ 162 | name: "Test Component", 163 | props: { 164 | key: "value", 165 | }, 166 | prompt: "This is a test component", 167 | }); 168 | 169 | contextValues = useInformAIContext(); 170 | return null; 171 | }; 172 | 173 | render( 174 | 175 | 176 | 177 | ); 178 | 179 | const messages = contextValues!.getRecentMessages(); 180 | expect(messages.length).toEqual(1); 181 | }); 182 | }); 183 | 184 | describe("clearing messages", () => { 185 | let contextValues: ReturnType | undefined = undefined; 186 | 187 | beforeEach(() => { 188 | const TestComponent = () => { 189 | useInformAI({ 190 | name: "Test Component", 191 | props: { 192 | key: "value", 193 | }, 194 | prompt: "This is a test component", 195 | }); 196 | 197 | contextValues = useInformAIContext(); 198 | return null; 199 | }; 200 | 201 | render( 202 | 203 | 204 | 205 | ); 206 | }); 207 | 208 | it("clears all messages", () => { 209 | act(() => { 210 | contextValues!.clearRecentMessages(); 211 | }); 212 | 213 | expect(contextValues!.messages.length).toEqual(0); 214 | }); 215 | }); 216 | 217 | describe("popping recent messages", () => { 218 | it("sets the conversation lastSentAt to now", () => { 219 | let contextValues: ReturnType | undefined = undefined; 220 | 221 | const TestComponent = () => { 222 | useInformAI({ 223 | name: "Test Component", 224 | props: { 225 | key: "value", 226 | }, 227 | prompt: "This is a test component", 228 | }); 229 | 230 | contextValues = useInformAIContext(); 231 | return null; 232 | }; 233 | 234 | render( 235 | 236 | 237 | 238 | ); 239 | 240 | const previousDate = contextValues!.conversation.lastSentAt; 241 | 242 | act(() => { 243 | contextValues!.popRecentMessages(); 244 | }); 245 | 246 | expect(+contextValues!.conversation.lastSentAt).toBeGreaterThanOrEqual(+previousDate); 247 | }); 248 | }); 249 | 250 | describe("Multiple state messages from the same component", () => { 251 | let contextValues: ReturnType | undefined = undefined; 252 | 253 | beforeEach(() => { 254 | const TestComponent = () => { 255 | const { addState, addEvent } = useInformAI({ 256 | name: "Test Component", 257 | props: { 258 | key: "value", 259 | }, 260 | prompt: "This is a test component", 261 | }); 262 | 263 | contextValues = useInformAIContext(); 264 | 265 | const handleClick = () => { 266 | addEvent({ type: "click", data: { key: "value" } }); 267 | addState({ props: { key: "newValue" } }); 268 | }; 269 | 270 | return
test component
; 271 | }; 272 | 273 | render( 274 | 275 | 276 | 277 | ); 278 | 279 | fireEvent.click(screen.getByText("test component")); 280 | }); 281 | 282 | it("does not generate a new componentId", () => { 283 | const messages = contextValues!.messages; 284 | 285 | expect(messages.length).toEqual(3); 286 | expect(messages[0].content.componentId!.length).toEqual(6); 287 | expect(messages[0].content.componentId).toEqual(messages[1].content.componentId); 288 | }); 289 | 290 | it("dedupes correctly, returning only the latest state message", () => { 291 | const deduped = dedupeMessages(contextValues!.messages); 292 | expect(deduped.length).toEqual(2); 293 | 294 | const eventMessage = deduped[0] as EventMessage; 295 | const stateMessage = deduped[1] as StateMessage; 296 | expect(stateMessage.content.props).toEqual({ key: "newValue" }); 297 | 298 | //should leave event messages unmolested 299 | expect(eventMessage.content.type).toEqual("click"); 300 | expect(eventMessage.content.data).toEqual({ key: "value" }); 301 | }); 302 | }); 303 | 304 | describe("event messages", () => { 305 | let contextValues: ReturnType | undefined = undefined; 306 | 307 | beforeEach(() => { 308 | const TestComponent = () => { 309 | const { addEvent } = useInformAI({ 310 | name: "Test Component", 311 | props: { 312 | key: "value", 313 | }, 314 | prompt: "This is a test component", 315 | }); 316 | 317 | contextValues = useInformAIContext(); 318 | 319 | const handleClick = () => { 320 | addEvent({ type: "click", data: { key: "value" } }); 321 | }; 322 | 323 | return
test component
; 324 | }; 325 | 326 | render( 327 | 328 | 329 | 330 | ); 331 | }); 332 | 333 | it("adds an event message", () => { 334 | fireEvent.click(screen.getByText("test component")); 335 | 336 | expect(contextValues!.messages.length).toEqual(2); 337 | 338 | const eventMessage = contextValues!.messages[1] as EventMessage; 339 | expect(eventMessage.content.type).toEqual("click"); 340 | expect(eventMessage.content.data).toEqual({ key: "value" }); 341 | }); 342 | }); 343 | }); 344 | -------------------------------------------------------------------------------- /src/test/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import { EventMessage, Message, StateMessage, FormattedMessage } from "../types"; 2 | import { randomId, mapComponentMessages } from "../utils"; 3 | 4 | describe("utils", () => { 5 | it("generates a random identifier of a specified length", () => { 6 | expect(randomId(6).length).toEqual(6); 7 | }); 8 | 9 | describe("mapComponentMessages", () => { 10 | let formattedMessages: FormattedMessage[]; 11 | let stateMessage: StateMessage; 12 | let eventMessage: EventMessage; 13 | let componentMessages: Message[]; 14 | 15 | beforeEach(() => { 16 | stateMessage = { 17 | type: "state", 18 | id: "message1", 19 | createdAt: new Date(), 20 | content: { 21 | componentId: "test", 22 | name: "test component name", 23 | prompt: "test component prompt", 24 | props: { 25 | test: "test", 26 | }, 27 | }, 28 | }; 29 | 30 | eventMessage = { 31 | type: "event", 32 | id: "message2", 33 | createdAt: new Date(), 34 | content: { 35 | componentId: "test", 36 | type: "test", 37 | description: "test", 38 | }, 39 | }; 40 | 41 | componentMessages = [stateMessage, eventMessage]; 42 | formattedMessages = mapComponentMessages(componentMessages); 43 | }); 44 | 45 | it("maps an array of component messages to an array of formatted messages", () => { 46 | expect(formattedMessages.length).toEqual(2); 47 | }); 48 | 49 | it("creates a new id for each formatted message", () => { 50 | expect(formattedMessages[0].id).not.toEqual(formattedMessages[1].id); 51 | }); 52 | 53 | describe("mapped state messages", () => { 54 | it("creates a new message id", () => { 55 | expect(formattedMessages[0].id).not.toBeUndefined(); 56 | }); 57 | 58 | it("exposes the type of event in the content", () => { 59 | expect(formattedMessages[0].content).toContain("Component test has updated its state"); 60 | }); 61 | 62 | it("exposes the name in the content", () => { 63 | expect(formattedMessages[0].content).toContain("Component Name: test component name"); 64 | }); 65 | 66 | it("exposes the props in the content", () => { 67 | expect(formattedMessages[0].content).toContain('Component props: {"test":"test"}'); 68 | }); 69 | 70 | it("exposes the prompt in the content", () => { 71 | expect(formattedMessages[0].content).toContain("Component self-description: test component prompt"); 72 | }); 73 | }); 74 | 75 | describe("mapped event messages", () => { 76 | it("creates a new message id", () => { 77 | expect(formattedMessages[0].id).not.toBeUndefined(); 78 | }); 79 | 80 | it("maps an event message to its formatted content", () => { 81 | expect(formattedMessages[1].content).toEqual("Component test sent event test.\n Description was: test"); 82 | }); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a conversation between a user and an AI. 3 | */ 4 | export type Conversation = { 5 | /** 6 | * Unique identifier for the conversation. 7 | */ 8 | id: string; 9 | /** 10 | * Timestamp when the conversation was started. 11 | */ 12 | createdAt: Date; 13 | /** 14 | * Timestamp when the last message was sent in the conversation. 15 | */ 16 | lastSentAt: Date; 17 | }; 18 | 19 | /** 20 | * Represents the state of a component. 21 | */ 22 | export type ComponentState = { 23 | /** 24 | * Unique identifier for the component. 25 | */ 26 | componentId?: string; 27 | /** 28 | * Display name for the component. 29 | */ 30 | name?: string; 31 | /** 32 | * A short description of the component. 33 | */ 34 | prompt?: string; 35 | /** 36 | * Additional properties of the component. 37 | */ 38 | props?: { 39 | [key: string]: any; 40 | }; 41 | /** 42 | * If true, this component does not have any state. 43 | */ 44 | noState?: boolean; 45 | }; 46 | 47 | /** 48 | * Represents an event that was sent by a component. 49 | */ 50 | export type ComponentEvent = { 51 | /** 52 | * Unique identifier for the component that sent the event. 53 | */ 54 | componentId: string; 55 | /** 56 | * Type of the event. 57 | */ 58 | type: string; 59 | /** 60 | * Additional data associated with the event. 61 | */ 62 | data?: any; 63 | /** 64 | * A short description of the event. 65 | */ 66 | description?: string; 67 | }; 68 | 69 | /** 70 | * Represents an event that was sent by a component, but with an optional componentId. 71 | */ 72 | export type OptionalComponentEvent = Omit & { componentId?: string }; 73 | 74 | /** 75 | * Base type for all messages sent between the user and the AI. 76 | */ 77 | export type BaseMessage = { 78 | /** 79 | * Unique identifier for the message. 80 | */ 81 | id: string; 82 | /** 83 | * Timestamp when the message was sent. 84 | */ 85 | createdAt: Date; 86 | }; 87 | 88 | /** 89 | * Represents a message that contains the state of a component. 90 | */ 91 | export type StateMessage = BaseMessage & { 92 | /** 93 | * Type of the message. 94 | */ 95 | type: "state"; 96 | /** 97 | * The state of the component. 98 | */ 99 | content: ComponentState; 100 | }; 101 | 102 | /** 103 | * Represents a message that contains an event sent by a component. 104 | */ 105 | export type EventMessage = BaseMessage & { 106 | /** 107 | * Type of the message. 108 | */ 109 | type: "event"; 110 | /** 111 | * The event sent by the component. 112 | */ 113 | content: ComponentEvent; 114 | }; 115 | 116 | /** 117 | * Represents a message that can be sent between the user and the AI. 118 | */ 119 | export type Message = StateMessage | EventMessage; 120 | 121 | /** 122 | * Represents a formatted message that can be displayed to the user or sent to the AI. 123 | * System messages like state and event messages are formatted into a string so that the LLM 124 | * can understand the component contents. 125 | */ 126 | export type FormattedMessage = { 127 | /** 128 | * Unique identifier for the message. 129 | */ 130 | id: string; 131 | /** 132 | * The content of the message. 133 | */ 134 | content: string; 135 | /** 136 | * The role of the message sender (e.g. user, assistant). 137 | */ 138 | role: string; 139 | }; 140 | -------------------------------------------------------------------------------- /src/ui/ChatBox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | /** 4 | * A reusable chat box component that accepts user input and submits it via a callback function. 5 | * 6 | * @param {boolean} autoFocus - Whether the input field should automatically focus when the component mounts. 7 | * @param {(message: string) => Promise} onSubmit - A callback function that handles the submission of the user's message. 8 | * @return {JSX.Element} The rendered chat box component. 9 | */ 10 | export function ChatBox({ 11 | autoFocus = false, 12 | placeholder = "Ask me anything...", 13 | sendButtonText = "Send", 14 | onSubmit, 15 | }: { 16 | autoFocus?: boolean; 17 | placeholder?: string | null; 18 | sendButtonText?: string; 19 | onSubmit: (message: string) => Promise; 20 | }) { 21 | return ( 22 |
{ 25 | e.preventDefault(); 26 | 27 | const messageEl = e.target.elements["message"]; 28 | 29 | // Blur focus on mobile 30 | if (window.innerWidth < 600) { 31 | messageEl?.blur(); 32 | } 33 | 34 | const submitSuccess = await onSubmit(messageEl?.value); 35 | 36 | if (submitSuccess) { 37 | messageEl.value = ""; 38 | } 39 | }} 40 | className="chatbox" 41 | > 42 |
43 | 46 | 54 |
55 | 58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/ui/ChatWrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useInformAIContext, dedupeMessages } from "../InformAIContext"; 4 | import { ChatBox } from "./ChatBox"; 5 | import { Messages, UserMessage } from "./Messages"; 6 | import clsx from "clsx"; 7 | import { mapComponentMessages, randomId } from "../utils"; 8 | import { FormattedMessage } from "../types"; 9 | 10 | export interface ChatWrapperProps { 11 | className?: string; 12 | submitUserMessage: (messages: any[]) => Promise; 13 | messages?: any[]; 14 | setMessages: (value: any[] | ((prevState: any[]) => any[])) => void; 15 | placeholder?: string; 16 | sendButtonText?: string; 17 | } 18 | 19 | /** 20 | * A basic chat wrapper that provides a chat box and message list. 21 | * Supports streaming LLM responses. 22 | * Sample usage, using the Vercel AI SDK: 23 | * 24 | * import { ChatWrapper } from "inform-ai"; 25 | * import { useActions, useUIState } from "ai/rsc"; 26 | * 27 | * export function MyCustomChatWrapper() { 28 | * const { submitUserMessage } = useActions(); 29 | * const [messages, setMessages] = useUIState(); 30 | * 31 | * return ( 32 | * 37 | * ); 38 | * } 39 | * 40 | * This assumes you have set up a Vercel AI Provider like this in a file called ./AI.tsx: 41 | * 42 | * "use server"; 43 | * 44 | * import { CoreMessage, generateId } from "ai"; 45 | * import { createInformAI } from "inform-ai"; 46 | * import { createAI } from "ai/rsc"; 47 | * import { submitUserMessage } from "../actions/AI"; 48 | * 49 | * export type ClientMessage = CoreMessage & { 50 | * id: string; 51 | * }; 52 | * 53 | * export type AIState = { 54 | * chatId: string; 55 | * messages: ClientMessage[]; 56 | * }; 57 | * 58 | * export type UIState = { 59 | * id: string; 60 | * role?: string; 61 | * content: React.ReactNode; 62 | * }[]; 63 | * 64 | * function submitUserMessage() { 65 | * //your implementation to send message to the LLM via Vercel AI SDK 66 | * //probably using the streamUI, streamText or similar functions 67 | * } 68 | * 69 | * export const AIProvider = createAI({ 70 | * actions: { 71 | * submitUserMessage, 72 | * }, 73 | * initialUIState: [] as UIState, 74 | * initialAIState: { chatId: generateId(), messages: [] } as AIState, 75 | * }); 76 | * 77 | * 78 | * @param {ChatWrapperProps} props - The properties of the ChatWrapper component. 79 | * @param {string} props.className - An optional class name for the component. 80 | * @param {function} props.submitUserMessage - A function to submit the user's message to the AI. 81 | * @param {any[]} props.messages - The current messages in the chat history. 82 | * @param {function} props.setMessages - A function to update the messages in the chat history. Can be async. 83 | * @return {JSX.Element} The JSX element representing the chat wrapper. 84 | */ 85 | export function ChatWrapper({ 86 | className, 87 | submitUserMessage, 88 | messages = [], 89 | setMessages, 90 | placeholder, 91 | sendButtonText, 92 | }: ChatWrapperProps) { 93 | const { popRecentMessages } = useInformAIContext(); 94 | 95 | async function onMessage(message: string) { 96 | const componentMessages = popRecentMessages(); 97 | 98 | //deduped set of component-generated messages like state updates and events, since the last user message 99 | const newSystemMessages = mapComponentMessages(dedupeMessages(componentMessages)); 100 | 101 | //this is the new user message that will be sent to the AI 102 | const newUserMessage: FormattedMessage = { id: randomId(10), content: message, role: "user" }; 103 | 104 | //the new user message UI that will be added to the chat history 105 | const newUserMessageUI = { ...newUserMessage, content: }; 106 | setMessages([...messages, ...newSystemMessages, newUserMessageUI]); 107 | 108 | //send the new user message to the AI, along with the all the recent messages from components 109 | const responseMessage = await submitUserMessage([...newSystemMessages, newUserMessage]); 110 | 111 | //update the UI with whatever the AI responded with 112 | setMessages((currentMessages: any[]) => [...currentMessages, { ...responseMessage, role: "assistant" }]); 113 | 114 | //return true to clear the chat box 115 | return true; 116 | } 117 | 118 | return ( 119 |
120 | 121 | 122 |
123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /src/ui/CurrentState.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useInformAIContext } from "../InformAIContext"; 3 | import clsx from "clsx"; 4 | import type { Message } from "../types"; 5 | import { useState } from "react"; 6 | 7 | import JsonView from "react18-json-view"; 8 | import "react18-json-view/src/style.css"; 9 | 10 | /** 11 | * Displays the current state of the InformAI conversation, including sent and unsent messages. 12 | * 13 | * @param {object} props - The component props. 14 | * @param {string} props.className - An optional CSS class name to apply to the component. 15 | * @return {JSX.Element} The rendered component. 16 | */ 17 | export function CurrentState({ className }: { className?: string }) { 18 | const { 19 | messages, 20 | conversation: { lastSentAt }, 21 | } = useInformAIContext(); 22 | 23 | const [collapsed, setCollapsed] = useState(false); 24 | 25 | return ( 26 |
27 |

setCollapsed(!collapsed)}> 28 | Current InformAI State{" "} 29 | 30 | 31 | 32 |

33 | 34 | {collapsed ? null : } 35 |
36 | ); 37 | } 38 | 39 | function CurrentStateRows({ messages, lastSentAt }: { messages: Message[]; lastSentAt: Date }) { 40 | if (!messages || messages.length === 0) { 41 | return

No messages yet. Use useInformAI() or add an InformAI component

; 42 | } 43 | 44 | const sentMessages = messages.filter((message) => message.createdAt < lastSentAt); 45 | const unsentMessages = messages.filter((message) => message.createdAt >= lastSentAt); 46 | 47 | return ( 48 |
49 | {sentMessages.map((message, index) => ( 50 | 51 | ))} 52 | 53 | {unsentMessages.map((message, index) => ( 54 | 55 | ))} 56 |
57 | ); 58 | } 59 | 60 | function Chevron({ collapsed }: { collapsed: boolean }) { 61 | return ( 62 | 71 | 72 | 76 | 80 | 81 | 82 | ); 83 | } 84 | 85 | /** 86 | * Displays a divider indicating the last sent message in the InformAI conversation. 87 | * 88 | * @return {JSX.Element} The rendered divider component. 89 | */ 90 | export function LastSentDivider() { 91 | const { 92 | conversation: { lastSentAt }, 93 | } = useInformAIContext(); 94 | 95 | return ( 96 |
97 |
98 |

99 | Last Sent: {lastSentAt.getHours()}:{lastSentAt.getMinutes().toString().padStart(2, "0")} 100 |

101 |
102 |
103 | ); 104 | } 105 | 106 | /** 107 | * Renders a pill-shaped element displaying the type of a given message. 108 | * 109 | * @param {Object} props - The component props. 110 | * @param {Message} props.message - The message object. 111 | * @return {JSX.Element} The rendered pill element. 112 | */ 113 | export function MessageTypePill({ message }: { message: Message }) { 114 | return {message.type}; 115 | } 116 | 117 | /** 118 | * Displays the name of a component based on the provided message. 119 | * 120 | * @param {Message} message - The message containing the component name or type. 121 | * @return {JSX.Element} The rendered component name as a paragraph element. 122 | */ 123 | export function ComponentName({ message }: { message: Message }) { 124 | const name = message.type === "state" ? message.content.name : message.content.type; 125 | 126 | return

{name}

; 127 | } 128 | 129 | /** 130 | * Displays a single message in the InformAI conversation. 131 | * 132 | * @param {Message} message - The message to be displayed. 133 | * @return {JSX.Element} The rendered message component. 134 | */ 135 | export function Row({ message }: { message: Message }) { 136 | const [expanded, setExpanded] = useState(false); 137 | 138 | const toggleExpanded = () => { 139 | setExpanded(!expanded); 140 | }; 141 | 142 | return ( 143 |
144 |
145 | 146 | 147 | 148 |
149 |
150 |

{message.createdAt.toLocaleTimeString()}

151 |
152 | {expanded && ( 153 |
154 |           
155 |         
156 | )} 157 |
158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /src/ui/InformAI.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useInformAI } from "../useInformAI"; 4 | 5 | /** 6 | * A component for relaying information about a component to a LLM via InformAI. 7 | * Sample usage: 8 | * 9 | * import { InformAI } from "inform-ai"; 10 | * 11 | * //inside your component's template 12 | * 13 | * 14 | * This is equivalent to calling the useInformAI hook in the component's code. The InformAI component can be used 15 | * in either client side React components or React Server Components. 16 | * 17 | * @param {string} name - The name of the component. Passed to the LLM so make it meaningful 18 | * @param {string} prompt - The prompt to pass to the LLM describing the component 19 | * @param {any} props - Props that will be sent to the LLM as part of the prompt. Must be JSON serializable 20 | * @param {string} [componentId] - A unique identifier for the component (one will be generated if not provided) 21 | * @returns {null} 22 | */ 23 | export function InformAI({ 24 | name, 25 | prompt, 26 | props, 27 | componentId, 28 | }: { 29 | name: string; 30 | prompt: string; 31 | props: any; 32 | componentId?: string; 33 | }) { 34 | useInformAI({ 35 | name, 36 | prompt, 37 | props, 38 | componentId, 39 | }); 40 | 41 | return null; 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/Messages.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import clsx from "clsx"; 4 | import { useStreamableContent } from "../useStreamableContent"; 5 | 6 | export type Role = "assistant" | "user" | "system"; 7 | 8 | /** 9 | * A component for rendering a list of messages, filtered by role. 10 | * 11 | * @param {any[]} messages - The list of messages to render. 12 | * @param {Role[]} [showRoles=["user", "assistant"]] - The roles of messages to show. 13 | * @param {string} [className=""] - Additional CSS class names for the component. 14 | * @return {JSX.Element|null} The rendered message list, or null if no messages are provided. 15 | */ 16 | export function Messages({ 17 | messages, 18 | showRoles = ["user", "assistant"], 19 | className = "", 20 | }: { 21 | messages: any[]; 22 | showRoles?: Role[]; 23 | className?: string; 24 | }) { 25 | if (!messages || messages.length === 0) { 26 | return null; 27 | } 28 | 29 | return ( 30 |
31 | {messages 32 | .filter((message) => showRoles.includes(message.role)) 33 | .map((message, index) => ( 34 |
{message.content}
35 | ))} 36 |
37 | ); 38 | } 39 | 40 | /** 41 | * A component for rendering a user message. 42 | * 43 | * @param {string} message - The content of the user message. 44 | * @return {JSX.Element} The rendered user message component. 45 | */ 46 | export function UserMessage({ message }: { message: string }) { 47 | return ( 48 |
49 |
{message}
50 |
51 | ); 52 | } 53 | 54 | /** 55 | * A component for rendering an assistant message. Supports streaming. 56 | * 57 | * @param {any} content - The content of the assistant message. 58 | * @return {JSX.Element} The rendered assistant message component. 59 | */ 60 | export function AssistantMessage({ content }: { content: any }) { 61 | const text = useStreamableContent(content); 62 | 63 | return ( 64 |
65 |
{text}
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ChatBox"; 2 | export * from "./CurrentState"; 3 | export * from "./Messages"; 4 | export * from "./ChatWrapper"; 5 | export * from "./InformAI"; 6 | -------------------------------------------------------------------------------- /src/ui/main.css: -------------------------------------------------------------------------------- 1 | @tailwind components; 2 | 3 | /* ChatBox styles*/ 4 | @layer components { 5 | .chatbox { 6 | @apply flex gap-2; 7 | 8 | label { 9 | @apply sr-only; 10 | } 11 | } 12 | .chatbox-wrap { 13 | @apply flex-1; 14 | } 15 | 16 | .chatbox-input { 17 | @apply px-4 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6; 18 | } 19 | 20 | .chatbox-button { 21 | @apply px-4 py-1.5 bg-blue-500 text-white rounded-md shadow-sm hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm sm:leading-5; 22 | } 23 | } 24 | 25 | /* Messages styles */ 26 | @layer components { 27 | .iai-messages { 28 | @apply flex flex-col gap-2 overflow-y-auto; 29 | 30 | .user-message { 31 | @apply flex justify-end; 32 | 33 | .inner { 34 | @apply max-w-96 border border-blue-200 bg-blue-200; 35 | } 36 | } 37 | 38 | .assistant-message { 39 | @apply flex justify-start; 40 | 41 | .inner { 42 | @apply max-w-3xl border border-gray-200 bg-gray-50 whitespace-pre-wrap; 43 | } 44 | } 45 | 46 | .inner { 47 | @apply px-4 py-2 rounded-lg; 48 | } 49 | } 50 | } 51 | 52 | /* CurrentState styles */ 53 | @layer components { 54 | .current-state { 55 | @apply border border-gray-300 flex flex-col gap-2 rounded-lg bg-white p-2 pr-0; 56 | 57 | .rows { 58 | @apply overflow-auto; 59 | } 60 | 61 | h1 { 62 | @apply text-lg font-semibold cursor-pointer my-0; 63 | 64 | .toggle { 65 | @apply float-right mr-4 font-normal pt-2; 66 | } 67 | } 68 | 69 | .last-sent-divider { 70 | @apply flex justify-center items-center; 71 | 72 | .last-sent-border { 73 | @apply h-0.5 w-1/2 bg-gray-300; 74 | } 75 | 76 | p { 77 | @apply mx-2 text-sm text-gray-500 whitespace-nowrap; 78 | } 79 | } 80 | 81 | .row { 82 | @apply flex flex-col pb-1; 83 | 84 | > .heading { 85 | @apply flex items-center pr-2; 86 | 87 | p { 88 | @apply flex-1 font-bold pl-1; 89 | } 90 | } 91 | 92 | pre { 93 | @apply ml-2 overflow-x-auto; 94 | } 95 | } 96 | 97 | .pill { 98 | @apply mt-0.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset; 99 | 100 | &.state { 101 | @apply text-green-700 bg-green-50 ring-green-600/20; 102 | } 103 | 104 | &.event { 105 | @apply text-orange-600 bg-orange-50 ring-orange-500/10; 106 | } 107 | } 108 | } 109 | 110 | .chevron { 111 | width: 18px; 112 | height: 15px; 113 | 114 | .chevron__group { 115 | transform: translateY(0); 116 | transition: transform 0.1s linear; 117 | } 118 | 119 | .chevron__box--left, 120 | .chevron__box--right { 121 | transform: rotate(0) translateY(0); 122 | transition: transform 0.1s linear; 123 | } 124 | 125 | .chevron__box--left { 126 | transform-origin: 1px 1px; 127 | } 128 | 129 | .chevron__box--right { 130 | transform-origin: 7px 1px; 131 | } 132 | 133 | &.chevron--flip .chevron__box--left { 134 | transform: rotate(-90deg) translateY(0); 135 | } 136 | 137 | &.chevron--flip .chevron__box--right { 138 | transform: rotate(90deg) translateY(0); 139 | } 140 | 141 | &.chevron--flip .chevron__group { 142 | transform: translateY(3px); 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/useInformAI.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useMemo, useRef } from "react"; 4 | import { randomId } from "./utils"; 5 | 6 | import { useInformAIContext } from "./InformAIContext"; 7 | import { StateMessage, EventMessage, Message, ComponentState, ComponentEvent, OptionalComponentEvent } from "./types"; 8 | 9 | /** 10 | * Represents the hook returned by the `useInformAI` function. This hook provides 11 | * methods to interact with the Inform AI context. 12 | */ 13 | export interface UseInformAIHook { 14 | /** 15 | * The ID of the component associated with this hook. 16 | */ 17 | componentId: string; 18 | 19 | /** 20 | * Adds a message to the Inform AI context. 21 | * 22 | * @param message - The message to add. 23 | */ 24 | addMessage: (message: Message) => void; 25 | 26 | /** 27 | * Adds an event to the Inform AI context. 28 | * 29 | * @param event - The event to add. 30 | */ 31 | addEvent: (event: OptionalComponentEvent) => void; 32 | 33 | /** 34 | * Adds an event message to the Inform AI context. 35 | * 36 | * @param event - The event message to add. 37 | */ 38 | addEventMessage: (event: EventMessage) => void; 39 | 40 | /** 41 | * Adds a state to the Inform AI context. 42 | * 43 | * @param state - The state to add. 44 | */ 45 | addState: (state: ComponentState) => void; 46 | 47 | /** 48 | * Adds a state message to the Inform AI context. 49 | * 50 | * @param state - The state message to add. 51 | */ 52 | addStateMessage: (state: StateMessage) => void; 53 | 54 | /** 55 | * Updates the state of the component associated with this hook. 56 | * 57 | * @param updates - The updates to apply to the state. 58 | */ 59 | updateState: (updates: ComponentState) => void; 60 | } 61 | 62 | /** 63 | * A hook to interact with the Inform AI context. 64 | * 65 | * Sample usage: 66 | * 67 | * import { useInformAI } from "use-inform-ai"; 68 | * 69 | * //inside your component: 70 | * useInformAI({name: "Logs", prompt: "This component displays backup logs in a monospace font", props: {data, logs}}); 71 | * 72 | * @param {ComponentState} componentData - The data for the component. 73 | * @return {UseInformAIHook} An object with methods to add messages, events, and states to the Inform AI context. 74 | */ 75 | export function useInformAI(componentData: ComponentState = {}): UseInformAIHook { 76 | const context = useInformAIContext(); 77 | const componentIdRef = useRef(componentData.componentId || randomId(6)); 78 | const componentId = componentIdRef.current; 79 | 80 | // Memoize componentData to ensure stability 81 | const stableComponentData = useMemo(() => componentData, [JSON.stringify(componentData)]); 82 | 83 | useEffect(() => { 84 | if (!componentData.noState) { 85 | context.addState({ 86 | componentId: componentIdRef.current, 87 | prompt: stableComponentData.prompt, 88 | props: stableComponentData.props, 89 | name: stableComponentData.name, 90 | }); 91 | } 92 | }, [componentId, stableComponentData]); 93 | 94 | return { 95 | componentId, 96 | addMessage: (message: Message) => { 97 | return context.addMessage(message); 98 | }, 99 | addEvent: (event: OptionalComponentEvent) => { 100 | const updatedEvent: ComponentEvent = { 101 | ...event, 102 | componentId: event.componentId || componentIdRef.current, 103 | }; 104 | return context.addEvent(updatedEvent); 105 | }, 106 | addEventMessage: (event: EventMessage) => { 107 | context.addEventMessage(event); 108 | }, 109 | addState: (state: ComponentState) => { 110 | state.componentId ||= componentIdRef.current; 111 | 112 | return context.addState(state); 113 | }, 114 | addStateMessage: (state: StateMessage) => { 115 | return context.addStateMessage(state); 116 | }, 117 | updateState: (updates: ComponentState) => { 118 | return context.updateState(componentIdRef.current, updates); 119 | }, 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /src/useStreamableContent.tsx: -------------------------------------------------------------------------------- 1 | import { StreamableValue, readStreamableValue } from "ai/rsc"; 2 | import { useEffect, useState } from "react"; 3 | 4 | /** 5 | * A hook to handle streamable content, allowing it to be used in a React component. Accepts either a string or a StreamableValue. 6 | * 7 | * @param {string | StreamableValue} content - The content to be streamed, either as a string or a StreamableValue. 8 | * @return {string} The raw content, updated as the streamable content is received. 9 | */ 10 | export const useStreamableContent = (content: string | StreamableValue) => { 11 | const [rawContent, setRawContent] = useState(typeof content === "string" ? content : ""); 12 | 13 | useEffect(() => { 14 | (async () => { 15 | if (typeof content === "object") { 16 | let value = ""; 17 | for await (const delta of readStreamableValue(content)) { 18 | if (typeof delta === "string") { 19 | setRawContent(delta); 20 | } 21 | } 22 | } 23 | })(); 24 | }, [content]); 25 | 26 | return rawContent; 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils.tsx: -------------------------------------------------------------------------------- 1 | import { EventMessage, Message, StateMessage, FormattedMessage } from "./types"; 2 | 3 | import { v4 as uuidv4 } from "uuid"; 4 | 5 | /** 6 | * Generates a random identifier of a specified length. 7 | * 8 | * @param {number} length - The length of the identifier to be generated (default is 8). 9 | * @return {string} A random identifier of the specified length. 10 | */ 11 | export function randomId(length = 8) { 12 | return uuidv4().replace(/-/g, "").slice(0, length); 13 | } 14 | 15 | /** 16 | * Maps an array of component messages to an array of formatted messages. 17 | * @param componentMessages - The array of component messages to be mapped. 18 | * @returns An array of formatted messages. 19 | */ 20 | export function mapComponentMessages(componentMessages: Message[]): FormattedMessage[] { 21 | return componentMessages.map((message) => { 22 | return { 23 | id: randomId(10), 24 | content: message.type === "event" ? mapEventToContent(message) : mapStateToContent(message), 25 | role: "system", 26 | }; 27 | }); 28 | } 29 | 30 | /** 31 | * Maps an event message to its formatted content. 32 | * @param event - The event message to be mapped. 33 | * @returns The formatted content of the event message. 34 | */ 35 | export function mapEventToContent(event: EventMessage) { 36 | return `Component ${event.content.componentId} sent event ${event.content.type}. 37 | Description was: ${event.content.description}`; 38 | } 39 | 40 | /** 41 | * Maps a state message to its formatted content. 42 | * @param state - The state message to be mapped. 43 | * @returns The formatted content of the state message. 44 | */ 45 | export function mapStateToContent(state: StateMessage) { 46 | const content = []; 47 | 48 | const { name, componentId, prompt, props } = state.content; 49 | 50 | content.push(`Component ${componentId} has updated its state`); 51 | 52 | if (name) { 53 | content.push(`Component Name: ${name}`); 54 | } 55 | 56 | if (prompt) { 57 | content.push(`Component self-description: ${prompt}`); 58 | } 59 | 60 | if (props) { 61 | content.push(`Component props: ${JSON.stringify(props)}`); 62 | } 63 | 64 | return content.join("\n"); 65 | } 66 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const config = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["jest", "@testing-library/jest-dom"], 4 | "target": "ES2022", 5 | "module": "ESNext", 6 | "lib": ["ES2022", "DOM"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "outDir": "dist", 11 | "rootDir": "src", 12 | "declaration": true, 13 | "declarationDir": "./dist/types", 14 | "moduleResolution": "node", 15 | "jsx": "react-jsx", 16 | "sourceMap": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | --------------------------------------------------------------------------------