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 */}
+
+
+ );
+};
+
+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"