Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(assistant): assistant improvements #4214

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ type AssistantCanvasProps = {

export const AssistantCanvas: React.FC<AssistantCanvasProps> = ({ selectedThreadID }) => {
const [mounted, setMounted] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false);
const [userInterctedScroll, setUserInteractedScroll] = React.useState(false);

const messages = useAssistantMessagesStore(useShallow((state) => state.messages));
const setMessages = useAssistantMessagesStore(useShallow((state) => state.setMessages));
const activeRun = useAssistantRunStore(useShallow((state) => state.activeRun));
const { activeRun, lastActiveRun, clearLastActiveRun } = useAssistantRunStore(useShallow((state) => state));
const isCreatingAResponse = useIsMutating({ mutationKey: ["create-assistant-run"] });

const memoedMessages = React.useMemo(() => messages, [messages]);
Expand Down Expand Up @@ -50,14 +53,46 @@ export const AssistantCanvas: React.FC<AssistantCanvasProps> = ({ selectedThread
setMounted(true);
}, []);

const scrollToChatEnd = (): void => {
const scrollPosition: any = scrollerRef.current;
const scrollHeight: any = loggerRef.current;
scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" });
};

// 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" });
scrollToChatEnd();
}, [memoedMessages, mounted]);

const onAnimationEnd = (): void => {
setIsAnimating(false);
setUserInteractedScroll(false);
// avoid reanimating the same message
clearLastActiveRun();
};

const onAnimationStart = (): void => {
setUserInteractedScroll(false);
setIsAnimating(true);
};

const userScrolled = (): void => setUserInteractedScroll(true);

React.useEffect(() => {
scrollerRef.current?.addEventListener("wheel", userScrolled);
scrollerRef.current?.addEventListener("touchmove", userScrolled);

const interval = setInterval(() => isAnimating && !userInterctedScroll && scrollToChatEnd(), 5);
return () => {
if (interval) clearInterval(interval);
scrollerRef.current?.removeEventListener("wheel", userScrolled);
scrollerRef.current?.removeEventListener("touchmove", userScrolled);
};
}, [isAnimating, userInterctedScroll]);

