From c093f432940842672f19c9f3aead2eef22eab097 Mon Sep 17 00:00:00 2001 From: Thomas Hermine Date: Wed, 15 Nov 2023 11:20:27 +0100 Subject: [PATCH] refactor(Samantha): Transform project from assistant to Samantha - Strip away feature for HomeAssistant AI Summaries - Update README --- README.md | 28 +++- package.json | 1 + src/_helpers/summaries/devices.ts | 145 ------------------- src/_helpers/summaries/job.ts | 48 ------- src/config.ts | 29 +--- src/index.ts | 39 ++++-- src/init.ts | 10 +- src/openai/assistant.ts | 222 ++++++++++++++++-------------- yarn.lock | 30 +++- 9 files changed, 208 insertions(+), 344 deletions(-) delete mode 100644 src/_helpers/summaries/devices.ts delete mode 100644 src/_helpers/summaries/job.ts diff --git a/README.md b/README.md index 750c760..125c154 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ -# assistant +# Samantha — Connecting OpenAI Assistant with my digital ecosystem -A NodeJS app bridging the gap between openai/chatgpt and various tools : Home-Assistant, calendar, emails +[Part of the OpenAI Projects of @thomashermine](https://thomashermine.notion.site/OpenAI-Projects-30b950137c124b3c8fd0622007a889f0?pvs=4) + +Samantha is a NodeJS app connecting OpenAI custom Asssistants with various third-party services : Google Calendar, Gmail/Google Mail, Slack, Spotify, HomeAssistant,... +It can be invoked via an iOS Shortcuts, or use as an HomeAssistant Conversation Agent. + +## Architecture + +```mermaid +sequenceDiagram + participant EndUser as End User + participant NodeJSApp as Samantha NodeJS App + participant OpenAI as OpenAI Custom Assistant + + EndUser->>NodeJSApp: HTTP POST message + NodeJSApp->>OpenAI: Send request to OpenAI + OpenAI->>NodeJSApp: Respond with actions to perform + NodeJSApp->>NodeJSApp: Perform Actions + NodeJSApp->>OpenAI: Send completion results of actions + OpenAI->>NodeJSApp: Respond with message + NodeJSApp->>EndUser: HTTP Response with message +``` + +## Documentation + +[Complete Documentation](https://thomashermine.notion.site/Samantha-Connecting-OpenAI-Assistant-with-my-digital-ecosystem-202bff7ef3054ca5a5ceb9432e3e06d6?pvs=4) diff --git a/package.json b/package.json index 1f66a56..f4953f5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dependencies": { "@esbuild-kit/cjs-loader": "^2.4.4", "@google-cloud/local-auth": "2.1.0", + "body-parser": "^1.20.2", "dotenv": "^16.3.1", "express": "^4.18.2", "googleapis": "105", diff --git a/src/_helpers/summaries/devices.ts b/src/_helpers/summaries/devices.ts deleted file mode 100644 index c226e25..0000000 --- a/src/_helpers/summaries/devices.ts +++ /dev/null @@ -1,145 +0,0 @@ -import "dotenv/config"; -import { summarizeEntities } from "../../home-assistant/entities"; -import { getFirstChatResponse } from "../../openai/response"; -import { log } from "../logs"; -import { HOME_ASSISTANT_SUMMARIES_DEVICE_ENTITY_PREFIX } from "../../config"; - -// TODO: Move this to a dedicated assistant - -/** - * Get a prompt for summarizing the state of a device - * @param {Array} states - List of states of all entities - * @param {string} deviceName - Name of the device to summarize - * @param {Object} paramOptions - Options for the prompt - * @param {number} paramOptions.length - Maximum length of the prompt - * @param {string} paramOptions.customPrompt - Custom prompt to add to the briefing - * @returns {string} - Prompt for summarizing the state of the device - */ -export function getPromptForDeviceSummary(states, deviceName, paramOptions) { - if (!states || states.length === 0) { - throw new Error("States must be an array"); - } - const deviceEntities = states - .filter((el) => el.entity_id.includes(deviceName)) - .filter( - (el) => - !el.entity_id.includes(HOME_ASSISTANT_SUMMARIES_DEVICE_ENTITY_PREFIX), - ); // Ignoring ourselves - if (deviceEntities.length === 0) { - log("app", "warn", `No entity found for device ${deviceName}`); - return null; - } - const summary = summarizeEntities(deviceEntities); - const defaultOptions = { - length: 100, - customPrompt: "", - }; - const options = { - ...defaultOptions, - ...paramOptions, - }; - - let prompt = []; - const briefing = ` - BRIEFING : - Following is an overview of the state of a device. - You must filter out the noise and respond with a single sentence, no bullets points summary of maximum ${ - options.length - } characters. - Keep the summary short no matter the amount of data you are given. - Only keep errors, warnings, statuses, entities called "Dock Status" and other important information. - Dont write "error" or "warning" if you dont see any. - Do not introduce with "Summary" or "Résumé", just the content itself. - Round float value to integer. Transform any duration in seconds to minutes. - Ignore any "unknown" or "unavailable" state. - ${options.customPrompt} - CONTEXT : - For relative date calculation, today is ${new Date().toLocaleDateString( - "en-US", - { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - timeZone: "UTC", - }, - )}. - DATA : - `; - prompt.push(briefing); - prompt = [...prompt, ...summary]; - return prompt.join("\n"); -} - -/** - * Get a summary of the state of a device - * @param {Object} openai - OpenAI API instance - * @param {Object} ha - Home Assistant instance - * @param {Array} states - List of states of all entities - * @param {string} deviceName - Name of the device to summarize - * @param {Object} paramOptions - Options for the prompt - * @param {number} paramOptions.length - Maximum length of the prompt - * @param {string} paramOptions.customPrompt - Custom prompt to add to the briefing - * @returns {Promise} - Promise that resolves to a summary of the state of the device - */ -export async function getDeviceSummary( - openai, - ha, - states, - deviceName, - paramOptions, -) { - const prompt = getPromptForDeviceSummary(states, deviceName, paramOptions); - if (!prompt) { - log("app", "warn", `No prompt found for device ${deviceName}`); - return null; - } - const summary = getFirstChatResponse(openai, prompt); - return summary; -} - -/** - * Update the summary of the state of a device - * @param {Object} openai - OpenAI API instance - * @param {Object} ha - Home Assistant instance - * @param {Array} states - List of states of all entities - * @param {string} deviceName - Name of the device to summarize - * @param {Object} [paramOptions] - Optional options for the prompt - * @param {number} paramOptions.length - Maximum length of the prompt - * @param {string} paramOptions.customPrompt - Custom prompt to add to the briefing - * @returns {Promise} - Promise that resolves to the updated summary entity - */ -export async function updateDeviceSummary( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - openai: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ha: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - states: any[], - deviceName: string, - paramOptions?: { length: number; customPrompt: string }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Promise { - const summary = await getDeviceSummary( - openai, - ha, - states, - deviceName, - paramOptions, - ); - if (!summary) { - log("app", "warn", `No summary found for device ${deviceName}`); - return null; - } - return ha.states.update( - "sensor", - `${HOME_ASSISTANT_SUMMARIES_DEVICE_ENTITY_PREFIX}${deviceName}`, - { - state: 1, - attributes: { - content: summary, - last_updated: new Date().toISOString(), - }, - }, - ); -} diff --git a/src/_helpers/summaries/job.ts b/src/_helpers/summaries/job.ts deleted file mode 100644 index 2aef3ef..0000000 --- a/src/_helpers/summaries/job.ts +++ /dev/null @@ -1,48 +0,0 @@ -import "dotenv/config"; -import { log } from "../logs"; -import { updateDeviceSummary } from "./devices"; - -import { - HOME_ASSISTANT_SUMMARIES_DEVICE_ENTITY_PREFIX, - HOME_ASSISTANT_SUMMARIES_DEVICES_ENTITIES, -} from "../../config"; - -/** - * Generate summaries for all devices we monitor, after getting their states from Home Assistant. - * @param {Object} ha - The home assistant object. - * @param {Object} openai - The openai object. - * @returns {Promise} - */ -export async function summariesJob(ha, openai) { - const states = await ha.states.list(); - // Device Summaries - // =================================================================================================================== - log( - "ha", - "info", - `Updating summary for ${HOME_ASSISTANT_SUMMARIES_DEVICES_ENTITIES.length} devices...`, - ); - await Promise.all( - HOME_ASSISTANT_SUMMARIES_DEVICES_ENTITIES.map((device) => - updateDeviceSummary(openai, ha, states, device), - ), - ); - log( - "ha", - "info", - `Summary updated for ${HOME_ASSISTANT_SUMMARIES_DEVICES_ENTITIES.length} devices.`, - ); - - // Device Summaries, Summary - // =================================================================================================================== - const deviceSummaries = states.filter((state) => - state.entity_id.startsWith( - "sensor." + HOME_ASSISTANT_SUMMARIES_DEVICE_ENTITY_PREFIX, - ), - ); - const deviceSummariesStates = deviceSummaries.map( - (state) => state.attributes.content, - ); - log("ha", "info", `Updated summary for ${deviceSummaries.length} devices.`); - log("ha", "info", "All Summaries", deviceSummariesStates.join("\n")); -} diff --git a/src/config.ts b/src/config.ts index 59098eb..106e463 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,14 +1,5 @@ export const { NODE_ENV = "development" } = process.env; -// Default values depending on the environment -const DEFAULT_HOME_ASSISTANT_HOST = - NODE_ENV === "production" - ? "http://supervisor/core" - : "http://homeassistant.local"; -const DEFAULT_HOME_ASSISTANT_PORT = NODE_ENV === "production" ? "80" : "8123"; -const DEFAULT_HOME_ASSISTANT_TOKEN = process.env.SUPERVISOR_TOKEN; -const DEFAULT_UPDATE_INTERVAL = NODE_ENV === "production" ? "3600000" : "10000"; // Every hour in production, every 5 seconds in dev - // ===================================================================================================================== // Config // ===================================================================================================================== @@ -27,15 +18,9 @@ export const { OPENAI_USER_FIRSTNAME, // 3rd party : Home Assistant - HOME_ASSISTANT_HOST = DEFAULT_HOME_ASSISTANT_HOST, - HOME_ASSISTANT_PORT_RAW = DEFAULT_HOME_ASSISTANT_PORT, - HOME_ASSISTANT_TOKEN = DEFAULT_HOME_ASSISTANT_TOKEN, - - HOME_ASSISTANT_SUMMARIES_UPDATE_INTERVAL: - HOME_ASSISTANT_SUMMARIES_UPDATE_INTERVAL_RAW = DEFAULT_UPDATE_INTERVAL, - HOME_ASSISTANT_SUMMARIES_DEVICE_ENTITY_PREFIX = "summary_device_", // Use for the summary entities we will create. will be followed by the device name - HOME_ASSISTANT_SUMMARIES_DEVICES_ENTITIES: - HOME_ASSISTANT_SUMMARIES_DEVICES_ENTITIES_RAW = "hall_tablet,iphone_thomas,iphone_caroline,macbookpro_thomas,roborock,octoprint,litterbox,toothbrush,pet_feeder,pet_fountain", + HOME_ASSISTANT_HOST, + HOME_ASSISTANT_PORT_RAW, + HOME_ASSISTANT_TOKEN, HOME_ASSISTANT_MEDIA_PLAYER, @@ -44,7 +29,6 @@ export const { GOOGLE_CLIENT_SECRET, GOOGLE_CLIENT_REDIRECT_URL = "http://localhost:80/oauth2callback", GOOGLE_CLIENT_TOKEN_FILE_PATH = "token.json", - GOOGLE_CLIENT_CREDENTIALS_FILE_PATH = "credentials.json", GOOGLE_CLIENT_SCOPES: GOOGLE_CLIENT_SCOPES_RAW = "https://www.googleapis.com/auth/calendar.events", // Comma separated list of scopes @@ -53,12 +37,5 @@ export const { // Config parsing // ===================================================================================================================== -export const HOME_ASSISTANT_SUMMARIES_UPDATE_INTERVAL = parseInt( - HOME_ASSISTANT_SUMMARIES_UPDATE_INTERVAL_RAW, - 10, -); export const HOME_ASSISTANT_PORT = parseInt(HOME_ASSISTANT_PORT_RAW, 10); - -export const HOME_ASSISTANT_SUMMARIES_DEVICES_ENTITIES = - HOME_ASSISTANT_SUMMARIES_DEVICES_ENTITIES_RAW.split(","); export const GOOGLE_CLIENT_SCOPES = GOOGLE_CLIENT_SCOPES_RAW.split(","); diff --git a/src/index.ts b/src/index.ts index 9ca4cfa..3dad315 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,32 +1,41 @@ import "dotenv/config"; - import { log } from "./_helpers/logs"; -import { sleep } from "./_helpers/sleep"; import { init } from "./init"; -import { summariesJob } from "./_helpers/summaries/job"; -import { HOME_ASSISTANT_SUMMARIES_UPDATE_INTERVAL } from "./config"; -import { launchAssistantConversation } from "./openai/assistant"; +import { + createAssistantThread, + handleUserMessageOnThread, +} from "./openai/assistant"; async function main() { // Init // =================================================================================================================== log("app", "info", "Starting up..."); - const { ha, openai } = await init(); + const { server } = await init(); log("app", "info", "Initialized."); // Start the OpenAI Assistant Conversation // =================================================================================================================== - await launchAssistantConversation("agenda"); + const { threadId, assistantId, instructions } = + await createAssistantThread("agenda"); - // Jobs - // =================================================================================================================== - // eslint-disable-next-line no-constant-condition - while (true) { - // TODO: Use a pub-sub model instead to update everytime one of the entities we summarize is updated - await summariesJob(ha, openai); - await sleep(HOME_ASSISTANT_SUMMARIES_UPDATE_INTERVAL); - } + server.post("/assistant/agenda/message", async (req, res) => { + console.log("req.body", req.body); + const { message } = req.body; + log("app", "debug", `Received message ${message}`); + const response = await handleUserMessageOnThread( + assistantId, + threadId, + message, + instructions, + ); + res.status(201).json({ message: response }); + }); + log( + "app", + "info", + "Ready to make conversation with assistant. Send a POST request to /assistant/agenda/message with a message in the body to start the conversation.", + ); } main(); diff --git a/src/init.ts b/src/init.ts index 83ae34e..3fe1ed1 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,5 +1,6 @@ import "dotenv/config"; import express from "express"; +import bodyParser from "body-parser"; import homeassistant from "homeassistant"; import OpenAI from "openai"; import prompt from "prompt"; @@ -36,7 +37,6 @@ export async function init(): Promise<{ }> { // Home Assistant // =================================================================================================================== - console.log(HOME_ASSISTANT_HOST, HOME_ASSISTANT_PORT, HOME_ASSISTANT_TOKEN); const ha: homeassistant = new homeassistant({ host: HOME_ASSISTANT_HOST, port: HOME_ASSISTANT_PORT, @@ -57,12 +57,12 @@ export async function init(): Promise<{ // Express Server // =================================================================================================================== const server = express(); + server.use(bodyParser()); // Register routes googleHandleAuthCallback(server); - // Express Server - // =================================================================================================================== + // Listen server.listen(PORT, (err) => { if (err) throw err; log("server", "info", `Listening on port ${PORT}`); @@ -74,9 +74,5 @@ export async function init(): Promise<{ const google = await googleAuthorize(); googleSetClient(google); - // Prompt - // =================================================================================================================== - prompt.start(); - return { ha, openai, google, server, prompt }; } diff --git a/src/openai/assistant.ts b/src/openai/assistant.ts index e575258..9e85168 100644 --- a/src/openai/assistant.ts +++ b/src/openai/assistant.ts @@ -54,17 +54,7 @@ export const toolFunctionWrapper = }); }; -/** - * Launches a conversation with the specified assistant. - * Will never resolve and keep the conversation going forever. - * - * @param {string} assitantName - The name of the assistant to converse with. - * @throws {Error} If the OpenAI client is not initialized. - * @throws {Error} If the assistant name is not provided. - * @throws {Error} If the assistant is not found. - - */ -export async function launchAssistantConversation(assitantName: string) { +export async function createAssistantThread(assitantName: string) { if (!openai) throw new Error("OpenAI client not initialized."); if (!assitantName) throw new Error("Assistant name is required."); if (!idByAssistantName[assitantName]) @@ -83,6 +73,119 @@ export async function launchAssistantConversation(assitantName: string) { ); const thread = await openai.beta.threads.create(); log("openai", "debug", `Created thread ${thread.id}`); + return { threadId: thread.id, assistantId, instructions }; +} + +export async function handleUserMessageOnThread( + assistantId, + threadId, + message, + instructions, +) { + // Ask for User Message + // ================================================================================================================= + await openai.beta.threads.messages.create(threadId, { + role: "user", + content: message, + }); + + // Run the Assistant + // ================================================================================================================= + let run = await openai.beta.threads.runs.create(threadId, { + assistant_id: assistantId, + instructions, + }); + + // Wait for Run Completion + // ================================================================================================================= + let hasCompleted = false; + while (!hasCompleted) { + const runStatus = await openai.beta.threads.runs.retrieve(threadId, run.id); + const { status } = runStatus; + + // Requires Action + // =============================================================================================================== + if (status === "requires_action") { + const requiredActions = runStatus.required_action; + + // Actions is of type "submit_tool_outputs" + // ============================================================================================================= + // The Assistant want us to run some code + if ( + requiredActions.type === "submit_tool_outputs" && + requiredActions.submit_tool_outputs + ) { + // Each Tool Call + // =========================================================================================================== + const toolPromises = requiredActions.submit_tool_outputs.tool_calls.map( + (tool_call) => { + const toolFunction = functionsForAssistant[tool_call.function.name]; + if (!toolFunction) + throw new Error(`Function ${tool_call.function.name} not found.`); + const functionOptions = JSON.parse(tool_call.function.arguments); + return toolFunctionWrapper( + toolFunction, + tool_call.id, + )(functionOptions); + }, + ); + const toolOuputs = await Promise.all(toolPromises); + log( + "openai", + "debug", + `${toolOuputs.length} functions tools ran and will be submitted.`, + ); + + // Submit Tool Call Results + run = await openai.beta.threads.runs.submitToolOutputs( + threadId, + run.id, + { + tool_outputs: toolOuputs, + }, + ); + } + hasCompleted = false; + // Run Completed + // ============================================================================================================= + } else if (status === "completed") { + console.log("completed"); + hasCompleted = true; + } else { + // Waiting for Completion + // ============================================================================================================= + logLoading(status); + await sleep(2000); + } + } + + // Thread Completed + // ================================================================================================================= + const threadMessages = await openai.beta.threads.messages.list(threadId); + + // Log the last message of the chat + const response = threadMessages.data[0]; + if (response.content[0].text.value) { + console.log(response.content[0].text.value); + return response.content[0].text.value; + } else { + console.log(response); + } +} + +/** + * Launches a conversation with the specified assistant. + * Will never resolve and keep the conversation going forever. + * + * @param {string} assitantName - The name of the assistant to converse with. + * @throws {Error} If the OpenAI client is not initialized. + * @throws {Error} If the assistant name is not provided. + * @throws {Error} If the assistant is not found. + + */ +export async function launchAssistantConversation(assitantName: string) { + const { threadId, assistantId, instructions } = + await createAssistantThread(assitantName); // Message Loop // =================================================================================================================== @@ -90,97 +193,16 @@ export async function launchAssistantConversation(assitantName: string) { while (true) { // Ask for User Message // ================================================================================================================= - const messageInput = await prompt.get(["message"]); - await openai.beta.threads.messages.create(thread.id, { - role: "user", - content: messageInput.message, - }); + prompt.start(); + const { message } = await prompt.get(["message"]); - // Run the Assistant + // Handle User Message // ================================================================================================================= - let run = await openai.beta.threads.runs.create(thread.id, { - assistant_id: assistantId, + await handleUserMessageOnThread( + assistantId, + threadId, + message, instructions, - }); - - // Wait for Run Completion - // ================================================================================================================= - let hasCompleted = false; - while (!hasCompleted) { - const runStatus = await openai.beta.threads.runs.retrieve( - thread.id, - run.id, - ); - const { status } = runStatus; - - // Requires Action - // =============================================================================================================== - if (status === "requires_action") { - const requiredActions = runStatus.required_action; - - // Actions is of type "submit_tool_outputs" - // ============================================================================================================= - // The Assistant want us to run some code - if ( - requiredActions.type === "submit_tool_outputs" && - requiredActions.submit_tool_outputs - ) { - // Each Tool Call - // =========================================================================================================== - const toolPromises = - requiredActions.submit_tool_outputs.tool_calls.map((tool_call) => { - const toolFunction = - functionsForAssistant[tool_call.function.name]; - if (!toolFunction) - throw new Error( - `Function ${tool_call.function.name} not found.`, - ); - const functionOptions = JSON.parse(tool_call.function.arguments); - return toolFunctionWrapper( - toolFunction, - tool_call.id, - )(functionOptions); - }); - const toolOuputs = await Promise.all(toolPromises); - log( - "openai", - "debug", - `${toolOuputs.length} functions tools ran and will be submitted.`, - ); - - // Submit Tool Call Results - run = await openai.beta.threads.runs.submitToolOutputs( - thread.id, - run.id, - { - tool_outputs: toolOuputs, - }, - ); - } - hasCompleted = false; - // Run Completed - // ============================================================================================================= - } else if (status === "completed") { - console.log("completed"); - hasCompleted = true; - } else { - // Waiting for Completion - // ============================================================================================================= - logLoading(status); - await sleep(2000); - } - } - - // Thread Completed - // ================================================================================================================= - const threadMessages = await openai.beta.threads.messages.list(thread.id); - - // Log the last message of the chat - const response = threadMessages.data[0]; - if (response.content[0].text.value) { - console.log(response.content[0].text.value); - } else { - console.log(response); - } + ); } } diff --git a/yarn.lock b/yarn.lock index 3eb9c47..19c0f93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1369,6 +1369,24 @@ body-parser@1.20.1: type-is "~1.6.18" unpipe "1.0.0" +body-parser@^1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + bplist-parser@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/bplist-parser/-/bplist-parser-0.2.0.tgz#43a9d183e5bf9d545200ceac3e712f79ebbe8d0e" @@ -1624,7 +1642,7 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4: +content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -4262,6 +4280,16 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + react-is@^18.0.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"