From 6614eb0a6e8cf2ccce55bec2d99bf4e33601cf46 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu, 5 Oct 2023 08:25:35 -0500 Subject: [PATCH] Auto-generate: Be able to improve the result sending feedback (#75204) * After the first auto-generate, the button changes to improve * When clicking "improve" a toggletip appears with different ways to interact with the model to refine the result * Analytics: Add analytics to history --------- Co-authored-by: nmarrs Co-authored-by: Ivan Ortega --- .../components/GenAI/GenAIButton.test.tsx | 34 +-- .../components/GenAI/GenAIButton.tsx | 149 ++++++++++--- .../GenAI/GenAIDashDescriptionButton.tsx | 11 +- .../components/GenAI/GenAIDashTitleButton.tsx | 13 +- .../GenAI/GenAIDashboardChangesButton.tsx | 6 +- .../components/GenAI/GenAIHistory.tsx | 207 ++++++++++++++++++ .../GenAI/GenAIPanelDescriptionButton.tsx | 11 +- .../GenAI/GenAIPanelTitleButton.tsx | 24 +- .../GenAI/GenerationHistoryCarousel.tsx | 67 ++++++ .../GenAI/MinimalisticPagination.tsx | 55 +++++ .../components/GenAI/QuickFeedback.tsx | 57 +++++ .../dashboard/components/GenAI/hooks.ts | 21 +- .../dashboard/components/GenAI/tracking.ts | 23 +- .../dashboard/components/GenAI/utils.ts | 6 + 14 files changed, 617 insertions(+), 67 deletions(-) create mode 100644 public/app/features/dashboard/components/GenAI/GenAIHistory.tsx create mode 100644 public/app/features/dashboard/components/GenAI/GenerationHistoryCarousel.tsx create mode 100644 public/app/features/dashboard/components/GenAI/MinimalisticPagination.tsx create mode 100644 public/app/features/dashboard/components/GenAI/QuickFeedback.tsx diff --git a/public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx b/public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx index 1682d561470..44dc08ec314 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx @@ -8,25 +8,31 @@ import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime'; import { GenAIButton, GenAIButtonProps } from './GenAIButton'; -import { useOpenAIStream } from './hooks'; +import { StreamStatus, useOpenAIStream } from './hooks'; +import { EventTrackingSrc } from './tracking'; import { Role } from './utils'; const mockedUseOpenAiStreamState = { setMessages: jest.fn(), reply: 'I am a robot', - isGenerationResponse: false, + streamStatus: StreamStatus.IDLE, error: null, value: null, }; jest.mock('./hooks', () => ({ useOpenAIStream: jest.fn(() => mockedUseOpenAiStreamState), + StreamStatus: { + IDLE: 'idle', + GENERATING: 'generating', + }, })); describe('GenAIButton', () => { const onGenerate = jest.fn(); + const eventTrackingSrc = EventTrackingSrc.unknown; - function setup(props: GenAIButtonProps = { onGenerate, messages: [] }) { + function setup(props: GenAIButtonProps = { onGenerate, messages: [], eventTrackingSrc }) { return render( @@ -38,7 +44,7 @@ describe('GenAIButton', () => { beforeAll(() => { jest.mocked(useOpenAIStream).mockReturnValue({ error: undefined, - isGenerating: false, + streamStatus: StreamStatus.IDLE, reply: 'Some completed genereated text', setMessages: jest.fn(), value: { @@ -60,7 +66,7 @@ describe('GenAIButton', () => { beforeEach(() => { jest.mocked(useOpenAIStream).mockReturnValue({ error: undefined, - isGenerating: false, + streamStatus: StreamStatus.IDLE, reply: 'Some completed genereated text', setMessages: setMessagesMock, value: { @@ -82,7 +88,7 @@ describe('GenAIButton', () => { }); it('should send the configured messages', async () => { - setup({ onGenerate, messages: [{ content: 'Generate X', role: 'system' as Role }] }); + setup({ onGenerate, messages: [{ content: 'Generate X', role: 'system' as Role }], eventTrackingSrc }); const generateButton = await screen.findByRole('button'); // Click the button @@ -98,7 +104,7 @@ describe('GenAIButton', () => { const onGenerate = jest.fn(); const onClick = jest.fn(); const messages = [{ content: 'Generate X', role: 'system' as Role }]; - setup({ onGenerate, messages, temperature: 3, onClick }); + setup({ onGenerate, messages, temperature: 3, onClick, eventTrackingSrc }); const generateButton = await screen.findByRole('button'); await fireEvent.click(generateButton); @@ -111,8 +117,8 @@ describe('GenAIButton', () => { beforeEach(() => { jest.mocked(useOpenAIStream).mockReturnValue({ error: undefined, - isGenerating: true, - reply: 'Some incompleted generated text', + streamStatus: StreamStatus.GENERATING, + reply: 'Some incomplete generated text', setMessages: jest.fn(), value: { enabled: true, @@ -143,11 +149,11 @@ describe('GenAIButton', () => { it('should call onGenerate when the text is generating', async () => { const onGenerate = jest.fn(); - setup({ onGenerate, messages: [] }); + setup({ onGenerate, messages: [], eventTrackingSrc: eventTrackingSrc }); await waitFor(() => expect(onGenerate).toHaveBeenCalledTimes(1)); - expect(onGenerate).toHaveBeenCalledWith('Some incompleted generated text'); + expect(onGenerate).toHaveBeenCalledWith('Some incomplete generated text'); }); }); @@ -156,7 +162,7 @@ describe('GenAIButton', () => { beforeEach(() => { jest.mocked(useOpenAIStream).mockReturnValue({ error: new Error('Something went wrong'), - isGenerating: false, + streamStatus: StreamStatus.IDLE, reply: '', setMessages: setMessagesMock, value: { @@ -180,7 +186,7 @@ describe('GenAIButton', () => { it('should retry when clicking', async () => { const onGenerate = jest.fn(); const messages = [{ content: 'Generate X', role: 'system' as Role }]; - const { getByText } = setup({ onGenerate, messages, temperature: 3 }); + const { getByText } = setup({ onGenerate, messages, temperature: 3, eventTrackingSrc }); const generateButton = getByText('Retry'); await fireEvent.click(generateButton); @@ -209,7 +215,7 @@ describe('GenAIButton', () => { const onGenerate = jest.fn(); const onClick = jest.fn(); const messages = [{ content: 'Generate X', role: 'system' as Role }]; - setup({ onGenerate, messages, temperature: 3, onClick }); + setup({ onGenerate, messages, temperature: 3, onClick, eventTrackingSrc }); const generateButton = await screen.findByRole('button'); await fireEvent.click(generateButton); diff --git a/public/app/features/dashboard/components/GenAI/GenAIButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIButton.tsx index 3e5b0e36ef2..3c16e8e24a1 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIButton.tsx @@ -1,10 +1,12 @@ import { css } from '@emotion/css'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Spinner, useStyles2, Tooltip } from '@grafana/ui'; +import { Button, Spinner, useStyles2, Tooltip, Toggletip, Text } from '@grafana/ui'; -import { useOpenAIStream } from './hooks'; +import { GenAIHistory } from './GenAIHistory'; +import { StreamStatus, useOpenAIStream } from './hooks'; +import { AutoGenerateItem, EventTrackingSrc, reportAutoGenerateInteraction } from './tracking'; import { OPEN_AI_MODEL, Message } from './utils'; export interface GenAIButtonProps { @@ -12,6 +14,7 @@ export interface GenAIButtonProps { text?: string; // Button label text when loading loadingText?: string; + toggleTipTitle?: string; // Button click handler onClick?: (e: React.MouseEvent) => void; // Messages to send to the LLM plugin @@ -21,70 +24,160 @@ export interface GenAIButtonProps { // Temperature for the LLM plugin. Default is 1. // Closer to 0 means more conservative, closer to 1 means more creative. temperature?: number; + // Event tracking source. Send as `src` to Rudderstack event + eventTrackingSrc: EventTrackingSrc; } export const GenAIButton = ({ text = 'Auto-generate', loadingText = 'Generating', + toggleTipTitle = '', onClick: onClickProp, messages, onGenerate, temperature = 1, + eventTrackingSrc, }: GenAIButtonProps) => { const styles = useStyles2(getStyles); - const { setMessages, reply, isGenerating, value, error } = useOpenAIStream(OPEN_AI_MODEL, temperature); + const { setMessages, reply, value, error, streamStatus } = useOpenAIStream(OPEN_AI_MODEL, temperature); + + const [history, setHistory] = useState([]); + const [showHistory, setShowHistory] = useState(true); + + const hasHistory = history.length > 0; + const isFirstHistoryEntry = streamStatus === StreamStatus.GENERATING && !hasHistory; + const isButtonDisabled = isFirstHistoryEntry || (value && !value.enabled && !error); + const reportInteraction = (item: AutoGenerateItem) => reportAutoGenerateInteraction(eventTrackingSrc, item); + + const onClick = (e: React.MouseEvent) => { + if (!hasHistory) { + onClickProp?.(e); + setMessages(messages); + } else { + if (setShowHistory) { + setShowHistory(true); + } + } + const buttonItem = error + ? AutoGenerateItem.erroredRetryButton + : hasHistory + ? AutoGenerateItem.improveButton + : AutoGenerateItem.autoGenerateButton; + reportInteraction(buttonItem); + }; + + const pushHistoryEntry = useCallback( + (historyEntry: string) => { + if (history.indexOf(historyEntry) === -1) { + setHistory([historyEntry, ...history]); + } + }, + [history] + ); useEffect(() => { // Todo: Consider other options for `"` sanitation - if (isGenerating && reply) { + if (isFirstHistoryEntry && reply) { onGenerate(reply.replace(/^"|"$/g, '')); } - }, [isGenerating, reply, onGenerate]); + }, [streamStatus, reply, onGenerate, isFirstHistoryEntry]); + + useEffect(() => { + if (streamStatus === StreamStatus.COMPLETED) { + pushHistoryEntry(reply.replace(/^"|"$/g, '')); + } + }, [history, streamStatus, reply, pushHistoryEntry]); // The button is disabled if the plugin is not installed or enabled if (!value?.enabled) { return null; } - const onClick = (e: React.MouseEvent) => { - onClickProp?.(e); - setMessages(messages); + const onApplySuggestion = (suggestion: string) => { + reportInteraction(AutoGenerateItem.applySuggestion); + onGenerate(suggestion); + setShowHistory(false); }; const getIcon = () => { - if (error || !value?.enabled) { - return 'exclamation-circle'; - } - if (isGenerating) { + if (isFirstHistoryEntry) { return undefined; } + if (error || (value && !value?.enabled)) { + return 'exclamation-circle'; + } return 'ai'; }; const getText = () => { + let buttonText = text; + if (error) { - return 'Retry'; + buttonText = 'Retry'; } - return !isGenerating ? text : loadingText; + if (isFirstHistoryEntry) { + buttonText = loadingText; + } + + if (hasHistory) { + buttonText = 'Improve'; + } + + return buttonText; + }; + + const button = ( + + ); + + const renderButtonWithToggletip = () => { + if (hasHistory) { + const title = {toggleTipTitle}; + + return ( + + } + placement="bottom-start" + fitContent={true} + show={showHistory ? undefined : false} + > + {button} + + ); + } + + return button; }; return (
- {isGenerating && } - - - + {isFirstHistoryEntry && } + {!hasHistory && ( + + {button} + + )} + {hasHistory && renderButtonWithToggletip()}
); }; diff --git a/public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx index d7067d29d23..b039735c06e 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { DashboardModel } from '../../state'; import { GenAIButton } from './GenAIButton'; -import { EventSource, reportGenerateAIButtonClicked } from './tracking'; +import { EventTrackingSrc } from './tracking'; import { getDashboardPanelPrompt, Message, Role } from './utils'; interface GenAIDashDescriptionButtonProps { @@ -24,10 +24,15 @@ const DESCRIPTION_GENERATION_STANDARD_PROMPT = export const GenAIDashDescriptionButton = ({ onGenerate, dashboard }: GenAIDashDescriptionButtonProps) => { const messages = React.useMemo(() => getMessages(dashboard), [dashboard]); - const onClick = React.useCallback(() => reportGenerateAIButtonClicked(EventSource.dashboardDescription), []); return ( - + ); }; diff --git a/public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx index 67165119c79..f39e7ce37bd 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { DashboardModel } from '../../state'; import { GenAIButton } from './GenAIButton'; -import { EventSource, reportGenerateAIButtonClicked } from './tracking'; +import { EventTrackingSrc } from './tracking'; import { getDashboardPanelPrompt, Message, Role } from './utils'; interface GenAIDashTitleButtonProps { @@ -24,9 +24,16 @@ const TITLE_GENERATION_STANDARD_PROMPT = export const GenAIDashTitleButton = ({ onGenerate, dashboard }: GenAIDashTitleButtonProps) => { const messages = React.useMemo(() => getMessages(dashboard), [dashboard]); - const onClick = React.useCallback(() => reportGenerateAIButtonClicked(EventSource.dashboardTitle), []); - return ; + return ( + + ); }; function getMessages(dashboard: DashboardModel): Message[] { diff --git a/public/app/features/dashboard/components/GenAI/GenAIDashboardChangesButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIDashboardChangesButton.tsx index 174bacb5047..dc07710bc87 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIDashboardChangesButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIDashboardChangesButton.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from 'react'; import { DashboardModel } from '../../state'; import { GenAIButton } from './GenAIButton'; -import { EventSource, reportGenerateAIButtonClicked } from './tracking'; +import { EventTrackingSrc } from './tracking'; import { getDashboardChanges, Message, Role } from './utils'; interface GenAIDashboardChangesButtonProps { @@ -27,15 +27,15 @@ const CHANGES_GENERATION_STANDARD_PROMPT = [ export const GenAIDashboardChangesButton = ({ dashboard, onGenerate }: GenAIDashboardChangesButtonProps) => { const messages = useMemo(() => getMessages(dashboard), [dashboard]); - const onClick = React.useCallback(() => reportGenerateAIButtonClicked(EventSource.dashboardChanges), []); return ( ); }; diff --git a/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx b/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx new file mode 100644 index 00000000000..db1dc543805 --- /dev/null +++ b/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx @@ -0,0 +1,207 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { + Alert, + Button, + HorizontalGroup, + Icon, + IconButton, + Input, + Spinner, + Text, + TextLink, + useStyles2, + VerticalGroup, +} from '@grafana/ui'; + +import { getFeedbackMessage } from './GenAIPanelTitleButton'; +import { GenerationHistoryCarousel } from './GenerationHistoryCarousel'; +import { QuickFeedback } from './QuickFeedback'; +import { StreamStatus, useOpenAIStream } from './hooks'; +import { AutoGenerateItem, EventTrackingSrc, reportAutoGenerateInteraction } from './tracking'; +import { Message, OPEN_AI_MODEL, QuickFeedbackType } from './utils'; + +export interface GenAIHistoryProps { + history: string[]; + messages: Message[]; + onApplySuggestion: (suggestion: string) => void; + updateHistory: (historyEntry: string) => void; + eventTrackingSrc: EventTrackingSrc; +} + +const temperature = 0.5; + +export const GenAIHistory = ({ + eventTrackingSrc, + history, + messages, + onApplySuggestion, + updateHistory, +}: GenAIHistoryProps) => { + const styles = useStyles2(getStyles); + + const [currentIndex, setCurrentIndex] = useState(1); + const [showError, setShowError] = useState(false); + const [customFeedback, setCustomPrompt] = useState(''); + + const { setMessages, reply, streamStatus, error } = useOpenAIStream(OPEN_AI_MODEL, temperature); + + const isStreamGenerating = streamStatus === StreamStatus.GENERATING; + + const reportInteraction = (item: AutoGenerateItem, otherMetadata?: object) => + reportAutoGenerateInteraction(eventTrackingSrc, item, otherMetadata); + + useEffect(() => { + if (!isStreamGenerating && reply !== '') { + setCurrentIndex(1); + } + }, [isStreamGenerating, reply]); + + useEffect(() => { + if (streamStatus === StreamStatus.COMPLETED) { + // TODO: Break out sanitize regex into shared util function + updateHistory(reply.replace(/^"|"$/g, '')); + } + }, [streamStatus, reply, updateHistory]); + + useEffect(() => { + if (error) { + setShowError(true); + } + + if (streamStatus === StreamStatus.GENERATING) { + setShowError(false); + } + }, [error, streamStatus]); + + const onSubmitCustomFeedback = (text: string) => { + onGenerateWithFeedback(text); + reportInteraction(AutoGenerateItem.customFeedback, { customFeedback: text }); + }; + + const onApply = () => { + onApplySuggestion(history[currentIndex - 1]); + }; + + const onNavigate = (index: number) => { + setCurrentIndex(index); + reportInteraction(index > currentIndex ? AutoGenerateItem.backHistoryItem : AutoGenerateItem.forwardHistoryItem); + }; + + const onGenerateWithFeedback = (suggestion: string | QuickFeedbackType) => { + if (suggestion !== QuickFeedbackType.Regenerate) { + messages = [...messages, ...getFeedbackMessage(history[currentIndex], suggestion)]; + } + + setMessages(messages); + + if (suggestion in QuickFeedbackType) { + reportInteraction(AutoGenerateItem.quickFeedback, { quickFeedbackItem: suggestion }); + } + }; + + const onKeyDownCustomFeedbackInput = (e: React.KeyboardEvent) => + e.key === 'Enter' && onSubmitCustomFeedback(customFeedback); + + const onChangeCustomFeedback = (e: React.FormEvent) => setCustomPrompt(e.currentTarget.value); + + const onClickSubmitCustomFeedback = () => onSubmitCustomFeedback(customFeedback); + + const onClickDocs = () => reportInteraction(AutoGenerateItem.linkToDocs); + + return ( +
+ {showError && ( +
+ + +
Sorry, I was unable to complete your request. Please try again.
+
+
+
+ )} + + + } + value={customFeedback} + onChange={onChangeCustomFeedback} + onKeyDown={onKeyDownCustomFeedbackInput} + /> +
+ + +
+
+ + {isStreamGenerating && } + + +
+
+ + + This content is AI-generated.{' '} + + Learn more + + +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + flexDirection: 'column', + width: 520, + // This is the space the footer height + paddingBottom: 35, + }), + applySuggestion: css({ + marginTop: theme.spacing(1), + }), + actions: css({ + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + }), + footer: css({ + // Absolute positioned since Toggletip doesn't support footer + position: 'absolute', + bottom: 0, + left: 0, + width: '100%', + display: 'flex', + flexDirection: 'row', + margin: 0, + padding: theme.spacing(1), + paddingLeft: theme.spacing(2), + alignItems: 'center', + gap: theme.spacing(1), + borderTop: `1px solid ${theme.colors.border.weak}`, + marginTop: theme.spacing(2), + }), + infoColor: css({ + color: theme.colors.info.main, + }), +}); diff --git a/public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx index 59f88567102..fc014eb1691 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx @@ -4,7 +4,7 @@ import { getDashboardSrv } from '../../services/DashboardSrv'; import { PanelModel } from '../../state'; import { GenAIButton } from './GenAIButton'; -import { EventSource, reportGenerateAIButtonClicked } from './tracking'; +import { EventTrackingSrc } from './tracking'; import { Message, Role } from './utils'; interface GenAIPanelDescriptionButtonProps { @@ -22,10 +22,15 @@ const DESCRIPTION_GENERATION_STANDARD_PROMPT = export const GenAIPanelDescriptionButton = ({ onGenerate, panel }: GenAIPanelDescriptionButtonProps) => { const messages = React.useMemo(() => getMessages(panel), [panel]); - const onClick = React.useCallback(() => reportGenerateAIButtonClicked(EventSource.panelDescription), []); return ( - + ); }; diff --git a/public/app/features/dashboard/components/GenAI/GenAIPanelTitleButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIPanelTitleButton.tsx index 164fe56356b..8e58103d826 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIPanelTitleButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIPanelTitleButton.tsx @@ -4,8 +4,8 @@ import { getDashboardSrv } from '../../services/DashboardSrv'; import { PanelModel } from '../../state'; import { GenAIButton } from './GenAIButton'; -import { EventSource, reportGenerateAIButtonClicked } from './tracking'; -import { Message, Role } from './utils'; +import { EventTrackingSrc } from './tracking'; +import { Message, QuickFeedbackType, Role } from './utils'; interface GenAIPanelTitleButtonProps { onGenerate: (title: string) => void; @@ -19,9 +19,16 @@ const TITLE_GENERATION_STANDARD_PROMPT = export const GenAIPanelTitleButton = ({ onGenerate, panel }: GenAIPanelTitleButtonProps) => { const messages = React.useMemo(() => getMessages(panel), [panel]); - const onClick = React.useCallback(() => reportGenerateAIButtonClicked(EventSource.panelTitle), []); - return ; + return ( + + ); }; function getMessages(panel: PanelModel): Message[] { @@ -46,3 +53,12 @@ function getMessages(panel: PanelModel): Message[] { }, ]; } + +export const getFeedbackMessage = (previousResponse: string, feedback: string | QuickFeedbackType): Message[] => { + return [ + { + role: Role.system, + content: `Your previous response was: ${previousResponse}. The user has provided the following feedback: ${feedback}. Re-generate your response according to the provided feedback.`, + }, + ]; +}; diff --git a/public/app/features/dashboard/components/GenAI/GenerationHistoryCarousel.tsx b/public/app/features/dashboard/components/GenAI/GenerationHistoryCarousel.tsx new file mode 100644 index 00000000000..5a8803a4cc7 --- /dev/null +++ b/public/app/features/dashboard/components/GenAI/GenerationHistoryCarousel.tsx @@ -0,0 +1,67 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Text, useStyles2 } from '@grafana/ui'; + +import { MinimalisticPagination } from './MinimalisticPagination'; +import { StreamStatus } from './hooks'; + +export interface GenerationHistoryCarouselProps { + history: string[]; + index: number; + reply: string; + streamStatus: StreamStatus; + onNavigate: (index: number) => void; +} + +export const GenerationHistoryCarousel = ({ + history, + index, + reply, + streamStatus, + onNavigate, +}: GenerationHistoryCarouselProps) => { + const styles = useStyles2(getStyles); + const historySize = history.length; + + const getHistoryText = () => { + if (reply && streamStatus !== StreamStatus.IDLE) { + return reply; + } + + return history[index - 1]; + }; + + return ( + <> + +
+ + {getHistoryText()} + +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + paginationWrapper: css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 15, + }), + contentWrapper: css({ + display: 'flex', + flexBasis: '100%', + flexGrow: 3, + marginTop: 20, + }), +}); diff --git a/public/app/features/dashboard/components/GenAI/MinimalisticPagination.tsx b/public/app/features/dashboard/components/GenAI/MinimalisticPagination.tsx new file mode 100644 index 00000000000..623532656ee --- /dev/null +++ b/public/app/features/dashboard/components/GenAI/MinimalisticPagination.tsx @@ -0,0 +1,55 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { IconButton, useStyles2 } from '@grafana/ui'; + +export interface MinimalisticPaginationProps { + currentPage: number; + numberOfPages: number; + onNavigate: (toPage: number) => void; + hideWhenSinglePage?: boolean; + className?: string; +} + +export const MinimalisticPagination = ({ + currentPage, + numberOfPages, + onNavigate, + hideWhenSinglePage, + className, +}: MinimalisticPaginationProps) => { + const styles = useStyles2(getStyles); + + if (hideWhenSinglePage && numberOfPages <= 1) { + return null; + } + + return ( +
+ onNavigate(currentPage - 1)} + disabled={currentPage === 1} + /> + {currentPage} of {numberOfPages} + onNavigate(currentPage + 1)} + disabled={currentPage === numberOfPages} + /> +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css({ + display: 'flex', + flexDirection: 'row', + gap: 16, + }), +}); diff --git a/public/app/features/dashboard/components/GenAI/QuickFeedback.tsx b/public/app/features/dashboard/components/GenAI/QuickFeedback.tsx new file mode 100644 index 00000000000..811048fcbf0 --- /dev/null +++ b/public/app/features/dashboard/components/GenAI/QuickFeedback.tsx @@ -0,0 +1,57 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, useStyles2 } from '@grafana/ui'; + +import { QuickFeedbackType } from './utils'; + +interface QuickActionsProps { + onSuggestionClick: (suggestion: QuickFeedbackType) => void; + isGenerating: boolean; +} + +export const QuickFeedback = ({ onSuggestionClick, isGenerating }: QuickActionsProps) => { + const styles = useStyles2(getStyles); + + return ( +
+ + + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + quickSuggestionsWrapper: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + flexGrow: 1, + gap: 8, + paddingTop: 10, + }), +}); diff --git a/public/app/features/dashboard/components/GenAI/hooks.ts b/public/app/features/dashboard/components/GenAI/hooks.ts index 00fcdb40171..02cc60b89e8 100644 --- a/public/app/features/dashboard/components/GenAI/hooks.ts +++ b/public/app/features/dashboard/components/GenAI/hooks.ts @@ -12,6 +12,12 @@ import { isLLMPluginEnabled, OPEN_AI_MODEL } from './utils'; // Ideally we will want to move the hook itself to a different scope later. type Message = openai.Message; +export enum StreamStatus { + IDLE = 'idle', + GENERATING = 'generating', + COMPLETED = 'completed', +} + // TODO: Add tests export function useOpenAIStream( model = OPEN_AI_MODEL, @@ -19,7 +25,7 @@ export function useOpenAIStream( ): { setMessages: React.Dispatch>; reply: string; - isGenerating: boolean; + streamStatus: StreamStatus; error: Error | undefined; value: | { @@ -36,7 +42,7 @@ export function useOpenAIStream( const [messages, setMessages] = useState([]); // The latest reply from the LLM. const [reply, setReply] = useState(''); - const [isGenerating, setIsGenerating] = useState(false); + const [streamStatus, setStreamStatus] = useState(StreamStatus.IDLE); const [error, setError] = useState(); const { error: notifyError } = useAppNotification(); @@ -50,7 +56,7 @@ export function useOpenAIStream( return { enabled }; } - setIsGenerating(true); + setStreamStatus(StreamStatus.GENERATING); setError(undefined); // Stream the completions. Each element is the next stream chunk. const stream = openai @@ -75,14 +81,17 @@ export function useOpenAIStream( stream: stream.subscribe({ next: setReply, error: (e: Error) => { - setIsGenerating(false); + setStreamStatus(StreamStatus.IDLE); setMessages([]); setError(e); notifyError('OpenAI Error', `${e.message}`); logError(e, { messages: JSON.stringify(messages), model, temperature: String(temperature) }); }, complete: () => { - setIsGenerating(false); + setStreamStatus(StreamStatus.COMPLETED); + setTimeout(() => { + setStreamStatus(StreamStatus.IDLE); + }); setMessages([]); setError(undefined); }, @@ -97,7 +106,7 @@ export function useOpenAIStream( return { setMessages, reply, - isGenerating, + streamStatus, error, value, }; diff --git a/public/app/features/dashboard/components/GenAI/tracking.ts b/public/app/features/dashboard/components/GenAI/tracking.ts index b299c9145de..8c51e8a729e 100644 --- a/public/app/features/dashboard/components/GenAI/tracking.ts +++ b/public/app/features/dashboard/components/GenAI/tracking.ts @@ -1,13 +1,30 @@ import { reportInteraction } from '@grafana/runtime'; -export enum EventSource { +export const GENERATE_AI_INTERACTION_EVENT_NAME = 'dashboards_autogenerate_clicked'; + +// Source of the interaction +export enum EventTrackingSrc { panelDescription = 'panel-description', panelTitle = 'panel-title', dashboardChanges = 'dashboard-changes', dashboardTitle = 'dashboard-title', dashboardDescription = 'dashboard-description', + unknown = 'unknown', } -export function reportGenerateAIButtonClicked(src: EventSource) { - reportInteraction('dashboards_autogenerate_clicked', { src }); +// Item of the interaction for the improve button and history poppover +export enum AutoGenerateItem { + autoGenerateButton = 'auto-generate-button', + erroredRetryButton = 'errored-retry-button', + improveButton = 'improve-button', + backHistoryItem = 'back-history-item', + forwardHistoryItem = 'forward-history-item', + quickFeedback = 'quick-feedback', + linkToDocs = 'link-to-docs', + customFeedback = 'custom-feedback', + applySuggestion = 'apply-suggestion', +} + +export function reportAutoGenerateInteraction(src: EventTrackingSrc, item: AutoGenerateItem, otherMeta?: object) { + reportInteraction(GENERATE_AI_INTERACTION_EVENT_NAME, { src, item, ...otherMeta }); } diff --git a/public/app/features/dashboard/components/GenAI/utils.ts b/public/app/features/dashboard/components/GenAI/utils.ts index 3a41070c664..91e57a4c729 100644 --- a/public/app/features/dashboard/components/GenAI/utils.ts +++ b/public/app/features/dashboard/components/GenAI/utils.ts @@ -13,6 +13,12 @@ export enum Role { export type Message = openai.Message; +export enum QuickFeedbackType { + Shorter = 'Even shorter', + MoreDescriptive = 'More descriptive', + Regenerate = 'Regenerate', +} + /** * The OpenAI model to be used. */