return (
<Box ref={scrollerRef} tabIndex={0} overflowY="auto">
<Box ref={scrollerRef} tabIndex={0} overflowY="auto" paddingX="space60">
<Box maxWidth="1000px" marginX="auto">
{activeRun != null && <AssistantMessagePoller />}
<AIChatLog ref={loggerRef}>
Expand Down Expand Up @@ -94,11 +129,21 @@ export const AssistantCanvas: React.FC<AssistantCanvasProps> = ({ selectedThread
Your conversations are not used to train OpenAI&apos;s models, but are stored by OpenAI.
</Text>
</Box>
{messages?.map((threadMessage): React.ReactNode => {
{messages?.map((threadMessage, index): React.ReactNode => {
if (threadMessage.role === "assistant") {
return <AssistantMessage key={threadMessage.id} threadMessage={threadMessage} />;
return (
<AssistantMessage
key={threadMessage.id}
threadMessage={threadMessage}
// Only animate the last message recieved from AI and must be most recent run to avoid reanimating
animated={index === messages.length - 1 && lastActiveRun?.id === threadMessage.run_id}
size="fullScreen"
onAnimationEnd={onAnimationEnd}
onAnimationStart={onAnimationStart}
/>
);
}
return <UserMessage key={threadMessage.id} threadMessage={threadMessage} />;
return <UserMessage key={threadMessage.id} threadMessage={threadMessage} size="fullScreen" />;
})}
{(isCreatingAResponse || activeRun != null) && <LoadingMessage />}
</AIChatLog>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useIsMutating } from "@tanstack/react-query";
import { Button } from "@twilio-paste/button";
import { ChatComposer, ChatComposerActionGroup, ChatComposerContainer } from "@twilio-paste/chat-composer";
import { SendIcon } from "@twilio-paste/icons/esm/SendIcon";
Expand All @@ -9,7 +10,9 @@ import {
type LexicalEditor,
} from "@twilio-paste/lexical-library";
import * as React from "react";
import { useShallow } from "zustand/react/shallow";

import { useAssistantRunStore } from "../../stores/assistantRunStore";
import { useAssistantThreadsStore } from "../../stores/assistantThreadsStore";
import useStoreWithLocalStorage from "../../stores/useStore";
import { EnterKeySubmitPlugin } from "./EnterKeySubmitPlugin";
Expand All @@ -20,9 +23,12 @@ export const AssistantComposer: React.FC<{ onMessageCreation: (message: string,
const [message, setMessage] = React.useState("");
const threadsStore = useStoreWithLocalStorage(useAssistantThreadsStore, (state) => state);
const selectedThread = threadsStore?.selectedThreadID;

const { activeRun } = useAssistantRunStore(useShallow((state) => state));
const isCreatingAResponse = useIsMutating({ mutationKey: ["create-assistant-run"] });
const editorInstanceRef = React.useRef<LexicalEditor>(null);

const isLoading = Boolean(isCreatingAResponse || activeRun != null);

const handleComposerChange = (editorState: EditorState): void => {
editorState.read(() => {
const text = $getRoot().getTextContent();
Expand All @@ -49,21 +55,25 @@ export const AssistantComposer: React.FC<{ onMessageCreation: (message: string,
throw error;
},
}}
disabled={isLoading}
ariaLabel="Message"
placeholder="Type here..."
onChange={handleComposerChange}
editorInstanceRef={editorInstanceRef}
>
<ClearEditorPlugin />
<EnterKeySubmitPlugin onKeyDown={submitMessage} />
<EnterKeySubmitPlugin onKeyDown={() => !isLoading && submitMessage()} />
</ChatComposer>
<ChatComposerActionGroup>
<Button
variant="primary_icon"
size="reset"
disabled={isLoading}
onClick={() => {
submitMessage();
editorInstanceRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
if (!isLoading) {
submitMessage();
editorInstanceRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
}
}}
>
<SendIcon decorative={false} title="Send" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from "react";

const Window: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Box display="grid" gridTemplateColumns="400px 1fr" height="100svh" width="100%">
<Box display="grid" gridTemplateColumns="300px 1fr" height="100svh" width="100%">
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found this was taking too much space on medium displays

{children}
</Box>
);
Expand Down
156 changes: 76 additions & 80 deletions packages/paste-website/src/components/assistant/AssistantMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 Markdown, { MarkdownToJSX } from "markdown-to-jsx";
import * as React from "react";

export const AssistantHeading: React.FC<React.PropsWithChildren> = ({ children }) => {
Expand Down Expand Up @@ -43,84 +43,80 @@ export const AssistantTable: React.FC<React.PropsWithChildren> = ({ children })
);
};

export const AssistantMarkdown: React.FC<{ children: string }> = ({ children }) => {
return (
<Markdown
options={{
renderRule(next, node) {
if (node.type === "3") {
return (
<Box marginBottom="space50">
<CodeBlockWrapper>
<CodeBlockHeader>{node.lang ? node.lang : "javascript"}</CodeBlockHeader>
<CodeBlock
code={String.raw`${node.text}`}
maxLines={10}
language={node.lang ? (node.lang as CodeBlockProps["language"]) : "javascript"}
/>
</CodeBlockWrapper>
</Box>
);
}
export const assistantMarkdownOptions = {
renderRule(next: () => React.ReactChild, node: MarkdownToJSX.ParserResult) {
if (node.type === "3") {
return (
<Box marginBottom="space50">
<CodeBlockWrapper>
<CodeBlockHeader>{node.lang ? node.lang : "javascript"}</CodeBlockHeader>
<CodeBlock
code={String.raw`${node.text}`}
maxLines={10}
language={node.lang ? (node.lang as CodeBlockProps["language"]) : "javascript"}
/>
</CodeBlockWrapper>
</Box>
);
}

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}
</Markdown>
);
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,
},
},
};

export const AssistantMarkdown: React.FC<{ children: string }> = ({ children }) => {
return <Markdown options={assistantMarkdownOptions}>{children}</Markdown>;
};
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { AIChatMessage, AIChatMessageAuthor, AIChatMessageBody } from "@twilio-paste/ai-chat-log";
import {
AIChatMessage,
AIChatMessageAuthor,
AIChatMessageBody,
AIChatMessageBodyProps,
} from "@twilio-paste/ai-chat-log";
import { compiler } from "markdown-to-jsx";
import { type Message } from "openai/resources/beta/threads/messages";
import * as React from "react";

import { formatTimestamp } from "../../utils/formatTimestamp";
import { AssistantMarkdown } from "./AssistantMarkdown";
import { assistantMarkdownOptions } from "./AssistantMarkdown";

export const AssistantMessage: React.FC<{ threadMessage: Message }> = ({ threadMessage }) => {
interface AssistantMessageProps extends AIChatMessageBodyProps {
threadMessage: Message;
}

export const AssistantMessage: React.FC<AssistantMessageProps> = ({ threadMessage, ...props }) => {
return (
<AIChatMessage variant="bot">
<AIChatMessageAuthor aria-label={`said by paste assistant at ${formatTimestamp(threadMessage.created_at)}`}>
PasteBot
</AIChatMessageAuthor>
<AIChatMessageBody>
{threadMessage.content[0].type === "text" && (
<AssistantMarkdown key={threadMessage.id}>{threadMessage.content[0].text.value}</AssistantMarkdown>
)}
<AIChatMessageBody {...props}>
{threadMessage.content.length > 0 &&
threadMessage.content[0]?.type === "text" &&
compiler(threadMessage.content[0].text.value, assistantMarkdownOptions)}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Vercel issue where it would sometimes throw the 500 Sarah was talking about was due to deleting a thread the threadMessage.content[0] was undefined and threw a 500 that type could not be found.

</AIChatMessageBody>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to use compiler because the animated children was causing the component in the library to throw errors. I also added some information to our docs for how to overcome this.

</AIChatMessage>
);
Expand Down
15 changes: 12 additions & 3 deletions packages/paste-website/src/components/assistant/UserMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { AIChatMessage, AIChatMessageAuthor, AIChatMessageBody } from "@twilio-paste/ai-chat-log";
import {
AIChatMessage,
AIChatMessageAuthor,
AIChatMessageBody,
AIChatMessageBodyProps,
} from "@twilio-paste/ai-chat-log";
import { UserIcon } from "@twilio-paste/icons/esm/UserIcon";
import { type Message } 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: Message }> = ({ threadMessage }) => {
interface UserMessageProps extends AIChatMessageBodyProps {
threadMessage: Message;
}

export const UserMessage: React.FC<UserMessageProps> = ({ threadMessage, ...props }) => {
return (
<AIChatMessage variant="user">
<AIChatMessageAuthor
Expand All @@ -15,7 +24,7 @@ export const UserMessage: React.FC<{ threadMessage: Message }> = ({ threadMessag
>
You
</AIChatMessageAuthor>
<AIChatMessageBody>
<AIChatMessageBody {...props}>
{threadMessage.content[0].type === "text" && (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed so we can pass in props for the body, for this one the size="fullScreen"

<AssistantMarkdown key={threadMessage.id}>{threadMessage.content[0].text.value}</AssistantMarkdown>
)}
Expand Down
Loading
Loading