Dashboard: Add ability to stop title/description generation (#77896)

pull/77943/head
Adela Almasan 2 years ago committed by GitHub
parent 1ef3ba965c
commit fcbc3fb879
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 26
      public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx
  2. 32
      public/app/features/dashboard/components/GenAI/GenAIButton.tsx
  3. 1
      public/app/features/dashboard/components/GenAI/GenAIDashDescriptionButton.tsx
  4. 1
      public/app/features/dashboard/components/GenAI/GenAIDashTitleButton.tsx
  5. 1
      public/app/features/dashboard/components/GenAI/GenAIDashboardChangesButton.tsx
  6. 21
      public/app/features/dashboard/components/GenAI/GenAIHistory.tsx
  7. 1
      public/app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton.tsx
  8. 1
      public/app/features/dashboard/components/GenAI/GenAIPanelTitleButton.tsx
  9. 16
      public/app/features/dashboard/components/GenAI/hooks.ts
  10. 1
      public/app/features/dashboard/components/GenAI/tracking.ts

@ -47,6 +47,7 @@ describe('GenAIButton', () => {
streamStatus: StreamStatus.IDLE, streamStatus: StreamStatus.IDLE,
reply: 'Some completed genereated text', reply: 'Some completed genereated text',
setMessages: jest.fn(), setMessages: jest.fn(),
setStopGeneration: jest.fn(),
value: { value: {
enabled: false, enabled: false,
stream: new Observable().subscribe(), stream: new Observable().subscribe(),
@ -63,12 +64,14 @@ describe('GenAIButton', () => {
describe('when LLM plugin is properly configured, so it is enabled', () => { describe('when LLM plugin is properly configured, so it is enabled', () => {
const setMessagesMock = jest.fn(); const setMessagesMock = jest.fn();
const setShouldStopMock = jest.fn();
beforeEach(() => { beforeEach(() => {
jest.mocked(useOpenAIStream).mockReturnValue({ jest.mocked(useOpenAIStream).mockReturnValue({
error: undefined, error: undefined,
streamStatus: StreamStatus.IDLE, streamStatus: StreamStatus.IDLE,
reply: 'Some completed genereated text', reply: 'Some completed genereated text',
setMessages: setMessagesMock, setMessages: setMessagesMock,
setStopGeneration: setShouldStopMock,
value: { value: {
enabled: true, enabled: true,
stream: new Observable().subscribe(), stream: new Observable().subscribe(),
@ -114,12 +117,15 @@ describe('GenAIButton', () => {
}); });
describe('when it is generating data', () => { describe('when it is generating data', () => {
const setShouldStopMock = jest.fn();
beforeEach(() => { beforeEach(() => {
jest.mocked(useOpenAIStream).mockReturnValue({ jest.mocked(useOpenAIStream).mockReturnValue({
error: undefined, error: undefined,
streamStatus: StreamStatus.GENERATING, streamStatus: StreamStatus.GENERATING,
reply: 'Some incomplete generated text', reply: 'Some incomplete generated text',
setMessages: jest.fn(), setMessages: jest.fn(),
setStopGeneration: setShouldStopMock,
value: { value: {
enabled: true, enabled: true,
stream: new Observable().subscribe(), stream: new Observable().subscribe(),
@ -138,13 +144,12 @@ describe('GenAIButton', () => {
waitFor(async () => expect(await screen.findByRole('button')).toBeEnabled()); waitFor(async () => expect(await screen.findByRole('button')).toBeEnabled());
}); });
it('disables the button while generating', async () => { it('shows the stop button while generating', async () => {
const { getByText, getByRole } = setup(); const { getByText, getByRole } = setup();
const generateButton = getByText('Generating'); const generateButton = getByText('Stop generating');
// The loading text should be visible and the button disabled
expect(generateButton).toBeVisible(); expect(generateButton).toBeVisible();
await waitFor(() => expect(getByRole('button')).toBeDisabled()); await waitFor(() => expect(getByRole('button')).toBeEnabled());
}); });
it('should call onGenerate when the text is generating', async () => { it('should call onGenerate when the text is generating', async () => {
@ -155,16 +160,29 @@ describe('GenAIButton', () => {
expect(onGenerate).toHaveBeenCalledWith('Some incomplete generated text'); expect(onGenerate).toHaveBeenCalledWith('Some incomplete generated text');
}); });
it('should stop generating when clicking the button', async () => {
const onGenerate = jest.fn();
const { getByText } = setup({ onGenerate, messages: [], eventTrackingSrc: eventTrackingSrc });
const generateButton = getByText('Stop generating');
await fireEvent.click(generateButton);
expect(setShouldStopMock).toHaveBeenCalledTimes(1);
expect(setShouldStopMock).toHaveBeenCalledWith(true);
});
}); });
describe('when there is an error generating data', () => { describe('when there is an error generating data', () => {
const setMessagesMock = jest.fn(); const setMessagesMock = jest.fn();
const setShouldStopMock = jest.fn();
beforeEach(() => { beforeEach(() => {
jest.mocked(useOpenAIStream).mockReturnValue({ jest.mocked(useOpenAIStream).mockReturnValue({
error: new Error('Something went wrong'), error: new Error('Something went wrong'),
streamStatus: StreamStatus.IDLE, streamStatus: StreamStatus.IDLE,
reply: '', reply: '',
setMessages: setMessagesMock, setMessages: setMessagesMock,
setStopGeneration: setShouldStopMock,
value: { value: {
enabled: true, enabled: true,
stream: new Observable().subscribe(), stream: new Observable().subscribe(),

@ -12,8 +12,6 @@ import { OAI_MODEL, DEFAULT_OAI_MODEL, Message, sanitizeReply } from './utils';
export interface GenAIButtonProps { export interface GenAIButtonProps {
// Button label text // Button label text
text?: string; text?: string;
// Button label text when loading
loadingText?: string;
toggleTipTitle?: string; toggleTipTitle?: string;
// Button click handler // Button click handler
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
@ -30,10 +28,10 @@ export interface GenAIButtonProps {
// Whether the button should be disabled // Whether the button should be disabled
disabled?: boolean; disabled?: boolean;
} }
export const STOP_GENERATION_TEXT = 'Stop generating';
export const GenAIButton = ({ export const GenAIButton = ({
text = 'Auto-generate', text = 'Auto-generate',
loadingText = 'Generating',
toggleTipTitle = '', toggleTipTitle = '',
onClick: onClickProp, onClick: onClickProp,
model = DEFAULT_OAI_MODEL, model = DEFAULT_OAI_MODEL,
@ -45,27 +43,34 @@ export const GenAIButton = ({
}: GenAIButtonProps) => { }: GenAIButtonProps) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { setMessages, reply, value, error, streamStatus } = useOpenAIStream(model, temperature); const { setMessages, setStopGeneration, reply, value, error, streamStatus } = useOpenAIStream(model, temperature);
const [history, setHistory] = useState<string[]>([]); const [history, setHistory] = useState<string[]>([]);
const [showHistory, setShowHistory] = useState(true); const [showHistory, setShowHistory] = useState(true);
const hasHistory = history.length > 0; const hasHistory = history.length > 0;
const isFirstHistoryEntry = streamStatus === StreamStatus.GENERATING && !hasHistory; const isFirstHistoryEntry = streamStatus === StreamStatus.GENERATING && !hasHistory;
const isButtonDisabled = disabled || isFirstHistoryEntry || (value && !value.enabled && !error); const isButtonDisabled = disabled || (value && !value.enabled && !error);
const reportInteraction = (item: AutoGenerateItem) => reportAutoGenerateInteraction(eventTrackingSrc, item); const reportInteraction = (item: AutoGenerateItem) => reportAutoGenerateInteraction(eventTrackingSrc, item);
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => { const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!hasHistory) { if (streamStatus === StreamStatus.GENERATING) {
onClickProp?.(e); setStopGeneration(true);
setMessages(messages);
} else { } else {
if (setShowHistory) { if (!hasHistory) {
setShowHistory(true); onClickProp?.(e);
setMessages(messages);
} else {
if (setShowHistory) {
setShowHistory(true);
}
} }
} }
const buttonItem = error const buttonItem = error
? AutoGenerateItem.erroredRetryButton ? AutoGenerateItem.erroredRetryButton
: isFirstHistoryEntry
? AutoGenerateItem.stopGenerationButton
: hasHistory : hasHistory
? AutoGenerateItem.improveButton ? AutoGenerateItem.improveButton
: AutoGenerateItem.autoGenerateButton; : AutoGenerateItem.autoGenerateButton;
@ -123,7 +128,7 @@ export const GenAIButton = ({
} }
if (isFirstHistoryEntry) { if (isFirstHistoryEntry) {
buttonText = loadingText; buttonText = STOP_GENERATION_TEXT;
} }
if (hasHistory) { if (hasHistory) {
@ -176,7 +181,7 @@ export const GenAIButton = ({
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
{isFirstHistoryEntry && <Spinner size="sm" />} {isFirstHistoryEntry && <Spinner size="sm" className={styles.spinner} />}
{!hasHistory && ( {!hasHistory && (
<Tooltip <Tooltip
show={error ? undefined : false} show={error ? undefined : false}
@ -197,4 +202,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({ wrapper: css({
display: 'flex', display: 'flex',
}), }),
spinner: css({
color: theme.colors.text.link,
}),
}); });

@ -31,7 +31,6 @@ export const GenAIDashDescriptionButton = ({ onGenerate, dashboard }: GenAIDashD
<GenAIButton <GenAIButton
messages={messages} messages={messages}
onGenerate={onGenerate} onGenerate={onGenerate}
loadingText={'Generating description'}
eventTrackingSrc={EventTrackingSrc.dashboardDescription} eventTrackingSrc={EventTrackingSrc.dashboardDescription}
toggleTipTitle={'Improve your dashboard description'} toggleTipTitle={'Improve your dashboard description'}
/> />

@ -31,7 +31,6 @@ export const GenAIDashTitleButton = ({ onGenerate, dashboard }: GenAIDashTitleBu
<GenAIButton <GenAIButton
messages={messages} messages={messages}
onGenerate={onGenerate} onGenerate={onGenerate}
loadingText={'Generating title'}
eventTrackingSrc={EventTrackingSrc.dashboardTitle} eventTrackingSrc={EventTrackingSrc.dashboardTitle}
toggleTipTitle={'Improve your dashboard title'} toggleTipTitle={'Improve your dashboard title'}
/> />

@ -41,7 +41,6 @@ export const GenAIDashboardChangesButton = ({ dashboard, onGenerate, disabled }:
<GenAIButton <GenAIButton
messages={messages} messages={messages}
onGenerate={onGenerate} onGenerate={onGenerate}
loadingText={'Generating changes summary'}
temperature={0} temperature={0}
model={'gpt-3.5-turbo-16k'} model={'gpt-3.5-turbo-16k'}
eventTrackingSrc={EventTrackingSrc.dashboardChanges} eventTrackingSrc={EventTrackingSrc.dashboardChanges}

@ -9,13 +9,13 @@ import {
Icon, Icon,
IconButton, IconButton,
Input, Input,
Spinner,
Text, Text,
TextLink, TextLink,
useStyles2, useStyles2,
VerticalGroup, VerticalGroup,
} from '@grafana/ui'; } from '@grafana/ui';
import { STOP_GENERATION_TEXT } from './GenAIButton';
import { GenerationHistoryCarousel } from './GenerationHistoryCarousel'; import { GenerationHistoryCarousel } from './GenerationHistoryCarousel';
import { QuickFeedback } from './QuickFeedback'; import { QuickFeedback } from './QuickFeedback';
import { StreamStatus, useOpenAIStream } from './hooks'; import { StreamStatus, useOpenAIStream } from './hooks';
@ -45,7 +45,10 @@ export const GenAIHistory = ({
const [showError, setShowError] = useState(false); const [showError, setShowError] = useState(false);
const [customFeedback, setCustomPrompt] = useState(''); const [customFeedback, setCustomPrompt] = useState('');
const { setMessages, reply, streamStatus, error } = useOpenAIStream(DEFAULT_OAI_MODEL, temperature); const { setMessages, setStopGeneration, reply, streamStatus, error } = useOpenAIStream(
DEFAULT_OAI_MODEL,
temperature
);
const isStreamGenerating = streamStatus === StreamStatus.GENERATING; const isStreamGenerating = streamStatus === StreamStatus.GENERATING;
@ -80,7 +83,14 @@ export const GenAIHistory = ({
}; };
const onApply = () => { const onApply = () => {
onApplySuggestion(history[currentIndex - 1]); if (isStreamGenerating) {
setStopGeneration(true);
if (reply !== '') {
updateHistory(sanitizeReply(reply));
}
} else {
onApplySuggestion(history[currentIndex - 1]);
}
}; };
const onNavigate = (index: number) => { const onNavigate = (index: number) => {
@ -148,9 +158,8 @@ export const GenAIHistory = ({
</div> </div>
<div className={styles.applySuggestion}> <div className={styles.applySuggestion}>
<HorizontalGroup justify={'flex-end'}> <HorizontalGroup justify={'flex-end'}>
{isStreamGenerating && <Spinner />} <Button icon={!isStreamGenerating ? 'check' : 'fa fa-spinner'} onClick={onApply}>
<Button onClick={onApply} disabled={isStreamGenerating}> {isStreamGenerating ? STOP_GENERATION_TEXT : 'Apply'}
Apply
</Button> </Button>
</HorizontalGroup> </HorizontalGroup>
</div> </div>

@ -30,7 +30,6 @@ export const GenAIPanelDescriptionButton = ({ onGenerate, panel }: GenAIPanelDes
<GenAIButton <GenAIButton
messages={messages} messages={messages}
onGenerate={onGenerate} onGenerate={onGenerate}
loadingText={'Generating description'}
eventTrackingSrc={EventTrackingSrc.panelDescription} eventTrackingSrc={EventTrackingSrc.panelDescription}
toggleTipTitle={'Improve your panel description'} toggleTipTitle={'Improve your panel description'}
/> />

@ -26,7 +26,6 @@ export const GenAIPanelTitleButton = ({ onGenerate, panel }: GenAIPanelTitleButt
<GenAIButton <GenAIButton
messages={messages} messages={messages}
onGenerate={onGenerate} onGenerate={onGenerate}
loadingText={'Generating title'}
eventTrackingSrc={EventTrackingSrc.panelTitle} eventTrackingSrc={EventTrackingSrc.panelTitle}
toggleTipTitle={'Improve your panel title'} toggleTipTitle={'Improve your panel title'}
/> />

@ -26,6 +26,7 @@ export function useOpenAIStream(
temperature = 1 temperature = 1
): { ): {
setMessages: React.Dispatch<React.SetStateAction<Message[]>>; setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
setStopGeneration: React.Dispatch<React.SetStateAction<boolean>>;
reply: string; reply: string;
streamStatus: StreamStatus; streamStatus: StreamStatus;
error: Error | undefined; error: Error | undefined;
@ -42,6 +43,7 @@ export function useOpenAIStream(
} { } {
// The messages array to send to the LLM, updated when the button is clicked. // The messages array to send to the LLM, updated when the button is clicked.
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [stopGeneration, setStopGeneration] = useState(false);
// The latest reply from the LLM. // The latest reply from the LLM.
const [reply, setReply] = useState(''); const [reply, setReply] = useState('');
const [streamStatus, setStreamStatus] = useState<StreamStatus>(StreamStatus.IDLE); const [streamStatus, setStreamStatus] = useState<StreamStatus>(StreamStatus.IDLE);
@ -52,6 +54,7 @@ export function useOpenAIStream(
(e: Error) => { (e: Error) => {
setStreamStatus(StreamStatus.IDLE); setStreamStatus(StreamStatus.IDLE);
setMessages([]); setMessages([]);
setStopGeneration(false);
setError(e); setError(e);
notifyError( notifyError(
'Failed to generate content using OpenAI', 'Failed to generate content using OpenAI',
@ -104,6 +107,7 @@ export function useOpenAIStream(
setStreamStatus(StreamStatus.IDLE); setStreamStatus(StreamStatus.IDLE);
}); });
setMessages([]); setMessages([]);
setStopGeneration(false);
setError(undefined); setError(undefined);
}, },
}), }),
@ -119,6 +123,17 @@ export function useOpenAIStream(
}; };
}, [value]); }, [value]);
// Unsubscribe from the stream when user stops the generation.
useEffect(() => {
if (stopGeneration) {
value?.stream?.unsubscribe();
setStreamStatus(StreamStatus.IDLE);
setStopGeneration(false);
setError(undefined);
setMessages([]);
}
}, [stopGeneration, value?.stream]);
// If the stream is generating and we haven't received a reply, it times out. // If the stream is generating and we haven't received a reply, it times out.
useEffect(() => { useEffect(() => {
let timeout: NodeJS.Timeout | undefined; let timeout: NodeJS.Timeout | undefined;
@ -138,6 +153,7 @@ export function useOpenAIStream(
return { return {
setMessages, setMessages,
setStopGeneration,
reply, reply,
streamStatus, streamStatus,
error, error,

@ -16,6 +16,7 @@ export enum EventTrackingSrc {
export enum AutoGenerateItem { export enum AutoGenerateItem {
autoGenerateButton = 'auto-generate-button', autoGenerateButton = 'auto-generate-button',
erroredRetryButton = 'errored-retry-button', erroredRetryButton = 'errored-retry-button',
stopGenerationButton = 'stop-generating-button',
improveButton = 'improve-button', improveButton = 'improve-button',
backHistoryItem = 'back-history-item', backHistoryItem = 'back-history-item',
forwardHistoryItem = 'forward-history-item', forwardHistoryItem = 'forward-history-item',

Loading…
Cancel
Save