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(nx-dev): add "new chat" button to AI page #19150

Merged
merged 1 commit into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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