Skip to content

Commit

Permalink
feat(assistant): assistant improvements (#4214)
Browse files Browse the repository at this point in the history
* chore(assistant): only write to slack in prod

* fix(assistant): fix the vercel error sometimes showing

* feat(assistant): use animations and composer disabled states

* chore(ci): lint and format

* chore(ci): biome

* docs(ai-chat-log): doucment how to animate markdown

* docs(ai-chat-log): doucment how to animate markdown

* fix(assistant): consistent font sizes

* fix(assistant): paragraph font size inherit

* feat(assistant): refactor assistant markdown components

* docs(ai-chat-log): update line number
  • Loading branch information
krisantrobus authored Feb 18, 2025
1 parent 50da077 commit c1006b3
Show file tree
Hide file tree
Showing 11 changed files with 260 additions and 183 deletions.
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%">
{children}
</Box>
);
Expand Down
176 changes: 79 additions & 97 deletions packages/paste-website/src/components/assistant/AssistantMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,39 @@ 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 Markdown, { MarkdownToJSX } from "markdown-to-jsx";
import * as React from "react";

export const AssistantHeading: React.FC<React.PropsWithChildren> = ({ children }) => {
export const AssistantHeading1: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Heading as="h2" variant="heading40">
<Heading as="h1" variant="heading10">
{children}
</Heading>
);
};
export const AssistantParagraph: React.FC<React.PropsWithChildren> = ({ children }) => {
export const AssistantHeading2: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Box
as="p"
color="inherit"
fontSize="fontSize30"
fontWeight="fontWeightNormal"
lineHeight="lineHeight30"
marginTop="space0"
marginBottom="space50"
>
<Heading as="h2" variant="heading20">
{children}
</Box>
</Heading>
);
};
export const AssistantSeparator: React.FC = () => {
return <Separator orientation="horizontal" verticalSpacing="space50" />;
export const AssistantHeading3: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Heading as="h3" variant="heading30">
{children}
</Heading>
);
};
export const AssistantHeading4: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Heading as="h4" variant="heading40">
{children}
</Heading>
);
};

export const AssistantTable: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Box marginBottom="space50">
Expand All @@ -43,84 +45,64 @@ 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,
},
table: {
component: AssistantTable,
},
thead: {
component: THead,
},
tbody: {
component: TBody,
},
tr: {
component: Tr,
},
td: {
component: Td,
},
th: {
component: Th,
},
h1: {
component: AssistantHeading1,
},
h2: {
component: AssistantHeading2,
},
h3: {
component: AssistantHeading3,
},
h4: {
component: AssistantHeading4,
},
},
};

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>
);
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)}
</AIChatMessageBody>
</AIChatMessage>
);
Expand Down
Loading

0 comments on commit c1006b3

Please sign in to comment.