├── .github └── workflows │ └── release.yml ├── .gitignore ├── .placeholder ├── README.md ├── index.js └── package.json ├── .vscode ├── cspell.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── VERSION ├── demo ├── backend │ ├── express │ │ ├── .gitignore │ │ ├── package.json │ │ ├── src │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── yarn.lock │ ├── next │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── next.config.js │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── public │ │ │ └── .gitkeep │ │ ├── src │ │ │ ├── app │ │ │ │ ├── favicon.ico │ │ │ │ ├── globals.css │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ └── pages │ │ │ │ └── api │ │ │ │ └── beak │ │ │ │ └── [...path].ts │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── yarn.lock │ └── remix │ │ ├── .eslintrc.cjs │ │ ├── .gitignore │ │ ├── README.md │ │ ├── app │ │ ├── entry.client.tsx │ │ ├── entry.server.tsx │ │ ├── root.tsx │ │ ├── routes │ │ │ ├── _index.tsx │ │ │ └── beak.$.tsx │ │ └── styles │ │ │ └── global.css │ │ ├── package.json │ │ ├── public │ │ └── favicon.ico │ │ ├── remix.config.js │ │ ├── remix.env.d.ts │ │ ├── tsconfig.json │ │ └── yarn.lock └── frontend │ └── presentation │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ └── .gitkeep │ ├── src │ ├── App.css │ ├── App.tsx │ ├── index.css │ ├── main.tsx │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── yarn.lock ├── docs ├── deployment │ ├── express.md │ ├── next.md │ └── remix.md └── img │ └── screenshot.png ├── package.json ├── packages ├── core │ ├── package.json │ ├── src │ │ ├── beak.ts │ │ ├── index.ts │ │ ├── openai.ts │ │ └── types.ts │ ├── tsconfig.cjs.json │ └── tsconfig.esm.json ├── css │ ├── Assistant.css │ ├── Button.css │ ├── Header.css │ ├── Input.css │ ├── Messages.css │ ├── Window.css │ └── index.css ├── express │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── next │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── openai │ ├── package.json │ ├── src │ │ ├── chat.ts │ │ ├── index.ts │ │ ├── openai.ts │ │ └── types.ts │ ├── test │ │ ├── chat.test.ts │ │ ├── openai.test.ts │ │ └── utils.ts │ ├── tsconfig.cjs.json │ └── tsconfig.esm.json ├── react │ ├── package.json │ ├── src │ │ ├── Assistant.tsx │ │ ├── Beak.tsx │ │ ├── Button.tsx │ │ ├── Header.tsx │ │ ├── Icons.tsx │ │ ├── Input.tsx │ │ ├── Messages.tsx │ │ ├── Textarea.tsx │ │ ├── Window.tsx │ │ ├── hooks.ts │ │ ├── index.ts │ │ └── props.ts │ ├── tsconfig.json │ └── vite.config.ts ├── remix │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json └── server │ ├── package.json │ ├── src │ └── index.ts │ └── tsconfig.json ├── scripts └── sync-versions.ts ├── tsconfig.json └── yarn.lock /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: "20" 17 | registry-url: "https://registry.npmjs.org" 18 | - name: Install dependencies 19 | run: | 20 | yarn install 21 | - name: Build 22 | run: | 23 | yarn build 24 | - name: Publish (openai) 25 | run: | 26 | yarn workspace @beakjs/openai publish --access public 27 | env: 28 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | - name: Publish (core) 30 | run: | 31 | yarn workspace @beakjs/core publish --access public 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | - name: Publish (react) 35 | run: | 36 | yarn workspace @beakjs/react publish --access public 37 | env: 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | - name: Publish (server) 40 | run: | 41 | yarn workspace @beakjs/server publish --access public 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | - name: Publish (next) 45 | run: | 46 | yarn workspace @beakjs/next publish --access public 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | - name: Publish (next) 50 | run: | 51 | yarn workspace @beakjs/remix publish --access public 52 | env: 53 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | - name: Publish (express) 55 | run: | 56 | yarn workspace @beakjs/express publish --access public 57 | env: 58 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /dist 3 | 4 | # OS files 5 | .DS_Store 6 | Thumbs.db 7 | .cache 8 | 9 | TODO.md 10 | TODO.later.md 11 | 12 | packages/*/dist -------------------------------------------------------------------------------- /.placeholder/README.md: -------------------------------------------------------------------------------- 1 | # Placeholder Package 2 | 3 | This package is a placeholder. 4 | 5 | Please use [@beakjs/react](https://www.npmjs.com/package/@beakjs/react) instead. 6 | -------------------------------------------------------------------------------- /.placeholder/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mme/beakjs/c837e84da19082ae6ed977381548efb74930d678/.placeholder/index.js -------------------------------------------------------------------------------- /.placeholder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beakjs", 3 | "version": "0.0.4", 4 | "description": "This is a placeholder package.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/mme/beakjs.git" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC" 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "words": [ 5 | "langchain", 6 | "openai", 7 | "beakjs", 8 | "tailwindcss", 9 | "headlessui", 10 | "Markus", 11 | "Ecker", 12 | "autosize", 13 | "jsonrepair", 14 | "chatcmpl", 15 | "uuidv4", 16 | "Hola", 17 | "Mundo", 18 | "OpenAIAPIKey", 19 | "nextjs", 20 | "typecheck", 21 | "isbot" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Debug Demo", 11 | "url": "http://localhost:5173", 12 | "webRoot": "${workspaceFolder}/demo/presentation" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.cache": true, 4 | "**/node_modules": true, 5 | ".placeholder": true, 6 | "packages/*/dist": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Markus Ecker 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🐦 Beak.js [![X (formerly Twitter) Follow](https://img.shields.io/twitter/follow/mme_xyz?style=flat&logo=x)](https://twitter.com/mme_xyz) [![npm (scoped)](https://img.shields.io/npm/v/%40beakjs/react)](https://www.npmjs.com/package/@beakjs/react) 2 | 3 | Beak.js contains everything you need to create custom AI-powered assistants for your React app. 4 | 5 | **Key Features:** 6 | 7 | - **Built-in UI** - Comes with a beautiful, fully customizable chat window. 8 | - **Easy to Use** - Integrates with your existing React app in just a few lines of code. 9 | - **Open Source** - Beak.js is open source and free to use. 10 | 11 | Beak.js Screenshot 12 | 13 | ## Getting Started 14 | 15 | ### Installation 16 | 17 | First up, add Beak.js to your project: 18 | 19 | ```bash 20 | npm install @beakjs/react --save 21 | 22 | # or with yarn 23 | yarn add @beakjs/react 24 | ``` 25 | 26 | ### Setup 27 | 28 | Next, wrap your app in the `Beak` component and add the assistant window: 29 | 30 | ```jsx 31 | import { Beak } from "@beakjs/react"; 32 | 33 | const App = () => ( 34 | 38 | 39 | 40 | 41 | ); 42 | ``` 43 | 44 | Now, you've got a chat window ready in the bottom right corner of your website. Give it a try! 45 | 46 | **Note:** Don't expose your API key in public-facing apps - this is for development only. See [Deployment](#deployment) for information on how to securely deploy your app without compromising your API key. 47 | 48 | ### Making Beak.js work with your app 49 | 50 | You can let the assistant carry out tasks in your app by setting up functions with `useBeakFunction`: 51 | 52 | ```jsx 53 | import { useBeakFunction } from "@beakjs/react"; 54 | 55 | const MyApp = () => { 56 | const [message, setMessage] = useState("Hello World!"); 57 | 58 | useBeakFunction({ 59 | name: "updateMessage", 60 | description: "This function updates the app's display message.", 61 | parameters: { 62 | message: { 63 | description: "A short message to display on screen.", 64 | }, 65 | }, 66 | handler: ({ message }) => { 67 | setMessage(message); 68 | 69 | return `Updated the message to: "${message}"`; 70 | }, 71 | }); 72 | 73 | return
{message}
; 74 | }; 75 | ``` 76 | 77 | Note that functions become available to the assistant as soon as their respective component is mounted. This is a powerful concept, ensuring that the assistant will be able to call functions relevant to the current context of your app. 78 | 79 | ### Let the Assistant Know What's Happening On Screen 80 | 81 | You can easily let the assistant know what it currently on screen by using `useBeakInfo`: 82 | 83 | ```jsx 84 | import { useBeakInfo } from "@beakjs/react"; 85 | 86 | const MyApp = () => { 87 | const [message, setMessage] = useState("Hello World!"); 88 | 89 | useBeakInfo("current message", message); 90 | 91 | // ... 92 | }; 93 | ``` 94 | 95 | By using `useBeakFunction` together with `useBeakInfo`, your assistant can see what's happening on the screen and take action within your app depending on the current context. 96 | 97 | ## Deployment 98 | 99 | To keep your API key safe, we use a server side handler that forwards your assistant's requests to OpenAI. Furthermore, this handler can be used to add authentication and rate limiting to your assistant. 100 | 101 | Currently, the following frameworks are supported: 102 | 103 | - [Next.js](/docs/deployment/next.md) 104 | - [Remix](/docs/deployment/remix.md) 105 | - [Express](/docs/deployment/express.md) 106 | 107 | Read more about secure deployment by clicking the links above. 108 | 109 | ## Run the Demo 110 | 111 | To run the demo, build the project and start the demo app: 112 | 113 | ```bash 114 | git clone git@github.com:mme/beakjs.git && cd beakjs 115 | yarn && yarn build 116 | cd demo/frontend/presentation 117 | echo "VITE_OPENAI_API_KEY=sk-your-openai-key" > .env 118 | yarn && yarn dev 119 | ``` 120 | 121 | Then go to http://localhost:5173/ to see the demo. 122 | 123 | ## Issues 124 | 125 | Feel free to submit issues and enhancement requests. 126 | 127 | ## License 128 | 129 | MIT 130 | 131 | Copyright (c) 2023, Markus Ecker 132 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.6 -------------------------------------------------------------------------------- /demo/backend/express/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | dist -------------------------------------------------------------------------------- /demo/backend/express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-backend-express", 3 | "version": "0.0.6", 4 | "main": "dist/index.js", 5 | "repository": "https://github.com/mme/beakjs.git", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc", 9 | "clean": "rm -rf node_modules && rm -rf .next && rm -rf dist", 10 | "start": "node dist/index.js" 11 | }, 12 | "author": "Markus Ecker", 13 | "license": "MIT", 14 | "private": true, 15 | "dependencies": { 16 | "@beakjs/express": "0.0.6", 17 | "dotenv": "^16.3.1", 18 | "express": "^4.18.2" 19 | }, 20 | "devDependencies": { 21 | "@types/express": "^4.17.21", 22 | "typescript": "^5.0.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/backend/express/src/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { beakHandler } from "@beakjs/express"; 3 | import dotenv from "dotenv"; 4 | dotenv.config(); 5 | 6 | const app = express(); 7 | const port = 3000; 8 | 9 | app.use(express.json()); 10 | app.use("/beak", beakHandler()); 11 | 12 | app.listen(port, () => { 13 | console.log(`Server running on http://localhost:${port}`); 14 | }); 15 | -------------------------------------------------------------------------------- /demo/backend/express/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist", 11 | "lib": ["es2019"], 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": false, 18 | "types": ["node"] 19 | }, 20 | "include": ["src"], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | -------------------------------------------------------------------------------- /demo/backend/express/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/body-parser@*": 6 | version "1.19.5" 7 | resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" 8 | integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== 9 | dependencies: 10 | "@types/connect" "*" 11 | "@types/node" "*" 12 | 13 | "@types/connect@*": 14 | version "3.4.38" 15 | resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" 16 | integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== 17 | dependencies: 18 | "@types/node" "*" 19 | 20 | "@types/express-serve-static-core@^4.17.33": 21 | version "4.17.41" 22 | resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6" 23 | integrity sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA== 24 | dependencies: 25 | "@types/node" "*" 26 | "@types/qs" "*" 27 | "@types/range-parser" "*" 28 | "@types/send" "*" 29 | 30 | "@types/express@^4.17.21": 31 | version "4.17.21" 32 | resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" 33 | integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== 34 | dependencies: 35 | "@types/body-parser" "*" 36 | "@types/express-serve-static-core" "^4.17.33" 37 | "@types/qs" "*" 38 | "@types/serve-static" "*" 39 | 40 | "@types/http-errors@*": 41 | version "2.0.4" 42 | resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" 43 | integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== 44 | 45 | "@types/mime@*": 46 | version "3.0.4" 47 | resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45" 48 | integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw== 49 | 50 | "@types/mime@^1": 51 | version "1.3.5" 52 | resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" 53 | integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== 54 | 55 | "@types/node@*": 56 | version "20.9.2" 57 | resolved "https://registry.yarnpkg.com/@types/node/-/node-20.9.2.tgz#002815c8e87fe0c9369121c78b52e800fadc0ac6" 58 | integrity sha512-WHZXKFCEyIUJzAwh3NyyTHYSR35SevJ6mZ1nWwJafKtiQbqRTIKSRcw3Ma3acqgsent3RRDqeVwpHntMk+9irg== 59 | dependencies: 60 | undici-types "~5.26.4" 61 | 62 | "@types/qs@*": 63 | version "6.9.10" 64 | resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.10.tgz#0af26845b5067e1c9a622658a51f60a3934d51e8" 65 | integrity sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw== 66 | 67 | "@types/range-parser@*": 68 | version "1.2.7" 69 | resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" 70 | integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== 71 | 72 | "@types/send@*": 73 | version "0.17.4" 74 | resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" 75 | integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== 76 | dependencies: 77 | "@types/mime" "^1" 78 | "@types/node" "*" 79 | 80 | "@types/serve-static@*": 81 | version "1.15.5" 82 | resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033" 83 | integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ== 84 | dependencies: 85 | "@types/http-errors" "*" 86 | "@types/mime" "*" 87 | "@types/node" "*" 88 | 89 | accepts@~1.3.8: 90 | version "1.3.8" 91 | resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" 92 | integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== 93 | dependencies: 94 | mime-types "~2.1.34" 95 | negotiator "0.6.3" 96 | 97 | array-flatten@1.1.1: 98 | version "1.1.1" 99 | resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" 100 | integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== 101 | 102 | body-parser@1.20.1: 103 | version "1.20.1" 104 | resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" 105 | integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== 106 | dependencies: 107 | bytes "3.1.2" 108 | content-type "~1.0.4" 109 | debug "2.6.9" 110 | depd "2.0.0" 111 | destroy "1.2.0" 112 | http-errors "2.0.0" 113 | iconv-lite "0.4.24" 114 | on-finished "2.4.1" 115 | qs "6.11.0" 116 | raw-body "2.5.1" 117 | type-is "~1.6.18" 118 | unpipe "1.0.0" 119 | 120 | bytes@3.1.2: 121 | version "3.1.2" 122 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" 123 | integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== 124 | 125 | call-bind@^1.0.0: 126 | version "1.0.5" 127 | resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513" 128 | integrity sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ== 129 | dependencies: 130 | function-bind "^1.1.2" 131 | get-intrinsic "^1.2.1" 132 | set-function-length "^1.1.1" 133 | 134 | content-disposition@0.5.4: 135 | version "0.5.4" 136 | resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" 137 | integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== 138 | dependencies: 139 | safe-buffer "5.2.1" 140 | 141 | content-type@~1.0.4: 142 | version "1.0.5" 143 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" 144 | integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== 145 | 146 | cookie-signature@1.0.6: 147 | version "1.0.6" 148 | resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" 149 | integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== 150 | 151 | cookie@0.5.0: 152 | version "0.5.0" 153 | resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" 154 | integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== 155 | 156 | debug@2.6.9: 157 | version "2.6.9" 158 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" 159 | integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 160 | dependencies: 161 | ms "2.0.0" 162 | 163 | define-data-property@^1.1.1: 164 | version "1.1.1" 165 | resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3" 166 | integrity sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ== 167 | dependencies: 168 | get-intrinsic "^1.2.1" 169 | gopd "^1.0.1" 170 | has-property-descriptors "^1.0.0" 171 | 172 | depd@2.0.0: 173 | version "2.0.0" 174 | resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" 175 | integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== 176 | 177 | destroy@1.2.0: 178 | version "1.2.0" 179 | resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" 180 | integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== 181 | 182 | ee-first@1.1.1: 183 | version "1.1.1" 184 | resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" 185 | integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== 186 | 187 | encodeurl@~1.0.2: 188 | version "1.0.2" 189 | resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" 190 | integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== 191 | 192 | escape-html@~1.0.3: 193 | version "1.0.3" 194 | resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" 195 | integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== 196 | 197 | etag@~1.8.1: 198 | version "1.8.1" 199 | resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" 200 | integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== 201 | 202 | express@^4.18.2: 203 | version "4.18.2" 204 | resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" 205 | integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== 206 | dependencies: 207 | accepts "~1.3.8" 208 | array-flatten "1.1.1" 209 | body-parser "1.20.1" 210 | content-disposition "0.5.4" 211 | content-type "~1.0.4" 212 | cookie "0.5.0" 213 | cookie-signature "1.0.6" 214 | debug "2.6.9" 215 | depd "2.0.0" 216 | encodeurl "~1.0.2" 217 | escape-html "~1.0.3" 218 | etag "~1.8.1" 219 | finalhandler "1.2.0" 220 | fresh "0.5.2" 221 | http-errors "2.0.0" 222 | merge-descriptors "1.0.1" 223 | methods "~1.1.2" 224 | on-finished "2.4.1" 225 | parseurl "~1.3.3" 226 | path-to-regexp "0.1.7" 227 | proxy-addr "~2.0.7" 228 | qs "6.11.0" 229 | range-parser "~1.2.1" 230 | safe-buffer "5.2.1" 231 | send "0.18.0" 232 | serve-static "1.15.0" 233 | setprototypeof "1.2.0" 234 | statuses "2.0.1" 235 | type-is "~1.6.18" 236 | utils-merge "1.0.1" 237 | vary "~1.1.2" 238 | 239 | finalhandler@1.2.0: 240 | version "1.2.0" 241 | resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" 242 | integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== 243 | dependencies: 244 | debug "2.6.9" 245 | encodeurl "~1.0.2" 246 | escape-html "~1.0.3" 247 | on-finished "2.4.1" 248 | parseurl "~1.3.3" 249 | statuses "2.0.1" 250 | unpipe "~1.0.0" 251 | 252 | forwarded@0.2.0: 253 | version "0.2.0" 254 | resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" 255 | integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== 256 | 257 | fresh@0.5.2: 258 | version "0.5.2" 259 | resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" 260 | integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== 261 | 262 | function-bind@^1.1.2: 263 | version "1.1.2" 264 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" 265 | integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== 266 | 267 | get-intrinsic@^1.0.2, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2: 268 | version "1.2.2" 269 | resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.2.tgz#281b7622971123e1ef4b3c90fd7539306da93f3b" 270 | integrity sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA== 271 | dependencies: 272 | function-bind "^1.1.2" 273 | has-proto "^1.0.1" 274 | has-symbols "^1.0.3" 275 | hasown "^2.0.0" 276 | 277 | gopd@^1.0.1: 278 | version "1.0.1" 279 | resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" 280 | integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== 281 | dependencies: 282 | get-intrinsic "^1.1.3" 283 | 284 | has-property-descriptors@^1.0.0: 285 | version "1.0.1" 286 | resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz#52ba30b6c5ec87fd89fa574bc1c39125c6f65340" 287 | integrity sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg== 288 | dependencies: 289 | get-intrinsic "^1.2.2" 290 | 291 | has-proto@^1.0.1: 292 | version "1.0.1" 293 | resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" 294 | integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== 295 | 296 | has-symbols@^1.0.3: 297 | version "1.0.3" 298 | resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" 299 | integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== 300 | 301 | hasown@^2.0.0: 302 | version "2.0.0" 303 | resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c" 304 | integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA== 305 | dependencies: 306 | function-bind "^1.1.2" 307 | 308 | http-errors@2.0.0: 309 | version "2.0.0" 310 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" 311 | integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== 312 | dependencies: 313 | depd "2.0.0" 314 | inherits "2.0.4" 315 | setprototypeof "1.2.0" 316 | statuses "2.0.1" 317 | toidentifier "1.0.1" 318 | 319 | iconv-lite@0.4.24: 320 | version "0.4.24" 321 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" 322 | integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 323 | dependencies: 324 | safer-buffer ">= 2.1.2 < 3" 325 | 326 | inherits@2.0.4: 327 | version "2.0.4" 328 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 329 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 330 | 331 | ipaddr.js@1.9.1: 332 | version "1.9.1" 333 | resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" 334 | integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== 335 | 336 | media-typer@0.3.0: 337 | version "0.3.0" 338 | resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" 339 | integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== 340 | 341 | merge-descriptors@1.0.1: 342 | version "1.0.1" 343 | resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" 344 | integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== 345 | 346 | methods@~1.1.2: 347 | version "1.1.2" 348 | resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" 349 | integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== 350 | 351 | mime-db@1.52.0: 352 | version "1.52.0" 353 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" 354 | integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== 355 | 356 | mime-types@~2.1.24, mime-types@~2.1.34: 357 | version "2.1.35" 358 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" 359 | integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== 360 | dependencies: 361 | mime-db "1.52.0" 362 | 363 | mime@1.6.0: 364 | version "1.6.0" 365 | resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 366 | integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 367 | 368 | ms@2.0.0: 369 | version "2.0.0" 370 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 371 | integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== 372 | 373 | ms@2.1.3: 374 | version "2.1.3" 375 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 376 | integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 377 | 378 | negotiator@0.6.3: 379 | version "0.6.3" 380 | resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" 381 | integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== 382 | 383 | object-inspect@^1.9.0: 384 | version "1.13.1" 385 | resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" 386 | integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== 387 | 388 | on-finished@2.4.1: 389 | version "2.4.1" 390 | resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" 391 | integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== 392 | dependencies: 393 | ee-first "1.1.1" 394 | 395 | parseurl@~1.3.3: 396 | version "1.3.3" 397 | resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" 398 | integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== 399 | 400 | path-to-regexp@0.1.7: 401 | version "0.1.7" 402 | resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" 403 | integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== 404 | 405 | proxy-addr@~2.0.7: 406 | version "2.0.7" 407 | resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" 408 | integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== 409 | dependencies: 410 | forwarded "0.2.0" 411 | ipaddr.js "1.9.1" 412 | 413 | qs@6.11.0: 414 | version "6.11.0" 415 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" 416 | integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== 417 | dependencies: 418 | side-channel "^1.0.4" 419 | 420 | range-parser@~1.2.1: 421 | version "1.2.1" 422 | resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" 423 | integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== 424 | 425 | raw-body@2.5.1: 426 | version "2.5.1" 427 | resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" 428 | integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== 429 | dependencies: 430 | bytes "3.1.2" 431 | http-errors "2.0.0" 432 | iconv-lite "0.4.24" 433 | unpipe "1.0.0" 434 | 435 | safe-buffer@5.2.1: 436 | version "5.2.1" 437 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 438 | integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 439 | 440 | "safer-buffer@>= 2.1.2 < 3": 441 | version "2.1.2" 442 | resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" 443 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 444 | 445 | send@0.18.0: 446 | version "0.18.0" 447 | resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" 448 | integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== 449 | dependencies: 450 | debug "2.6.9" 451 | depd "2.0.0" 452 | destroy "1.2.0" 453 | encodeurl "~1.0.2" 454 | escape-html "~1.0.3" 455 | etag "~1.8.1" 456 | fresh "0.5.2" 457 | http-errors "2.0.0" 458 | mime "1.6.0" 459 | ms "2.1.3" 460 | on-finished "2.4.1" 461 | range-parser "~1.2.1" 462 | statuses "2.0.1" 463 | 464 | serve-static@1.15.0: 465 | version "1.15.0" 466 | resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" 467 | integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== 468 | dependencies: 469 | encodeurl "~1.0.2" 470 | escape-html "~1.0.3" 471 | parseurl "~1.3.3" 472 | send "0.18.0" 473 | 474 | set-function-length@^1.1.1: 475 | version "1.1.1" 476 | resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.1.1.tgz#4bc39fafb0307224a33e106a7d35ca1218d659ed" 477 | integrity sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ== 478 | dependencies: 479 | define-data-property "^1.1.1" 480 | get-intrinsic "^1.2.1" 481 | gopd "^1.0.1" 482 | has-property-descriptors "^1.0.0" 483 | 484 | setprototypeof@1.2.0: 485 | version "1.2.0" 486 | resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" 487 | integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== 488 | 489 | side-channel@^1.0.4: 490 | version "1.0.4" 491 | resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" 492 | integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== 493 | dependencies: 494 | call-bind "^1.0.0" 495 | get-intrinsic "^1.0.2" 496 | object-inspect "^1.9.0" 497 | 498 | statuses@2.0.1: 499 | version "2.0.1" 500 | resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" 501 | integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== 502 | 503 | toidentifier@1.0.1: 504 | version "1.0.1" 505 | resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" 506 | integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== 507 | 508 | type-is@~1.6.18: 509 | version "1.6.18" 510 | resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" 511 | integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== 512 | dependencies: 513 | media-typer "0.3.0" 514 | mime-types "~2.1.24" 515 | 516 | typescript@^5.3.2: 517 | version "5.3.2" 518 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" 519 | integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== 520 | 521 | undici-types@~5.26.4: 522 | version "5.26.5" 523 | resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" 524 | integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== 525 | 526 | unpipe@1.0.0, unpipe@~1.0.0: 527 | version "1.0.0" 528 | resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 529 | integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== 530 | 531 | utils-merge@1.0.1: 532 | version "1.0.1" 533 | resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 534 | integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== 535 | 536 | vary@~1.1.2: 537 | version "1.1.2" 538 | resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" 539 | integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== 540 | -------------------------------------------------------------------------------- /demo/backend/next/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /demo/backend/next/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | .env 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /demo/backend/next/README.md: -------------------------------------------------------------------------------- 1 | # Next.js Demo 2 | 3 | To run this demo: 4 | 5 | ```bash 6 | yarn install && 7 | cat "OPENAI_API_KEY=" > .env && 8 | yarn dev 9 | ``` 10 | 11 | Then go to [http://localhost:3000](http://localhost:3000) to see the result. 12 | -------------------------------------------------------------------------------- /demo/backend/next/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /demo/backend/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-backend-next", 3 | "version": "0.0.6", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "clean": "rm -rf node_modules && rm -rf .next && rm -rf dist" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "next": "14.0.3", 16 | "@beakjs/react": "0.0.6", 17 | "@beakjs/next": "0.0.6" 18 | }, 19 | "devDependencies": { 20 | "typescript": "^5.0.4", 21 | "@types/node": "^20.1.0", 22 | "@types/react": "^18.2.15", 23 | "@types/react-dom": "^18.2.7", 24 | "autoprefixer": "^10.0.1", 25 | "postcss": "^8", 26 | "tailwindcss": "^3.3.0", 27 | "eslint": "^8", 28 | "eslint-config-next": "14.0.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/backend/next/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /demo/backend/next/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mme/beakjs/c837e84da19082ae6ed977381548efb74930d678/demo/backend/next/public/.gitkeep -------------------------------------------------------------------------------- /demo/backend/next/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mme/beakjs/c837e84da19082ae6ed977381548efb74930d678/demo/backend/next/src/app/favicon.ico -------------------------------------------------------------------------------- /demo/backend/next/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | #root { 8 | height: 100%; 9 | margin: 0; 10 | padding: 0; 11 | } 12 | 13 | #root { 14 | display: flex; 15 | } 16 | 17 | .slide { 18 | display: flex; 19 | inset: 0; 20 | display: flex; 21 | flex: 1; 22 | inset: 0; 23 | font-family: sans-serif; 24 | justify-content: center; 25 | align-items: center; 26 | font-size: 2rem; 27 | padding: 5rem; 28 | background-repeat: no-repeat; 29 | background-size: cover; 30 | text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white; 31 | } 32 | 33 | @media (min-width: 640px) { 34 | .slide { 35 | font-size: 5rem; 36 | padding: 10rem; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo/backend/next/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | 20 |
{children}
21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /demo/backend/next/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { 5 | Beak, 6 | AssistantWindow, 7 | useBeakInfo, 8 | useBeakFunction, 9 | } from "@beakjs/react"; 10 | 11 | import { DebugLogger } from "@beakjs/core"; 12 | 13 | const App = () => { 14 | const debugLogger = new DebugLogger(["chat-api"]); 15 | return ( 16 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | const Presentation = () => { 33 | const [state, setState] = useState({ 34 | message: "Hello World!", 35 | backgroundImage: "none", 36 | }); 37 | 38 | useBeakInfo("current slide", state); 39 | 40 | useBeakFunction({ 41 | name: "presentSlide", 42 | description: "Present a slide in the presentation you are giving.", 43 | feedback: "auto", 44 | parameters: { 45 | message: { 46 | description: 47 | "A short message to display in the presentation slide, max 40 words.", 48 | }, 49 | backgroundImage: { 50 | description: 51 | "What to display in the background of the slide (i.e. 'dog' or 'house'), or 'none' for a white background", 52 | }, 53 | }, 54 | handler: ({ message, backgroundImage }) => { 55 | setState({ 56 | message: message, 57 | backgroundImage: backgroundImage, 58 | }); 59 | 60 | return `Presented slide with message "${message}" and background image "${backgroundImage}"`; 61 | }, 62 | }); 63 | 64 | return ; 65 | }; 66 | 67 | type SlideProps = { 68 | message: string; 69 | backgroundImage: string; 70 | }; 71 | 72 | const Slide = ({ message, backgroundImage }: SlideProps) => { 73 | if (backgroundImage !== "none") { 74 | backgroundImage = 75 | 'url("https://source.unsplash.com/featured/?' + 76 | encodeURIComponent(backgroundImage) + 77 | '")'; 78 | } 79 | return ( 80 |
86 | {message} 87 |
88 | ); 89 | }; 90 | 91 | export default App; 92 | -------------------------------------------------------------------------------- /demo/backend/next/src/pages/api/beak/[...path].ts: -------------------------------------------------------------------------------- 1 | import { beakHandler } from "@beakjs/next"; 2 | 3 | const handler = beakHandler(); 4 | 5 | export default handler; 6 | -------------------------------------------------------------------------------- /demo/backend/next/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /demo/backend/next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /demo/backend/remix/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /demo/backend/remix/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /demo/backend/remix/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | -------------------------------------------------------------------------------- /demo/backend/remix/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /demo/backend/remix/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import isbot from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | loadContext: AppLoadContext 23 | ) { 24 | return isbot(request.headers.get("user-agent")) 25 | ? handleBotRequest( 26 | request, 27 | responseStatusCode, 28 | responseHeaders, 29 | remixContext 30 | ) 31 | : handleBrowserRequest( 32 | request, 33 | responseStatusCode, 34 | responseHeaders, 35 | remixContext 36 | ); 37 | } 38 | 39 | function handleBotRequest( 40 | request: Request, 41 | responseStatusCode: number, 42 | responseHeaders: Headers, 43 | remixContext: EntryContext 44 | ) { 45 | return new Promise((resolve, reject) => { 46 | let shellRendered = false; 47 | const { pipe, abort } = renderToPipeableStream( 48 | , 53 | { 54 | onAllReady() { 55 | shellRendered = true; 56 | const body = new PassThrough(); 57 | const stream = createReadableStreamFromReadable(body); 58 | 59 | responseHeaders.set("Content-Type", "text/html"); 60 | 61 | resolve( 62 | new Response(stream, { 63 | headers: responseHeaders, 64 | status: responseStatusCode, 65 | }) 66 | ); 67 | 68 | pipe(body); 69 | }, 70 | onShellError(error: unknown) { 71 | reject(error); 72 | }, 73 | onError(error: unknown) { 74 | responseStatusCode = 500; 75 | // Log streaming rendering errors from inside the shell. Don't log 76 | // errors encountered during initial shell rendering since they'll 77 | // reject and get logged in handleDocumentRequest. 78 | if (shellRendered) { 79 | console.error(error); 80 | } 81 | }, 82 | } 83 | ); 84 | 85 | setTimeout(abort, ABORT_DELAY); 86 | }); 87 | } 88 | 89 | function handleBrowserRequest( 90 | request: Request, 91 | responseStatusCode: number, 92 | responseHeaders: Headers, 93 | remixContext: EntryContext 94 | ) { 95 | return new Promise((resolve, reject) => { 96 | let shellRendered = false; 97 | const { pipe, abort } = renderToPipeableStream( 98 | , 103 | { 104 | onShellReady() { 105 | shellRendered = true; 106 | const body = new PassThrough(); 107 | const stream = createReadableStreamFromReadable(body); 108 | 109 | responseHeaders.set("Content-Type", "text/html"); 110 | 111 | resolve( 112 | new Response(stream, { 113 | headers: responseHeaders, 114 | status: responseStatusCode, 115 | }) 116 | ); 117 | 118 | pipe(body); 119 | }, 120 | onShellError(error: unknown) { 121 | reject(error); 122 | }, 123 | onError(error: unknown) { 124 | responseStatusCode = 500; 125 | // Log streaming rendering errors from inside the shell. Don't log 126 | // errors encountered during initial shell rendering since they'll 127 | // reject and get logged in handleDocumentRequest. 128 | if (shellRendered) { 129 | console.error(error); 130 | } 131 | }, 132 | } 133 | ); 134 | 135 | setTimeout(abort, ABORT_DELAY); 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /demo/backend/remix/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { cssBundleHref } from "@remix-run/css-bundle"; 2 | import type { LinksFunction } from "@remix-run/node"; 3 | import { 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | } from "@remix-run/react"; 11 | 12 | export const links: LinksFunction = () => [ 13 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), 14 | ]; 15 | 16 | export default function App() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /demo/backend/remix/app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | import { useState } from "react"; 3 | import { 4 | Beak, 5 | AssistantWindow, 6 | useBeakInfo, 7 | useBeakFunction, 8 | } from "@beakjs/react"; 9 | 10 | import "../styles/global.css"; 11 | 12 | export const meta: MetaFunction = () => { 13 | return [ 14 | { title: "New Remix App" }, 15 | { name: "description", content: "Welcome to Remix!" }, 16 | ]; 17 | }; 18 | 19 | const App = () => { 20 | return ( 21 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | const Presentation = () => { 37 | const [state, setState] = useState({ 38 | message: "Hello World!", 39 | backgroundImage: "none", 40 | }); 41 | 42 | useBeakInfo("current slide", state); 43 | 44 | useBeakFunction({ 45 | name: "presentSlide", 46 | description: "Present a slide in the presentation you are giving.", 47 | feedback: "auto", 48 | parameters: { 49 | message: { 50 | description: 51 | "A short message to display in the presentation slide, max 40 words.", 52 | }, 53 | backgroundImage: { 54 | description: 55 | "What to display in the background of the slide (i.e. 'dog' or 'house'), or 'none' for a white background", 56 | }, 57 | }, 58 | handler: ({ message, backgroundImage }) => { 59 | setState({ 60 | message: message, 61 | backgroundImage: backgroundImage, 62 | }); 63 | 64 | return `Presented slide with message "${message}" and background image "${backgroundImage}"`; 65 | }, 66 | }); 67 | 68 | return ; 69 | }; 70 | 71 | type SlideProps = { 72 | message: string; 73 | backgroundImage: string; 74 | }; 75 | 76 | export const Slide = ({ message, backgroundImage }: SlideProps) => { 77 | if (backgroundImage !== "none") { 78 | backgroundImage = 79 | 'url("https://source.unsplash.com/featured/?' + 80 | encodeURIComponent(backgroundImage) + 81 | '")'; 82 | } 83 | return ( 84 |
90 | {message} 91 |
92 | ); 93 | }; 94 | 95 | export default App; 96 | -------------------------------------------------------------------------------- /demo/backend/remix/app/routes/beak.$.tsx: -------------------------------------------------------------------------------- 1 | import { beakHandler } from "@beakjs/remix"; 2 | 3 | export const action = beakHandler(); 4 | -------------------------------------------------------------------------------- /demo/backend/remix/app/styles/global.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | body { 9 | display: flex; 10 | } 11 | 12 | .slide { 13 | display: flex; 14 | inset: 0; 15 | display: flex; 16 | flex: 1; 17 | inset: 0; 18 | font-family: sans-serif; 19 | justify-content: center; 20 | align-items: center; 21 | font-size: 2rem; 22 | padding: 5rem; 23 | background-repeat: no-repeat; 24 | background-size: cover; 25 | text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white; 26 | } 27 | 28 | @media (min-width: 640px) { 29 | .slide { 30 | font-size: 5rem; 31 | padding: 10rem; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /demo/backend/remix/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-backend-remix", 3 | "version": "0.0.6", 4 | "private": true, 5 | "sideEffects": false, 6 | "type": "module", 7 | "scripts": { 8 | "build": "remix build", 9 | "dev": "remix dev --manual", 10 | "start": "remix-serve ./build/index.js", 11 | "typecheck": "tsc", 12 | "clean": "rm -rf build node_modules public/build", 13 | "routes": "remix routes" 14 | }, 15 | "dependencies": { 16 | "@remix-run/css-bundle": "^2.3.0", 17 | "@remix-run/node": "^2.3.0", 18 | "@remix-run/react": "^2.3.0", 19 | "@remix-run/serve": "^2.3.0", 20 | "isbot": "^3.6.8", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "@beakjs/remix": "0.0.6", 24 | "@beakjs/react": "0.0.6" 25 | }, 26 | "devDependencies": { 27 | "@remix-run/dev": "^2.3.0", 28 | "@remix-run/eslint-config": "^2.3.0", 29 | "@types/react": "^18.2.20", 30 | "@types/react-dom": "^18.2.7", 31 | "eslint": "^8.38.0", 32 | "typescript": "^5.1.6" 33 | }, 34 | "engines": { 35 | "node": ">=18.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /demo/backend/remix/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mme/beakjs/c837e84da19082ae6ed977381548efb74930d678/demo/backend/remix/public/favicon.ico -------------------------------------------------------------------------------- /demo/backend/remix/remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | ignoredRouteFiles: ["**/.*"], 4 | // appDirectory: "app", 5 | // assetsBuildDirectory: "public/build", 6 | // publicPath: "/build/", 7 | // serverBuildPath: "build/index.js", 8 | }; 9 | -------------------------------------------------------------------------------- /demo/backend/remix/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /demo/backend/remix/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "Bundler", 9 | "resolveJsonModule": true, 10 | "target": "ES2022", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/frontend/presentation/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /demo/frontend/presentation/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env -------------------------------------------------------------------------------- /demo/frontend/presentation/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /demo/frontend/presentation/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Vite + React + TS 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/frontend/presentation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-frontend-presentation", 3 | "private": true, 4 | "version": "0.0.6", 5 | "type": "module", 6 | "homepage": "https://mme.github.io/beakjs", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview", 12 | "clean": "rm -rf dist", 13 | "deploy": "gh-pages -d dist" 14 | }, 15 | "dependencies": { 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^18.2.15", 21 | "@types/react-dom": "^18.2.7", 22 | "@typescript-eslint/eslint-plugin": "^6.0.0", 23 | "@typescript-eslint/parser": "^6.0.0", 24 | "@vitejs/plugin-react": "^4.0.3", 25 | "eslint": "^8.45.0", 26 | "eslint-plugin-react-hooks": "^4.6.0", 27 | "eslint-plugin-react-refresh": "^0.4.3", 28 | "gh-pages": "^6.1.0", 29 | "typescript": "^5.0.4", 30 | "vite": "^4.3.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /demo/frontend/presentation/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mme/beakjs/c837e84da19082ae6ed977381548efb74930d678/demo/frontend/presentation/public/.gitkeep -------------------------------------------------------------------------------- /demo/frontend/presentation/src/App.css: -------------------------------------------------------------------------------- 1 | .slide { 2 | display: flex; 3 | flex: 1; 4 | inset: 0; 5 | font-family: sans-serif; 6 | justify-content: center; 7 | align-items: center; 8 | font-size: 2rem; 9 | padding: 5rem; 10 | background-repeat: no-repeat; 11 | background-size: cover; 12 | text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white; 13 | margin: 0; 14 | width: 100%; 15 | height: 100%; 16 | box-sizing: border-box; 17 | } 18 | 19 | @media (min-width: 640px) { 20 | .slide { 21 | font-size: 5rem; 22 | padding: 10rem; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/frontend/presentation/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | Beak, 4 | AssistantWindow, 5 | useBeakInfo, 6 | useBeakFunction, 7 | } from "@beakjs/react"; 8 | 9 | import { DebugLogger } from "@beakjs/core"; 10 | import "./App.css"; 11 | 12 | const App = () => { 13 | const [openAIApiKey, setOpenAIApiKey] = useState( 14 | import.meta.env.VITE_OPENAI_API_KEY 15 | ); 16 | 17 | const debugLogger = new DebugLogger(["chat-api"]); 18 | 19 | const handleSubmit = (event: React.FormEvent) => { 20 | event.preventDefault(); 21 | 22 | const form = event.currentTarget; 23 | const apiKeyInputElement = form.elements.namedItem( 24 | "apiKey" 25 | ) as HTMLInputElement; 26 | 27 | if (apiKeyInputElement) { 28 | const apiKey = apiKeyInputElement.value; 29 | setOpenAIApiKey(apiKey); 30 | } 31 | }; 32 | 33 | return ( 34 |
35 | {openAIApiKey ? ( 36 | 46 | 47 | 48 | 49 | ) : ( 50 | 51 | )} 52 |
53 | ); 54 | }; 55 | 56 | const Presentation = () => { 57 | const [state, setState] = useState({ 58 | message: "Hello World!", 59 | backgroundImage: "none", 60 | }); 61 | 62 | useBeakInfo("current slide", state); 63 | 64 | useBeakFunction({ 65 | name: "presentSlide", 66 | description: "Present a slide in the presentation you are giving.", 67 | feedback: "auto", 68 | parameters: { 69 | message: { 70 | description: 71 | "A short message to display in the presentation slide, max 30 words.", 72 | }, 73 | backgroundImage: { 74 | description: 75 | "What to display in the background of the slide (i.e. 'dog' or 'house'), or 'none' for a white background", 76 | }, 77 | }, 78 | handler: ({ message, backgroundImage }) => { 79 | setState({ 80 | message: message, 81 | backgroundImage: backgroundImage, 82 | }); 83 | 84 | return `Presented slide with message "${message}" and background image "${backgroundImage}"`; 85 | }, 86 | }); 87 | 88 | return ; 89 | }; 90 | 91 | type SlideProps = { 92 | message: string; 93 | backgroundImage: string; 94 | }; 95 | 96 | export const Slide = ({ message, backgroundImage }: SlideProps) => { 97 | if (backgroundImage !== "none") { 98 | backgroundImage = 99 | 'url("https://source.unsplash.com/featured/?' + 100 | encodeURIComponent(backgroundImage) + 101 | '")'; 102 | } 103 | return ( 104 |
110 | {message} 111 |
112 | ); 113 | }; 114 | 115 | const ApiKeyForm = ({ handleSubmit }: { handleSubmit: any }) => { 116 | return ( 117 |
126 |
136 | 146 | 158 | 171 |
172 |
173 | ); 174 | }; 175 | 176 | export default App; 177 | -------------------------------------------------------------------------------- /demo/frontend/presentation/src/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #root { 4 | height: 100%; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | #root { 10 | display: flex; 11 | } 12 | -------------------------------------------------------------------------------- /demo/frontend/presentation/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | 5 | import "./index.css"; 6 | 7 | ReactDOM.createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /demo/frontend/presentation/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/frontend/presentation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /demo/frontend/presentation/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /demo/frontend/presentation/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: "/beakjs/", 8 | }); 9 | -------------------------------------------------------------------------------- /docs/deployment/express.md: -------------------------------------------------------------------------------- 1 | # Setting up Express 2 | 3 | ## Credentials 4 | 5 | In the root of your project, create a `.env` file and add your OpenAI API key: 6 | 7 | ```bash 8 | OPENAI_API_KEY=sk-... 9 | ``` 10 | 11 | Then install the `dotenv` package if you haven't already: 12 | 13 | ```bash 14 | npm install dotenv --save 15 | # or 16 | yarn add dotenv 17 | ``` 18 | 19 | ## Install Beak.js for Express 20 | 21 | ```bash 22 | npm install @beak/express --save 23 | # or 24 | yarn add @beak/express 25 | ``` 26 | 27 | ## Add the middleware 28 | 29 | For example: 30 | 31 | ```typescript 32 | import express from "express"; 33 | import { beakHandler } from "@beakjs/express"; 34 | import dotenv from "dotenv"; 35 | dotenv.config(); 36 | 37 | const app = express(); 38 | const port = 3000; 39 | 40 | app.use("/beak", beakHandler()); 41 | 42 | app.listen(port, () => { 43 | console.log(`Server running on http://localhost:${port}`); 44 | }); 45 | ``` 46 | 47 | ## Configure Beak to use the Express backend 48 | 49 | ```typescript 50 | const App = () => { 51 | return ... your app code goes here; 52 | }; 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/deployment/next.md: -------------------------------------------------------------------------------- 1 | # Setting up Next.js 2 | 3 | ## Credentials 4 | 5 | In the root of your project, create a `.env` file and add your OpenAI API key: 6 | 7 | ```bash 8 | OPENAI_API_KEY=sk-... 9 | ``` 10 | 11 | ## Install Beak.js for Next.js 12 | 13 | ```bash 14 | npm install @beak/next --save 15 | # or 16 | yarn add @beak/next 17 | ``` 18 | 19 | ## Add the Beak.js handler 20 | 21 | Create a new wildcard API route in `pages/api/beak/[...path].ts`: 22 | 23 | ```typescript 24 | import { beakHandler } from "@beakjs/next"; 25 | 26 | const handler = beakHandler(); 27 | 28 | export default handler; 29 | ``` 30 | 31 | ## Configure Beak to use the Next.js backend 32 | 33 | ```typescript 34 | const App = () => { 35 | return ... your app code goes here; 36 | }; 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/deployment/remix.md: -------------------------------------------------------------------------------- 1 | # Setting up Remix 2 | 3 | ## Credentials 4 | 5 | In the root of your project, create a `.env` file and add your OpenAI API key: 6 | 7 | ```bash 8 | OPENAI_API_KEY=sk-... 9 | ``` 10 | 11 | ## Install Beak.js for Remix 12 | 13 | ```bash 14 | npm install @beak/remix --save 15 | # or 16 | yarn add @beak/remix 17 | ``` 18 | 19 | ## Add the Beak.js handler 20 | 21 | Create a new wildcard API route in `app/routes/beak.$.tsx`: 22 | 23 | ```typescript 24 | import { beakHandler } from "@beakjs/remix"; 25 | export const action = beakHandler(); 26 | ``` 27 | 28 | ## Configure Beak to use the Remix backend 29 | 30 | ```typescript 31 | const App = () => { 32 | return ... your app code goes here; 33 | }; 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mme/beakjs/c837e84da19082ae6ed977381548efb74930d678/docs/img/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beakjs", 3 | "version": "0.0.6", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "eventemitter3": "^5.0.1", 8 | "jsonrepair": "^3.2.4", 9 | "react-textarea-autosize": "^8.4.1", 10 | "uuid": "^9.0.1", 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0" 13 | }, 14 | "devDependencies": { 15 | "@types/jest": "^29.5.1", 16 | "@types/node": "^20.1.0", 17 | "@types/react": "^18.2.15", 18 | "@types/react-dom": "^18.2.7", 19 | "@types/uuid": "^9.0.6", 20 | "jest": "^29.5.0", 21 | "ts-jest": "^29.1.0", 22 | "ts-node": "^10.9.1", 23 | "typescript": "^5.0.4", 24 | "vite": "^4.3.5", 25 | "vite-tsconfig-paths": "^4.2.0" 26 | }, 27 | "scripts": { 28 | "dev": "vite", 29 | "build": "yarn workspaces run build", 30 | "prepublishOnly": "yarn build", 31 | "serve": "vite preview", 32 | "test": "jest", 33 | "sync-versions": "ts-node scripts/sync-versions.ts", 34 | "clean": "rm -rf dist && rm -rf node_modules && yarn workspaces run clean" 35 | }, 36 | "jest": { 37 | "testTimeout": 60000, 38 | "transform": { 39 | "^.+\\.(ts|tsx)$": "ts-jest" 40 | }, 41 | "testEnvironment": "node", 42 | "moduleFileExtensions": [ 43 | "ts", 44 | "tsx", 45 | "js" 46 | ], 47 | "testPathIgnorePatterns": [ 48 | "/node_modules/" 49 | ] 50 | }, 51 | "files": [ 52 | "dist/**/*" 53 | ], 54 | "private": true, 55 | "workspaces": [ 56 | "packages/openai", 57 | "packages/core", 58 | "packages/react", 59 | "packages/server", 60 | "packages/next", 61 | "packages/remix", 62 | "packages/express", 63 | "demo/frontend/presentation", 64 | "demo/backend/next", 65 | "demo/backend/express", 66 | "demo/backend/remix" 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Markus Ecker", 3 | "license": "MIT", 4 | "name": "@beakjs/core", 5 | "version": "0.0.6", 6 | "description": "BeakJS core library", 7 | "main": "dist/cjs/index.js", 8 | "module": "dist/esm/index.js", 9 | "types": "dist/cjs/index.d.ts", 10 | "scripts": { 11 | "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", 12 | "prepublishOnly": "cp ../../README.md .", 13 | "clean": "rm -rf dist && rm -rf node_modules", 14 | "test": "jest" 15 | }, 16 | "files": [ 17 | "dist/**/*", 18 | "README.md" 19 | ], 20 | "dependencies": { 21 | "@beakjs/openai": "0.0.6", 22 | "eventemitter3": "^5.0.1", 23 | "uuid": "^9.0.1" 24 | }, 25 | "devDependencies": { 26 | "@types/uuid": "^9.0.6", 27 | "typescript": "^5.0.4", 28 | "@types/jest": "^29.5.1", 29 | "jest": "^29.5.0", 30 | "ts-jest": "^29.1.0" 31 | }, 32 | "peerDependencies": {}, 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/mme/beakjs.git" 36 | }, 37 | "jest": { 38 | "testTimeout": 60000, 39 | "transform": { 40 | "^.+\\.(ts|tsx)$": "ts-jest" 41 | }, 42 | "testEnvironment": "node", 43 | "moduleFileExtensions": [ 44 | "ts", 45 | "tsx", 46 | "js" 47 | ], 48 | "testPathIgnorePatterns": [ 49 | "/node_modules/" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/core/src/beak.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "eventemitter3"; 2 | import { OpenAI, OpenAIModel, CustomModel } from "@beakjs/openai"; 3 | import { 4 | LLMAdapter, 5 | Message, 6 | FunctionDefinition, 7 | FunctionCall, 8 | QueryChatCompletionParams, 9 | DebugLogger, 10 | } from "./types"; 11 | import { v4 as uuidv4 } from "uuid"; 12 | import { OpenAIAdapter } from "./openai"; 13 | 14 | const DEFAULT_INSTRUCTIONS = 15 | "Assistant is running inside a web application. Assistant never returns JSON " + 16 | "as a text reply, always uses the correct format for function calls."; 17 | 18 | const FORMATTING_INSTRUCTIONS = 19 | "Never provide instructions for executing function calls to the user, instead " + 20 | "use the function call interface."; 21 | 22 | export interface BeakConfiguration { 23 | openAIApiKey?: string; 24 | baseUrl?: string; 25 | openAIModel?: OpenAIModel | CustomModel; 26 | maxFeedback?: number; 27 | instructions?: string; 28 | temperature?: number; 29 | maxTokens?: number; 30 | debugLogger?: DebugLogger; 31 | formattingInstructions?: string; 32 | } 33 | 34 | interface BeakEvents { 35 | change: (messages: Message) => void; 36 | partial: (name: string, args: any) => void; 37 | function: (functionCall: FunctionCall) => void; 38 | content: (content: string) => void; 39 | error: (error: Error) => void; 40 | end: () => void; 41 | } 42 | 43 | interface BeakInfo { 44 | description: string; 45 | data: any; 46 | } 47 | 48 | export class BeakCore extends EventEmitter { 49 | public _messages: Message[] = []; 50 | 51 | private instructionsMessage: Message; 52 | private formattingInstructionsMessage?: Message; 53 | public configuration: BeakConfiguration; 54 | public functions: Record = {}; 55 | public info: Record = {}; 56 | private debug: DebugLogger; 57 | 58 | constructor(configuration: BeakConfiguration) { 59 | super(); 60 | this.configuration = configuration; 61 | 62 | let client = this.newAdapter(); 63 | this.instructionsMessage = new Message({ 64 | role: "system", 65 | content: this.configuration.instructions || DEFAULT_INSTRUCTIONS, 66 | status: "success", 67 | }); 68 | this.instructionsMessage.calculateNumTokens(client); 69 | 70 | if (this.configuration.formattingInstructions !== "") { 71 | this.formattingInstructionsMessage = new Message({ 72 | role: "system", 73 | content: 74 | this.configuration.formattingInstructions || FORMATTING_INSTRUCTIONS, 75 | status: "success", 76 | }); 77 | this.formattingInstructionsMessage.calculateNumTokens(client); 78 | } 79 | 80 | this.debug = configuration.debugLogger || new DebugLogger(); 81 | } 82 | 83 | public addFunction(fun: FunctionDefinition) { 84 | this.functions[fun.name] = fun; 85 | } 86 | 87 | public removeFunction(fun: FunctionDefinition | string) { 88 | if (typeof fun === "string") { 89 | delete this.functions[fun]; 90 | } else { 91 | delete this.functions[fun.name]; 92 | } 93 | } 94 | 95 | public addInfo(data: any, description: string = "data"): string { 96 | const id = uuidv4(); 97 | this.info[id] = { description, data }; 98 | return id; 99 | } 100 | 101 | public removeInfo(id: string) { 102 | delete this.info[id]; 103 | } 104 | 105 | private infoMessage(): Message | undefined { 106 | const info = Object.values(this.info); 107 | if (info.length == 0) { 108 | return undefined; 109 | } 110 | const infoStrings = info.map( 111 | (c) => c.description + ": " + JSON.stringify(c.data) 112 | ); 113 | infoStrings.sort(); 114 | const ctxString = infoStrings.join("\n"); 115 | return new Message({ 116 | role: "system", 117 | content: 118 | "Partial snapshot of the application's current state:\n\n" + ctxString, 119 | }); 120 | } 121 | 122 | public get messages(): Message[] { 123 | return this._messages 124 | .filter((message) => message.role != "system") 125 | .map((message) => message.copy()); 126 | } 127 | 128 | public async runChatCompletion(content: string): Promise { 129 | this.debug.log( 130 | "beak-complete", 131 | "Running runChatCompletion with content:", 132 | content 133 | ); 134 | 135 | const userMessage = new Message({ 136 | role: "user", 137 | content: content, 138 | status: "success", 139 | }); 140 | userMessage.calculateNumTokens(this.newAdapter()); 141 | 142 | this._messages.push(userMessage); 143 | this.emit("change", userMessage.copy()); 144 | 145 | const maxIterations = (this.configuration.maxFeedback || 2) + 1; 146 | this.debug.log("beak-complete", "Max iterations:", maxIterations); 147 | 148 | let functionCall: "auto" | "none" = "auto"; 149 | 150 | for (let i = 0; i < maxIterations; i++) { 151 | this.debug.log("beak-complete", "Iteration:", i); 152 | 153 | let message = new Message({ role: "assistant", status: "pending" }); 154 | this._messages.push(message); 155 | this.emit("change", message.copy()); 156 | 157 | const client = this.newAdapter(); 158 | 159 | const contextMessage = this.infoMessage(); 160 | let newMessages: Message[] = []; 161 | try { 162 | newMessages = await this.runChatCompletionAsync(client, { 163 | maxTokens: this.configuration.maxTokens, 164 | messages: [ 165 | this.instructionsMessage, 166 | ...(this.formattingInstructionsMessage !== undefined 167 | ? [this.formattingInstructionsMessage] 168 | : []), 169 | ...(contextMessage !== undefined ? [contextMessage] : []), 170 | // we leave out the last message, because it is the one we are currently working on 171 | ...this._messages.slice(0, this._messages.length - 1), 172 | ], 173 | functions: Object.values(this.functions), 174 | functionCall: functionCall, 175 | temperature: this.configuration.temperature, 176 | }); 177 | 178 | this.debug.log( 179 | "beak-complete", 180 | "runChatCompletionAsync returned message:", 181 | message 182 | ); 183 | } catch (error) { 184 | console.error(error); 185 | return; 186 | } 187 | 188 | let hasFeedback = false; 189 | 190 | // first, filter out any empty messages 191 | for (const message of newMessages) { 192 | if (!message.functionCall && !message.content) { 193 | this.debug.log( 194 | "beak-complete", 195 | "No content, removing message:", 196 | message 197 | ); 198 | this._messages = this._messages.filter((m) => m.id !== message.id); 199 | this.emit("change", this._messages.slice(-1)[0].copy()); 200 | } 201 | } 202 | 203 | for (const message of newMessages) { 204 | message.calculateNumTokens(client); 205 | 206 | // handle text message 207 | if (!message.functionCall) { 208 | message.status = "success"; 209 | this.debug.log( 210 | "beak-complete", 211 | "No function call, finalizing message:", 212 | message 213 | ); 214 | this.emit("change", message.copy()); 215 | 216 | continue; // next message 217 | } 218 | 219 | // handle function call 220 | const currentCall = message.functionCall; 221 | 222 | // handle function call not found 223 | if (!(currentCall.name in this.functions)) { 224 | message.status = "error"; 225 | message.calculateNumTokens(client); 226 | this.emit("change", message.copy()); 227 | 228 | // Insert a new message for the error message 229 | const errorMessage = new Message({ 230 | role: "function", 231 | name: currentCall.name, 232 | status: "error", 233 | content: `Error: Function ${currentCall.name} not found.`, 234 | }); 235 | errorMessage.calculateNumTokens(client); 236 | 237 | this._messages.push(errorMessage); 238 | this.emit("change", errorMessage.copy()); 239 | 240 | continue; // next message 241 | } 242 | 243 | const fun = this.functions[currentCall.name]; 244 | 245 | this.debug.log( 246 | "beak-complete", 247 | "Calling function:", 248 | currentCall.name, 249 | "with arguments:", 250 | currentCall.arguments 251 | ); 252 | 253 | try { 254 | const result = await fun.handler(currentCall.arguments); 255 | 256 | message.status = "success"; 257 | 258 | const resultString = 259 | typeof result === "string" ? result : JSON.stringify(result); 260 | const resultMessage = new Message({ 261 | role: "function", 262 | name: currentCall.name, 263 | status: "success", 264 | content: resultString, 265 | result: result, 266 | }); 267 | resultMessage.calculateNumTokens(client); 268 | this._messages.push(resultMessage); 269 | 270 | this.emit("change", resultMessage.copy()); 271 | 272 | // in case the function wants feedback, signal that the result should be fed back to the LLM 273 | if (fun.feedback !== "none") { 274 | hasFeedback = true; 275 | } 276 | 277 | // if the function only wants text feedback, set function call to none 278 | if (fun.feedback === "text") { 279 | functionCall = "none"; 280 | } 281 | 282 | continue; // next message 283 | } catch (error) { 284 | this.debug.log("beak-complete", "Error calling function:", error); 285 | 286 | message.status = "error"; 287 | 288 | const errorMessage = new Message({ 289 | role: "function", 290 | name: currentCall.name, 291 | status: "error", 292 | content: `Error: ${error}`, 293 | }); 294 | errorMessage.calculateNumTokens(client); 295 | this._messages.push(errorMessage); 296 | 297 | this.emit("change", errorMessage.copy()); 298 | 299 | // feed back the error message to the llm 300 | if (fun.feedback !== "none") { 301 | hasFeedback = true; 302 | } 303 | 304 | continue; // next message 305 | } 306 | } 307 | 308 | if (!hasFeedback) { 309 | break; 310 | } 311 | } 312 | 313 | this.debug.log("beak-complete", "Done running runChatCompletion"); 314 | 315 | return; 316 | } 317 | 318 | private async runChatCompletionAsync( 319 | client: LLMAdapter, 320 | params: QueryChatCompletionParams 321 | ) { 322 | return new Promise((resolve, reject) => { 323 | const newMessages: Message[] = this._messages.slice(-1); 324 | 325 | const cleanup = () => { 326 | client.off("partial"); 327 | client.off("function"); 328 | client.off("content"); 329 | client.off("error"); 330 | client.off("end"); 331 | }; 332 | client.on("partial", (name, args) => { 333 | let [message] = newMessages.slice(-1); 334 | 335 | if (message.content) { 336 | // finalize this text message 337 | message.status = "success"; 338 | message.calculateNumTokens(client); 339 | 340 | // create a new message for the function call 341 | message = new Message({ role: "assistant" }); 342 | newMessages.push(message); 343 | this._messages.push(message); 344 | } 345 | 346 | message.status = "partial"; 347 | this.emit("partial", name, args); 348 | this.emit("change", message.copy()); 349 | }); 350 | 351 | client.on("function", (functionCall: FunctionCall) => { 352 | const [message] = newMessages.slice(-1); 353 | message.functionCall = functionCall; 354 | 355 | this.emit("function", functionCall); 356 | this.emit("change", message.copy()); 357 | }); 358 | 359 | client.on("content", (content: string) => { 360 | let [message] = newMessages.slice(-1); 361 | 362 | if (message.functionCall) { 363 | // we leave the function call message unfinished and create a new one 364 | // for the text message 365 | message = new Message({ role: "assistant" }); 366 | newMessages.push(message); 367 | this._messages.push(message); 368 | } 369 | 370 | message.content = message.content ? message.content + content : content; 371 | 372 | this.emit("content", content); 373 | this.emit("change", message.copy()); 374 | }); 375 | 376 | client.on("error", (error: Error) => { 377 | const [message] = newMessages.slice(-1); 378 | message.status = "error"; 379 | message.content = "Error: " + error; 380 | 381 | this.emit("error", error); 382 | this.emit("change", message.copy()); 383 | cleanup(); 384 | reject(error); 385 | }); 386 | 387 | client.on("end", () => { 388 | const [message] = newMessages.slice(-1); 389 | 390 | this.emit("end"); 391 | cleanup(); 392 | resolve(newMessages); 393 | }); 394 | 395 | client.queryChatCompletion(params); 396 | }); 397 | } 398 | 399 | private newAdapter(): LLMAdapter { 400 | return new OpenAIAdapter( 401 | new OpenAI({ 402 | apiKey: this.configuration.openAIApiKey, 403 | baseUrl: this.configuration.baseUrl, 404 | model: this.configuration.openAIModel, 405 | debugLogger: this.configuration.debugLogger, 406 | }) 407 | ); 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { BeakCore } from "./beak"; 2 | export type { FunctionDefinition } from "./types"; 3 | export { Message, DebugLogger } from "./types"; 4 | export type { OpenAIModel, CustomModel } from "@beakjs/openai"; 5 | -------------------------------------------------------------------------------- /packages/core/src/openai.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OpenAI, 3 | FetchChatCompletionParams, 4 | OpenAIFunction, 5 | OpenAIMessage, 6 | } from "@beakjs/openai"; 7 | import { 8 | FunctionCall, 9 | FunctionDefinition, 10 | LLMAdapter, 11 | LLMEvent, 12 | Message, 13 | QueryChatCompletionParams, 14 | } from "./types"; 15 | 16 | export class OpenAIAdapter implements LLMAdapter { 17 | constructor(private openai: OpenAI) {} 18 | 19 | countTokens(message: Message): number { 20 | return this.openai.countTokens(message); 21 | } 22 | 23 | async queryChatCompletion(params: QueryChatCompletionParams): Promise { 24 | const openAIParams: FetchChatCompletionParams = { 25 | messages: params.messages.map(messageToOpenAI), 26 | functions: functionsToOpenAIFormat(params.functions), 27 | maxTokens: params.maxTokens, 28 | temperature: params.temperature, 29 | }; 30 | return await this.openai.queryChatCompletion(openAIParams); 31 | } 32 | 33 | on(event: LLMEvent, listener: (...args: any[]) => void): this { 34 | this.openai.on(event, listener); 35 | return this; 36 | } 37 | 38 | off(event: LLMEvent, listener?: (...args: any[]) => void): this { 39 | this.openai.off(event, listener); 40 | return this; 41 | } 42 | } 43 | 44 | function messageToOpenAI(message: Message): OpenAIMessage { 45 | const content = message.content || ""; 46 | if (message.role === "system") { 47 | return { role: message.role, content }; 48 | } else if (message.role === "function") { 49 | return { 50 | role: message.role, 51 | content, 52 | name: message.name, 53 | }; 54 | } else { 55 | let functionCall = functionCallToOpenAI(message.functionCall); 56 | 57 | return { 58 | role: message.role, 59 | content, 60 | ...(functionCall !== undefined && { function_call: functionCall }), 61 | }; 62 | } 63 | } 64 | 65 | function functionsToOpenAIFormat( 66 | functions?: FunctionDefinition[] 67 | ): OpenAIFunction[] | undefined { 68 | if (functions === undefined) { 69 | return undefined; 70 | } 71 | return functions.map((fun) => { 72 | const args = fun.parameters; 73 | let openAiProperties: { [key: string]: any } = {}; 74 | let required: string[] = []; 75 | 76 | if (args) { 77 | for (const [name, arg] of Object.entries(args)) { 78 | const description = arg.description; 79 | if (typeof arg.type === "string" || arg.type === undefined) { 80 | const type = arg.type || "string"; 81 | openAiProperties[name] = { 82 | type: arg.type, 83 | ...(description ? { description } : {}), 84 | }; 85 | } else if (Array.isArray(arg.type)) { 86 | openAiProperties[name] = { 87 | type: "enum", 88 | enum: arg.type, 89 | ...(description ? { description } : {}), 90 | }; 91 | } 92 | 93 | if (arg.optional !== true) { 94 | required.push(name); 95 | } 96 | } 97 | } 98 | 99 | return { 100 | name: fun.name, 101 | description: fun.description, 102 | parameters: { 103 | type: "object", 104 | properties: openAiProperties, 105 | ...(required.length ? { required } : {}), 106 | }, 107 | }; 108 | }); 109 | } 110 | 111 | function functionCallToOpenAI(functionCall?: FunctionCall): any { 112 | if (functionCall === undefined) { 113 | return undefined; 114 | } 115 | return { 116 | name: functionCall.name, 117 | arguments: JSON.stringify(functionCall.arguments), 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | 3 | export type Role = "system" | "user" | "assistant" | "function"; 4 | 5 | export type MessageStatus = "pending" | "partial" | "success" | "error"; 6 | 7 | export type MessageOptions = { 8 | role?: Role; 9 | content?: string; 10 | status?: MessageStatus; 11 | functionCall?: FunctionCall; 12 | name?: string; 13 | result?: any; 14 | }; 15 | 16 | /** 17 | * A message in BeakJs. 18 | */ 19 | export class Message { 20 | /** 21 | * The unique identifier for the message. 22 | * @type {string} 23 | */ 24 | public id: string = uuidv4(); 25 | 26 | /** 27 | * The role of the message. Can be "system", "user", "assistant" or "function". 28 | * @type {Role} 29 | */ 30 | public role: Role = "user"; 31 | 32 | /** 33 | * Optional content of the message. 34 | * @type {string} 35 | */ 36 | public content?: string; 37 | 38 | /** 39 | * The status of the message. Can be "pending", "partial", "success" or "error". 40 | * @type {MessageStatus} 41 | */ 42 | public status: MessageStatus = "pending"; 43 | 44 | /** 45 | * An optional function call returned by the assistant. 46 | * @type {FunctionCall} 47 | */ 48 | public functionCall?: FunctionCall; 49 | 50 | /** 51 | * The name of the function called. Only available if the role is "function". 52 | * @type {string} 53 | */ 54 | public name?: string; 55 | 56 | /** 57 | * The result of the function call as object. Only available if the role is "function". 58 | * 59 | * Note that a string representation of the call will also be available in the content field. 60 | * @type {any} 61 | */ 62 | public result?: any; 63 | 64 | /** 65 | * The number of tokens in the message. 66 | * @type {number} 67 | */ 68 | public numTokens: number = 0; 69 | 70 | /** 71 | * The date the message was created. 72 | * @type {Date} 73 | */ 74 | public createdAt: Date = new Date(); 75 | 76 | constructor(options?: MessageOptions) { 77 | options ||= {}; 78 | Object.assign(this, options); 79 | } 80 | 81 | calculateNumTokens(llm: LLMAdapter) { 82 | this.numTokens = llm.countTokens(this); 83 | } 84 | 85 | copy(): Message { 86 | return Object.assign(new Message({}), this); 87 | } 88 | } 89 | 90 | export type FunctionHandler = (args: { [key: string]: any }) => any; 91 | 92 | export type Feedback = "none" | "auto" | "text"; 93 | 94 | export interface FunctionDefinition { 95 | name: string; 96 | description?: string; 97 | parameters?: { [key: string]: FunctionParameter }; 98 | feedback?: Feedback; 99 | handler: FunctionHandler; 100 | } 101 | 102 | export interface FunctionParameter { 103 | description?: string; 104 | type?: "string" | "number" | string[]; 105 | optional?: boolean; 106 | } 107 | 108 | export interface FunctionCall { 109 | name: string; 110 | arguments: { [key: string]: any }; 111 | } 112 | 113 | export type LLMEvent = "content" | "function" | "partial" | "error" | "end"; 114 | 115 | export abstract class LLMAdapter { 116 | abstract countTokens(message: Message): number; 117 | 118 | abstract queryChatCompletion( 119 | params: QueryChatCompletionParams 120 | ): Promise; 121 | 122 | abstract on(event: LLMEvent, listener: (...args: any[]) => void): this; 123 | 124 | abstract off(event: LLMEvent, listener?: (...args: any[]) => void): this; 125 | } 126 | 127 | export interface QueryChatCompletionParams { 128 | messages: Message[]; 129 | functions?: FunctionDefinition[]; 130 | functionCall?: "none" | "auto"; 131 | maxTokens?: number; 132 | temperature?: number; 133 | } 134 | 135 | type DebugEvent = "chat-internal" | "chat-api" | "beak-complete"; 136 | 137 | export class DebugLogger { 138 | private debugEvents: DebugEvent[] = []; 139 | 140 | constructor(debugEvents?: DebugEvent[]) { 141 | this.debugEvents = debugEvents || []; 142 | } 143 | 144 | log(debugEvent: DebugEvent, ...args: any[]) { 145 | if (this.debugEvents.includes(debugEvent)) { 146 | console.log(`[${debugEvent}]`, ...args); 147 | } 148 | } 149 | table(debugEvent: DebugEvent, message: string, ...args: any[]) { 150 | if (this.debugEvents.includes(debugEvent)) { 151 | console.log(`[${debugEvent}] - ${message}:`); 152 | console.table(...args); 153 | } 154 | } 155 | warn(debugEvent: DebugEvent, ...args: any[]) { 156 | if (this.debugEvents.includes(debugEvent)) { 157 | console.warn(`[${debugEvent}]`, ...args); 158 | } 159 | } 160 | error(debugEvent: DebugEvent, ...args: any[]) { 161 | if (this.debugEvents.includes(debugEvent)) { 162 | console.error(`[${debugEvent}]`, ...args); 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /packages/core/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "CommonJS", 5 | "declaration": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist/cjs", 11 | "lib": ["esnext", "DOM"], 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": false 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist/esm", 11 | "lib": ["esnext", "DOM"], 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": false 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/css/Assistant.css: -------------------------------------------------------------------------------- 1 | .beakAssistantWindow { 2 | position: fixed; 3 | bottom: 1rem; 4 | right: 1rem; 5 | z-index: 30; 6 | line-height: 1.5; 7 | -webkit-text-size-adjust: 100%; 8 | -moz-tab-size: 4; 9 | -o-tab-size: 4; 10 | tab-size: 4; 11 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 12 | "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, 13 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 14 | font-feature-settings: normal; 15 | font-variation-settings: normal; 16 | touch-action: manipulation; 17 | } 18 | 19 | .beakAssistantWindow svg { 20 | display: inline-block; 21 | vertical-align: middle; 22 | } 23 | -------------------------------------------------------------------------------- /packages/css/Button.css: -------------------------------------------------------------------------------- 1 | .beakButton { 2 | width: 3.5rem; 3 | height: 3.5rem; 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | border-radius: 50%; 8 | border: 1px solid rgba(255, 255, 255, 0.2); 9 | outline: none; 10 | position: relative; 11 | transform: scale(1); 12 | transition: transform 200ms; 13 | background-color: var(--beak-button-background-color); 14 | color: var(--beak-button-icon-color); 15 | cursor: pointer; 16 | } 17 | 18 | .beakButton:hover { 19 | transform: scale(1.1); 20 | } 21 | 22 | .beakButton:active { 23 | transform: scale(0.75); 24 | } 25 | 26 | .beakButtonIcon { 27 | transition: opacity 100ms, transform 300ms; 28 | position: absolute; 29 | top: 50%; 30 | left: 50%; 31 | transform: translate(-50%, -50%); 32 | } 33 | 34 | /* State when the chat is open */ 35 | .beakButton.open .beakButtonIconOpen { 36 | transform: translate(-50%, -50%) scale(0) rotate(90deg); 37 | opacity: 0; 38 | } 39 | 40 | .beakButton.open .beakButtonIconClose { 41 | transform: translate(-50%, -50%) scale(1) rotate(0deg); 42 | opacity: 1; 43 | } 44 | 45 | /* State when the chat is closed */ 46 | .beakButton:not(.open) .beakButtonIconOpen { 47 | transform: translate(-50%, -50%) scale(1) rotate(0deg); 48 | opacity: 1; 49 | } 50 | 51 | .beakButton:not(.open) .beakButtonIconClose { 52 | transform: translate(-50%, -50%) scale(0) rotate(-90deg); 53 | opacity: 0; 54 | } 55 | -------------------------------------------------------------------------------- /packages/css/Header.css: -------------------------------------------------------------------------------- 1 | .beakHeader { 2 | height: 56px; 3 | font-weight: 500; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | position: relative; 8 | background-color: var(--beak-header-background-color); 9 | color: var(--beak-header-title-color); 10 | border-top-left-radius: 0; 11 | border-top-right-radius: 0; 12 | border-bottom: 1px solid var(--beak-header-separator-color); 13 | } 14 | 15 | @media (min-width: 640px) { 16 | .beakHeader { 17 | padding-left: 24px; 18 | padding-right: 24px; 19 | border-top-left-radius: 8px; 20 | border-top-right-radius: 8px; 21 | } 22 | } 23 | 24 | .beakHeader > button { 25 | border: 0; 26 | padding: 0px; 27 | position: absolute; 28 | top: 50%; 29 | right: 16px; 30 | transform: translateY(-50%); 31 | outline: none; 32 | color: var(--beak-header-close-button-color); 33 | background-color: transparent; 34 | cursor: pointer; 35 | } 36 | 37 | .beakHeader > button:focus { 38 | outline: none; 39 | } 40 | -------------------------------------------------------------------------------- /packages/css/Input.css: -------------------------------------------------------------------------------- 1 | .beakInput { 2 | border-top: 1px solid var(--beak-input-separator-color); 3 | padding-left: 2rem; 4 | padding-right: 2.5rem; 5 | padding-top: 1rem; 6 | padding-bottom: 1rem; 7 | display: flex; 8 | align-items: center; 9 | cursor: text; 10 | position: relative; 11 | border-bottom-left-radius: 0.75rem; 12 | border-bottom-right-radius: 0.75rem; 13 | background-color: var(--beak-input-background-color); 14 | } 15 | 16 | .beakInput > button { 17 | position: absolute; 18 | right: 0.5rem; 19 | padding: 0.25rem; 20 | cursor: pointer; 21 | transition-property: transform; 22 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 23 | transition-duration: 200ms; 24 | transform: scale(1); 25 | color: rgba(0, 0, 0, 0.25); 26 | -webkit-appearance: button; 27 | appearance: button; 28 | background-color: transparent; 29 | background-image: none; 30 | text-transform: none; 31 | font-family: inherit; 32 | font-size: 100%; 33 | font-weight: inherit; 34 | line-height: inherit; 35 | border: 0; 36 | margin: 0; 37 | text-indent: 0px; 38 | text-shadow: none; 39 | display: inline-block; 40 | text-align: center; 41 | } 42 | 43 | .beakInput > button:not([disabled]) { 44 | color: var(--beak-input-send-button-color); 45 | } 46 | 47 | .beakInput > button:not([disabled]):hover { 48 | transform: scale(1.1); 49 | } 50 | 51 | .beakInput > button[disabled] { 52 | color: var(--beak-input-send-button-disabled-color); 53 | } 54 | 55 | .beakInput > textarea { 56 | width: 100%; 57 | outline: 2px solid transparent; 58 | outline-offset: 2px; 59 | resize: none; 60 | white-space: pre-wrap; 61 | overflow-wrap: break-word; 62 | -webkit-font-smoothing: antialiased; 63 | -moz-osx-font-smoothing: grayscale; 64 | cursor: text; 65 | font-size: 0.875rem; 66 | line-height: 1.25rem; 67 | margin: 0; 68 | padding: 0; 69 | font-family: inherit; 70 | font-weight: inherit; 71 | color: var(--beak-input-color); 72 | border: 0px; 73 | background-color: var(--beak-input-background-color); 74 | } 75 | 76 | .beakInput > textarea::placeholder { 77 | color: var(--beak-input-placeholder-color); 78 | opacity: 1; 79 | } 80 | -------------------------------------------------------------------------------- /packages/css/Messages.css: -------------------------------------------------------------------------------- 1 | .beakMessages { 2 | overflow-y: scroll; 3 | flex: 1; 4 | padding: 1rem 2rem; 5 | display: flex; 6 | flex-direction: column; 7 | background-color: var(--beak-messages-background-color); 8 | } 9 | 10 | .beakMessages::-webkit-scrollbar { 11 | width: 9px; 12 | } 13 | 14 | .beakMessages::-webkit-scrollbar-thumb { 15 | background-color: var(--beak-scrollbar-color); 16 | border-radius: 10rem; 17 | border: 2px solid var(--beak-messages-background-color); 18 | } 19 | 20 | .beakMessages::-webkit-scrollbar-track-piece:start { 21 | background: transparent; 22 | } 23 | 24 | .beakMessages::-webkit-scrollbar-track-piece:end { 25 | background: transparent; 26 | } 27 | 28 | .beakMessage { 29 | border-radius: 0.5rem; 30 | padding: 1rem; 31 | font-size: 0.875rem; 32 | line-height: 1.25rem; 33 | overflow-wrap: break-word; 34 | max-width: 80%; 35 | margin-bottom: 0.5rem; 36 | } 37 | 38 | .beakMessage.beakUserMessage { 39 | background: var(--beak-message-user-background-color); 40 | color: var(--beak-message-user-color); 41 | margin-left: auto; 42 | } 43 | 44 | .beakMessage.beakAssistantMessage { 45 | background: var(--beak-message-assistant-background-color); 46 | color: var(--beak-message-assistant-color); 47 | margin-right: auto; 48 | } 49 | 50 | .beakMessage.beakUserMessage + .beakMessage.beakAssistantMessage { 51 | margin-top: 1.5rem; 52 | } 53 | 54 | .beakMessage.beakAssistantMessage + .beakMessage.beakUserMessage { 55 | margin-top: 1.5rem; 56 | } 57 | -------------------------------------------------------------------------------- /packages/css/Window.css: -------------------------------------------------------------------------------- 1 | .beakWindow { 2 | position: fixed; 3 | inset: 0px; 4 | transform-origin: bottom; 5 | border-color: rgb(229 231 235); 6 | background-color: rgb(255 255 255); 7 | border-radius: 0.75rem; 8 | box-shadow: rgba(0, 0, 0, 0.16) 0px 5px 40px; 9 | flex-direction: column; 10 | transition: opacity 100ms ease-out, transform 200ms ease-out; 11 | opacity: 0; 12 | transform: scale(0.95) translateY(20px); 13 | display: flex; 14 | pointer-events: none; 15 | } 16 | 17 | .beakWindow.open { 18 | opacity: 1; 19 | transform: scale(1) translateY(0); 20 | pointer-events: auto; 21 | } 22 | 23 | @media (min-width: 640px) { 24 | .beakWindow { 25 | transform-origin: bottom right; 26 | bottom: 5rem; 27 | right: 1rem; 28 | top: auto; 29 | left: auto; 30 | border-width: 1px; 31 | margin-bottom: 1rem; 32 | width: 24rem; 33 | height: 600px; 34 | min-height: 200px; 35 | max-height: calc(100% - 6rem); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/css/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --beak-primary-color: rgb(59 130 246); 3 | --beak-contrast-color: rgb(255 255 255); 4 | --beak-secondary-color: rgb(243 244 246); 5 | --beak-secondary-contrast-color: rgb(0 0 0); 6 | --beak-background-color: rgb(255 255 255); 7 | --beak-muted-color: rgb(106 106 106); 8 | --beak-separator-color: rgba(0, 0, 0, 0.08); 9 | --beak-scrollbar-color: rgba(0, 0, 0, 0.2); 10 | 11 | --beak-button-background-color: var(--beak-primary-color); 12 | 13 | /* The color of the icon in the open/close button */ 14 | --beak-button-icon-color: var(--beak-contrast-color); 15 | 16 | /* The background color of the header of the chat window */ 17 | --beak-header-background-color: var(--beak-primary-color); 18 | 19 | /* The color of the title in the header of the chat window */ 20 | --beak-header-title-color: var(--beak-contrast-color); 21 | 22 | /* The color of the close button in the header of the chat window */ 23 | --beak-header-close-button-color: var(--beak-contrast-color); 24 | 25 | /* The color of the separator between the header and the messages area */ 26 | --beak-header-separator-color: var(--beak-separator-color); 27 | 28 | /* The background color of the input area */ 29 | --beak-input-background-color: var(--beak-background-color); 30 | 31 | /* The color of the send button */ 32 | --beak-input-send-button-color: var(--beak-primary-color); 33 | 34 | /* The color of the send button when disabled */ 35 | --beak-input-send-button-disabled-color: var(--beak-muted-color); 36 | 37 | /* The color of the placeholder text in the input area */ 38 | --beak-input-placeholder-color: var(--beak-muted-color); 39 | 40 | /* The color of the text in the input area */ 41 | --beak-input-color: var(--beak-secondary-contrast-color); 42 | 43 | /* The color of the separator between the input area and the messages area */ 44 | --beak-input-separator-color: var(--beak-separator-color); 45 | 46 | /* The background color of the messages area */ 47 | --beak-messages-background-color: var(--beak-background-color); 48 | 49 | /* The background color of a user message */ 50 | --beak-message-user-background-color: var(--beak-primary-color); 51 | --beak-message-user-color: var(--beak-contrast-color); 52 | 53 | /* The background color of a assistant message */ 54 | --beak-message-assistant-background-color: var(--beak-secondary-color); 55 | --beak-message-assistant-color: var(--beak-secondary-contrast-color); 56 | 57 | /* 58 | * Dark Mode 59 | */ 60 | 61 | --beak-dark-primary-color: rgb(59 130 246); 62 | --beak-dark-contrast-color: rgb(255 255 255); 63 | --beak-dark-secondary-color: rgb(37, 36, 42); 64 | --beak-dark-secondary-contrast-color: rgb(255 255 255); 65 | --beak-dark-background-color: rgb(0 0 0); 66 | --beak-dark-muted-color: rgb(106 106 106); 67 | --beak-dark-separator-color: rgba(255, 255, 255, 0.15); 68 | --beak-dark-scrollbar-color: rgba(255, 255, 255, 0.3); 69 | --beak-dark-button-background-color: var(--beak-dark-primary-color); 70 | --beak-dark-button-icon-color: var(--beak-dark-contrast-color); 71 | --beak-dark-header-background-color: var(--beak-dark-primary-color); 72 | --beak-dark-header-title-color: var(--beak-dark-contrast-color); 73 | --beak-dark-header-close-button-color: var(--beak-dark-contrast-color); 74 | --beak-dark-header-separator-color: var(--beak-dark-separator-color); 75 | --beak-dark-input-background-color: var(--beak-dark-background-color); 76 | --beak-dark-input-send-button-color: var(--beak-dark-primary-color); 77 | --beak-dark-input-send-button-disabled-color: var(--beak-dark-muted-color); 78 | --beak-dark-input-placeholder-color: var(--beak-dark-muted-color); 79 | --beak-dark-input-color: var(--beak-dark-secondary-contrast-color); 80 | --beak-dark-input-separator-color: var(--beak-dark-separator-color); 81 | --beak-dark-messages-background-color: var(--beak-dark-background-color); 82 | --beak-dark-message-user-background-color: var(--beak-dark-primary-color); 83 | --beak-dark-message-user-color: var(--beak-dark-contrast-color); 84 | --beak-dark-message-assistant-background-color: var( 85 | --beak-dark-secondary-color 86 | ); 87 | --beak-dark-message-assistant-color: var( 88 | --beak-dark-secondary-contrast-color 89 | ); 90 | } 91 | 92 | .beakColorSchemeDark, 93 | .beakColorSchemeDark * { 94 | color-scheme: light dark; 95 | --beak-primary-color: var(--beak-dark-primary-color); 96 | --beak-contrast-color: var(--beak-dark-contrast-color); 97 | --beak-secondary-color: var(--beak-dark-secondary-color); 98 | --beak-secondary-contrast-color: var(--beak-dark-secondary-contrast-color); 99 | --beak-background-color: var(--beak-dark-background-color); 100 | --beak-muted-color: var(--beak-dark-muted-color); 101 | --beak-separator-color: var(--beak-dark-separator-color); 102 | --beak-scrollbar-color: var(--beak-dark-scrollbar-color); 103 | --beak-button-background-color: var(--beak-dark-button-background-color); 104 | --beak-button-icon-color: var(--beak-dark-button-icon-color); 105 | --beak-header-background-color: var(--beak-dark-header-background-color); 106 | --beak-header-title-color: var(--beak-dark-header-title-color); 107 | --beak-header-close-button-color: var(--beak-dark-header-close-button-color); 108 | --beak-header-separator-color: var(--beak-dark-header-separator-color); 109 | --beak-input-background-color: var(--beak-dark-input-background-color); 110 | --beak-input-send-button-color: var(--beak-dark-input-send-button-color); 111 | --beak-input-send-button-disabled-color: var( 112 | --beak-dark-input-send-button-disabled-color 113 | ); 114 | --beak-input-placeholder-color: var(--beak-dark-input-placeholder-color); 115 | --beak-input-color: var(--beak-dark-input-color); 116 | --beak-input-separator-color: var(--beak-dark-input-separator-color); 117 | --beak-messages-background-color: var(--beak-dark-messages-background-color); 118 | --beak-message-user-background-color: var( 119 | --beak-dark-message-user-background-color 120 | ); 121 | --beak-message-user-color: var(--beak-dark-message-user-color); 122 | --beak-message-assistant-background-color: var( 123 | --beak-dark-message-assistant-background-color 124 | ); 125 | --beak-message-assistant-color: var(--beak-dark-message-assistant-color); 126 | } 127 | 128 | @media (prefers-color-scheme: dark) { 129 | .beakColorSchemeAuto, 130 | .beakColorSchemeAuto * { 131 | color-scheme: light dark; 132 | --beak-primary-color: var(--beak-dark-primary-color); 133 | --beak-contrast-color: var(--beak-dark-contrast-color); 134 | --beak-secondary-color: var(--beak-dark-secondary-color); 135 | --beak-secondary-contrast-color: var(--beak-dark-secondary-contrast-color); 136 | --beak-background-color: var(--beak-dark-background-color); 137 | --beak-muted-color: var(--beak-dark-muted-color); 138 | --beak-separator-color: var(--beak-dark-separator-color); 139 | --beak-scrollbar-color: var(--beak-dark-scrollbar-color); 140 | --beak-button-background-color: var(--beak-dark-button-background-color); 141 | --beak-button-icon-color: var(--beak-dark-button-icon-color); 142 | --beak-header-background-color: var(--beak-dark-header-background-color); 143 | --beak-header-title-color: var(--beak-dark-header-title-color); 144 | --beak-header-close-button-color: var( 145 | --beak-dark-header-close-button-color 146 | ); 147 | --beak-header-separator-color: var(--beak-dark-header-separator-color); 148 | --beak-input-background-color: var(--beak-dark-input-background-color); 149 | --beak-input-send-button-color: var(--beak-dark-input-send-button-color); 150 | --beak-input-send-button-disabled-color: var( 151 | --beak-dark-input-send-button-disabled-color 152 | ); 153 | --beak-input-placeholder-color: var(--beak-dark-input-placeholder-color); 154 | --beak-input-color: var(--beak-dark-input-color); 155 | --beak-input-separator-color: var(--beak-dark-input-separator-color); 156 | --beak-messages-background-color: var( 157 | --beak-dark-messages-background-color 158 | ); 159 | --beak-message-user-background-color: var( 160 | --beak-dark-message-user-background-color 161 | ); 162 | --beak-message-user-color: var(--beak-dark-message-user-color); 163 | --beak-message-assistant-background-color: var( 164 | --beak-dark-message-assistant-background-color 165 | ); 166 | --beak-message-assistant-color: var(--beak-dark-message-assistant-color); 167 | } 168 | } 169 | 170 | .beakActivityDot1 { 171 | animation: beakActivityDotsAnimation 1.05s infinite; 172 | } 173 | .beakActivityDot2 { 174 | animation-delay: 0.1s; 175 | } 176 | .beakActivityDot3 { 177 | animation-delay: 0.2s; 178 | } 179 | @keyframes beakActivityDotsAnimation { 180 | 0%, 181 | 57.14% { 182 | animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); 183 | transform: translate(0); 184 | } 185 | 28.57% { 186 | animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); 187 | transform: translateY(-6px); 188 | } 189 | 100% { 190 | transform: translate(0); 191 | } 192 | } 193 | 194 | @keyframes beakSpinAnimation { 195 | to { 196 | transform: rotate(360deg); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /packages/express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beakjs/express", 3 | "version": "0.0.6", 4 | "description": "Beak.js proxy for Express", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "prepublishOnly": "cp ../../README.md .", 10 | "clean": "rm -rf dist && rm -rf node_modules" 11 | }, 12 | "files": [ 13 | "dist/**/*", 14 | "README.md" 15 | ], 16 | "dependencies": { 17 | "@beakjs/server": "0.0.6" 18 | }, 19 | "peerDependencies": { 20 | "express": "^4.18.2" 21 | }, 22 | "devDependencies": { 23 | "express": "^4.18.2", 24 | "@types/express": "^4.17.21" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/mme/beakjs.git" 29 | }, 30 | "author": "Markus Ecker", 31 | "license": "MIT" 32 | } 33 | -------------------------------------------------------------------------------- /packages/express/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { 3 | BeakServer, 4 | BeakServerProps, 5 | HttpAdapter, 6 | FetchChatCompletionParams, 7 | } from "@beakjs/server"; 8 | 9 | export function beakHandler(beakProps?: BeakServerProps) { 10 | const beakServer = new BeakServer(beakProps); 11 | 12 | return async function handler(req: Request, res: Response) { 13 | if (req.path.endsWith("/v1/chat/completions") && req.method === "POST") { 14 | const adapter = createHttpAdapter(res); 15 | try { 16 | const params = await parseBody(req); 17 | await beakServer.handleRequest(req, params, adapter); 18 | } catch (error: any) { 19 | console.error(error); 20 | res.status(500).send("Internal Server Error."); 21 | } 22 | } else { 23 | res.status(404).send("Not found"); 24 | } 25 | }; 26 | } 27 | 28 | function createHttpAdapter(res: Response): HttpAdapter { 29 | return { 30 | onData(data: any) { 31 | res.write(`data: ${JSON.stringify(data)}\n`); 32 | }, 33 | onEnd() { 34 | res.end("[DONE]\n"); 35 | }, 36 | onError(error: any) { 37 | console.error(error); 38 | res.status(500).send(`Error: ${error.message || "An error occurred"}`); 39 | }, 40 | }; 41 | } 42 | 43 | async function parseBody(req: Request): Promise { 44 | if (req.body) { 45 | return req.body as T; 46 | } 47 | if (!req.readable) { 48 | throw new Error("No body found."); 49 | } 50 | const rawData = await new Promise((resolve, reject) => { 51 | let data = ""; 52 | req.on("data", (chunk) => (data += chunk)); 53 | req.on("end", () => resolve(data)); 54 | req.on("error", (err) => reject(err)); 55 | }); 56 | 57 | return JSON.parse(rawData) as T; 58 | } 59 | -------------------------------------------------------------------------------- /packages/express/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist", 11 | "lib": ["es2019", "dom"], 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": false, 18 | "types": ["node"] 19 | }, 20 | "include": ["src"], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beakjs/next", 3 | "version": "0.0.6", 4 | "description": "Beak.js proxy for Next.js", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "prepublishOnly": "cp ../../README.md .", 10 | "clean": "rm -rf dist && rm -rf node_modules" 11 | }, 12 | "files": [ 13 | "dist/**/*", 14 | "README.md" 15 | ], 16 | "dependencies": { 17 | "@beakjs/server": "0.0.6" 18 | }, 19 | "peerDependencies": { 20 | "next": "^14.0.3" 21 | }, 22 | "devDependencies": { 23 | "next": "^14.0.3" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/mme/beakjs.git" 28 | }, 29 | "author": "Markus Ecker", 30 | "license": "MIT" 31 | } 32 | -------------------------------------------------------------------------------- /packages/next/src/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { 3 | BeakServer, 4 | BeakServerProps, 5 | HttpAdapter, 6 | FetchChatCompletionParams, 7 | } from "@beakjs/server"; 8 | 9 | export function beakHandler(beakProps?: BeakServerProps) { 10 | const beakServer = new BeakServer(beakProps); 11 | 12 | async function handler(req: NextApiRequest, res: NextApiResponse) { 13 | if (req.url?.endsWith("/v1/chat/completions") && req.method === "POST") { 14 | const adapter = createHttpAdapter(res); 15 | 16 | try { 17 | const params = req.body as FetchChatCompletionParams; 18 | await beakServer.handleRequest(req, params, adapter); 19 | } catch (error) { 20 | console.error(error); 21 | res.status(500).send("Internal Server Error."); 22 | } 23 | } else { 24 | res.status(404).send("Not found"); 25 | } 26 | } 27 | 28 | return handler; 29 | } 30 | 31 | function createHttpAdapter(res: NextApiResponse): HttpAdapter { 32 | return { 33 | onData(data: any) { 34 | res.write(`data: ${JSON.stringify(data)}\n`); 35 | }, 36 | onEnd() { 37 | res.end("[DONE]\n"); 38 | }, 39 | onError(error: any) { 40 | console.error(error); 41 | res.status(500).send(`Error: ${error.message || "An error occurred"}`); 42 | }, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /packages/next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist", 11 | "lib": ["es2019", "dom"], 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": false, 18 | "types": ["node"] 19 | }, 20 | "include": ["src"], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/openai/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beakjs/openai", 3 | "version": "0.0.6", 4 | "author": "Markus Ecker", 5 | "license": "MIT", 6 | "description": "Beak.js OpenAI library", 7 | "main": "dist/cjs/index.js", 8 | "module": "dist/esm/index.js", 9 | "types": "dist/cjs/index.d.ts", 10 | "scripts": { 11 | "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", 12 | "prepublishOnly": "cp ../../README.md .", 13 | "clean": "rm -rf dist && rm -rf node_modules", 14 | "test": "jest" 15 | }, 16 | "files": [ 17 | "dist/**/*", 18 | "README.md" 19 | ], 20 | "dependencies": { 21 | "eventemitter3": "^5.0.1", 22 | "jsonrepair": "^3.2.4" 23 | }, 24 | "devDependencies": { 25 | "typescript": "^5.0.4", 26 | "@types/jest": "^29.5.1", 27 | "jest": "^29.5.0", 28 | "ts-jest": "^29.1.0" 29 | }, 30 | "peerDependencies": {}, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/mme/beakjs.git" 34 | }, 35 | "jest": { 36 | "testTimeout": 60000, 37 | "transform": { 38 | "^.+\\.(ts|tsx)$": "ts-jest" 39 | }, 40 | "testEnvironment": "node", 41 | "moduleFileExtensions": [ 42 | "ts", 43 | "tsx", 44 | "js" 45 | ], 46 | "testPathIgnorePatterns": [ 47 | "/node_modules/" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/openai/src/chat.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | import { 3 | OpenAIMessage, 4 | OpenAIFunction, 5 | DebugLogger, 6 | NoopDebugLogger, 7 | DEFAULT_MODEL, 8 | } from "./types"; 9 | 10 | interface ChatCompletionConfiguration { 11 | apiKey?: string; 12 | baseUrl?: string; 13 | debugLogger?: DebugLogger; 14 | } 15 | 16 | interface ChatCompletionEvents { 17 | end: void; 18 | data: any; 19 | error: any; 20 | } 21 | 22 | export interface FetchChatCompletionParams { 23 | model?: string; 24 | messages: OpenAIMessage[]; 25 | functions?: OpenAIFunction[]; 26 | functionCall?: "none" | "auto"; 27 | temperature?: number; 28 | maxTokens?: number; 29 | } 30 | 31 | const DEFAULT_BASE_URL = "https://api.openai.com"; 32 | const COMPLETIONS_PATH = "/v1/chat/completions"; 33 | 34 | export class ChatCompletion extends EventEmitter { 35 | private apiKey?: string; 36 | private buffer = new Uint8Array(); 37 | private bodyReader: ReadableStreamDefaultReader | null = null; 38 | private debug: DebugLogger; 39 | private url: string; 40 | 41 | constructor({ apiKey, baseUrl, debugLogger }: ChatCompletionConfiguration) { 42 | super(); 43 | this.apiKey = apiKey; 44 | this.debug = debugLogger || NoopDebugLogger; 45 | this.url = 46 | (baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "") + COMPLETIONS_PATH; 47 | 48 | if (!apiKey && !baseUrl) { 49 | console.warn("No API key or base URL provided."); 50 | } 51 | } 52 | 53 | private async cleanup() { 54 | this.debug.log("chat-internal", "Cleaning up..."); 55 | 56 | if (this.bodyReader) { 57 | try { 58 | await this.bodyReader.cancel(); 59 | } catch (error) { 60 | console.warn("Failed to cancel body reader:", error); 61 | } 62 | } 63 | this.bodyReader = null; 64 | this.buffer = new Uint8Array(); 65 | } 66 | 67 | public async fetchChatCompletion({ 68 | model, 69 | messages, 70 | functions, 71 | functionCall, 72 | temperature, 73 | }: FetchChatCompletionParams): Promise { 74 | await this.cleanup(); 75 | 76 | functionCall ||= "auto"; 77 | temperature ||= 0.5; 78 | functions ||= []; 79 | model ||= DEFAULT_MODEL; 80 | 81 | if (functions.length == 0) { 82 | functionCall = undefined; 83 | } 84 | 85 | try { 86 | this.debug.log("chat-api", "Fetching chat completion..."); 87 | this.debug.table("chat-api", "Params", { 88 | model, 89 | functionCall, 90 | temperature, 91 | }); 92 | this.debug.table("chat-api", "Functions", functions); 93 | this.debug.table("chat-api", "Messages", messages); 94 | 95 | const response = await fetch(this.url, { 96 | method: "POST", 97 | headers: { 98 | "Content-Type": "application/json", 99 | ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), 100 | }, 101 | body: JSON.stringify({ 102 | model, 103 | messages, 104 | stream: true, 105 | ...(functions.length ? { functions } : {}), 106 | ...(temperature ? { temperature } : {}), 107 | ...(functionCall && functions.length 108 | ? { function_call: functionCall } 109 | : {}), 110 | }), 111 | }); 112 | 113 | this.debug.table("chat-api", "Response", { 114 | status: response.status, 115 | statusText: response.statusText, 116 | }); 117 | 118 | if (!response.ok) { 119 | try { 120 | const errorText = await response.text(); 121 | await this.cleanup(); 122 | const msg = `Error ${response.status}: ${errorText}`; 123 | this.debug.error("chat-api", msg); 124 | this.emit("error", new Error(msg)); 125 | } catch (_error) { 126 | await this.cleanup(); 127 | const msg = `Error ${response.status}: ${response.statusText}`; 128 | this.debug.error("chat-api", msg); 129 | this.emit("error", new Error(msg)); 130 | } 131 | return; 132 | } 133 | 134 | if (response.body == null) { 135 | await this.cleanup(); 136 | const msg = "Response body is null"; 137 | this.debug.error("chat-api", msg); 138 | this.emit("error", new Error(msg)); 139 | return; 140 | } 141 | 142 | this.bodyReader = response.body.getReader(); 143 | 144 | await this.streamBody(); 145 | } catch (error) { 146 | await this.cleanup(); 147 | this.debug.error("chat-api", error); 148 | this.emit("error", error); 149 | return; 150 | } 151 | } 152 | 153 | private async streamBody() { 154 | while (true) { 155 | try { 156 | const { done, value } = await this.bodyReader!.read(); 157 | 158 | if (done) { 159 | await this.cleanup(); 160 | this.emit("end"); 161 | return; 162 | } 163 | 164 | const shouldContinue = await this.processData(value); 165 | 166 | if (!shouldContinue) { 167 | return; 168 | } 169 | } catch (error) { 170 | await this.cleanup(); 171 | this.emit("error", error); 172 | return; 173 | } 174 | } 175 | } 176 | 177 | private async processData(data: Uint8Array): Promise { 178 | // Append new data to the temp buffer 179 | const newBuffer = new Uint8Array(this.buffer.length + data.length); 180 | newBuffer.set(this.buffer); 181 | newBuffer.set(data, this.buffer.length); 182 | this.buffer = newBuffer; 183 | 184 | const dataString = new TextDecoder("utf-8").decode(this.buffer); 185 | this.debug.log("chat-internal", "Received data chunk:", dataString); 186 | 187 | let lines = dataString.split("\n").filter((line) => line.trim() !== ""); 188 | 189 | // If the last line isn't complete, keep it in the buffer for next time 190 | if (!dataString.endsWith("\n")) { 191 | const lastLine = lines.pop() || ""; 192 | const remainingBytes = new TextEncoder().encode(lastLine); 193 | this.buffer = new Uint8Array(remainingBytes); 194 | } else { 195 | this.buffer = new Uint8Array(); 196 | } 197 | 198 | for (const line of lines) { 199 | const cleanedLine = line.replace(/^data: /, ""); 200 | 201 | if (cleanedLine === "[DONE]") { 202 | this.debug.log("chat-internal", "Received DONE signal."); 203 | await this.cleanup(); 204 | this.emit("end"); 205 | return false; 206 | } 207 | 208 | let json; 209 | try { 210 | json = JSON.parse(cleanedLine); 211 | } catch (error) { 212 | console.error("Failed to parse JSON:", error); 213 | continue; 214 | } 215 | this.debug.log("chat-internal", "Parsed JSON from line:", json); 216 | 217 | this.emit("data", json); 218 | } 219 | return true; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /packages/openai/src/index.ts: -------------------------------------------------------------------------------- 1 | export { OpenAI } from "./openai"; 2 | export { ChatCompletion } from "./chat"; 3 | export type { 4 | OpenAIModel, 5 | OpenAIMessage, 6 | OpenAIFunction, 7 | CustomModel, 8 | } from "./types"; 9 | export type { FetchChatCompletionParams } from "./chat"; 10 | -------------------------------------------------------------------------------- /packages/openai/src/openai.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | import { jsonrepair } from "jsonrepair"; 3 | import { 4 | OpenAIChatCompletionChunk, 5 | OpenAIModel, 6 | OpenAIFunction, 7 | OpenAIMessage, 8 | DebugLogger, 9 | NoopDebugLogger, 10 | DEFAULT_MODEL, 11 | CustomModel, 12 | } from "./types"; 13 | import { ChatCompletion, FetchChatCompletionParams } from "./chat"; 14 | 15 | interface OpenAIConfiguration { 16 | apiKey?: string; 17 | baseUrl?: string; 18 | model?: OpenAIModel | CustomModel; 19 | debugLogger?: DebugLogger; 20 | } 21 | 22 | interface OpenAIEvents { 23 | content: string; 24 | partial: [string, string]; 25 | error: any; 26 | function: { 27 | name: string; 28 | arguments: any; 29 | }; 30 | end: void; 31 | } 32 | 33 | export class OpenAI extends EventEmitter { 34 | private apiKey?: string; 35 | private baseUrl?: string; 36 | private model: OpenAIModel | CustomModel; 37 | private debug: DebugLogger; 38 | 39 | private completionClient: ChatCompletion | null = null; 40 | private mode: "function" | "message" | null = null; 41 | private functionCallName: string = ""; 42 | private functionCallArguments: string = ""; 43 | 44 | constructor(params: OpenAIConfiguration) { 45 | super(); 46 | this.apiKey = params.apiKey; 47 | this.model = params.model || DEFAULT_MODEL; 48 | this.debug = params.debugLogger || NoopDebugLogger; 49 | this.baseUrl = params.baseUrl; 50 | } 51 | 52 | public async queryChatCompletion(params: FetchChatCompletionParams) { 53 | params = { ...params }; 54 | if (!(this.model instanceof CustomModel)) { 55 | params.maxTokens ||= maxTokensForModel(this.model); 56 | } else if (!params.maxTokens) { 57 | throw new Error("maxTokens must be specified for custom models."); 58 | } 59 | params.functions ||= []; 60 | params.model = 61 | this.model instanceof CustomModel ? this.model.name : this.model; 62 | params.messages = this.buildPrompt(params); 63 | return await this.runPrompt(params); 64 | } 65 | 66 | private buildPrompt(params: FetchChatCompletionParams): OpenAIMessage[] { 67 | let maxTokens = params.maxTokens!; 68 | const messages = params.messages!; 69 | const functions = params.functions!; 70 | const functionsNumTokens = countFunctionsTokens(functions); 71 | if (functionsNumTokens > maxTokens) { 72 | throw new Error( 73 | `Too many tokens in function calls: ${functionsNumTokens} > ${maxTokens}` 74 | ); 75 | } 76 | maxTokens -= functionsNumTokens; 77 | 78 | for (const message of messages) { 79 | if (message.role === "system") { 80 | const numTokens = message.numTokens || this.countTokens(message); 81 | maxTokens -= numTokens; 82 | 83 | if (maxTokens < 0) { 84 | throw new Error("Not enough tokens for system message."); 85 | } 86 | } 87 | } 88 | 89 | const result: OpenAIMessage[] = []; 90 | let cutoff: boolean = false; 91 | 92 | const reversedMessages = [...messages].reverse(); 93 | for (const message of reversedMessages) { 94 | if (message.role === "system") { 95 | result.unshift(message); 96 | continue; 97 | } else if (cutoff) { 98 | continue; 99 | } 100 | let numTokens = message.numTokens || this.countTokens(message); 101 | if (maxTokens < numTokens) { 102 | cutoff = true; 103 | continue; 104 | } 105 | result.unshift(message); 106 | maxTokens -= numTokens; 107 | } 108 | 109 | return result; 110 | } 111 | 112 | private async runPrompt(params: FetchChatCompletionParams): Promise { 113 | this.completionClient = new ChatCompletion({ 114 | apiKey: this.apiKey, 115 | baseUrl: this.baseUrl, 116 | debugLogger: this.debug, 117 | }); 118 | 119 | this.completionClient.on("data", this.onData); 120 | this.completionClient.on("error", this.onError); 121 | this.completionClient.on("end", this.onEnd); 122 | 123 | await this.completionClient.fetchChatCompletion(params); 124 | } 125 | 126 | private onData = (data: OpenAIChatCompletionChunk) => { 127 | // In case we are in a function call but the next message is not a function call, flush it. 128 | if (this.mode === "function" && !data.choices[0].delta.function_call) { 129 | const success = this.tryFlushFunctionCall(); 130 | if (!success) { 131 | return; 132 | } 133 | } 134 | 135 | this.mode = data.choices[0].delta.function_call ? "function" : "message"; 136 | 137 | if (this.mode === "message") { 138 | // if we get a message, emit the content and return; 139 | 140 | if (data.choices[0].delta.content) { 141 | this.emit("content", data.choices[0].delta.content); 142 | } 143 | 144 | return; 145 | } else if (this.mode === "function") { 146 | // if we get a function call, we buffer the name and arguments, then emit a partial event. 147 | 148 | if (data.choices[0].delta.function_call!.name) { 149 | this.functionCallName = data.choices[0].delta.function_call!.name!; 150 | } 151 | if (data.choices[0].delta.function_call!.arguments) { 152 | this.functionCallArguments += 153 | data.choices[0].delta.function_call!.arguments!; 154 | } 155 | this.emit("partial", this.functionCallName, this.functionCallArguments); 156 | 157 | return; 158 | } 159 | }; 160 | 161 | private onError = (error: any) => { 162 | this.emit("error", error); 163 | this.cleanup(); 164 | }; 165 | 166 | private onEnd = () => { 167 | if (this.mode === "function") { 168 | const success = this.tryFlushFunctionCall(); 169 | if (!success) { 170 | return; 171 | } 172 | } 173 | this.emit("end"); 174 | this.cleanup(); 175 | }; 176 | 177 | private tryFlushFunctionCall(): boolean { 178 | let args: any = null; 179 | try { 180 | args = JSON.parse(fixJson(this.functionCallArguments)); 181 | } catch (error) { 182 | this.emit("error", error); 183 | this.cleanup(); 184 | return false; 185 | } 186 | this.emit("function", { 187 | name: this.functionCallName, 188 | arguments: args, 189 | }); 190 | this.mode = null; 191 | this.functionCallName = ""; 192 | this.functionCallArguments = ""; 193 | return true; 194 | } 195 | 196 | private cleanup() { 197 | if (this.completionClient) { 198 | this.completionClient.off("data", this.onData); 199 | this.completionClient.off("error", this.onError); 200 | this.completionClient.off("end", this.onEnd); 201 | } 202 | this.completionClient = null; 203 | this.mode = null; 204 | this.functionCallName = ""; 205 | this.functionCallArguments = ""; 206 | } 207 | 208 | public countTokens(message: OpenAIMessage): number { 209 | if (message.content) { 210 | return estimateTokens(message.content); 211 | } else if (message.function_call) { 212 | return estimateTokens(JSON.stringify(message.function_call)); 213 | } 214 | return 0; 215 | } 216 | } 217 | 218 | const maxTokensByModel: { [key in OpenAIModel]: number } = { 219 | "gpt-3.5-turbo": 4097, 220 | "gpt-3.5-turbo-16k": 16385, 221 | "gpt-4": 8192, 222 | "gpt-4-32k": 32768, 223 | "gpt-3.5-turbo-0301": 4097, 224 | "gpt-4-0314": 8192, 225 | "gpt-4-32k-0314": 32768, 226 | "gpt-3.5-turbo-0613": 4097, 227 | "gpt-4-0613": 8192, 228 | "gpt-4-32k-0613": 32768, 229 | "gpt-3.5-turbo-16k-0613": 16385, 230 | }; 231 | 232 | function estimateTokens(text: string): number { 233 | return text.length / 3; 234 | } 235 | 236 | function maxTokensForModel(model: OpenAIModel): number { 237 | return maxTokensByModel[model]; 238 | } 239 | 240 | function fixJson(json: string): string { 241 | if (json === "") { 242 | json = "{}"; 243 | } 244 | json = json.trim(); 245 | if (!json.startsWith("{")) { 246 | json = "{" + json; 247 | } 248 | if (!json.endsWith("}")) { 249 | json = json + "}"; 250 | } 251 | return jsonrepair(json); 252 | } 253 | 254 | function countFunctionsTokens(functions: OpenAIFunction[]): number { 255 | if (functions.length === 0) { 256 | return 0; 257 | } 258 | const json = JSON.stringify(functions); 259 | return estimateTokens(json); 260 | } 261 | -------------------------------------------------------------------------------- /packages/openai/src/types.ts: -------------------------------------------------------------------------------- 1 | export type OpenAIRole = "system" | "user" | "assistant" | "function"; 2 | 3 | export type OpenAIModel = 4 | | "gpt-3.5-turbo" 5 | | "gpt-3.5-turbo-16k" 6 | | "gpt-4" 7 | | "gpt-4-32k" 8 | | "gpt-3.5-turbo-0301" 9 | | "gpt-4-0314" 10 | | "gpt-4-32k-0314" 11 | | "gpt-3.5-turbo-0613" 12 | | "gpt-4-0613" 13 | | "gpt-4-32k-0613" 14 | | "gpt-3.5-turbo-16k-0613"; 15 | 16 | export class CustomModel { 17 | constructor(public name: string) {} 18 | } 19 | 20 | export interface OpenAIChatCompletionChunk { 21 | choices: { 22 | delta: { 23 | role: OpenAIRole; 24 | content?: string | null; 25 | function_call?: { 26 | name?: string; 27 | arguments?: string; 28 | }; 29 | }; 30 | }[]; 31 | } 32 | 33 | export interface OpenAIFunction { 34 | name: string; 35 | parameters: Record; 36 | description?: string; 37 | } 38 | 39 | export interface OpenAIMessage { 40 | content?: string; 41 | role: OpenAIRole; 42 | numTokens?: number; 43 | name?: string; 44 | function_call?: any; 45 | } 46 | 47 | type DebugEvent = "chat-internal" | "chat-api" | "beak-complete"; 48 | 49 | export interface DebugLogger { 50 | log(debugEvent: DebugEvent, ...args: any[]): void; 51 | table(debugEvent: DebugEvent, message: string, ...args: any[]): void; 52 | warn(debugEvent: DebugEvent, ...args: any[]): void; 53 | error(debugEvent: DebugEvent, ...args: any[]): void; 54 | } 55 | 56 | export const NoopDebugLogger: DebugLogger = { 57 | log() {}, 58 | table() {}, 59 | warn() {}, 60 | error() {}, 61 | }; 62 | 63 | export const DEFAULT_MODEL = "gpt-4"; 64 | -------------------------------------------------------------------------------- /packages/openai/test/chat.test.ts: -------------------------------------------------------------------------------- 1 | import { mockContentResponses, mockFunctionResponse } from "./utils"; 2 | import { ChatCompletion } from "../src/chat"; 3 | import { OpenAIFunction, OpenAIMessage } from "../src/types"; 4 | 5 | global.fetch = jest.fn(); 6 | 7 | describe("ChatCompletion", () => { 8 | it("should stream chat completion events correctly", async () => { 9 | const model = "gpt-3.5-turbo"; 10 | 11 | const mockStream = new ReadableStream({ 12 | start(controller) { 13 | for (let data of mockContentResponses("Hello world!", model)) { 14 | controller.enqueue(new TextEncoder().encode(data)); 15 | } 16 | controller.close(); 17 | }, 18 | }); 19 | (fetch as jest.Mock).mockResolvedValue({ 20 | ok: true, 21 | status: 200, 22 | statusText: "OK", 23 | body: mockStream, 24 | }); 25 | 26 | const apiKey = "sk-xyz"; 27 | const chatCompletion = new ChatCompletion({ apiKey }); 28 | 29 | const onDataMock = jest.fn(); 30 | const onErrorMock = jest.fn(); 31 | const onEndMock = jest.fn(); 32 | chatCompletion.on("data", onDataMock); 33 | chatCompletion.on("error", onErrorMock); 34 | chatCompletion.on("end", onEndMock); 35 | 36 | const messages: OpenAIMessage[] = [ 37 | { 38 | role: "user", 39 | content: "Hello!", 40 | }, 41 | ]; 42 | await chatCompletion.fetchChatCompletion({ 43 | model, 44 | messages, 45 | }); 46 | 47 | expect(onDataMock).toHaveBeenCalledTimes(5); 48 | expect(onErrorMock).not.toHaveBeenCalled(); 49 | expect(onEndMock).toHaveBeenCalledTimes(1); 50 | 51 | const extractedData = onDataMock.mock.calls.map((call) => { 52 | const arg = call[0]; 53 | const choice = arg.choices[0]; 54 | return { 55 | delta: choice.delta, 56 | finish_reason: choice.finish_reason, 57 | }; 58 | }); 59 | 60 | expect(extractedData[0].delta).toEqual({ content: "" }); 61 | expect(extractedData[0].finish_reason).toBe(null); 62 | expect(extractedData[1].delta).toEqual({ content: "Hell" }); 63 | expect(extractedData[1].finish_reason).toBe(null); 64 | expect(extractedData[2].delta).toEqual({ content: "o wo" }); 65 | expect(extractedData[2].finish_reason).toBe(null); 66 | expect(extractedData[3].delta).toEqual({ content: "rld!" }); 67 | expect(extractedData[3].finish_reason).toBe(null); 68 | expect(extractedData[4].delta).toEqual({}); 69 | expect(extractedData[4].finish_reason).toBe("stop"); 70 | }); 71 | 72 | it("should stream chat completion function events correctly", async () => { 73 | const model = "gpt-3.5-turbo"; 74 | 75 | const mockStream = new ReadableStream({ 76 | start(controller) { 77 | for (let data of mockFunctionResponse( 78 | "sayHi", 79 | { name: "Markus" }, 80 | model 81 | )) { 82 | controller.enqueue(new TextEncoder().encode(data)); 83 | } 84 | controller.close(); 85 | }, 86 | }); 87 | 88 | (fetch as any).mockResolvedValue({ 89 | ok: true, 90 | status: 200, 91 | statusText: "OK", 92 | body: mockStream, 93 | }); 94 | 95 | const apiKey = "sk-xyz"; 96 | const chatCompletion = new ChatCompletion({ apiKey }); 97 | 98 | const onDataMock = jest.fn(); 99 | const onErrorMock = jest.fn(); 100 | const onEndMock = jest.fn(); 101 | chatCompletion.on("data", onDataMock); 102 | chatCompletion.on("error", onErrorMock); 103 | chatCompletion.on("end", onEndMock); 104 | 105 | const messages: OpenAIMessage[] = [ 106 | { 107 | role: "user", 108 | content: "Hello!", 109 | }, 110 | ]; 111 | const functions: OpenAIFunction[] = [ 112 | { 113 | name: "sayHi", 114 | parameters: { 115 | type: "object", 116 | properties: { 117 | name: { 118 | type: "string", 119 | }, 120 | }, 121 | }, 122 | description: "Say hi to someone.", 123 | }, 124 | ]; 125 | await chatCompletion.fetchChatCompletion({ 126 | model, 127 | messages, 128 | functions, 129 | }); 130 | 131 | expect(onDataMock).toHaveBeenCalledTimes(7); 132 | expect(onErrorMock).not.toHaveBeenCalled(); 133 | expect(onEndMock).toHaveBeenCalledTimes(1); 134 | 135 | const extractedData = onDataMock.mock.calls.map((call) => { 136 | const arg = call[0]; 137 | const choice = arg.choices[0]; 138 | return { 139 | delta: choice.delta, 140 | finish_reason: choice.finish_reason, 141 | }; 142 | }); 143 | 144 | expect(extractedData[0].delta).toEqual({ 145 | role: "assistant", 146 | content: null, 147 | function_call: { 148 | name: "sayHi", 149 | arguments: "", 150 | }, 151 | }); 152 | expect(extractedData[0].finish_reason).toBe(null); 153 | expect(extractedData[1].delta).toEqual({ 154 | function_call: { 155 | arguments: '{"na', 156 | }, 157 | }); 158 | expect(extractedData[1].finish_reason).toBe(null); 159 | expect(extractedData[2].delta).toEqual({ 160 | function_call: { 161 | arguments: 'me":', 162 | }, 163 | }); 164 | expect(extractedData[2].finish_reason).toBe(null); 165 | expect(extractedData[3].delta).toEqual({ 166 | function_call: { 167 | arguments: '"Mar', 168 | }, 169 | }); 170 | expect(extractedData[3].finish_reason).toBe(null); 171 | expect(extractedData[4].delta).toEqual({ 172 | function_call: { 173 | arguments: 'kus"', 174 | }, 175 | }); 176 | expect(extractedData[4].finish_reason).toBe(null); 177 | expect(extractedData[5].delta).toEqual({ 178 | function_call: { 179 | arguments: "}", 180 | }, 181 | }); 182 | expect(extractedData[5].finish_reason).toBe(null); 183 | expect(extractedData[6].delta).toEqual({}); 184 | expect(extractedData[6].finish_reason).toBe("function_call"); 185 | }); 186 | 187 | it("should handle HTTP error events correctly", async () => { 188 | (fetch as any).mockRejectedValueOnce({ 189 | response: { 190 | ok: false, 191 | status: 500, 192 | statusText: "Internal Server Error", 193 | }, 194 | }); 195 | 196 | const apiKey = "sk-xyz"; 197 | const chatCompletion = new ChatCompletion({ apiKey }); 198 | 199 | const onDataMock = jest.fn(); 200 | const onErrorMock = jest.fn(); 201 | const onEndMock = jest.fn(); 202 | chatCompletion.on("data", onDataMock); 203 | chatCompletion.on("error", onErrorMock); 204 | chatCompletion.on("end", onEndMock); 205 | 206 | const model = "gpt-3.5-turbo"; 207 | const messages: OpenAIMessage[] = [ 208 | { 209 | role: "user", 210 | content: "Hello!", 211 | }, 212 | ]; 213 | 214 | await chatCompletion.fetchChatCompletion({ 215 | model, 216 | messages, 217 | }); 218 | 219 | expect(onDataMock).not.toHaveBeenCalled(); 220 | expect(onErrorMock).toHaveBeenCalledTimes(1); 221 | expect(onEndMock).not.toHaveBeenCalled(); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /packages/openai/test/openai.test.ts: -------------------------------------------------------------------------------- 1 | import { mockContentResponses, mockFunctionResponse } from "./utils"; 2 | import { OpenAI, FetchChatCompletionParams } from "../src"; 3 | import { OpenAIMessage } from "../src/types"; 4 | 5 | global.fetch = jest.fn(); 6 | 7 | describe("OpenAI", () => { 8 | it("should handle stream content events correctly", async () => { 9 | const model = "gpt-3.5-turbo"; 10 | 11 | const mockStream = new ReadableStream({ 12 | start(controller) { 13 | for (let data of mockContentResponses("Hello world!", model)) { 14 | controller.enqueue(new TextEncoder().encode(data)); 15 | } 16 | controller.close(); 17 | }, 18 | }); 19 | 20 | (fetch as jest.Mock).mockResolvedValue({ 21 | ok: true, 22 | status: 200, 23 | statusText: "OK", 24 | body: mockStream, 25 | }); 26 | 27 | const apiKey = "sk-xyz"; 28 | const openai = new OpenAI({ apiKey, model }); 29 | 30 | const onContentMock = jest.fn(); 31 | const onFunctionMock = jest.fn(); 32 | const onErrorMock = jest.fn(); 33 | const onEndMock = jest.fn(); 34 | 35 | openai.on("content", onContentMock); 36 | openai.on("function", onFunctionMock); 37 | openai.on("error", onErrorMock); 38 | openai.on("end", onEndMock); 39 | 40 | await openai.queryChatCompletion({ 41 | messages: [{ role: "user", content: "Hello!" }], 42 | }); 43 | 44 | expect(onContentMock).toHaveBeenCalledTimes(3); 45 | expect(onFunctionMock).not.toHaveBeenCalled(); 46 | expect(onErrorMock).not.toHaveBeenCalled(); 47 | expect(onEndMock).toHaveBeenCalledTimes(1); 48 | 49 | const extractedData = onContentMock.mock.calls.map((call) => { 50 | return call[0]; 51 | }); 52 | 53 | expect(extractedData[0]).toEqual("Hell"); 54 | expect(extractedData[1]).toEqual("o wo"); 55 | expect(extractedData[2]).toEqual("rld!"); 56 | }); 57 | 58 | it("should handle function events correctly", async () => { 59 | const model = "gpt-3.5-turbo"; 60 | 61 | // Set up the ReadableStream for the fetch mock 62 | const mockStream = new ReadableStream({ 63 | start(controller) { 64 | for (let data of mockFunctionResponse( 65 | "sayHello", 66 | { name: "world" }, 67 | model 68 | )) { 69 | controller.enqueue(new TextEncoder().encode(data)); 70 | } 71 | controller.close(); 72 | }, 73 | }); 74 | 75 | // Mock the fetch function 76 | (fetch as jest.Mock).mockResolvedValue({ 77 | ok: true, 78 | status: 200, 79 | statusText: "OK", 80 | body: mockStream, 81 | }); 82 | 83 | const apiKey = "sk-xyz"; 84 | const openai = new OpenAI({ apiKey, model }); 85 | 86 | const onContentMock = jest.fn(); 87 | const onFunctionMock = jest.fn(); 88 | const onErrorMock = jest.fn(); 89 | const onEndMock = jest.fn(); 90 | 91 | openai.on("content", onContentMock); 92 | openai.on("function", onFunctionMock); 93 | openai.on("error", onErrorMock); 94 | openai.on("end", onEndMock); 95 | 96 | await openai.queryChatCompletion({ 97 | messages: [{ role: "user", content: "Say hello to the world!" }], 98 | functions: [ 99 | { 100 | name: "sayHello", 101 | description: "Say hello to someone", 102 | parameters: { 103 | name: { 104 | type: "string", 105 | description: "The name of the person to say hello to", 106 | }, 107 | }, 108 | }, 109 | ], 110 | }); 111 | 112 | expect(onContentMock).not.toHaveBeenCalled(); 113 | expect(onFunctionMock).toHaveBeenCalledTimes(1); 114 | expect(onErrorMock).not.toHaveBeenCalled(); 115 | expect(onEndMock).toHaveBeenCalledTimes(1); 116 | 117 | const extractedData = onFunctionMock.mock.calls[0][0]; 118 | 119 | expect(extractedData).toEqual({ 120 | name: "sayHello", 121 | arguments: { 122 | name: "world", 123 | }, 124 | }); 125 | }); 126 | 127 | it("should handle partial function events correctly", async () => { 128 | const model = "gpt-3.5-turbo"; 129 | 130 | // Set up the ReadableStream for the fetch mock 131 | const mockStream = new ReadableStream({ 132 | start(controller) { 133 | for (let data of mockFunctionResponse( 134 | "sayHello", 135 | { name: "world" }, 136 | model 137 | )) { 138 | controller.enqueue(new TextEncoder().encode(data)); 139 | } 140 | controller.close(); 141 | }, 142 | }); 143 | 144 | // Mock the fetch function 145 | (fetch as jest.Mock).mockResolvedValue({ 146 | ok: true, 147 | status: 200, 148 | statusText: "OK", 149 | body: mockStream, 150 | }); 151 | 152 | const apiKey = "sk-xyz"; 153 | 154 | const openai = new OpenAI({ apiKey, model }); 155 | 156 | const onContentMock = jest.fn(); 157 | const onFunctionMock = jest.fn(); 158 | const onPartialMock = jest.fn(); 159 | const onErrorMock = jest.fn(); 160 | const onEndMock = jest.fn(); 161 | 162 | openai.on("content", onContentMock); 163 | openai.on("function", onFunctionMock); 164 | openai.on("partial", onPartialMock); 165 | openai.on("error", onErrorMock); 166 | openai.on("end", onEndMock); 167 | 168 | const content = "Hello!"; 169 | await openai.queryChatCompletion({ 170 | messages: [{ role: "user", content }], 171 | functions: [ 172 | { 173 | name: "sayHello", 174 | description: "Say hello to someone", 175 | parameters: { 176 | name: { 177 | type: "string", 178 | description: "The name of the person to say hello to", 179 | }, 180 | }, 181 | }, 182 | ], 183 | }); 184 | 185 | expect(onContentMock).not.toHaveBeenCalled(); 186 | expect(onFunctionMock).toHaveBeenCalledTimes(1); 187 | expect(onErrorMock).not.toHaveBeenCalled(); 188 | expect(onEndMock).toHaveBeenCalledTimes(1); 189 | 190 | const extractedData = onFunctionMock.mock.calls[0][0]; 191 | 192 | expect(extractedData).toEqual({ 193 | name: "sayHello", 194 | arguments: { 195 | name: "world", 196 | }, 197 | }); 198 | 199 | const extractedPartialData = onPartialMock.mock.calls.map((call) => { 200 | return { 201 | name: call[0], 202 | args: call[1], 203 | }; 204 | }); 205 | 206 | expect(extractedPartialData[0]).toEqual({ name: "sayHello", args: "" }); 207 | expect(extractedPartialData[1]).toEqual({ name: "sayHello", args: '{"na' }); 208 | expect(extractedPartialData[2]).toEqual({ 209 | name: "sayHello", 210 | args: '{"name":', 211 | }); 212 | expect(extractedPartialData[3]).toEqual({ 213 | name: "sayHello", 214 | args: '{"name":"wor', 215 | }); 216 | expect(extractedPartialData[4]).toEqual({ 217 | name: "sayHello", 218 | args: '{"name":"world"}', 219 | }); 220 | }); 221 | 222 | it("should handle HTTP error events correctly", async () => { 223 | (fetch as any).mockRejectedValueOnce({ 224 | response: { 225 | ok: false, 226 | status: 500, 227 | statusText: "Internal Server Error", 228 | }, 229 | }); 230 | 231 | const apiKey = "sk-xyz"; 232 | const model = "gpt-3.5-turbo"; 233 | 234 | const openai = new OpenAI({ apiKey, model }); 235 | 236 | const onContentMock = jest.fn(); 237 | const onFunctionMock = jest.fn(); 238 | const onErrorMock = jest.fn(); 239 | const onEndMock = jest.fn(); 240 | 241 | openai.on("content", onContentMock); 242 | openai.on("function", onFunctionMock); 243 | openai.on("error", onErrorMock); 244 | openai.on("end", onEndMock); 245 | 246 | await openai.queryChatCompletion({ 247 | messages: [{ role: "user", content: "Hello!" }], 248 | }); 249 | 250 | expect(onContentMock).not.toHaveBeenCalled(); 251 | expect(onFunctionMock).not.toHaveBeenCalled(); 252 | expect(onErrorMock).toHaveBeenCalledTimes(1); 253 | expect(onEndMock).not.toHaveBeenCalled(); 254 | }); 255 | 256 | it("does not remove messages when enough tokens are available", async () => { 257 | let openai = new OpenAI({ apiKey: "sk-xyz", model: "gpt-3.5-turbo" }); 258 | const messages: OpenAIMessage[] = [ 259 | { role: "user", content: "Hello world!" }, 260 | ]; 261 | let params: FetchChatCompletionParams = { 262 | messages: messages, 263 | maxTokens: 5, 264 | functions: [], 265 | }; 266 | let prompt = (openai as any).buildPrompt(params); 267 | expect(prompt).toEqual(messages); 268 | }); 269 | 270 | it("does not remove messages when enough tokens are available", async () => { 271 | let openai = new OpenAI({ apiKey: "sk-xyz", model: "gpt-3.5-turbo" }); 272 | const messages: OpenAIMessage[] = [ 273 | { role: "user", content: "Hello world!" }, 274 | { role: "user", content: "Hallo welt!" }, 275 | ]; 276 | let params: FetchChatCompletionParams = { 277 | messages: messages, 278 | maxTokens: 10, 279 | functions: [], 280 | }; 281 | let prompt = (openai as any).buildPrompt(params); 282 | 283 | expect(prompt).toEqual(messages); 284 | }); 285 | 286 | it("does remove messages when too few tokens are available", async () => { 287 | let openai = new OpenAI({ apiKey: "sk-xyz", model: "gpt-3.5-turbo" }); 288 | const messages: OpenAIMessage[] = [ 289 | { role: "user", content: "Hello world!" }, 290 | { role: "user", content: "Hallo welt!" }, 291 | ]; 292 | let params: FetchChatCompletionParams = { 293 | messages: messages, 294 | maxTokens: 5, 295 | functions: [], 296 | }; 297 | let prompt = (openai as any).buildPrompt(params); 298 | expect(prompt).toEqual([ 299 | { 300 | content: "Hallo welt!", 301 | role: "user", 302 | }, 303 | ]); 304 | }); 305 | 306 | it("does not remove system messages", async () => { 307 | let openai = new OpenAI({ apiKey: "sk-xyz", model: "gpt-3.5-turbo" }); 308 | const messages: OpenAIMessage[] = [ 309 | { role: "system", content: "Hola mundo!" }, 310 | { role: "user", content: "Hello world!" }, 311 | { role: "user", content: "Hallo welt!" }, 312 | ]; 313 | let params: FetchChatCompletionParams = { 314 | messages: messages, 315 | maxTokens: 10, 316 | functions: [], 317 | }; 318 | let prompt = (openai as any).buildPrompt(params); 319 | expect(prompt).toEqual([ 320 | { 321 | content: "Hola mundo!", 322 | role: "system", 323 | }, 324 | { 325 | content: "Hallo welt!", 326 | role: "user", 327 | }, 328 | ]); 329 | }); 330 | }); 331 | -------------------------------------------------------------------------------- /packages/openai/test/utils.ts: -------------------------------------------------------------------------------- 1 | export function mockContentResponses( 2 | sentence: string, 3 | model: string 4 | ): string[] { 5 | const responses: any[] = []; 6 | const chunks: string[] = []; 7 | let start = 0; 8 | const chunkSize = 4; 9 | 10 | while (start < sentence.length) { 11 | chunks.push(sentence.slice(start, start + chunkSize)); 12 | start += chunkSize; 13 | } 14 | 15 | const id = `chatcmpl-aaaaaaaaaaaaaaaaaaaaaa`; 16 | const timestamp = Math.floor(Date.now() / 1000); 17 | 18 | responses.push(createJsonString(id, timestamp, model, "")); 19 | 20 | for (const chunk of chunks) { 21 | responses.push(createJsonString(id, timestamp, model, chunk)); 22 | } 23 | 24 | responses.push(createJsonString(id, timestamp, model, null, "stop")); 25 | responses.push("data: [DONE]\n"); 26 | 27 | return responses; 28 | } 29 | 30 | function createJsonString( 31 | id: string, 32 | timestamp: number, 33 | model: string, 34 | content: string | null, 35 | finishReason: string | null = null 36 | ): string { 37 | const jsonString = { 38 | id: id, 39 | object: "chat.completion.chunk", 40 | created: timestamp, 41 | model: model, 42 | choices: [ 43 | { 44 | index: 0, 45 | delta: content !== null ? { content: content } : {}, 46 | finish_reason: finishReason, 47 | }, 48 | ], 49 | }; 50 | 51 | return `data: ${JSON.stringify(jsonString)}\n`; 52 | } 53 | 54 | export function mockFunctionResponse( 55 | functionName: string, 56 | functionArgs: any, 57 | model: string 58 | ): string[] { 59 | const serializedArgs = JSON.stringify(functionArgs); 60 | const chunks: any[] = []; 61 | let start = 0; 62 | const chunkSize = 4; 63 | 64 | while (start < serializedArgs.length) { 65 | chunks.push(serializedArgs.slice(start, start + chunkSize)); 66 | start += chunkSize; 67 | } 68 | 69 | const id = "chatcmpl-aaaaaaaaaaaaaaaaaaaaaa"; 70 | const timestamp = Math.floor(Date.now() / 1000); 71 | 72 | let responses: string[] = []; 73 | 74 | responses.push( 75 | createFunctionJsonString(id, timestamp, model, functionName, null) 76 | ); 77 | 78 | for (const chunk of chunks) { 79 | responses.push(createFunctionJsonString(id, timestamp, model, null, chunk)); 80 | } 81 | 82 | responses.push( 83 | createFunctionJsonString(id, timestamp, model, null, null, "function_call") 84 | ); 85 | 86 | responses.push("data: [DONE]\n"); 87 | 88 | return responses; 89 | } 90 | 91 | function createFunctionJsonString( 92 | id: string, 93 | timestamp: number, 94 | model: string, 95 | name: string | null, 96 | content: string | null, 97 | finishReason: string | null = null 98 | ): string { 99 | let delta = {}; 100 | if (content !== null) { 101 | delta = { 102 | function_call: { 103 | arguments: content, 104 | }, 105 | }; 106 | } else if (name !== null) { 107 | delta = { 108 | role: "assistant", 109 | content: null, 110 | function_call: { 111 | name: name, 112 | arguments: "", 113 | }, 114 | }; 115 | } 116 | const jsonString = { 117 | id: id, 118 | object: "chat.completion.chunk", 119 | created: timestamp, 120 | model: model, 121 | choices: [ 122 | { 123 | index: 0, 124 | delta: delta, 125 | finish_reason: finishReason, 126 | }, 127 | ], 128 | }; 129 | 130 | return `data: ${JSON.stringify(jsonString)}\n`; 131 | } 132 | -------------------------------------------------------------------------------- /packages/openai/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "CommonJS", 5 | "declaration": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist/cjs", 11 | "lib": ["DOM"], 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": false 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/openai/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist/esm", 11 | "lib": ["DOM"], 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": false 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beakjs/react", 3 | "version": "0.0.6", 4 | "description": "React library for Beak.js", 5 | "main": "dist/beakjs.umd.js", 6 | "module": "dist/beakjs.es.mjs", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | "import": "./dist/beakjs.es.mjs", 10 | "require": "./dist/beakjs.umd.js", 11 | "types": "./dist/index.d.ts" 12 | }, 13 | "scripts": { 14 | "build": "vite build", 15 | "prepublishOnly": "cp ../../README.md .", 16 | "clean": "rm -rf dist && rm -rf node_modules" 17 | }, 18 | "files": [ 19 | "dist/**/*", 20 | "README.md" 21 | ], 22 | "dependencies": { 23 | "@beakjs/core": "0.0.6" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^18.2.15", 27 | "@types/react-dom": "^18.2.7", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "rollup-plugin-visualizer": "^5.9.2", 31 | "typescript": "^5.0.4", 32 | "vite": "^4.3.5", 33 | "vite-plugin-css-injected-by-js": "^3.3.0", 34 | "vite-plugin-dts": "^3.6.3", 35 | "vite-tsconfig-paths": "^4.2.0" 36 | }, 37 | "peerDependencies": { 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0" 40 | }, 41 | "repository": { 42 | "type": "git", 43 | "url": "https://github.com/mme/beakjs.git" 44 | }, 45 | "author": "Markus Ecker", 46 | "license": "MIT" 47 | } 48 | -------------------------------------------------------------------------------- /packages/react/src/Assistant.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo } from "react"; 2 | import { 3 | BeakColorScheme, 4 | BeakContext, 5 | BeakIcons, 6 | useBeakContext, 7 | } from "./Beak"; 8 | import { Message } from "@beakjs/core"; 9 | import { 10 | ButtonProps, 11 | HeaderProps, 12 | WindowProps, 13 | MessagesProps, 14 | InputProps, 15 | } from "./props"; 16 | import { Window as DefaultWindow } from "./Window"; 17 | import { Button as DefaultButton } from "./Button"; 18 | import { Header as DefaultHeader } from "./Header"; 19 | import { Messages as DefaultMessages } from "./Messages"; 20 | import { Input as DefaultInput } from "./Input"; 21 | import "../../css/Assistant.css"; 22 | 23 | interface AssistantWindowProps { 24 | defaultOpen?: boolean; 25 | clickOutsideToClose?: boolean; 26 | hitEscapeToClose?: boolean; 27 | hotkey?: string; 28 | icons?: BeakIcons; 29 | colorScheme?: BeakColorScheme; 30 | Window?: React.ComponentType; 31 | Button?: React.ComponentType; 32 | Header?: React.ComponentType; 33 | Messages?: React.ComponentType; 34 | Input?: React.ComponentType; 35 | } 36 | 37 | export const AssistantWindow: React.FC = ({ 38 | defaultOpen = false, 39 | clickOutsideToClose = true, 40 | hitEscapeToClose = true, 41 | hotkey = "K", 42 | icons, 43 | colorScheme, 44 | Window = DefaultWindow, 45 | Button = DefaultButton, 46 | Header = DefaultHeader, 47 | Messages = DefaultMessages, 48 | Input = DefaultInput, 49 | }) => { 50 | const context = useBeakContext(); 51 | const beak = context.beak; 52 | 53 | const [open, setOpen] = React.useState(defaultOpen); 54 | const [messages, setMessages] = React.useState( 55 | initialMessages(context.labels.initial) 56 | ); 57 | 58 | const inProgress = messages.some((message) => message.status === "pending"); 59 | 60 | const onChange = useCallback(() => { 61 | setMessages([...initialMessages(context.labels.initial), ...beak.messages]); 62 | }, [beak]); 63 | 64 | useEffect(() => { 65 | beak.on("change", onChange); 66 | return () => { 67 | beak.off("change", onChange); 68 | }; 69 | }, [onChange]); 70 | 71 | const sendMessage = async (message: string) => { 72 | await beak.runChatCompletion(message); 73 | }; 74 | 75 | const ctx = useMemo(() => { 76 | return { 77 | ...context, 78 | icons: { 79 | ...context.icons, 80 | ...icons, 81 | }, 82 | colorScheme: colorScheme || context.colorScheme, 83 | }; 84 | }, [context, icons, colorScheme]); 85 | 86 | const colorSchemeClass = 87 | "beakColorScheme" + 88 | ctx.colorScheme[0].toUpperCase() + 89 | ctx.colorScheme.slice(1); 90 | 91 | return ( 92 | 93 |
94 | 95 | 102 |
103 | 104 | 105 | 106 |
107 |
108 | ); 109 | }; 110 | 111 | function initialMessages(initial?: string | string[]): Message[] { 112 | let initialArray: string[] = []; 113 | if (initial) { 114 | if (Array.isArray(initial)) { 115 | initialArray.push(...initial); 116 | } else { 117 | initialArray.push(initial); 118 | } 119 | } 120 | 121 | return initialArray.map( 122 | (message) => 123 | new Message({ 124 | role: "assistant", 125 | content: message, 126 | status: "success", 127 | }) 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /packages/react/src/Beak.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { BeakCore, OpenAIModel, CustomModel, DebugLogger } from "@beakjs/core"; 3 | import * as DefaultIcons from "./Icons"; 4 | 5 | const DEFAULT_DEBUG_LOGGER = new DebugLogger([]); 6 | 7 | export type BeakColorScheme = "auto" | "light" | "dark"; 8 | 9 | export interface BeakIcons { 10 | openIcon?: React.ReactNode; 11 | closeIcon?: React.ReactNode; 12 | headerCloseIcon?: React.ReactNode; 13 | sendIcon?: React.ReactNode; 14 | activityIcon?: React.ReactNode; 15 | spinnerIcon?: React.ReactNode; 16 | } 17 | 18 | interface BeakLabels { 19 | initial?: string | string[]; 20 | title?: string; 21 | placeholder?: string; 22 | thinking?: string; 23 | done?: string; 24 | error?: string; 25 | } 26 | 27 | interface BeakContext { 28 | beak: BeakCore; 29 | labels: Required; 30 | icons: Required; 31 | colorScheme: BeakColorScheme; 32 | debugLogger: DebugLogger; 33 | } 34 | 35 | export const BeakContext = React.createContext( 36 | undefined 37 | ); 38 | 39 | export function useBeakContext(): BeakContext { 40 | const context = React.useContext(BeakContext); 41 | if (context === undefined) { 42 | throw new Error( 43 | "Context not found. Did you forget to wrap your app in a component?" 44 | ); 45 | } 46 | return context; 47 | } 48 | 49 | interface BeakProps { 50 | __unsafeOpenAIApiKey__?: string; 51 | baseUrl?: string; 52 | openAIModel?: OpenAIModel | CustomModel; 53 | temperature?: number; 54 | instructions?: string; 55 | maxFeedback?: number; 56 | labels?: BeakLabels; 57 | debugLogger?: DebugLogger; 58 | children?: React.ReactNode; 59 | } 60 | 61 | export const Beak: React.FC = ({ 62 | __unsafeOpenAIApiKey__, 63 | baseUrl, 64 | openAIModel, 65 | temperature, 66 | instructions, 67 | maxFeedback, 68 | labels, 69 | debugLogger, 70 | children, 71 | }) => { 72 | const beak = useMemo( 73 | () => 74 | new BeakCore({ 75 | openAIApiKey: __unsafeOpenAIApiKey__, 76 | baseUrl: baseUrl, 77 | openAIModel: openAIModel, 78 | maxFeedback: maxFeedback, 79 | instructions: instructions, 80 | temperature: temperature, 81 | debugLogger: debugLogger, 82 | }), 83 | [ 84 | __unsafeOpenAIApiKey__, 85 | baseUrl, 86 | openAIModel, 87 | maxFeedback, 88 | instructions, 89 | temperature, 90 | debugLogger, 91 | ] 92 | ); 93 | const context = useMemo( 94 | () => ({ 95 | beak: beak, 96 | labels: { 97 | ...{ 98 | initial: "", 99 | title: "Assistant", 100 | placeholder: "Type a message...", 101 | thinking: "Thinking...", 102 | done: "✅ Done", 103 | error: "❌ An error occurred. Please try again.", 104 | }, 105 | ...labels, 106 | }, 107 | 108 | debugLogger: debugLogger || DEFAULT_DEBUG_LOGGER, 109 | colorScheme: "auto" as BeakColorScheme, 110 | icons: { 111 | openIcon: DefaultIcons.OpenIcon, 112 | closeIcon: DefaultIcons.CloseIcon, 113 | headerCloseIcon: DefaultIcons.HeaderCloseIcon, 114 | sendIcon: DefaultIcons.SendIcon, 115 | activityIcon: DefaultIcons.ActivityIcon, 116 | spinnerIcon: DefaultIcons.SpinnerIcon, 117 | }, 118 | }), 119 | [labels, debugLogger] 120 | ); 121 | return ( 122 | {children} 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /packages/react/src/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ButtonProps } from "./props"; 3 | import { useBeakContext } from "./Beak"; 4 | import "../../css/Button.css"; 5 | 6 | export const Button: React.FC = ({ open, setOpen }) => { 7 | const context = useBeakContext(); 8 | // To ensure that the mouse handler fires even when the button is scaled down 9 | // we wrap the button in a div and attach the handler to the div 10 | return ( 11 |
setOpen(!open)}> 12 | 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/react/src/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HeaderProps } from "./props"; 3 | import "../../css/Header.css"; 4 | import { useBeakContext } from "./Beak"; 5 | 6 | export const Header: React.FC = ({ setOpen }) => { 7 | const context = useBeakContext(); 8 | 9 | return ( 10 |
11 |
{context.labels.title}
12 | 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/react/src/Icons.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const OpenIcon = ( 4 | 11 | 12 | 17 | 18 | 19 | ); 20 | 21 | export const CloseIcon = ( 22 | 31 | 36 | 37 | ); 38 | 39 | export const HeaderCloseIcon = ( 40 | 49 | 54 | 55 | ); 56 | 57 | export const SendIcon = ( 58 | 67 | 72 | 73 | ); 74 | 75 | export const SpinnerIcon = ( 76 | 87 | 95 | 100 | 101 | ); 102 | 103 | export const ActivityIcon = ( 104 | 116 | 117 | 123 | 129 | 130 | ); 131 | -------------------------------------------------------------------------------- /packages/react/src/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import { InputProps } from "./props"; 3 | import { useBeakContext } from "./Beak"; 4 | import "../../css/Input.css"; 5 | import AutoResizingTextarea from "./Textarea"; 6 | 7 | export const Input: React.FC = ({ inProgress, onSend }) => { 8 | const context = useBeakContext(); 9 | const textareaRef = useRef(null); 10 | 11 | const handleDivClick = (event: React.MouseEvent) => { 12 | // Check if the clicked element is not the textarea itself 13 | if (event.target !== event.currentTarget) return; 14 | 15 | textareaRef.current?.focus(); 16 | }; 17 | 18 | const [text, setText] = useState(""); 19 | const send = () => { 20 | if (inProgress) return; 21 | onSend(text); 22 | setText(""); 23 | 24 | textareaRef.current?.focus(); 25 | }; 26 | 27 | const icon = inProgress ? context.icons.activityIcon : context.icons.sendIcon; 28 | const disabled = inProgress || text.length === 0; 29 | 30 | return ( 31 |
32 | 35 | setText(event.target.value)} 42 | onKeyDown={(event) => { 43 | if (event.key === "Enter" && !event.shiftKey) { 44 | event.preventDefault(); 45 | send(); 46 | } 47 | }} 48 | /> 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /packages/react/src/Messages.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { MessagesProps } from "./props"; 3 | import { useBeakContext } from "./Beak"; 4 | import "../../css/Messages.css"; 5 | 6 | export const Messages: React.FC = ({ messages }) => { 7 | const context = useBeakContext(); 8 | const messagesEndRef = React.useRef(null); 9 | 10 | const scrollToBottom = () => { 11 | if (messagesEndRef.current) { 12 | messagesEndRef.current.scrollIntoView({ 13 | behavior: "auto", 14 | }); 15 | } 16 | }; 17 | 18 | useEffect(() => { 19 | scrollToBottom(); 20 | }, [messages]); 21 | 22 | return ( 23 |
24 | {messages.map((message, index) => { 25 | if (message.role === "user") { 26 | return ( 27 |
28 | {message.content} 29 |
30 | ); 31 | } else if (message.role == "assistant" && message.status !== "error") { 32 | if (message.status === "pending" && !message.content) { 33 | return ( 34 |
35 | {context.icons.spinnerIcon} 36 |
37 | ); 38 | } else if (message.status === "partial") { 39 | return ( 40 |
41 | {context.labels.thinking} {context.icons.spinnerIcon} 42 |
43 | ); 44 | } else if (message.content) { 45 | return ( 46 |
47 | {message.content} 48 |
49 | ); 50 | } 51 | } else if ( 52 | message.role === "function" && 53 | message.status === "success" 54 | ) { 55 | return ( 56 |
57 | {context.labels.done} 58 |
59 | ); 60 | } else if (message.status === "error") { 61 | return ( 62 |
63 | {context.labels.error} 64 |
65 | ); 66 | } 67 | })} 68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /packages/react/src/Textarea.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | useEffect, 5 | forwardRef, 6 | useImperativeHandle, 7 | } from "react"; 8 | 9 | interface AutoResizingTextareaProps { 10 | maxRows?: number; 11 | placeholder?: string; 12 | value: string; 13 | onChange: (event: React.ChangeEvent) => void; 14 | onKeyDown?: (event: React.KeyboardEvent) => void; 15 | autoFocus?: boolean; 16 | } 17 | 18 | const AutoResizingTextarea = forwardRef< 19 | HTMLTextAreaElement, 20 | AutoResizingTextareaProps 21 | >( 22 | ( 23 | { maxRows = 1, placeholder, value, onChange, onKeyDown, autoFocus }, 24 | ref 25 | ) => { 26 | const internalTextareaRef = useRef(null); 27 | const [maxHeight, setMaxHeight] = useState(0); 28 | 29 | useImperativeHandle( 30 | ref, 31 | () => internalTextareaRef.current as HTMLTextAreaElement 32 | ); 33 | 34 | useEffect(() => { 35 | const calculateMaxHeight = () => { 36 | const textarea = internalTextareaRef.current; 37 | if (textarea) { 38 | textarea.style.height = "auto"; 39 | const singleRowHeight = textarea.scrollHeight; 40 | setMaxHeight(singleRowHeight * maxRows); 41 | if (autoFocus) { 42 | textarea.focus(); 43 | } 44 | } 45 | }; 46 | 47 | calculateMaxHeight(); 48 | }, [maxRows]); 49 | 50 | useEffect(() => { 51 | const textarea = internalTextareaRef.current; 52 | if (textarea) { 53 | textarea.style.height = "auto"; 54 | textarea.style.height = `${Math.min( 55 | textarea.scrollHeight, 56 | maxHeight 57 | )}px`; 58 | } 59 | }, [value, maxHeight]); 60 | 61 | return ( 62 |