Skip to content

Commit

Permalink
Auto-generate: Be able to improve the result sending feedback (#75204)
Browse files Browse the repository at this point in the history
* 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 <nathanielmarrs@gmail.com>
Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
  • Loading branch information
3 people committed Oct 5, 2023
1 parent f2bf066 commit 6614eb0
Show file tree
Hide file tree
Showing 14 changed files with 617 additions and 67 deletions.
34 changes: 20 additions & 14 deletions public/app/features/dashboard/components/GenAI/GenAIButton.test.tsx
Expand Up @@ -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(
<Router history={locationService.getHistory()}>
<GenAIButton text="Auto-generate" {...props} />
Expand All @@ -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: {
Expand All @@ -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: {
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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');
});
});

Expand All @@ -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: {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
149 changes: 121 additions & 28 deletions public/app/features/dashboard/components/GenAI/GenAIButton.tsx
@@ -1,17 +1,20 @@
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 {
// Button label text
text?: string;
// Button label text when loading
loadingText?: string;
toggleTipTitle?: string;
// Button click handler
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
// Messages to send to the LLM plugin
Expand All @@ -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<string[]>([]);
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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';
}

if (isFirstHistoryEntry) {
buttonText = loadingText;
}

return !isGenerating ? text : loadingText;
if (hasHistory) {
buttonText = 'Improve';
}

return buttonText;
};

const button = (
<Button
icon={getIcon()}
onClick={onClick}
fill="text"
size="sm"
disabled={isButtonDisabled}
variant={error ? 'destructive' : 'primary'}
>
{getText()}
</Button>
);

const renderButtonWithToggletip = () => {
if (hasHistory) {
const title = <Text element="p">{toggleTipTitle}</Text>;

return (
<Toggletip
title={title}
content={
<GenAIHistory
history={history}
messages={messages}
onApplySuggestion={onApplySuggestion}
updateHistory={pushHistoryEntry}
eventTrackingSrc={eventTrackingSrc}
/>
}
placement="bottom-start"
fitContent={true}
show={showHistory ? undefined : false}
>
{button}
</Toggletip>
);
}

return button;
};

return (
<div className={styles.wrapper}>
{isGenerating && <Spinner size={14} />}
<Tooltip show={error ? undefined : false} interactive content={`OpenAI error: ${error?.message}`}>
<Button
icon={getIcon()}
onClick={onClick}
fill="text"
size="sm"
disabled={isGenerating || (!value?.enabled && !error)}
variant={error ? 'destructive' : 'primary'}
>
{getText()}
</Button>
</Tooltip>
{isFirstHistoryEntry && <Spinner size={14} />}
{!hasHistory && (
<Tooltip show={error ? undefined : false} interactive content={`OpenAI error: ${error?.message}`}>
{button}
</Tooltip>
)}
{hasHistory && renderButtonWithToggletip()}
</div>
);
};
Expand Down
Expand Up @@ -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 {
Expand All @@ -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 (
<GenAIButton messages={messages} onGenerate={onGenerate} onClick={onClick} loadingText={'Generating description'} />
<GenAIButton
messages={messages}
onGenerate={onGenerate}
loadingText={'Generating description'}
eventTrackingSrc={EventTrackingSrc.dashboardDescription}
toggleTipTitle={'Improve your dashboard description'}
/>
);
};

Expand Down
Expand Up @@ -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 {
Expand All @@ -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 <GenAIButton messages={messages} onClick={onClick} onGenerate={onGenerate} loadingText={'Generating title'} />;
return (
<GenAIButton
messages={messages}
onGenerate={onGenerate}
loadingText={'Generating title'}
eventTrackingSrc={EventTrackingSrc.dashboardTitle}
toggleTipTitle={'Improve your dashboard title'}
/>
);
};

function getMessages(dashboard: DashboardModel): Message[] {
Expand Down

0 comments on commit 6614eb0

Please sign in to comment.