From af53ccd72728974b2b7ec56fc4a67ad99014f7dd Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Wed, 13 Sep 2023 14:13:31 -0400 Subject: [PATCH] feat(nx-dev): add "new chat" button to AI page --- nx-dev/feature-ai/src/lib/error-message.tsx | 8 +- nx-dev/feature-ai/src/lib/feed-container.tsx | 116 ++++++++++++------ .../feature-ai/src/lib/feed/feed-answer.tsx | 6 +- nx-dev/feature-ai/src/lib/feed/feed.tsx | 6 +- nx-dev/feature-ai/src/lib/prompt.tsx | 106 +++++++++++++--- nx-dev/ui-common/src/lib/button.tsx | 9 +- 6 files changed, 187 insertions(+), 64 deletions(-) diff --git a/nx-dev/feature-ai/src/lib/error-message.tsx b/nx-dev/feature-ai/src/lib/error-message.tsx index a7432776a577d2..afe3d075d03835 100644 --- a/nx-dev/feature-ai/src/lib/error-message.tsx +++ b/nx-dev/feature-ai/src/lib/error-message.tsx @@ -1,9 +1,10 @@ +import { type JSX, memo } from 'react'; import { - XCircleIcon, ExclamationTriangleIcon, + XCircleIcon, } from '@heroicons/react/24/outline'; -export function ErrorMessage({ error }: { error: any }): JSX.Element { +function ErrorMessage({ error }: { error: any }): JSX.Element { try { if (error.message) { error = JSON.parse(error.message); @@ -57,3 +58,6 @@ export function ErrorMessage({ error }: { error: any }): JSX.Element { ); } } + +const MemoErrorMessage = memo(ErrorMessage); +export { MemoErrorMessage as ErrorMessage }; diff --git a/nx-dev/feature-ai/src/lib/feed-container.tsx b/nx-dev/feature-ai/src/lib/feed-container.tsx index b16b433a7f73ec..4a01fbc4cdc8dc 100644 --- a/nx-dev/feature-ai/src/lib/feed-container.tsx +++ b/nx-dev/feature-ai/src/lib/feed-container.tsx @@ -1,11 +1,19 @@ import { sendCustomEvent } from '@nx/nx-dev/feature-analytics'; -import { RefObject, useEffect, useRef, useState } from 'react'; +import { + type FormEvent, + type JSX, + RefObject, + useEffect, + useRef, + useState, +} from 'react'; import { ErrorMessage } from './error-message'; import { Feed } from './feed/feed'; import { LoadingState } from './loading-state'; import { Prompt } from './prompt'; import { getQueryFromUid, storeQueryForUid } from '@nx/nx-dev/util-ai'; import { Message, useChat } from 'ai/react'; +import { cx } from '@nx/nx-dev/ui-primitives'; const assistantWelcome: Message = { id: 'first-custom-message', @@ -16,36 +24,63 @@ const assistantWelcome: Message = { export function FeedContainer(): JSX.Element { const [error, setError] = useState(null); - const [startedReply, setStartedReply] = useState(false); + const [hasReply, setHasReply] = useState(false); - const feedContainer: RefObject | undefined = useRef(null); - const { messages, input, handleInputChange, handleSubmit, isLoading } = - useChat({ - api: '/api/query-ai-handler', - onError: (error) => { - setError(error); - }, - onResponse: (_response) => { - setStartedReply(true); - sendCustomEvent('ai_query', 'ai', 'query', undefined, { - query: input, - }); - setError(null); - }, - onFinish: (response: Message) => { - setStartedReply(false); - storeQueryForUid(response.id, input); - }, - }); + const wrapperRef: RefObject | undefined = useRef(null); + + const { + messages, + setMessages, + input, + handleInputChange, + handleSubmit: _handleSubmit, + stop: handleStopGenerating, + reload: handleRegenerate, + isLoading, + } = useChat({ + api: '/api/query-ai-handler', + onError: (error) => { + setError(error); + }, + onResponse: (_response) => { + setHasReply(true); + sendCustomEvent('ai_query', 'ai', 'query', undefined, { + query: input, + }); + setError(null); + }, + onFinish: (response: Message) => { + setHasReply(false); + storeQueryForUid(response.id, input); + }, + }); + // Scroll the wrapper to the bottom if the user is at the bottom. + // Otherwise, if the user manually scrolled away from the bottom, don't scroll. + const userHasScrolled = useRef(false); + const handleScroll = () => { + userHasScrolled.current = true; + }; useEffect(() => { - if (feedContainer.current) { - const elements = - feedContainer.current.getElementsByClassName('feed-item'); - elements[elements.length - 1].scrollIntoView({ behavior: 'smooth' }); + if (wrapperRef?.current) { + const el = wrapperRef.current; + if (!userHasScrolled.current) { + el.scrollTo(0, el.scrollHeight); + } } }, [messages, isLoading]); + const handleSubmit = (event: FormEvent) => { + userHasScrolled.current = false; + _handleSubmit(event); + }; + + const handleNewChat = () => { + setMessages([]); + setError(null); + setHasReply(false); + }; + const handleFeedback = (statement: 'good' | 'bad', chatItemUid: string) => { const query = getQueryFromUid(chatItemUid); sendCustomEvent('ai_feedback', 'ai', statement, undefined, { @@ -57,6 +92,8 @@ export function FeedContainer(): JSX.Element { <> {/*WRAPPER*/}
{/*MAIN CONTENT*/} -
+
- handleFeedback(statement, chatItemUid) - } + onFeedback={handleFeedback} /> {/* Change this message if it's loading but it's writing as well */} - {isLoading && !startedReply && } + {isLoading && !hasReply && } {error && } -
+
diff --git a/nx-dev/feature-ai/src/lib/feed/feed-answer.tsx b/nx-dev/feature-ai/src/lib/feed/feed-answer.tsx index 678fb96ea834ec..9ede178fee62c3 100644 --- a/nx-dev/feature-ai/src/lib/feed/feed-answer.tsx +++ b/nx-dev/feature-ai/src/lib/feed/feed-answer.tsx @@ -66,7 +66,7 @@ export function FeedAnswer({
{!isFirst && ( -
+
{feedbackStatement ? (

{feedbackStatement === 'good' @@ -89,7 +89,7 @@ export function FeedAnswer({ title="Bad" > Bad answer -

diff --git a/nx-dev/feature-ai/src/lib/feed/feed.tsx b/nx-dev/feature-ai/src/lib/feed/feed.tsx index 8b3a00cb383d7c..aaa313a0070636 100644 --- a/nx-dev/feature-ai/src/lib/feed/feed.tsx +++ b/nx-dev/feature-ai/src/lib/feed/feed.tsx @@ -4,10 +4,10 @@ import { Message } from 'ai/react'; export function Feed({ activity, - handleFeedback, + onFeedback, }: { activity: Message[]; - handleFeedback: (statement: 'bad' | 'good', chatItemUid: string) => void; + onFeedback: (statement: 'bad' | 'good', chatItemUid: string) => void; }) { return (
@@ -21,7 +21,7 @@ export function Feed({ - handleFeedback(statement, activityItem.id) + onFeedback(statement, activityItem.id) } isFirst={activityItemIdx === 0} /> diff --git a/nx-dev/feature-ai/src/lib/prompt.tsx b/nx-dev/feature-ai/src/lib/prompt.tsx index 64fb78e4c69e10..dd40dafb01cecd 100644 --- a/nx-dev/feature-ai/src/lib/prompt.tsx +++ b/nx-dev/feature-ai/src/lib/prompt.tsx @@ -1,40 +1,114 @@ -import { ChangeEvent, FormEvent, useEffect, useRef } from 'react'; -import { PaperAirplaneIcon } from '@heroicons/react/24/outline'; +import { ChangeEvent, FormEvent, useEffect, useRef, useState } from 'react'; +import { + ArrowPathIcon, + PaperAirplaneIcon, + PlusIcon, + StopIcon, +} from '@heroicons/react/24/outline'; import { Button } from '@nx/nx-dev/ui-common'; import Textarea from 'react-textarea-autosize'; import { ChatRequestOptions } from 'ai'; +import { cx } from '@nx/nx-dev/ui-primitives'; export function Prompt({ - isDisabled, - handleSubmit, - handleInputChange, + isGenerating, + showNewChatCta, + onSubmit, + onInputChange, + onNewChat, + onStopGenerating, + onRegenerate, input, }: { - isDisabled: boolean; - handleSubmit: ( + isGenerating: boolean; + showNewChatCta: boolean; + onSubmit: ( e: FormEvent, chatRequestOptions?: ChatRequestOptions | undefined ) => void; - handleInputChange: ( + onInputChange: ( e: ChangeEvent | ChangeEvent ) => void; + onNewChat: () => void; + onStopGenerating: () => void; + onRegenerate: () => void; input: string; }) { const formRef = useRef(null); const inputRef = useRef(null); + const [isStopped, setStopped] = useState(false); useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, []); + if (!isGenerating) inputRef.current?.focus(); + }, [isGenerating]); + const handleSubmit = (event: FormEvent) => { + setStopped(false); + if (inputRef.current?.value.trim()) onSubmit(event); + else event.preventDefault(); + }; + + const handleNewChat = () => { + onNewChat(); + inputRef.current?.focus(); + }; + + const handleStopGenerating = () => { + setStopped(true); + onStopGenerating(); + inputRef.current?.focus(); + }; + + const handleOnRegenerate = () => { + setStopped(false); + onRegenerate(); + }; return (
+
+ {isGenerating && ( + + )} + {showNewChatCta && ( + + )} + {isStopped && ( + + )} +