diff --git a/nx-dev/data-access-ai/src/lib/data-access-ai.ts b/nx-dev/data-access-ai/src/lib/data-access-ai.ts index 27935269a5d78..a0e8d94ab3f0a 100644 --- a/nx-dev/data-access-ai/src/lib/data-access-ai.ts +++ b/nx-dev/data-access-ai/src/lib/data-access-ai.ts @@ -51,7 +51,7 @@ let totalTokensSoFar = 0; let supabaseClient: SupabaseClient; -export async function nxDevDataAccessAi( +export async function queryAi( query: string, aiResponse?: string ): Promise<{ @@ -208,7 +208,11 @@ export async function nxDevDataAccessAi( throw new ApplicationError('Failed to generate completion', error); } - const message = getMessageFromResponse(response.data); + // Message asking to double-check + const callout: string = + '{% callout type="warning" title="Always double-check!" %}The results may not be accurate, so please always double check with our documentation.{% /callout %}\n'; + // Append the warning message asking to double-check! + const message = [callout, getMessageFromResponse(response.data)].join(''); const responseWithoutBadLinks = await sanitizeLinksInResponse(message); @@ -248,13 +252,13 @@ export function getHistory(): ChatItem[] { return chatFullHistory; } -export async function handleFeedback(feedback: {}): Promise< +export async function sendFeedbackAnalytics(feedback: {}): Promise< PostgrestSingleResponse > { return supabaseClient.from('feedback').insert(feedback); } -export async function handleQueryReporting(queryInfo: {}) { +export async function sendQueryAnalytics(queryInfo: {}) { const { error } = await supabaseClient.from('user_queries').insert(queryInfo); if (error) { diff --git a/nx-dev/data-access-ai/src/lib/utils.ts b/nx-dev/data-access-ai/src/lib/utils.ts index 4659d8419f300..ff821d4cf61ed 100644 --- a/nx-dev/data-access-ai/src/lib/utils.ts +++ b/nx-dev/data-access-ai/src/lib/utils.ts @@ -119,6 +119,18 @@ export class UserError extends ApplicationError { } } +/** + * Initializes a chat session by generating the initial chat messages based on the given parameters. + * + * @param {ChatItem[]} chatFullHistory - The full chat history. + * @param {string} query - The user's query. + * @param {string} contextText - The context text or Nx Documentation. + * @param {string} prompt - The prompt message displayed to the user. + * @param {string} [aiResponse] - The AI assistant's response. + * @returns {Object} - An object containing the generated chat messages and updated chat history. + * - chatMessages: An array of chat messages for the chat session. + * - chatHistory: The updated chat history. + */ export function initializeChat( chatFullHistory: ChatItem[], query: string, diff --git a/nx-dev/feature-ai/src/index.ts b/nx-dev/feature-ai/src/index.ts index a717de0bafc73..f6083bbad04a0 100644 --- a/nx-dev/feature-ai/src/index.ts +++ b/nx-dev/feature-ai/src/index.ts @@ -1,3 +1 @@ -// Use this file to export React client components (e.g. those with 'use client' directive) or other non-server utilities - -export * from './lib/feature-ai'; +export * from './lib/feed-container'; diff --git a/nx-dev/feature-ai/src/lib/error-message.tsx b/nx-dev/feature-ai/src/lib/error-message.tsx new file mode 100644 index 0000000000000..c4406b1b6b0a6 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/error-message.tsx @@ -0,0 +1,19 @@ +import { XCircleIcon } from '@heroicons/react/24/outline'; + +export function ErrorMessage({ error }: { error: any }): JSX.Element { + return ( +
+
+
+
+
+

+ Oopsies! I encountered an error +

+
{error['message']}
+
+
+
+ ); +} diff --git a/nx-dev/feature-ai/src/lib/feature-ai.tsx b/nx-dev/feature-ai/src/lib/feature-ai.tsx deleted file mode 100644 index ad3f4000c5243..0000000000000 --- a/nx-dev/feature-ai/src/lib/feature-ai.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Button } from '@nx/nx-dev/ui-common'; -import { sendCustomEvent } from '@nx/nx-dev/feature-analytics'; -import { renderMarkdown } from '@nx/nx-dev/ui-markdoc'; -import { - nxDevDataAccessAi, - resetHistory, - getProcessedHistory, - ChatItem, - handleFeedback, - handleQueryReporting, -} from '@nx/nx-dev/data-access-ai'; -import { warning, infoBox, noResults } from './utils'; - -export function FeatureAi(): JSX.Element { - const [chatHistory, setChatHistory] = useState([]); - const [textResponse, setTextResponse] = useState(''); - const [error, setError] = useState(null); - const [query, setSearchTerm] = useState(''); - const [loading, setLoading] = useState(false); - const [feedbackSent, setFeedbackSent] = useState>({}); - const [sources, setSources] = useState(''); - const [input, setInput] = useState(''); - const lastMessageRef: React.RefObject | undefined = - useRef(null); - - useEffect(() => { - if (lastMessageRef.current) { - lastMessageRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [chatHistory]); - - const handleSubmit = async () => { - setInput(''); - if (query) { - setChatHistory([ - ...(chatHistory ?? []), - { role: 'user', content: query }, - { role: 'assistant', content: 'Let me think about that...' }, - ]); - } - setLoading(true); - setError(null); - let completeText = ''; - let usage; - let sourcesMarkdown = ''; - try { - const aiResponse = await nxDevDataAccessAi(query, textResponse); - completeText = aiResponse.textResponse; - setTextResponse(completeText); - usage = aiResponse.usage; - setSources( - JSON.stringify(aiResponse.sources?.map((source) => source.url)) - ); - sourcesMarkdown = aiResponse.sourcesMarkdown; - - setLoading(false); - } catch (error: any) { - setError(error); - setLoading(false); - } - sendCustomEvent('ai_query', 'ai', 'query', undefined, { - query, - }); - handleQueryReporting({ - action: 'ai_query', - query, - ...usage, - }); - const sourcesMd = - sourcesMarkdown.length === 0 - ? '' - : ` -\n -{% callout type="info" title="Sources" %} -${sourcesMarkdown} -{% /callout %} -\n - `; - - if (completeText) { - setChatHistory([ - ...getProcessedHistory(), - { role: 'assistant', content: completeText + sourcesMd }, - ]); - } - }; - - const handleUserFeedback = (result: 'good' | 'bad', index: number) => { - try { - sendCustomEvent('ai_feedback', 'ai', result); - handleFeedback({ - action: 'evaluation', - result, - query, - response: textResponse, - sources, - }); - setFeedbackSent((prev) => ({ ...prev, [index]: true })); - } catch (error) { - setFeedbackSent((prev) => ({ ...prev, [index]: false })); - } - }; - - const handleReset = () => { - resetHistory(); - setSearchTerm(''); - setTextResponse(''); - setSources(''); - setChatHistory(null); - setInput(''); - setFeedbackSent({}); - }; - - return ( -
-
-
- {infoBox} - {warning} -
- {chatHistory && renderChatHistory(chatHistory)} -
- {renderChatInput()} -
- ); - - function renderChatHistory(history: ChatItem[]) { - return ( -
- {history.length > 30 && ( -
- You've reached the maximum message history limit. Some previous - messages will be removed. You can always start a new chat. -
- )}{' '} - {history.map((chatItem, index) => - renderChatItem(chatItem, index, history.length) - )} -
- ); - } - - function renderChatItem( - chatItem: ChatItem, - index: number, - historyLength: number - ) { - return ( -
- {chatItem.role === 'assistant' && ( - - nx assistant{' '} - - 🐳 - - - )} - {((chatItem.role === 'assistant' && !error) || - chatItem.role === 'user') && ( -
- {renderMarkdown(chatItem.content, { filePath: '' }).node} -
- )} - {chatItem.role === 'assistant' && - !error && - chatHistory?.length && - (index === chatHistory.length - 1 && loading ? null : !feedbackSent[ - index - ] ? ( -
- - -
- ) : ( -

- - ✅ - {' '} - Thank you for your feedback! -

- ))} - - {error && !loading && chatItem.role === 'assistant' ? ( - error['data']?.['no_results'] ? ( - noResults - ) : ( -
There was an error: {error['message']}
- ) - ) : null} -
- ); - } - - function renderChatInput() { - return ( -
- { - setSearchTerm(event.target.value); - setInput(event.target.value); - }} - onKeyDown={(event) => { - if (event.keyCode === 13 || event.key === 'Enter') { - handleSubmit(); - } - }} - type="search" - /> - - -
- ); - } -} - -export default FeatureAi; diff --git a/nx-dev/feature-ai/src/lib/feed-container.tsx b/nx-dev/feature-ai/src/lib/feed-container.tsx new file mode 100644 index 0000000000000..531cf75821ff4 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/feed-container.tsx @@ -0,0 +1,176 @@ +import { + ChatItem, + getProcessedHistory, + queryAi, + resetHistory, + sendFeedbackAnalytics, + sendQueryAnalytics, +} from '@nx/nx-dev/data-access-ai'; +import { sendCustomEvent } from '@nx/nx-dev/feature-analytics'; +import { 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 { WarningMessage } from './sidebar/warning-message'; +import { formatMarkdownSources } from './utils'; + +interface LastQueryMetadata { + sources: string[]; + textResponse: string; + usage: { + completion_tokens: number; + prompt_tokens: number; + total_tokens: number; + } | null; +} + +const assistantWelcome: ChatItem = { + role: 'assistant', + content: + "👋 Hi, I'm your Nx Assistant. With my ocean of knowledge about Nx, I can answer your questions and guide you to the relevant documentation. What would you like to know?", +}; + +export function FeedContainer(): JSX.Element { + const [chatHistory, setChatHistory] = useState([]); + const [hasError, setHasError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [lastQueryMetadata, setLastQueryMetadata] = + useState(null); + + const feedContainer: RefObject | undefined = useRef(null); + + useEffect(() => { + if (feedContainer.current) { + const elements = + feedContainer.current.getElementsByClassName('feed-item'); + elements[elements.length - 1].scrollIntoView({ behavior: 'smooth' }); + } + }, [chatHistory, isLoading]); + + const handleSubmit = async (query: string, currentHistory: ChatItem[]) => { + if (!query) return; + + currentHistory.push({ role: 'user', content: query }); + + setIsLoading(true); + setHasError(null); + + try { + const lastAnswerChatItem = + currentHistory.filter((item) => item.role === 'assistant').pop() || + null; + // Use previous assistant's answer if it exists + const aiResponse = await queryAi( + query, + lastAnswerChatItem ? lastAnswerChatItem.content : '' + ); + // TODO: Save a list of metadata corresponding to each query + // Saving Metadata for usage like feedback and analytics + setLastQueryMetadata({ + sources: aiResponse.sources + ? aiResponse.sources.map((source) => source.url) + : [], + textResponse: aiResponse.textResponse, + usage: aiResponse.usage || null, + }); + let content = aiResponse.textResponse; + if (aiResponse.sourcesMarkdown.length !== 0) + content += formatMarkdownSources(aiResponse.sourcesMarkdown); + + // Saving the new chat history used by AI for follow-up prompts + setChatHistory([ + ...getProcessedHistory(), + { role: 'assistant', content }, + ]); + + sendCustomEvent('ai_query', 'ai', 'query', undefined, { + query, + ...aiResponse.usage, + }); + sendQueryAnalytics({ + action: 'ai_query', + query, + ...aiResponse.usage, + }); + } catch (error: any) { + setHasError(error); + } + + setIsLoading(false); + }; + + const handleFeedback = (statement: 'good' | 'bad', chatItemIndex: number) => { + const question = chatHistory[chatItemIndex - 1]; + const answer = chatHistory[chatItemIndex]; + + sendCustomEvent('ai_feedback', 'ai', statement, undefined, { + query: question ? question.content : 'Could not retrieve the question', + result: answer ? answer.content : 'Could not retrieve the answer', + sources: lastQueryMetadata + ? JSON.stringify(lastQueryMetadata.sources) + : 'Could not retrieve last answer sources', + }); + sendFeedbackAnalytics({ + action: 'evaluation', + result: answer ? answer.content : 'Could not retrieve the answer', + query: question ? question.content : 'Could not retrieve the question', + response: null, // TODO: Use query metadata here + sources: lastQueryMetadata + ? JSON.stringify(lastQueryMetadata.sources) + : 'Could not retrieve last answer sources', + }); + }; + + const handleReset = () => { + resetHistory(); + setChatHistory([]); + setHasError(null); + }; + + return ( + <> + {/*WRAPPER*/} +
+
+
+
+ {/*MAIN CONTENT*/} +
+ + handleFeedback(statement, chatItemIndex) + } + /> + + {isLoading && } + {hasError && } + +
+ handleSubmit(query, chatHistory)} + isDisabled={isLoading} + /> +
+
+
+
+
+
+ + ); +} diff --git a/nx-dev/feature-ai/src/lib/feed/chat-gpt-logo.tsx b/nx-dev/feature-ai/src/lib/feed/chat-gpt-logo.tsx new file mode 100644 index 0000000000000..b19abf71506f6 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/feed/chat-gpt-logo.tsx @@ -0,0 +1,19 @@ +import { ComponentProps } from 'react'; + +export function ChatGptLogo( + props: ComponentProps<'svg'> & { title?: string; titleId?: string } +): JSX.Element { + return ( + + + + ); +} diff --git a/nx-dev/feature-ai/src/lib/feed/feed-answer.tsx b/nx-dev/feature-ai/src/lib/feed/feed-answer.tsx new file mode 100644 index 0000000000000..3107efe53439b --- /dev/null +++ b/nx-dev/feature-ai/src/lib/feed/feed-answer.tsx @@ -0,0 +1,109 @@ +import { + HandThumbDownIcon, + HandThumbUpIcon, +} from '@heroicons/react/24/outline'; +import { renderMarkdown } from '@nx/nx-dev/ui-markdoc'; +import { cx } from '@nx/nx-dev/ui-primitives'; +import Link from 'next/link'; +import { useState } from 'react'; +import { ChatGptLogo } from './chat-gpt-logo'; +import { NrwlLogo } from './nrwl-logo'; + +export function FeedAnswer({ + content, + feedbackButtonCallback, + isFirst, +}: { + content: string; + feedbackButtonCallback: (value: 'bad' | 'good') => void; + isFirst: boolean; +}) { + const [feedbackStatement, setFeedbackStatement] = useState< + 'bad' | 'good' | null + >(null); + + function handleFeedbackButtonClicked(statement: 'bad' | 'good'): void { + if (!!feedbackStatement) return; + + setFeedbackStatement(statement); + feedbackButtonCallback(statement); + } + + return ( + <> +
+ + Nx + + +
+
+
+
+ Nx Assistant{' '} + + alpha + +
+

+

+
+
+ {renderMarkdown(content, { filePath: '' }).node} +
+ {!isFirst && ( +
+ {feedbackStatement ? ( +

+ {feedbackStatement === 'good' + ? 'Glad I could help!' + : 'Sorry, could you please rephrase your question?'} +

+ ) : ( +

+ Is that the answer you were looking for? +

+ )} +
+ + +
+
+ )} +
+ + ); +} diff --git a/nx-dev/feature-ai/src/lib/feed/feed-question.tsx b/nx-dev/feature-ai/src/lib/feed/feed-question.tsx new file mode 100644 index 0000000000000..0edae51646094 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/feed/feed-question.tsx @@ -0,0 +1,9 @@ +export function FeedQuestion({ content }: { content: string }) { + return ( +
+

+ {content} +

+
+ ); +} diff --git a/nx-dev/feature-ai/src/lib/feed/feed.tsx b/nx-dev/feature-ai/src/lib/feed/feed.tsx new file mode 100644 index 0000000000000..2e1b6cb74b5b6 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/feed/feed.tsx @@ -0,0 +1,36 @@ +import { ChatItem } from '@nx/nx-dev/data-access-ai'; +import { FeedAnswer } from './feed-answer'; +import { FeedQuestion } from './feed-question'; + +export function Feed({ + activity, + handleFeedback, +}: { + activity: ChatItem[]; + handleFeedback: (statement: 'bad' | 'good', chatItemIndex: number) => void; +}) { + return ( +
+
    + {activity.map((activityItem, activityItemIdx) => ( +
  • + {activityItem.role === 'assistant' ? ( + + handleFeedback(statement, activityItemIdx) + } + isFirst={activityItemIdx === 0} + /> + ) : ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/nx-dev/feature-ai/src/lib/feed/nrwl-logo.tsx b/nx-dev/feature-ai/src/lib/feed/nrwl-logo.tsx new file mode 100644 index 0000000000000..5a71f29d236a7 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/feed/nrwl-logo.tsx @@ -0,0 +1,25 @@ +import { ComponentProps } from 'react'; + +export function NrwlLogo( + props: ComponentProps<'svg'> & { title?: string; titleId?: string } +): JSX.Element { + return ( + + + + + ); +} diff --git a/nx-dev/feature-ai/src/lib/loading-state.tsx b/nx-dev/feature-ai/src/lib/loading-state.tsx new file mode 100644 index 0000000000000..18a859e0af7d6 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/loading-state.tsx @@ -0,0 +1,29 @@ +export function LoadingState(): JSX.Element { + return ( +
+ +

Let me check the docs for you…

+
+ ); +} diff --git a/nx-dev/feature-ai/src/lib/prompt.tsx b/nx-dev/feature-ai/src/lib/prompt.tsx new file mode 100644 index 0000000000000..4c16df3bf7e20 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/prompt.tsx @@ -0,0 +1,42 @@ +import { PaperAirplaneIcon } from '@heroicons/react/24/outline'; +import { Button } from '@nx/nx-dev/ui-common'; + +export function Prompt({ + isDisabled, + handleSubmit, +}: { + isDisabled: boolean; + handleSubmit: (query: string) => void; +}) { + return ( +
{ + event.preventDefault(); + handleSubmit((event.target as any).query.value); + event.currentTarget.reset(); + }} + className="relative flex gap-4 max-w-xl mx-auto py-2 px-4 shadow-lg rounded-md border border-slate-300 bg-white dark:border-slate-900 dark:bg-slate-700" + > + + +
+ ); +} diff --git a/nx-dev/feature-ai/src/lib/sidebar/activity-limit-reached.tsx b/nx-dev/feature-ai/src/lib/sidebar/activity-limit-reached.tsx new file mode 100644 index 0000000000000..f597f18b6639c --- /dev/null +++ b/nx-dev/feature-ai/src/lib/sidebar/activity-limit-reached.tsx @@ -0,0 +1,22 @@ +import { InformationCircleIcon } from '@heroicons/react/24/outline'; + +export function ActivityLimitReached(): JSX.Element { + return ( +
+
+
+
+
+

+ You've reached the maximum message history limit. Previous messages + will be removed. +

+
+
+
+ ); +} diff --git a/nx-dev/feature-ai/src/lib/sidebar/sidebar-container.tsx b/nx-dev/feature-ai/src/lib/sidebar/sidebar-container.tsx new file mode 100644 index 0000000000000..b1cc67f4d2152 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/sidebar/sidebar-container.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; + +export function SidebarContainer({ children }: { children: ReactNode[] }) { + return ( + + ); +} diff --git a/nx-dev/feature-ai/src/lib/sidebar/warning-message.tsx b/nx-dev/feature-ai/src/lib/sidebar/warning-message.tsx new file mode 100644 index 0000000000000..c4365fa4354c9 --- /dev/null +++ b/nx-dev/feature-ai/src/lib/sidebar/warning-message.tsx @@ -0,0 +1,21 @@ +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; + +export function WarningMessage(): JSX.Element { + return ( +
+

+

+
+

+ The results may not be accurate, so please always double check with + our documentation. +

+
+
+ ); +} diff --git a/nx-dev/feature-ai/src/lib/utils.ts b/nx-dev/feature-ai/src/lib/utils.ts index a3a4552afa131..9dc7bb44aece9 100644 --- a/nx-dev/feature-ai/src/lib/utils.ts +++ b/nx-dev/feature-ai/src/lib/utils.ts @@ -1,28 +1,7 @@ -import { renderMarkdown } from '@nx/nx-dev/ui-markdoc'; - -export const warning = renderMarkdown( - ` - {% callout type="warning" title="Always double check!" %} - This feature is still in Alpha. - The results may not be accurate, so please always double check with our documentation. +export function formatMarkdownSources(sourcesMarkdown: string): string { + return `\n +{% callout type="info" title="Sources" %} +${sourcesMarkdown} {% /callout %} - `, - { filePath: '' } -).node; - -export const infoBox = renderMarkdown( - ` - {% callout type="info" title="New question or continue chat?" %} - This chat has memory. It will answer all it's questions in the context of the previous questions. - If you want to ask a new question, you can reset the chat history by clicking the "Ask new question" button. - {% /callout %} - `, - { filePath: '' } -).node; - -export const noResults = renderMarkdown( - ` - Sorry, I don't know how to help with that. You can visit the [Nx documentation](https://nx.dev/getting-started/intro) for more info. - `, - { filePath: '' } -).node; +\n`; +} diff --git a/nx-dev/nx-dev/pages/ai/index.tsx b/nx-dev/nx-dev/pages/ai/index.tsx index 1f55df54c8cdf..7906d0fa84dc5 100644 --- a/nx-dev/nx-dev/pages/ai/index.tsx +++ b/nx-dev/nx-dev/pages/ai/index.tsx @@ -1,7 +1,7 @@ +import { FeedContainer } from '@nx/nx-dev/feature-ai'; import { DocumentationHeader } from '@nx/nx-dev/ui-common'; -import { FeatureAi } from '@nx/nx-dev/feature-ai'; -import { useNavToggle } from '../../lib/navigation-toggle.effect'; import { NextSeo } from 'next-seo'; +import { useNavToggle } from '../../lib/navigation-toggle.effect'; export default function AiDocs(): JSX.Element { const { toggleNav, navIsOpen } = useNavToggle(); @@ -23,8 +23,12 @@ export default function AiDocs(): JSX.Element {
-
- +
+