Skip to content

Commit

Permalink
feat(nx-dev): add "new chat" button to AI page (#19150)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaysoo authored Sep 14, 2023
1 parent 7fc0eda commit 94f71cd
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 52 deletions.
8 changes: 6 additions & 2 deletions nx-dev/feature-ai/src/lib/error-message.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -57,3 +58,6 @@ export function ErrorMessage({ error }: { error: any }): JSX.Element {
);
}
}

const MemoErrorMessage = memo(ErrorMessage);
export { MemoErrorMessage as ErrorMessage };
105 changes: 78 additions & 27 deletions nx-dev/feature-ai/src/lib/feed-container.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { sendCustomEvent } from '@nx/nx-dev/feature-analytics';
import { RefObject, useEffect, useRef, useState } from 'react';
import {
type FormEvent,
type JSX,
RefObject,
useEffect,
useMemo,
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',
Expand All @@ -17,26 +26,38 @@ const assistantWelcome: Message = {
export function FeedContainer(): JSX.Element {
const [error, setError] = useState<Error | null>(null);
const [startedReply, setStartedReply] = useState(false);
const [isStopped, setStopped] = useState(false);

const feedContainer: RefObject<HTMLDivElement> | 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 {
messages,
setMessages,
input,
handleInputChange,
handleSubmit: _handleSubmit,
stop,
reload,
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 hasReply = useMemo(() => messages.length > 0, [messages]);

useEffect(() => {
if (feedContainer.current) {
Expand All @@ -46,13 +67,35 @@ export function FeedContainer(): JSX.Element {
}
}, [messages, isLoading]);

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
setStopped(false);
_handleSubmit(event);
};

const handleNewChat = () => {
setMessages([]);
setError(null);
setStartedReply(false);
setStopped(false);
};

const handleFeedback = (statement: 'good' | 'bad', chatItemUid: string) => {
const query = getQueryFromUid(chatItemUid);
sendCustomEvent('ai_feedback', 'ai', statement, undefined, {
query: query ?? 'Could not retrieve the question',
});
};

const handleStopGenerating = () => {
setStopped(true);
stop();
};

const handleRegenerate = () => {
setStopped(false);
reload();
};

return (
<>
{/*WRAPPER*/}
Expand All @@ -71,25 +114,33 @@ export function FeedContainer(): JSX.Element {
<div
ref={feedContainer}
data-document="main"
className="relative"
className="relative pb-36"
>
<Feed
activity={!!messages.length ? messages : [assistantWelcome]}
handleFeedback={(statement, chatItemUid) =>
handleFeedback(statement, chatItemUid)
}
onFeedback={handleFeedback}
/>

{/* Change this message if it's loading but it's writing as well */}
{isLoading && !startedReply && <LoadingState />}
{error && <ErrorMessage error={error} />}

<div className="sticky bottom-0 left-0 right-0 w-full pt-6 pb-4 bg-gradient-to-t from-white via-white dark:from-slate-900 dark:via-slate-900">
<div
className={cx(
'fixed bottom-0 left0 right-0 w-full py-4 px-4 lg:py-6 lg:px-0',
'bg-gradient-to-t from-white via-white/75 dark:from-slate-900 dark:via-slate-900/75'
)}
>
<Prompt
handleSubmit={handleSubmit}
handleInputChange={handleInputChange}
onSubmit={handleSubmit}
onInputChange={handleInputChange}
onNewChat={handleNewChat}
onStopGenerating={handleStopGenerating}
onRegenerate={handleRegenerate}
input={input}
isDisabled={isLoading}
isGenerating={isLoading}
showNewChatCta={!isLoading && hasReply}
showRegenerateCta={isStopped}
/>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions nx-dev/feature-ai/src/lib/feed/feed-answer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function FeedAnswer({
<ReactMarkdown children={content} />
</div>
{!isFirst && (
<div className="group text-xs flex-1 md:flex md:justify-end gap-4 md:items-center text-slate-400 hover:text-slate-500 transition">
<div className="group text-md flex-1 md:flex md:justify-end gap-4 md:items-center text-slate-400 hover:text-slate-500 transition">
{feedbackStatement ? (
<p className="italic group-hover:flex">
{feedbackStatement === 'good'
Expand All @@ -89,7 +89,7 @@ export function FeedAnswer({
title="Bad"
>
<span className="sr-only">Bad answer</span>
<HandThumbDownIcon className="h-5 w-5" aria-hidden="true" />
<HandThumbDownIcon className="h-6 w-6" aria-hidden="true" />
</button>
<button
className={cx(
Expand All @@ -101,7 +101,7 @@ export function FeedAnswer({
title="Good"
>
<span className="sr-only">Good answer</span>
<HandThumbUpIcon className="h-5 w-5" aria-hidden="true" />
<HandThumbUpIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions nx-dev/feature-ai/src/lib/feed/feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flow-root my-12">
Expand All @@ -21,7 +21,7 @@ export function Feed({
<FeedAnswer
content={activityItem.content}
feedbackButtonCallback={(statement) =>
handleFeedback(statement, activityItem.id)
onFeedback(statement, activityItem.id)
}
isFirst={activityItemIdx === 0}
/>
Expand Down
98 changes: 83 additions & 15 deletions nx-dev/feature-ai/src/lib/prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,108 @@
import { ChangeEvent, FormEvent, useEffect, useRef } from 'react';
import { PaperAirplaneIcon } from '@heroicons/react/24/outline';
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,
showRegenerateCta,
onSubmit,
onInputChange,
onNewChat,
onStopGenerating,
onRegenerate,
input,
}: {
isDisabled: boolean;
handleSubmit: (
isGenerating: boolean;
showNewChatCta: boolean;
showRegenerateCta: boolean;
onSubmit: (
e: FormEvent<HTMLFormElement>,
chatRequestOptions?: ChatRequestOptions | undefined
) => void;
handleInputChange: (
onInputChange: (
e: ChangeEvent<HTMLTextAreaElement> | ChangeEvent<HTMLInputElement>
) => void;
onNewChat: () => void;
onStopGenerating: () => void;
onRegenerate: () => void;
input: string;
}) {
const formRef = useRef<HTMLFormElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
if (!isGenerating) inputRef.current?.focus();
}, [isGenerating]);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
if (inputRef.current?.value.trim()) onSubmit(event);
else event.preventDefault();
};

const handleNewChat = () => {
onNewChat();
inputRef.current?.focus();
};

const handleStopGenerating = () => {
onStopGenerating();
inputRef.current?.focus();
};

return (
<form
ref={formRef}
onSubmit={handleSubmit}
className="relative flex gap-2 max-w-2xl mx-auto py-0 px-2 shadow-lg rounded-md border border-slate-300 bg-white dark:border-slate-900 dark:bg-slate-700"
className="relative flex gap-2 max-w-3xl mx-auto py-0 px-2 shadow-lg rounded-md border border-slate-300 bg-white dark:border-slate-900 dark:bg-slate-700"
>
<div
className={cx(
'absolute -top-full left-1/2 mt-1 -translate-x-1/2',
'flex gap-4'
)}
>
{isGenerating && (
<Button
variant="secondary"
size="small"
className={cx('bg-white dark:bg-slate-900')}
onClick={handleStopGenerating}
>
<StopIcon aria-hidden="true" className="h-5 w-5" />
<span className="text-base">Stop generating</span>
</Button>
)}
{showNewChatCta && (
<Button
variant="secondary"
size="small"
className={cx('bg-white dark:bg-slate-900')}
onClick={handleNewChat}
>
<PlusIcon aria-hidden="true" className="h-5 w-5" />
<span className="text-base">New Chat</span>
</Button>
)}
{showRegenerateCta && (
<Button
variant="secondary"
size="small"
className={cx('bg-white dark:bg-slate-900')}
onClick={onRegenerate}
>
<ArrowPathIcon aria-hidden="true" className="h-5 w-5" />
<span className="text-base">Regenerate</span>
</Button>
)}
</div>
<div className="overflow-y-auto w-full h-full max-h-[300px]">
<Textarea
onKeyDown={(event) => {
Expand All @@ -49,10 +117,10 @@ export function Prompt({
}}
ref={inputRef}
value={input}
onChange={handleInputChange}
onChange={onInputChange}
id="query-prompt"
name="query"
disabled={isDisabled}
disabled={isGenerating}
className="block w-full p-0 resize-none bg-transparent text-sm placeholder-slate-500 pl-2 py-[1.3rem] focus-within:outline-none focus:placeholder-slate-400 dark:focus:placeholder-slate-300 dark:text-white focus:outline-none focus:ring-0 border-none disabled:cursor-not-allowed"
placeholder="How does caching work?"
rows={1}
Expand All @@ -63,7 +131,7 @@ export function Prompt({
variant="primary"
size="small"
type="submit"
disabled={isDisabled}
disabled={isGenerating}
className="self-end w-12 h-12 disabled:cursor-not-allowed"
>
<div hidden className="sr-only">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export default function AiDocs(): JSX.Element {
return (
<>
<NextSeo
title="Nx AI Chat (Alpha)"
description="AI chat powered by Nx docs."
noindex={true}
robotsProps={{
nosnippet: true,
Expand Down
8 changes: 8 additions & 0 deletions nx-dev/nx-dev/redirect-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,13 @@ const coreFeatureRefactoring = {
'/core-features/share-your-cache': '/core-features/remote-cache',
};

/*
* For AI Chat to make sure old URLs are not broken (added 2023-09-14)
*/
const aiChat = {
'/ai': '/ai-chat',
};

/**
* Public export API
*/
Expand All @@ -892,4 +899,5 @@ module.exports = {
pluginsToExtendNx,
latestRecipesRefactoring,
coreFeatureRefactoring,
aiChat,
};
Loading

1 comment on commit 94f71cd

@vercel
Copy link

@vercel vercel bot commented on 94f71cd Sep 14, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

nx-dev – ./

nx-five.vercel.app
nx-dev-git-master-nrwl.vercel.app
nx-dev-nrwl.vercel.app
nx.dev

Please sign in to comment.