diff --git a/cypress/integration/api/og-image.spec.ts b/cypress/integration/api/og-image.spec.ts index a0cb14537e..8a71b5ce73 100644 --- a/cypress/integration/api/og-image.spec.ts +++ b/cypress/integration/api/og-image.spec.ts @@ -1,9 +1,21 @@ -context("GET /api/og-image", () => { +context("GET /api/component-og-image", () => { it("gets an image", () => { - cy.request("GET", "/api/og-image?componentRequested=primitives/box").then((response) => { + cy.request("GET", "/api/component-og-image?componentRequested=primitives/box").then((response) => { expect(response.status).to.eq(200); expect(response.headers["content-type"]).to.eq("image/png"); console.log(response); }); }); }); + +context("GET /api/simple-og-image", () => { + it("gets an image", () => { + cy.request("GET", "/api/simple-og-image?title=Hello%20World&description=This%20is%20a%20description").then( + (response) => { + expect(response.status).to.eq(200); + expect(response.headers["content-type"]).to.eq("image/png"); + console.log(response); + }, + ); + }); +}); diff --git a/cypress/integration/api/paste-assistant-message.spec.ts b/cypress/integration/api/paste-assistant-message.spec.ts index e36e224ee1..3c1c8b578d 100644 --- a/cypress/integration/api/paste-assistant-message.spec.ts +++ b/cypress/integration/api/paste-assistant-message.spec.ts @@ -10,7 +10,7 @@ context("POST /api/paste-assistant-message", () => { after(() => { // delete the thread - cy.request("DELETE", "/api/paste-assistant-thread", { id: threadId }); + cy.request("DELETE", `/api/paste-assistant-thread/${threadId}`); }); it("creates an message on an ai thread", () => { diff --git a/cypress/integration/api/paste-assistant-thread.spec.ts b/cypress/integration/api/paste-assistant-thread.spec.ts index 9f288b7b38..abba7cc2e4 100644 --- a/cypress/integration/api/paste-assistant-thread.spec.ts +++ b/cypress/integration/api/paste-assistant-thread.spec.ts @@ -1,4 +1,4 @@ -context("POST /api/paste-assistant-thread", () => { +context("/api/paste-assistant-thread", () => { let threadId: string; it("creates an ai thread", () => { cy.request("POST", "/api/paste-assistant-thread", {}).then((response) => { @@ -7,7 +7,7 @@ context("POST /api/paste-assistant-thread", () => { }); }); it("updates an ai thread", () => { - cy.request("PUT", "/api/paste-assistant-thread", { id: threadId, metadata: { testKey: "testData" } }).then( + cy.request("PUT", `/api/paste-assistant-thread/${threadId}`, { metadata: { testKey: "testData" } }).then( (response) => { expect(response.status).to.eq(200); expect(response.body.metadata.testKey).to.eq("testData"); @@ -21,7 +21,7 @@ context("POST /api/paste-assistant-thread", () => { }); }); it("deletes an ai thread", () => { - cy.request("DELETE", "/api/paste-assistant-thread", { id: threadId }).then((response) => { + cy.request("DELETE", `/api/paste-assistant-thread/${threadId}`).then((response) => { expect(response.status).to.eq(200); expect(response.body.deleted).to.eq(true); }); diff --git a/cypress/integration/sitemap-vrt/constants.ts b/cypress/integration/sitemap-vrt/constants.ts index 983c65f97b..ee40c5a4d7 100644 --- a/cypress/integration/sitemap-vrt/constants.ts +++ b/cypress/integration/sitemap-vrt/constants.ts @@ -1,6 +1,7 @@ export const SITEMAP = [ "/customization/", "/", + "/assistant", "/blog/", "/blog/2020-11-26-growing-pains-and-how-we-scaled-our-design-system-support/", "/blog/2021-04-29-insights-and-metrics-that-inform-the-paste-design-system/", diff --git a/internal-docs/engineering/doc-site/open-graph-image-preview-function.md b/internal-docs/engineering/doc-site/open-graph-image-preview-function.md index 1c2a1ad4c2..290f94b177 100644 --- a/internal-docs/engineering/doc-site/open-graph-image-preview-function.md +++ b/internal-docs/engineering/doc-site/open-graph-image-preview-function.md @@ -8,7 +8,7 @@ When sharing a link to a component page on the internet, we supply a dynamically ## The way it works -We use [`@vercel/og`](https://vercel.com/docs/functions/edge-functions/og-image-generation) to generate the image via an edge function, `/api/og-image`. +We use [`@vercel/og`](https://vercel.com/docs/functions/edge-functions/og-image-generation) to generate the image via an edge function, `/api/component-og-image`. ## Local development @@ -18,7 +18,7 @@ To start, run: yarn start:website ``` -Once the website have started running, you can hit the function at `/api/og-image`. +Once the website have started running, you can hit the function at `/api/component-og-image`. ## Technology / Stack diff --git a/packages/paste-website/.eslintrc b/packages/paste-website/.eslintrc index 4b84bb8f96..ca4d6dfe50 100644 --- a/packages/paste-website/.eslintrc +++ b/packages/paste-website/.eslintrc @@ -1,5 +1,10 @@ { - "extends": ["../../.eslintrc.js", "plugin:@next/next/recommended", "plugin:@next/next/core-web-vitals"], + "extends": [ + "../../.eslintrc.js", + "plugin:@next/next/recommended", + "plugin:@next/next/core-web-vitals", + "plugin:@tanstack/eslint-plugin-query/recommended" + ], "root": true, "rules": { "import/no-default-export": "off", diff --git a/packages/paste-website/package.json b/packages/paste-website/package.json index 6dda75c5d9..50e025f907 100644 --- a/packages/paste-website/package.json +++ b/packages/paste-website/package.json @@ -31,6 +31,8 @@ "@octokit/plugin-paginate-graphql": "^4.0.0", "@slack/web-api": "^7.0.1", "@supabase/supabase-js": "^2.36.0", + "@tanstack/react-query": "^5.17.9", + "@tanstack/react-query-devtools": "^5.17.10", "@twilio-paste/account-switcher": "^3.0.1", "@twilio-paste/alert": "^14.1.0", "@twilio-paste/alert-dialog": "^9.2.0", @@ -155,7 +157,7 @@ "highcharts-react-official": "^3.1.0", "lodash": "4.17.21", "lottie-web": "^5.7.4", - "markdown-to-jsx": "^7.3.2", + "markdown-to-jsx": "^7.4.0", "mdast-util-from-markdown": "^1.3.0", "mdast-util-mdx": "^2.0.0", "mdast-util-to-markdown": "^1.5.0", @@ -185,11 +187,13 @@ "use-resize-observer": "^9.1.0", "uuid": "^9.0.1", "winston": "^3.11.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^4.4.7" }, "devDependencies": { "@next/eslint-plugin-next": "^14.0.0", "@storybook/react": "7.6.4", + "@tanstack/eslint-plugin-query": "^5.17.7", "@testing-library/react": "^13.4.0", "tsx": "^4.0.0" }, diff --git a/packages/paste-website/src/api/assistantAPIs.ts b/packages/paste-website/src/api/assistantAPIs.ts new file mode 100644 index 0000000000..a33c462314 --- /dev/null +++ b/packages/paste-website/src/api/assistantAPIs.ts @@ -0,0 +1,68 @@ +import { type UseMutationResult, useMutation } from "@tanstack/react-query"; + +export const useCreateThreadMutation = (): UseMutationResult => { + return useMutation({ + mutationFn: async () => { + return fetch("/api/paste-assistant-thread", { + method: "POST", + }); + }, + mutationKey: ["create-thread"], + }); +}; + +export const useDeleteThreadMutation = (): UseMutationResult => { + return useMutation({ + mutationFn: async (id) => { + return fetch(`/api/paste-assistant-thread/${id}`, { + method: "DELETE", + }); + }, + mutationKey: ["delete-thread"], + }); +}; + +export const useUpdateThreadMutation = (): UseMutationResult => { + return useMutation({ + mutationFn: async ({ id, threadTitle }: any) => { + return fetch(`/api/paste-assistant-thread/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ metadata: { threadTitle } }), + }); + }, + mutationKey: ["update-thread"], + }); +}; + +export const useCreateAssistantRunMutation = (): UseMutationResult => { + return useMutation({ + mutationFn: async (messageDetails) => { + return fetch("/api/paste-assistant-message", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(messageDetails), + }); + }, + mutationKey: ["create-assistant-run"], + }); +}; + +export const useSimpleCompletionMutation = (): UseMutationResult => { + return useMutation({ + mutationFn: async (completionDetails) => { + return fetch("/api/paste-assistant-simple-completion/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(completionDetails), + }); + }, + mutationKey: ["simple-completion"], + }); +}; diff --git a/packages/paste-website/src/components/assistant/Assistant.tsx b/packages/paste-website/src/components/assistant/Assistant.tsx new file mode 100644 index 0000000000..83282a9ed9 --- /dev/null +++ b/packages/paste-website/src/components/assistant/Assistant.tsx @@ -0,0 +1,143 @@ +/* eslint-disable camelcase */ +import type { ThreadMessage } from "openai/resources/beta/threads/messages/messages"; +import * as React from "react"; + +import { + useCreateAssistantRunMutation, + useCreateThreadMutation, + useSimpleCompletionMutation, + useUpdateThreadMutation, +} from "../../api/assistantAPIs"; +import { useAssistantMessagesStore } from "../../stores/assistantMessagesStore"; +import { useAssistantRunStore } from "../../stores/assistantRunStore"; +import { useAssistantThreadsStore } from "../../stores/assistantThreadsStore"; +import useStoreWithLocalStorage from "../../stores/useStore"; +import { AssistantCanvas } from "./AssistantCanvas"; +import { AssistantComposer } from "./AssistantComposer"; +import { AssistantEmptyState } from "./AssistantEmptyState"; +import { AsssistantLayout } from "./AssistantLayout"; +import { AssistantThreads } from "./AssistantThreads"; +import { AssistantHeader } from "./AsststantHeader"; + +const getMockMessage = ({ message }: { message: string }): ThreadMessage => { + const date = new Date(); + + return { + id: "", + object: "thread.message", + created_at: Math.floor(date.getTime() / 1000), + thread_id: "xxxx", + role: "user", + content: [ + { + type: "text", + text: { + value: message, + annotations: [], + }, + }, + ], + file_ids: [], + assistant_id: null, + run_id: null, + metadata: {}, + }; +}; + +export const Assistant: React.FC = () => { + const threadsStore = useStoreWithLocalStorage(useAssistantThreadsStore, (state) => state); + const createAssistantRun = useCreateAssistantRunMutation(); + const createThreadMutation = useCreateThreadMutation(); + const updateThreadMutation = useUpdateThreadMutation(); + const simpleCompletionMutation = useSimpleCompletionMutation(); + const setActiveRun = useAssistantRunStore((state) => state.setActiveRun); + const addMessage = useAssistantMessagesStore((state) => state.addMessage); + const messages = useAssistantMessagesStore((state) => state.messages); + + if (threadsStore == null) return null; + + const handleMessageCreation = (message: string, threadId: string): void => { + // add the new user message to the store to optimistically render it whilst we wait for openAI to do its thing + addMessage(getMockMessage({ message })); + + // Create a new "assistant run" on the thread so that openAI processes the new message and updates the thread with a response + createAssistantRun.mutate( + { threadId, message }, + { + onSuccess: async (run) => { + // @ts-expect-error I don't know how to type this right now so it knows it's a response + const newRun = await run.json(); + setActiveRun(newRun.run); + }, + }, + ); + + if (messages.length === 0) { + /** + * summarise the first question as the title of the + * thread, if this is the first message in that thread + * for it to appear in the thread list as an identifier + * of the thread + */ + simpleCompletionMutation.mutate( + { + prompt: + "If this is the start of a threaded conversation, summarise the subject of the converation in as few words as possible: ", + context: message, + }, + { + onSuccess: async (completion) => { + // @ts-expect-error I don't know how to type this right now so it knows it's a response + const newCompletion = await completion.json(); + // update the thread title in the store + threadsStore?.setThreadTitle(threadId, newCompletion.choices[0].message.content); + // update the thread title in openAI + updateThreadMutation.mutate({ id: threadId, threadTitle: newCompletion.choices[0].message.content }); + }, + }, + ); + } + }; + + /** + * From one of the canned new thread messages, create a new thread, select it, and then + * create a new message run on the newly created thread. + * + * @param {string} message + */ + const handleCannedThreadCreation = (message: string): void => { + createThreadMutation.mutate( + {}, + { + onSuccess: async (data) => { + // @ts-expect-error I don't know how to type this right now so it knows it's a response + const newThread = await data.json(); + if (threadsStore == null) return; + threadsStore.createAndSelectThread(newThread); + handleMessageCreation(message, newThread.id); + }, + }, + ); + }; + + return ( + + + + + + + + + {threadsStore.selectedThreadID == null && ( + + )} + {threadsStore.selectedThreadID != null && } + + + + + + ); +}; +/* eslint-enable camelcase */ diff --git a/packages/paste-website/src/components/assistant/AssistantCanvas.tsx b/packages/paste-website/src/components/assistant/AssistantCanvas.tsx new file mode 100644 index 0000000000..ccd4124d08 --- /dev/null +++ b/packages/paste-website/src/components/assistant/AssistantCanvas.tsx @@ -0,0 +1,91 @@ +import { useIsMutating, useQuery } from "@tanstack/react-query"; +import { Box } from "@twilio-paste/box"; +import { ChatBookend, ChatBookendItem, ChatLog } from "@twilio-paste/chat-log"; +import * as React from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { useAssistantMessagesStore } from "../../stores/assistantMessagesStore"; +import { useAssistantRunStore } from "../../stores/assistantRunStore"; +import { AssistantMessage } from "./AssistantMessage"; +import { AssistantMessagePoller } from "./AssistantMessagePoller"; +import { LoadingMessage } from "./LoadingMessage"; +import { UserMessage } from "./UserMessage"; + +type AssistantCanvasProps = { + selectedThreadID: string; +}; + +export const AssistantCanvas: React.FC = ({ selectedThreadID }) => { + const [mounted, setMounted] = React.useState(false); + const [logWidth, setLogWidth] = React.useState(0); + const messages = useAssistantMessagesStore(useShallow((state) => state.messages)); + const setMessages = useAssistantMessagesStore(useShallow((state) => state.setMessages)); + const activeRun = useAssistantRunStore(useShallow((state) => state.activeRun)); + const isCreatingAResponse = useIsMutating({ mutationKey: ["create-assistant-run"] }); + + const memoedMessages = React.useMemo(() => messages, [messages]); + + const scrollerRef = React.useRef(null); + const loggerRef = React.useRef(null); + + // fetch messages for the selected thread + useQuery({ + queryKey: ["assistant-messages", selectedThreadID], + queryFn: async () => { + const response = await fetch(`/api/paste-assistant-messages/${selectedThreadID}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const responseJSON = await response.json(); + const newMessages = responseJSON.data; + setMessages(newMessages); + return newMessages; + }, + notifyOnChangeProps: ["data", "error"], + }); + + React.useEffect(() => { + setMounted(true); + // whats the width of the log? You'll need it to render the skeleton loader + setLogWidth(loggerRef.current?.offsetWidth ?? 0); + }, []); + + // scroll to bottom of chat log when new messages are added + React.useEffect(() => { + if (!mounted || !loggerRef.current) return; + scrollerRef.current?.scrollTo({ top: loggerRef.current.scrollHeight, behavior: "smooth" }); + }, [memoedMessages, mounted]); + + return ( + + + {activeRun != null && } + + + + Welcome to the Paste Design System Assistant! We're excited to have you here. + + + + + Keep in mind that this is an experimental tool and so the information provided{" "} + may not be entirely accurate. + + + Your conversations are not used to train OpenAI's models, but are stored by OpenAI. + + + {messages?.map((threadMessage): React.ReactNode => { + if (threadMessage.role === "assistant") { + return ; + } + return ; + })} + {(isCreatingAResponse || activeRun != null) && } + + + + ); +}; diff --git a/packages/paste-website/src/components/assistant/AssistantComposer.tsx b/packages/paste-website/src/components/assistant/AssistantComposer.tsx new file mode 100644 index 0000000000..a44aef1d55 --- /dev/null +++ b/packages/paste-website/src/components/assistant/AssistantComposer.tsx @@ -0,0 +1,52 @@ +import { ChatComposer } from "@twilio-paste/chat-composer"; +import { $getRoot, ClearEditorPlugin, type EditorState } from "@twilio-paste/lexical-library"; +import * as React from "react"; + +import { useAssistantThreadsStore } from "../../stores/assistantThreadsStore"; +import useStoreWithLocalStorage from "../../stores/useStore"; +import { EnterKeySubmitPlugin } from "./EnterKeySubmitPlugin"; +import { FocusComposerPlugin } from "./FocusComposerPlugin"; +import { SendButtonPlugin } from "./SendButtonPlugin"; + +export const AssistantComposer: React.FC<{ onMessageCreation: (message: string, selectedThread: string) => void }> = ({ + onMessageCreation, +}) => { + const [message, setMessage] = React.useState(""); + const threadsStore = useStoreWithLocalStorage(useAssistantThreadsStore, (state) => state); + const selectedThread = threadsStore?.selectedThreadID; + + const editorRef = React.useRef(null); + + const handleComposerChange = (editorState: EditorState): void => { + editorState.read(() => { + const text = $getRoot().getTextContent(); + setMessage(text); + }); + }; + + const submitMessage = (): void => { + if (message === "" || selectedThread == null) return; + onMessageCreation(message, selectedThread); + }; + + return ( + { + throw error; + }, + }} + ariaLabel="Message" + placeholder="Type here..." + onChange={handleComposerChange} + ref={editorRef} + > + + + + + + ); +}; diff --git a/packages/paste-website/src/components/assistant/AssistantEmptyState.tsx b/packages/paste-website/src/components/assistant/AssistantEmptyState.tsx new file mode 100644 index 0000000000..1a87ffc05d --- /dev/null +++ b/packages/paste-website/src/components/assistant/AssistantEmptyState.tsx @@ -0,0 +1,60 @@ +import { Box } from "@twilio-paste/box"; +import { Button } from "@twilio-paste/button"; +import { Heading } from "@twilio-paste/heading"; +import { Paragraph } from "@twilio-paste/paragraph"; +import Image from "next/image"; +import * as React from "react"; + +import { Logo } from "../../assets/Logo"; +import EmptyDoSomething from "../../assets/illustrations/empty-do-something.svg"; + +const cannedActions = { + REACT_QUESTION: "How do I create a Primary button in React?", + COMPONENT_GUIDE: "When should I use a Popover over a tooltip?", + ACCESSIBILITY: "What are the accessibilty considerations when using color?", +}; + +export const AssistantEmptyState: React.FC<{ onCannedThreadCreation: (message: string) => void }> = ({ + onCannedThreadCreation, +}) => { + return ( + + + + + + Welcome to the Paste Design System Assistant + + + The Assistant is a tool to help you design and build with Paste. It's a place to ask questions, get + answers, and learn about using Paste. + + + Create different threads to interact with PasteBot, and revisit your conversations. It can help with + writing Code, understanding component guidelines and Design guidelines. + + + + + + + + Ask Paste anything: + + + + + + + + + + + ); +}; diff --git a/packages/paste-website/src/components/assistant/AssistantLayout.tsx b/packages/paste-website/src/components/assistant/AssistantLayout.tsx new file mode 100644 index 0000000000..7351a704b7 --- /dev/null +++ b/packages/paste-website/src/components/assistant/AssistantLayout.tsx @@ -0,0 +1,79 @@ +import { Box } from "@twilio-paste/box"; +import React from "react"; + +const Window: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; + +const Canvas: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; + +const Threads: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; +const ThreadsHeader: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; + +export const Composer: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; + +export const AsssistantLayout = { Window, Threads, ThreadsHeader, Canvas, Composer }; diff --git a/packages/paste-website/src/components/assistant/AssistantMarkdown.tsx b/packages/paste-website/src/components/assistant/AssistantMarkdown.tsx new file mode 100644 index 0000000000..ea3f43c7ca --- /dev/null +++ b/packages/paste-website/src/components/assistant/AssistantMarkdown.tsx @@ -0,0 +1,125 @@ +import { Anchor } from "@twilio-paste/anchor"; +import { Box } from "@twilio-paste/box"; +import { CodeBlock, CodeBlockHeader, type CodeBlockProps, CodeBlockWrapper } from "@twilio-paste/code-block"; +import { Heading } from "@twilio-paste/heading"; +import { InlineCode } from "@twilio-paste/inline-code"; +import { ListItem, OrderedList, UnorderedList } from "@twilio-paste/list"; +import { Separator } from "@twilio-paste/separator"; +import { TBody, THead, Table, Td, Th, Tr } from "@twilio-paste/table"; +import Markdown from "markdown-to-jsx"; +import * as React from "react"; + +export const AssistantHeading: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; +export const AssistantParagraph: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; +export const AssistantSeparator: React.FC = () => { + return ; +}; +export const AssistantTable: React.FC = ({ children }) => { + return ( + + {/* @ts-expect-error there is children being passed */} + {children}
+
+ ); +}; + +export const AssistantMarkdown: React.FC<{ children: string }> = ({ children }) => { + return ( + + + {node.lang ? node.lang : "javascript"} + + + + ); + } + + return next(); + }, + overrides: { + code: { + component: InlineCode, + }, + a: { + component: Anchor, + }, + h1: { + component: AssistantHeading, + }, + h2: { + component: AssistantHeading, + }, + h3: { + component: AssistantHeading, + }, + h4: { + component: AssistantHeading, + }, + p: { + component: AssistantParagraph, + }, + ol: { + component: OrderedList, + }, + ul: { + component: UnorderedList, + }, + li: { + component: ListItem, + }, + hr: { + component: AssistantSeparator, + }, + table: { + component: AssistantTable, + }, + thead: { + component: THead, + }, + tbody: { + component: TBody, + }, + tr: { + component: Tr, + }, + td: { + component: Td, + }, + th: { + component: Th, + }, + }, + }} + > + {children} + + ); +}; diff --git a/packages/paste-website/src/components/assistant/AssistantMessage.tsx b/packages/paste-website/src/components/assistant/AssistantMessage.tsx index 2fd09c0966..9653e5151c 100644 --- a/packages/paste-website/src/components/assistant/AssistantMessage.tsx +++ b/packages/paste-website/src/components/assistant/AssistantMessage.tsx @@ -1,17 +1,17 @@ import { ChatBubble, ChatMessage, ChatMessageMeta, ChatMessageMetaItem } from "@twilio-paste/chat-log"; -import Markdown from "markdown-to-jsx"; import { type ThreadMessage } from "openai/resources/beta/threads/messages"; import * as React from "react"; import { Logo } from "../../assets/Logo"; import { formatTimestamp } from "../../utils/formatTimestamp"; +import { AssistantMarkdown } from "./AssistantMarkdown"; export const AssistantMessage: React.FC<{ threadMessage: ThreadMessage }> = ({ threadMessage }) => { return ( {threadMessage.content[0].type === "text" && ( - {threadMessage.content[0].text.value} + {threadMessage.content[0].text.value} )} diff --git a/packages/paste-website/src/components/assistant/AssistantMessagePoller.ts b/packages/paste-website/src/components/assistant/AssistantMessagePoller.ts new file mode 100644 index 0000000000..be7e0141b7 --- /dev/null +++ b/packages/paste-website/src/components/assistant/AssistantMessagePoller.ts @@ -0,0 +1,44 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useAssistantRunStore } from "../../stores/assistantRunStore"; +import { useAssistantThreadsStore } from "../../stores/assistantThreadsStore"; +import useStoreWithLocalStorage from "../../stores/useStore"; + +/** + * When an assistant thread run is active, poll the server for updates. + * When the run is complete, invalidate the messages query to refetch + * the thread messages. + * + * @return {*} + */ +export const AssistantMessagePoller: React.FC = () => { + const queryClient = useQueryClient(); + const selectedThread = useStoreWithLocalStorage(useAssistantThreadsStore, (state) => state.selectedThreadID); + const activeRun = useAssistantRunStore((state) => state.activeRun); + const setActiveRun = useAssistantRunStore((state) => state.setActiveRun); + + useQuery({ + queryKey: ["assistant-active-run", selectedThread, activeRun?.id], + queryFn: async () => { + if (selectedThread == null || activeRun?.id == null) return {}; + const response = await fetch(`/api/paste-assistant-thread/${selectedThread}/run/${activeRun?.id}`, { + method: "GET", + }); + const runDetails = await response.json(); + if (runDetails.status === "completed") { + setActiveRun(undefined); + // invalidate the assistant-messages query, essentially tell it to refetch + queryClient.invalidateQueries({ queryKey: ["assistant-messages"] }); + } else { + // reset the active run to the latest run details so we can montior the status of the run + setActiveRun(runDetails); + } + + return runDetails; + }, + notifyOnChangeProps: ["data", "error"], + // Poll every second whilst we wait for it to complete + refetchInterval: 1000, + }); + return null; +}; diff --git a/packages/paste-website/src/components/assistant/AssistantThreads.tsx b/packages/paste-website/src/components/assistant/AssistantThreads.tsx new file mode 100644 index 0000000000..be8e5d6f85 --- /dev/null +++ b/packages/paste-website/src/components/assistant/AssistantThreads.tsx @@ -0,0 +1,61 @@ +import { useListboxPrimitiveState } from "@twilio-paste/listbox-primitive"; +import * as React from "react"; + +import { useDeleteThreadMutation } from "../../api/assistantAPIs"; +import { useAssistantMessagesStore } from "../../stores/assistantMessagesStore"; +import { type AssistantThread, useAssistantThreadsStore } from "../../stores/assistantThreadsStore"; +import useStoreWithLocalStorage from "../../stores/useStore"; +import { formatTimestamp } from "../../utils/formatTimestamp"; +import { ThreadList, ThreadListItem, ThreadListMeta, ThreadListTitle } from "./ThreadList"; + +export const AssistantThreads: React.FC = () => { + const listboxState = useListboxPrimitiveState(); + const threadsStore = useStoreWithLocalStorage(useAssistantThreadsStore, (state) => state); + const deleteThreadMutation = useDeleteThreadMutation(); + const resetMessages = useAssistantMessagesStore((state) => state.resetMessages); + + const handleThreadDelete = (threadID: AssistantThread["id"]): void => { + deleteThreadMutation.mutate(threadID, { + onSuccess: async () => { + if (threadsStore?.deleteThread != null) { + threadsStore?.deleteThread(threadID); + } + if (threadsStore?.selectedThreadID === threadID) { + resetMessages(); + } + }, + }); + }; + + const handleThreadSelect = (threadID: AssistantThread["id"]): void => { + if (threadsStore?.setSelectedThread != null) { + threadsStore?.setSelectedThread(threadID); + } + }; + + if (threadsStore?.threads === undefined) return null; + + return ( + + {threadsStore?.threads.map((thread) => ( + { + handleThreadSelect(thread.id); + }} + onDelete={(): void => { + handleThreadDelete(thread.id); + }} + > + + {/* @ts-expect-error threadTitle is a custom prop on metadata */} + {thread.metadata?.threadTitle ?? thread.id} + + {formatTimestamp(thread.created_at)} + + ))} + + ); +}; diff --git a/packages/paste-website/src/components/assistant/AsststantHeader.tsx b/packages/paste-website/src/components/assistant/AsststantHeader.tsx new file mode 100644 index 0000000000..2dfa9a2417 --- /dev/null +++ b/packages/paste-website/src/components/assistant/AsststantHeader.tsx @@ -0,0 +1,42 @@ +import { Box } from "@twilio-paste/box"; +import { Button } from "@twilio-paste/button"; +import { Heading } from "@twilio-paste/heading"; +import { PlusIcon } from "@twilio-paste/icons/esm/PlusIcon"; +import * as React from "react"; + +import { useCreateThreadMutation } from "../../api/assistantAPIs"; +import { Logo } from "../../assets/Logo"; +import { useAssistantThreadsStore } from "../../stores/assistantThreadsStore"; +import useStoreWithLocalStorage from "../../stores/useStore"; + +export const AssistantHeader: React.FC = () => { + const threadStore = useStoreWithLocalStorage(useAssistantThreadsStore, (state) => state); + const createThreadMutation = useCreateThreadMutation(); + + const handleCreateNewThread = (): void => { + createThreadMutation.mutate( + {}, + { + onSuccess: async (data) => { + // @ts-expect-error I don't know how to type this right now so it knows it's a response + const newThread = await data.json(); + if (threadStore == null) return; + threadStore.createAndSelectThread(newThread); + }, + }, + ); + }; + + return ( + <> + + + Assistant + + + + + ); +}; diff --git a/packages/paste-website/src/components/assistant/FocusComposerPlugin.tsx b/packages/paste-website/src/components/assistant/FocusComposerPlugin.tsx new file mode 100644 index 0000000000..f45cdda77d --- /dev/null +++ b/packages/paste-website/src/components/assistant/FocusComposerPlugin.tsx @@ -0,0 +1,11 @@ +import { useLexicalComposerContext } from "@twilio-paste/lexical-library"; +import * as React from "react"; + +export const FocusComposerPlugin = ({ selectedThread }: { selectedThread?: string }): null => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + editor.focus(); + }, [editor, selectedThread]); + return null; +}; diff --git a/packages/paste-website/src/components/assistant/LoadingMessage.tsx b/packages/paste-website/src/components/assistant/LoadingMessage.tsx new file mode 100644 index 0000000000..2c92a38a75 --- /dev/null +++ b/packages/paste-website/src/components/assistant/LoadingMessage.tsx @@ -0,0 +1,55 @@ +/* eslint-disable camelcase */ +import { Box } from "@twilio-paste/box"; +import { ChatBubble, ChatMessage, ChatMessageMeta, ChatMessageMetaItem } from "@twilio-paste/chat-log"; +import { SkeletonLoader } from "@twilio-paste/skeleton-loader"; +import { useUID } from "@twilio-paste/uid-library"; +import * as React from "react"; + +import { Logo } from "../../assets/Logo"; +import { useAssistantRunStore } from "../../stores/assistantRunStore"; +import { formatTimestamp } from "../../utils/formatTimestamp"; + +const getRandomNumber = (max: number): number => { + return Math.floor(Math.random() * max); +}; + +const STATUS_MAP = { + queued: "Queued...", + in_progress: "Researching...", + requires_action: "Researching...", + cancelling: "Concelling...", + cancelled: "Cancelled.", + failed: "Failed.", + completed: "Finished.", + expired: "Expired.", +}; + +export const LoadingMessage: React.FC<{ maxWidth: number }> = ({ maxWidth }) => { + const activeRun = useAssistantRunStore((state) => state.activeRun); + + const newDateTime = new Date(); + const timestamp = Math.floor(newDateTime.getTime() / 1000); + + const randomWidths = React.useMemo(() => { + return Array.from({ length: 3 }, () => getRandomNumber(maxWidth)); + }, [maxWidth]); + + return ( + + + + {randomWidths.map((width) => ( + + ))} + + + + + + PasteBot ・ {activeRun?.status ? STATUS_MAP[activeRun?.status] : "Thinking..."} + + + + ); +}; +/* eslint-enable camelcase */ diff --git a/packages/paste-website/src/components/assistant/UserMessage.tsx b/packages/paste-website/src/components/assistant/UserMessage.tsx index 4775604cb8..b8feb9eaff 100644 --- a/packages/paste-website/src/components/assistant/UserMessage.tsx +++ b/packages/paste-website/src/components/assistant/UserMessage.tsx @@ -1,16 +1,16 @@ import { ChatBubble, ChatMessage, ChatMessageMeta, ChatMessageMetaItem } from "@twilio-paste/chat-log"; -import Markdown from "markdown-to-jsx"; import { type ThreadMessage } from "openai/resources/beta/threads/messages"; import * as React from "react"; import { formatTimestamp } from "../../utils/formatTimestamp"; +import { AssistantMarkdown } from "./AssistantMarkdown"; export const UserMessage: React.FC<{ threadMessage: ThreadMessage }> = ({ threadMessage }) => { return ( {threadMessage.content[0].type === "text" && ( - {threadMessage.content[0].text.value} + {threadMessage.content[0].text.value} )} diff --git a/packages/paste-website/src/pages/api/og-image.tsx b/packages/paste-website/src/pages/api/component-og-image.tsx similarity index 99% rename from packages/paste-website/src/pages/api/og-image.tsx rename to packages/paste-website/src/pages/api/component-og-image.tsx index d5c34c9805..9821f3cafa 100644 --- a/packages/paste-website/src/pages/api/og-image.tsx +++ b/packages/paste-website/src/pages/api/component-og-image.tsx @@ -225,7 +225,7 @@ function OgImageCard({ packageData }: { packageData: PackageData }): JSX.Element ); } -const LOG_PREFIX = "[/api/og-image]:"; +const LOG_PREFIX = "[/api/component-og-image]:"; export default async function handler(req: NextRequest): Promise { console.log(`${LOG_PREFIX} Incoming request`); diff --git a/packages/paste-website/src/pages/api/paste-assistant-message.ts b/packages/paste-website/src/pages/api/paste-assistant-message.ts index 5df92bc75f..d511a1921f 100644 --- a/packages/paste-website/src/pages/api/paste-assistant-message.ts +++ b/packages/paste-website/src/pages/api/paste-assistant-message.ts @@ -38,6 +38,16 @@ async function getRelevantDocs(functionArguments: string): Promise { }); } +/** + * End point for the paste assistant to call to send a message to an OpenAI assistant thread, wait for a response, + * process if the assistant response is asking for a "Function" to be called, call the function and return the output + * to the assistant for it to process a chat response and add it to the thread. + * + * @export + * @param {NextApiRequest} req + * @param {NextApiResponse} res + * @return {*} {(Promise)} + */ export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { logger.info(`${LOG_PREFIX} Incoming request`); @@ -102,7 +112,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) let run = await openai.beta.threads.runs.create(threadId, { assistant_id: assistantID }); /** - * poll the run to see if it's completeor if the assistant need to call some "Functions" find it's status + * poll the run to see if it's complete or if the assistant need to call some "Functions" find it's status * keep polling while the status is ['queued', 'in_progress'] */ while (run.status === "queued" || run.status === "in_progress") { @@ -110,7 +120,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } /** - * if the run status becomes `completed` we can respond to the user with the assistant's response + * if the run status becomes `completed` we can respond to the user with the assistant's run response */ if (run.status === "completed") { logger.info(`${LOG_PREFIX} Assistant run response`, { run }); diff --git a/packages/paste-website/src/pages/api/paste-assistant-messages/[tid].ts b/packages/paste-website/src/pages/api/paste-assistant-messages/[tid].ts index 9f934eddd6..8aab8567ea 100644 --- a/packages/paste-website/src/pages/api/paste-assistant-messages/[tid].ts +++ b/packages/paste-website/src/pages/api/paste-assistant-messages/[tid].ts @@ -26,6 +26,16 @@ async function getThreadMessages({ return openai.beta.threads.messages.list(threadId, { order: "asc" }); } +/** + * Simple endpoint for returning messages from a thread based on a threadId. + * + * /api/paste-assistant-messages/:tid + * + * @export + * @param {NextApiRequest} req + * @param {NextApiResponse} res + * @return {*} {(Promise)} + */ export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { logger.info(`${LOG_PREFIX} Incoming request`); @@ -66,10 +76,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return; } - logger.info(`${LOG_PREFIX} Getting thread messages`, { threadId }); - - const threadMessages = await getThreadMessages({ threadId }); - - logger.info(`${LOG_PREFIX} Recieved thread message`, { threadMessages }); - res.status(200).json(threadMessages); + try { + logger.info(`${LOG_PREFIX} Getting thread messages`, { threadId }); + const threadMessages = await getThreadMessages({ threadId }); + logger.info(`${LOG_PREFIX} Recieved thread message`, { threadMessages }); + res.status(200).json(threadMessages); + } catch (error) { + logger.error(`${LOG_PREFIX} Error getting thread message`, { error }); + rollbar.error(`${LOG_PREFIX} Error getting thread message`, { error }); + res.status(500).json({ + error: "Error getting thread message", + }); + } } diff --git a/packages/paste-website/src/pages/api/paste-assistant-simple-completion.ts b/packages/paste-website/src/pages/api/paste-assistant-simple-completion.ts new file mode 100644 index 0000000000..71816a1b8e --- /dev/null +++ b/packages/paste-website/src/pages/api/paste-assistant-simple-completion.ts @@ -0,0 +1,86 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import OpenAI from "openai"; +import Rollbar from "rollbar"; + +import { logger } from "../../functions-utils/logger"; + +const rollbar = new Rollbar({ + accessToken: process.env.ROLLBAR_ACCESS_TOKEN, + captureUncaught: true, + captureUnhandledRejections: true, +}); + +const openAiKey = process.env.OPENAI_API_KEY; +const openAiSecret = process.env.OPENAI_API_SECRET; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, // defaults to process.env["OPENAI_API_KEY"] +}); + +const LOG_PREFIX = "[/api/paste-assistant-simple-completion]:"; + +async function getCompletion(prompt: OpenAI.Chat.ChatCompletionMessageParam): Promise { + return openai.chat.completions.create({ + model: "gpt-4-1106-preview", + messages: [prompt], + // eslint-disable-next-line camelcase + max_tokens: 2000, + temperature: 0, + }); +} + +/** + * Super simple OpenAI completion endpoint for the paste assistant to call. + * Provide a prompt and any context you would like to provide and get a completion back from OpenAI. + * + * @export + * @param {NextApiRequest} req + * @param {NextApiResponse} res + * @return {*} {(Promise)} + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { + logger.info(`${LOG_PREFIX} Incoming request`); + + if (openAiKey === undefined || openAiSecret === undefined) { + logger.error(`${LOG_PREFIX} OpenAI API key or secret is undefined`); + rollbar.error(`${LOG_PREFIX} OpenAI API key or secret is undefined`); + res.status(500).json({ + error: "OpenAI API key or secret is undefined", + }); + } + + const requestData = typeof req.body === "string" ? JSON.parse(req.body) : req.body; + logger.info(`${LOG_PREFIX} Request data`, { requestData }); + + const { prompt, context } = requestData; + + if (prompt === undefined || prompt === "") { + logger.error(`${LOG_PREFIX} prompt is undefined`); + rollbar.error(`${LOG_PREFIX} prompt is undefined`); + res.status(500).json({ + error: "Prompt is undefined", + }); + return; + } + + logger.info(`${LOG_PREFIX} Getting assistant completion`, { prompt, context }); + + const combinedPrompt = `${prompt}\n${context}`; + + const chatMessage: OpenAI.Chat.ChatCompletionMessageParam = { + role: "user", + content: combinedPrompt, + }; + + try { + const completion = await getCompletion(chatMessage); + logger.info(`${LOG_PREFIX} Recieved completion`, { completion }); + res.status(200).json(completion); + } catch (error) { + logger.error(`${LOG_PREFIX} Error getting assistant completion`, { error }); + rollbar.error(`${LOG_PREFIX} Error getting assistant completion`, { error }); + res.status(500).json({ + error: "Error getting assistant completion", + }); + } +} diff --git a/packages/paste-website/src/pages/api/paste-assistant-thread/[tid].ts b/packages/paste-website/src/pages/api/paste-assistant-thread/[tid].ts index aa98928ad8..58b95c61ef 100644 --- a/packages/paste-website/src/pages/api/paste-assistant-thread/[tid].ts +++ b/packages/paste-website/src/pages/api/paste-assistant-thread/[tid].ts @@ -24,6 +24,30 @@ async function getThread(id: OpenAI.Beta.Thread["id"]): Promise { + return openai.beta.threads.del(id); +} + +async function updateThread({ + id, + metadata, +}: { + id: OpenAI.Beta.Thread["id"]; + metadata: OpenAI.Beta.Thread["metadata"]; +}): Promise { + return openai.beta.threads.update(id, { metadata }); +} + +/** + * Simple endpoint for getting, deleting, and updating a thread via http methods for a given thread. + * + * /api/paste-assistant-thread/:tid + * + * @export + * @param {NextApiRequest} req + * @param {NextApiResponse} res + * @return {*} {(Promise)} + */ export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { logger.info(`${LOG_PREFIX} Incoming request`); @@ -43,6 +67,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } + const { method } = req; + const { tid: threadId } = req.query; if (threadId === undefined || threadId === "") { @@ -63,10 +89,54 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return; } - logger.info(`${LOG_PREFIX} Getting thread messages`, { threadId }); - - const thread = await getThread(threadId); + switch (method) { + case "GET": { + logger.info(`${LOG_PREFIX} POST request`); + try { + const thread = await getThread(threadId); + logger.info(`${LOG_PREFIX} Created thread`, { thread }); + res.status(200).json(thread); + } catch (error) { + logger.error(`${LOG_PREFIX} Error creating thread`, { error }); + rollbar.error(`${LOG_PREFIX} Error creating thread`, { error }); + res.status(500).json({ + error: "Error creating thread", + }); + } + break; + } + case "DELETE": { + logger.info(`${LOG_PREFIX} DELETE request`); + try { + const thread = await deleteThread(threadId); + logger.info(`${LOG_PREFIX} Deleted thread`, { thread }); + res.status(200).json(thread); + } catch (error) { + logger.error(`${LOG_PREFIX} Error deleting thread`, { error }); + rollbar.error(`${LOG_PREFIX} Error deleting thread`, { error }); + res.status(500).json({ + error: "Error deleting thread", + }); + } + break; + } + case "PUT": { + logger.info(`${LOG_PREFIX} PUT request`); + try { + const requestData = typeof req.body === "string" ? JSON.parse(req.body) : req.body; + logger.info(`${LOG_PREFIX} Request data`, { requestData }); - logger.info(`${LOG_PREFIX} Recieved thread`, { thread }); - res.status(200).json(thread); + const thread = await updateThread({ id: threadId, metadata: requestData.metadata }); + logger.info(`${LOG_PREFIX} Updated thread`, { thread }); + res.status(200).json(thread); + } catch (error) { + logger.error(`${LOG_PREFIX} Error updating thread`, { error }); + rollbar.error(`${LOG_PREFIX} Error updating thread`, { error }); + res.status(500).json({ + error: "Error updating thread", + }); + } + break; + } + } } diff --git a/packages/paste-website/src/pages/api/paste-assistant-thread/[tid]/run/[rid].ts b/packages/paste-website/src/pages/api/paste-assistant-thread/[tid]/run/[rid].ts new file mode 100644 index 0000000000..f40f4f4404 --- /dev/null +++ b/packages/paste-website/src/pages/api/paste-assistant-thread/[tid]/run/[rid].ts @@ -0,0 +1,109 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import OpenAI from "openai"; +import Rollbar from "rollbar"; + +import { logger } from "../../../../../functions-utils/logger"; + +const rollbar = new Rollbar({ + accessToken: process.env.ROLLBAR_ACCESS_TOKEN, + captureUncaught: true, + captureUnhandledRejections: true, +}); + +const openAiKey = process.env.OPENAI_API_KEY; +const openAiSecret = process.env.OPENAI_API_SECRET; +const assistantID = process.env.OPENAI_ASSISTANT_ID; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, // defaults to process.env["OPENAI_API_KEY"] +}); + +const LOG_PREFIX = "[/api/paste-assistant-thread/:tid/run/:rid]:"; + +async function getRun( + threadId: OpenAI.Beta.Thread["id"], + runId: OpenAI.Beta.Threads.Run["id"], +): Promise { + return openai.beta.threads.runs.retrieve(threadId, runId); +} + +/** + * Simple endpoint for returning a run from a given thread based on a threadId and runId. + * + * /api/paste-assistant-thread/:tid/run/:rid + * + * @export + * @param {NextApiRequest} req + * @param {NextApiResponse} res + * @return {*} {(Promise)} + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { + logger.info(`${LOG_PREFIX} Incoming request`); + + if (openAiKey === undefined || openAiSecret === undefined) { + logger.error(`${LOG_PREFIX} OpenAI API key or secret is undefined`); + rollbar.error(`${LOG_PREFIX} OpenAI API key or secret is undefined`); + res.status(500).json({ + error: "OpenAI API key or secret is undefined", + }); + } + + if (assistantID === undefined) { + logger.error(`${LOG_PREFIX} OpenAI assistant ID is undefined`); + rollbar.error(`${LOG_PREFIX} OpenAI assistant ID is undefined`); + res.status(500).json({ + error: "OpenAI assistant ID is undefined", + }); + } + + const { tid: threadId, rid: runId } = req.query; + + if (threadId === undefined || threadId === "") { + logger.error(`${LOG_PREFIX} Thread ID is undefined`); + rollbar.error(`${LOG_PREFIX} Thread ID is undefined`); + res.status(500).json({ + error: "Thread ID is undefined", + }); + return; + } + + if (typeof threadId !== "string") { + logger.error(`${LOG_PREFIX} Thread ID is not a string`); + rollbar.error(`${LOG_PREFIX} Thread ID is not a string`); + res.status(500).json({ + error: "Thread ID is not a string", + }); + return; + } + + if (runId === undefined || runId === "") { + logger.error(`${LOG_PREFIX} Run ID is undefined`); + rollbar.error(`${LOG_PREFIX} Run ID is undefined`); + res.status(500).json({ + error: "Run ID is undefined", + }); + return; + } + + if (typeof runId !== "string") { + logger.error(`${LOG_PREFIX} Run ID is not a string`); + rollbar.error(`${LOG_PREFIX} Run ID is not a string`); + res.status(500).json({ + error: "Run ID is not a string", + }); + return; + } + + try { + logger.info(`${LOG_PREFIX} Getting assistant run`, { threadId, runId }); + const run = await getRun(threadId, runId); + logger.info(`${LOG_PREFIX} Recieved run`, { run }); + res.status(200).json(run); + } catch (error) { + logger.error(`${LOG_PREFIX} Error getting assistant run`, { error }); + rollbar.error(`${LOG_PREFIX} Error getting assistant run`, { error }); + res.status(500).json({ + error: "Error getting assistant run", + }); + } +} diff --git a/packages/paste-website/src/pages/api/paste-assistant-thread/index.ts b/packages/paste-website/src/pages/api/paste-assistant-thread/index.ts index bbdb9a83fe..18f860937d 100644 --- a/packages/paste-website/src/pages/api/paste-assistant-thread/index.ts +++ b/packages/paste-website/src/pages/api/paste-assistant-thread/index.ts @@ -24,21 +24,14 @@ async function createThread(): Promise { return openai.beta.threads.create({ metadata: { threadTitle: "New thread" } }); } -async function deleteThread(id: string): Promise { - return openai.beta.threads.del(id); -} - -async function updateThread({ - id, - metadata, -}: { - id: OpenAI.Beta.Thread["id"]; - metadata: OpenAI.Beta.Thread["metadata"]; -}): Promise { - return openai.beta.threads.update(id, { metadata }); -} - -export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { +/** + * Simple endpoint for creating a thread via a POST. + * + * @export + * @param {NextApiResponse} res + * @return {*} {(Promise)} + */ +export default async function handler(_req: NextApiRequest, res: NextApiResponse): Promise { logger.info(`${LOG_PREFIX} Incoming request`); if (openAiKey === undefined || openAiSecret === undefined) { @@ -57,56 +50,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } - const requestData = typeof req.body === "string" ? JSON.parse(req.body) : req.body; - logger.info(`${LOG_PREFIX} Request data`, { requestData }); - - const { method } = req; - - switch (method) { - case "POST": { - logger.info(`${LOG_PREFIX} POST request`); - try { - const thread = await createThread(); - logger.info(`${LOG_PREFIX} Created thread`, { thread }); - res.status(200).json(thread); - } catch (error) { - logger.error(`${LOG_PREFIX} Error creating thread`, { error }); - rollbar.error(`${LOG_PREFIX} Error creating thread`, { error }); - res.status(500).json({ - error: "Error creating thread", - }); - } - break; - } - case "DELETE": { - logger.info(`${LOG_PREFIX} DELETE request`); - try { - const thread = await deleteThread(requestData.id); - logger.info(`${LOG_PREFIX} Deleted thread`, { thread }); - res.status(200).json(thread); - } catch (error) { - logger.error(`${LOG_PREFIX} Error deleting thread`, { error }); - rollbar.error(`${LOG_PREFIX} Error deleting thread`, { error }); - res.status(500).json({ - error: "Error deleting thread", - }); - } - break; - } - case "PUT": { - logger.info(`${LOG_PREFIX} PUT request`); - try { - const thread = await updateThread(requestData); - logger.info(`${LOG_PREFIX} Updated thread`, { thread }); - res.status(200).json(thread); - } catch (error) { - logger.error(`${LOG_PREFIX} Error updating thread`, { error }); - rollbar.error(`${LOG_PREFIX} Error updating thread`, { error }); - res.status(500).json({ - error: "Error updating thread", - }); - } - break; - } + try { + const thread = await createThread(); + logger.info(`${LOG_PREFIX} Created thread`, { thread }); + res.status(200).json(thread); + } catch (error) { + logger.error(`${LOG_PREFIX} Error creating thread`, { error }); + rollbar.error(`${LOG_PREFIX} Error creating thread`, { error }); + res.status(500).json({ + error: "Error creating thread", + }); } } diff --git a/packages/paste-website/src/pages/api/simple-og-image.tsx b/packages/paste-website/src/pages/api/simple-og-image.tsx new file mode 100644 index 0000000000..396c7934ea --- /dev/null +++ b/packages/paste-website/src/pages/api/simple-og-image.tsx @@ -0,0 +1,158 @@ +/* eslint-disable no-console */ +import { ImageResponse } from "@vercel/og"; +import type { NextRequest } from "next/server"; + +export const config = { + runtime: "edge", +}; + +function PasteLogo(): JSX.Element { + return ( +
+ + + + + + + + + + + + +
+ ); +} + +function OgImageCard({ title, description }: { title: string; description: string }): JSX.Element { + return ( +
+
+
+
+ {title} +
+
+ {description} +
+
+
+ +
+
+
+ ); +} + +const LOG_PREFIX = "[/api/simple-og-image]:"; + +/** + * Create a very basic open graph image with a static title and description for static pages on the website. + * + * usage: + * + * + * @export + * @param {NextRequest} req + * @return {*} {Promise} + */ +export default async function handler(req: NextRequest): Promise { + console.log(`${LOG_PREFIX} Incoming request`); + + const { searchParams } = new URL(req.url); + const cardTitle = searchParams.get("title"); + const cardDescription = searchParams.get("description"); + + if (!cardTitle || !cardDescription) { + throw new Error("No parameters provided"); + } + + console.log(`${LOG_PREFIX} Title: ${cardTitle}`); + console.log(`${LOG_PREFIX} Description: ${cardDescription}`); + + console.log(`${LOG_PREFIX} get fonts`); + + // Make sure the font exists in the specified path: + const fontData = await fetch( + "https://assets.twilio.com/public_assets/paste-fonts/1.5.1/TwilioSansText-Regular.woff", + ).then(async (res) => res.arrayBuffer()); + const fontDataMedium = await fetch( + "https://assets.twilio.com/public_assets/paste-fonts/1.5.1/TwilioSansText-Medium.woff", + ).then(async (res) => res.arrayBuffer()); + const fontDataSemiBold = await fetch( + "https://assets.twilio.com/public_assets/paste-fonts/1.5.1/TwilioSansText-Semibold.woff", + ).then(async (res) => res.arrayBuffer()); + + console.log(`${LOG_PREFIX} return image`); + + return new ImageResponse(, { + width: 800, + height: 420, + fonts: [ + { + name: "TwilioSansText", + data: fontData, + style: "normal", + }, + { + name: "TwilioSansTextMedium", + data: fontDataMedium, + style: "normal", + weight: 500, + }, + { + name: "TwilioSansTextSemiBold", + data: fontDataSemiBold, + style: "normal", + weight: 600, + }, + ], + }); +} +/* eslint-enable no-console */ diff --git a/packages/paste-website/src/pages/assistant.tsx b/packages/paste-website/src/pages/assistant.tsx index 93860f280f..89907e48ec 100644 --- a/packages/paste-website/src/pages/assistant.tsx +++ b/packages/paste-website/src/pages/assistant.tsx @@ -1,260 +1,32 @@ -/* eslint-disable camelcase */ -import { Box } from "@twilio-paste/box"; -import { Button } from "@twilio-paste/button"; -import { ChatComposer } from "@twilio-paste/chat-composer"; -import { ChatLog } from "@twilio-paste/chat-log"; -import { Heading } from "@twilio-paste/heading"; -import { PlusIcon } from "@twilio-paste/icons/esm/PlusIcon"; -import { $getRoot, ClearEditorPlugin, type EditorState } from "@twilio-paste/lexical-library"; -import { useListboxPrimitiveState } from "@twilio-paste/listbox-primitive"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import type { NextPage } from "next"; -import { type ThreadMessagesPage } from "openai/resources/beta/threads/messages"; -import { type Thread } from "openai/resources/beta/threads/threads"; -import * as React from "react"; +import Head from "next/head"; -import { Logo } from "../assets/Logo"; -import { AssistantMessage } from "../components/assistant/AssistantMessage"; -import { EnterKeySubmitPlugin } from "../components/assistant/EnterKeySubmitPlugin"; -import { SendButtonPlugin } from "../components/assistant/SendButtonPlugin"; -import { ThreadList, ThreadListItem, ThreadListMeta, ThreadListTitle } from "../components/assistant/ThreadList"; -import { UserMessage } from "../components/assistant/UserMessage"; -import { formatTimestamp } from "../utils/formatTimestamp"; +import { Assistant as AssistantPage } from "../components/assistant/Assistant"; +import { useLocationOrigin } from "../utils/RouteUtils"; -const createAssistantThreadRun = async ({ - userMessage, - threadID, -}: { userMessage: string; threadID: string }): Promise => { - await fetch("/api/paste-assistant-message/", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ threadId: threadID, message: userMessage }), - }).catch((error) => { - throw error; - }); -}; +const queryClient = new QueryClient(); const Assistant: NextPage = () => { - const listboxState = useListboxPrimitiveState(); - const [selectedThread, setSelectedThread] = React.useState(); - const [selectedThreadMessages, setSelectedThreadMessages] = React.useState(); - - const [threads, setThreads] = React.useState([ - { - created_at: 1700526686, - id: "thread_vhBUVa68I5nnBTCOM02qSY00", - metadata: { threadTitle: "New thread" }, - object: "thread", - }, - { - created_at: 1700526686, - id: "thread_GOupC2K3OoGUbCwYiYATNB0l", - metadata: { threadTitle: "New thread" }, - object: "thread", - }, - ]); - const [message, setMessage] = React.useState(""); - const [mounted, setMounted] = React.useState(false); - const [scrolled, setScrolled] = React.useState(false); - const loggerRef = React.useRef(null); - const scrollerRef = React.useRef(null); - - const handleCreateNewThread = async (): Promise => { - const thread = await fetch("/api/paste-assistant-thread", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - }).catch((error) => { - throw error; - }); - setThreads([...threads, await thread.json()]); - }; - - const handleThreadDelete = async (threadID: Thread["id"]): Promise => { - await fetch("/api/paste-assistant-thread", { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ id: threadID }), - }).catch((error) => { - throw error; - }); - setThreads(threads.filter((thread) => thread.id !== threadID)); - }; - - const handleThreadClick = (threadID: Thread["id"]): void => { - setSelectedThread(threadID); - setSelectedThreadMessages([]); - }; - - const handleComposerChange = (editorState: EditorState): void => { - editorState.read(() => { - const text = $getRoot().getTextContent(); - setMessage(text); - }); - }; - - const submitMessage = (): void => { - if (message === "" || selectedThread == null) return; - createAssistantThreadRun({ userMessage: message, threadID: selectedThread }); - }; - - React.useEffect(() => { - setMounted(true); - }, []); - - // poll the assistant thread for new messages when a thread is selected - React.useEffect(() => { - if (selectedThread == null) - return () => { - return null; - }; - - setScrolled(false); - - const fetchSelectedThreadMessages = async (): Promise => { - const thread = await fetch(`/api/paste-assistant-messages/${selectedThread}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }).catch((error) => { - throw error; - }); - const threadData = (await thread.json()) as ThreadMessagesPage; - - // update the selected thread messages if the returned threadData.data is different from the current state - if (JSON.stringify(threadData.data) !== JSON.stringify(selectedThreadMessages)) { - setSelectedThreadMessages(threadData.data); - } - }; - const intervalId = setInterval(fetchSelectedThreadMessages, 1000); // polling every 5 seconds - - // cleanup function on component unmount or ID change - return () => { - clearInterval(intervalId); - }; - }, [selectedThread, selectedThreadMessages]); - - // scroll to bottom of chat log when new messages are added - React.useEffect(() => { - if (!mounted || !loggerRef.current || scrolled) return; - scrollerRef.current?.scrollTo({ top: loggerRef.current.scrollHeight, behavior: "smooth" }); - }, [selectedThreadMessages, mounted, scrolled]); - + const origin = useLocationOrigin(); return ( - - - - - - Assistant - - - - - - - {threads.map((thread) => ( - { - handleThreadClick(thread.id); - }} - onDelete={(): void => { - handleThreadDelete(thread.id); - }} - > - - {/* @ts-expect-error threadTitle is a custom prop on metadata */} - {thread.metadata?.threadTitle ?? thread.id} - - {formatTimestamp(thread.created_at)} - - ))} - - - - - { - setScrolled(true); - }} - > - - - {selectedThreadMessages?.map((threadMessage): React.ReactNode => { - if (threadMessage.role === "assistant") { - return ; - } - return ; - })} - - - - - { - throw error; - }, - }} - ariaLabel="Message" - placeholder="Type here..." - onChange={handleComposerChange} - > - - - - - - - + + + Paste Design System Assistant + + + + + + ); }; export default Assistant; -/* eslint-enable camelcase */ diff --git a/packages/paste-website/src/stores/assistantMessagesStore.ts b/packages/paste-website/src/stores/assistantMessagesStore.ts new file mode 100644 index 0000000000..5dea7727b2 --- /dev/null +++ b/packages/paste-website/src/stores/assistantMessagesStore.ts @@ -0,0 +1,26 @@ +import { type ThreadMessage, type ThreadMessagesPage } from "openai/resources/beta/threads/messages"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +export type AssistantThreadMessages = ThreadMessagesPage["data"]; +type State = { messages: AssistantThreadMessages }; +type Actions = { + setMessages: (newMessages: AssistantThreadMessages) => void; + addMessage: (newMessage: ThreadMessage) => void; + resetMessages: () => void; +}; + +export const useAssistantMessagesStore = create()( + devtools((set) => ({ + messages: [], + setMessages: (newMessages) => { + set(() => ({ messages: newMessages })); + }, + addMessage: (newMessage) => { + set((state) => ({ messages: [...state.messages, newMessage] })); + }, + resetMessages: () => { + set(() => ({ messages: [] })); + }, + })), +); diff --git a/packages/paste-website/src/stores/assistantRunStore.ts b/packages/paste-website/src/stores/assistantRunStore.ts new file mode 100644 index 0000000000..da6915ed23 --- /dev/null +++ b/packages/paste-website/src/stores/assistantRunStore.ts @@ -0,0 +1,15 @@ +import { type Run } from "openai/resources/beta/threads/runs"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +type State = { activeRun: Run | undefined }; +type Actions = { setActiveRun: (newRun: Run | undefined) => void }; + +export const useAssistantRunStore = create()( + devtools((set) => ({ + activeRun: undefined, + setActiveRun: (newRun) => { + set(() => ({ activeRun: newRun })); + }, + })), +); diff --git a/packages/paste-website/src/stores/assistantThreadsStore.ts b/packages/paste-website/src/stores/assistantThreadsStore.ts new file mode 100644 index 0000000000..0cd4a8ca11 --- /dev/null +++ b/packages/paste-website/src/stores/assistantThreadsStore.ts @@ -0,0 +1,71 @@ +import { type Thread } from "openai/resources/beta/threads/threads"; +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +export type AssistantThread = Thread & { + selected: boolean; +}; +type State = { threads: AssistantThread[]; selectedThreadID: string | undefined }; +type Actions = { + createThread: (newThread: AssistantThread) => void; + createAndSelectThread: (newThread: AssistantThread) => void; + deleteThread: (threadId: string) => void; + setSelectedThread: (threadId: string) => void; + setThreadTitle: (threadId: string, title: string) => void; +}; + +export const useAssistantThreadsStore = create()( + devtools( + // persist middleware adds the thread list to localstorage + persist( + (set) => ({ + threads: [], + selectedThreadID: undefined, + createThread: (newThread) => { + set((state) => ({ threads: [...state.threads, newThread] })); + }, + deleteThread: (id) => { + set((state) => ({ + threads: state.threads.filter((thread) => thread.id !== id), + selectedThreadID: id === state.selectedThreadID ? undefined : state.selectedThreadID, + })); + }, + setSelectedThread: (id) => { + set((state) => ({ + threads: state.threads.map((thread) => ({ + ...thread, + selected: thread.id === id, + })), + selectedThreadID: id, + })); + }, + createAndSelectThread: (newThread) => { + set((state) => ({ + threads: [ + // deselect any current threads + ...state.threads.map((thread) => ({ + ...thread, + selected: false, + })), + // add new thread and set it to selected + { ...newThread, selected: true }, + ], + selectedThreadID: newThread.id, + })); + }, + setThreadTitle: (id, title) => { + set((state) => ({ + threads: state.threads.map((thread) => ({ + ...thread, + // @ts-expect-error bad types, metadata is an object not any + metadata: thread.id === id ? { ...thread.metadata, threadTitle: title } : thread.metadata, + })), + })); + }, + }), + { + name: "threads-storage", // unique name for local storage + }, + ), + ), +); diff --git a/packages/paste-website/src/stores/useStore.ts b/packages/paste-website/src/stores/useStore.ts new file mode 100644 index 0000000000..4f080dd0bc --- /dev/null +++ b/packages/paste-website/src/stores/useStore.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; + +/** + * useStoreWithLocalStorage is a custom hook specifically for use with NextJS and dealing with reydration and local storage. + * https://docs.pmnd.rs/zustand/integrations/persisting-store-data#usage-in-next.js + * + * @template T + * @template F + * @param {(callback: (state: T) => unknown) => unknown} store + * @param {(state: T) => F} callback + * @return {*} {(F | undefined)} + */ +const useStoreWithLocalStorage = ( + store: (callback: (state: T) => unknown) => unknown, + callback: (state: T) => F, +): F | undefined => { + const result = store(callback) as F; + const [data, setData] = useState(); + + useEffect(() => { + setData(result); + }, [result]); + + return data; +}; + +export default useStoreWithLocalStorage; diff --git a/packages/paste-website/src/utils/RouteUtils.ts b/packages/paste-website/src/utils/RouteUtils.ts index 03ac51de2f..be91163600 100644 --- a/packages/paste-website/src/utils/RouteUtils.ts +++ b/packages/paste-website/src/utils/RouteUtils.ts @@ -26,7 +26,7 @@ export function useLocationOrigin(): string { export function useOpengraphServiceUrl(path: string): string { const origin = useLocationOrigin(); - return `${origin}/api/og-image/?componentRequested=${path}`; + return `${origin}/api/component-og-image/?componentRequested=${path}`; } // Returns "aspect-ratio" from "@twilio-paste/aspect-ratio" diff --git a/yarn.lock b/yarn.lock index b80bb9bdc6..4270d4c62d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10926,6 +10926,54 @@ __metadata: languageName: node linkType: hard +"@tanstack/eslint-plugin-query@npm:^5.17.7": + version: 5.17.7 + resolution: "@tanstack/eslint-plugin-query@npm:5.17.7" + dependencies: + "@typescript-eslint/utils": ^5.62.0 + peerDependencies: + eslint: ^8.0.0 + checksum: 29a779fcd11e8b287ef7c286675a7ef99689ad4e4f712ed20eb24d7512dbf7ea0ecfc474e55f498ec4a313c07a3bfebfabac67c83bf2635bbb942412ff3186fd + languageName: node + linkType: hard + +"@tanstack/query-core@npm:5.17.9": + version: 5.17.9 + resolution: "@tanstack/query-core@npm:5.17.9" + checksum: 721970a2c07a280c192a7aee56d38b24d51b8823a31f4a054ed827f37c179feb02f5b35f24f6aa5115e985f0e9e334c6751b5c9937ed3445ef6ec043e95e282d + languageName: node + linkType: hard + +"@tanstack/query-devtools@npm:5.17.7": + version: 5.17.7 + resolution: "@tanstack/query-devtools@npm:5.17.7" + checksum: 5336f5366aa1fd377a54d296d3a7a115c1a70180e0a2e8729a7cb087d780a9260625c52ce83f159f2fb3f152e91e2d98ad2fc86f5c22801e462117e275b332d8 + languageName: node + linkType: hard + +"@tanstack/react-query-devtools@npm:^5.17.10": + version: 5.17.10 + resolution: "@tanstack/react-query-devtools@npm:5.17.10" + dependencies: + "@tanstack/query-devtools": 5.17.7 + peerDependencies: + "@tanstack/react-query": ^5.17.10 + react: ^18.0.0 + checksum: 89eb663816bb77fc3834d6ce2887c97e613377128a016093da4935e6684c1f91b4113ff94bde92e14b83ec1fb7f087955cd028ac724aaee88d0bb5d8cfa90e9a + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.17.9": + version: 5.17.9 + resolution: "@tanstack/react-query@npm:5.17.9" + dependencies: + "@tanstack/query-core": 5.17.9 + peerDependencies: + react: ^18.0.0 + checksum: f8b77dbbdc0712a6907f65fb0d77acfe487a4e991434295ce964625dce833bba403055ac1d0649940c57bc6f03e88cf1755683c19c80b9aed7e1e2b1efe4cb30 + languageName: node + linkType: hard + "@testing-library/dom@npm:^8.5.0": version: 8.18.1 resolution: "@testing-library/dom@npm:8.18.1" @@ -15708,6 +15756,9 @@ __metadata: "@slack/web-api": ^7.0.1 "@storybook/react": 7.6.4 "@supabase/supabase-js": ^2.36.0 + "@tanstack/eslint-plugin-query": ^5.17.7 + "@tanstack/react-query": ^5.17.9 + "@tanstack/react-query-devtools": ^5.17.10 "@testing-library/react": ^13.4.0 "@twilio-paste/account-switcher": ^3.0.1 "@twilio-paste/alert": ^14.1.0 @@ -15833,7 +15884,7 @@ __metadata: highcharts-react-official: ^3.1.0 lodash: 4.17.21 lottie-web: ^5.7.4 - markdown-to-jsx: ^7.3.2 + markdown-to-jsx: ^7.4.0 mdast-util-from-markdown: ^1.3.0 mdast-util-mdx: ^2.0.0 mdast-util-to-markdown: ^1.5.0 @@ -15865,6 +15916,7 @@ __metadata: uuid: ^9.0.1 winston: ^3.11.0 zod: ^3.22.4 + zustand: ^4.4.7 languageName: unknown linkType: soft @@ -17350,6 +17402,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:5.62.0": + version: 5.62.0 + resolution: "@typescript-eslint/scope-manager@npm:5.62.0" + dependencies: + "@typescript-eslint/types": 5.62.0 + "@typescript-eslint/visitor-keys": 5.62.0 + checksum: 6062d6b797fe1ce4d275bb0d17204c827494af59b5eaf09d8a78cdd39dadddb31074dded4297aaf5d0f839016d601032857698b0e4516c86a41207de606e9573 + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:5.46.0": version: 5.46.0 resolution: "@typescript-eslint/type-utils@npm:5.46.0" @@ -17388,6 +17450,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:5.62.0": + version: 5.62.0 + resolution: "@typescript-eslint/types@npm:5.62.0" + checksum: 48c87117383d1864766486f24de34086155532b070f6264e09d0e6139449270f8a9559cfef3c56d16e3bcfb52d83d42105d61b36743626399c7c2b5e0ac3b670 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:5.17.0": version: 5.17.0 resolution: "@typescript-eslint/typescript-estree@npm:5.17.0" @@ -17442,6 +17511,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:5.62.0": + version: 5.62.0 + resolution: "@typescript-eslint/typescript-estree@npm:5.62.0" + dependencies: + "@typescript-eslint/types": 5.62.0 + "@typescript-eslint/visitor-keys": 5.62.0 + debug: ^4.3.4 + globby: ^11.1.0 + is-glob: ^4.0.3 + semver: ^7.3.7 + tsutils: ^3.21.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 3624520abb5807ed8f57b1197e61c7b1ed770c56dfcaca66372d584ff50175225798bccb701f7ef129d62c5989070e1ee3a0aa2d84e56d9524dcf011a2bb1a52 + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:5.17.0": version: 5.17.0 resolution: "@typescript-eslint/utils@npm:5.17.0" @@ -17494,6 +17581,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:^5.62.0": + version: 5.62.0 + resolution: "@typescript-eslint/utils@npm:5.62.0" + dependencies: + "@eslint-community/eslint-utils": ^4.2.0 + "@types/json-schema": ^7.0.9 + "@types/semver": ^7.3.12 + "@typescript-eslint/scope-manager": 5.62.0 + "@typescript-eslint/types": 5.62.0 + "@typescript-eslint/typescript-estree": 5.62.0 + eslint-scope: ^5.1.1 + semver: ^7.3.7 + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: ee9398c8c5db6d1da09463ca7bf36ed134361e20131ea354b2da16a5fdb6df9ba70c62a388d19f6eebb421af1786dbbd79ba95ddd6ab287324fc171c3e28d931 + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:5.17.0": version: 5.17.0 resolution: "@typescript-eslint/visitor-keys@npm:5.17.0" @@ -17524,6 +17629,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:5.62.0": + version: 5.62.0 + resolution: "@typescript-eslint/visitor-keys@npm:5.62.0" + dependencies: + "@typescript-eslint/types": 5.62.0 + eslint-visitor-keys: ^3.3.0 + checksum: 976b05d103fe8335bef5c93ad3f76d781e3ce50329c0243ee0f00c0fcfb186c81df50e64bfdd34970148113f8ade90887f53e3c4938183afba830b4ba8e30a35 + languageName: node + linkType: hard + "@vercel/og@npm:^0.5.20": version: 0.5.20 resolution: "@vercel/og@npm:0.5.20" @@ -33043,7 +33158,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"markdown-to-jsx@npm:^7.1.8, markdown-to-jsx@npm:^7.3.2": +"markdown-to-jsx@npm:^7.1.8": version: 7.3.2 resolution: "markdown-to-jsx@npm:7.3.2" peerDependencies: @@ -33052,6 +33167,15 @@ fsevents@^1.2.7: languageName: node linkType: hard +"markdown-to-jsx@npm:^7.4.0": + version: 7.4.0 + resolution: "markdown-to-jsx@npm:7.4.0" + peerDependencies: + react: ">= 0.14.0" + checksum: 59959d14d7927ed8a97e42d39771e2b445b90fa098477fb6ab040f044d230517dc4a95ba38a4f924cfc965a96b32211d93def150a6184f0e51d2cefdc8cb415d + languageName: node + linkType: hard + "matchdep@npm:^2.0.0": version: 2.0.0 resolution: "matchdep@npm:2.0.0" @@ -44611,7 +44735,7 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard -"use-sync-external-store@npm:^1.2.0": +"use-sync-external-store@npm:1.2.0, use-sync-external-store@npm:^1.2.0": version: 1.2.0 resolution: "use-sync-external-store@npm:1.2.0" peerDependencies: @@ -46344,6 +46468,26 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"zustand@npm:^4.4.7": + version: 4.4.7 + resolution: "zustand@npm:4.4.7" + dependencies: + use-sync-external-store: 1.2.0 + peerDependencies: + "@types/react": ">=16.8" + immer: ">=9.0" + react: ">=16.8" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + checksum: 9aeb6cc86162296c1dac504b8906ff952252c129fb3f05cc8926a7f5c9d7fbe098571d5161b3efe3347c0ee1d80197f787b768c7522721864f7323c28766717e + languageName: node + linkType: hard + "zwitch@npm:^1.0.0": version: 1.0.5 resolution: "zwitch@npm:1.0.5"