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 <nathanielmarrs@gmail.com>
Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
This commit is contained in:
Adela Almasan 2023-10-05 08:25:35 -05:00 committed by GitHub
parent f2bf066ad2
commit 6614eb0a6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 617 additions and 67 deletions

View File

@ -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} />
@ -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);

View File

@ -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<HTMLButtonElement>) => 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<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';
}
return !isGenerating ? text : loadingText;
if (isFirstHistoryEntry) {
buttonText = 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>
);
};

View File

@ -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 (
<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'}
/>
);
};

View File

@ -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 <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[] {

View File

@ -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 (
<GenAIButton
messages={messages}
onGenerate={onGenerate}
onClick={onClick}
loadingText={'Generating changes summary'}
temperature={0}
eventTrackingSrc={EventTrackingSrc.dashboardChanges}
toggleTipTitle={'Improve your dashboard changes summary'}
/>
);
};

View File

@ -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<HTMLInputElement>) =>
e.key === 'Enter' && onSubmitCustomFeedback(customFeedback);
const onChangeCustomFeedback = (e: React.FormEvent<HTMLInputElement>) => setCustomPrompt(e.currentTarget.value);
const onClickSubmitCustomFeedback = () => onSubmitCustomFeedback(customFeedback);
const onClickDocs = () => reportInteraction(AutoGenerateItem.linkToDocs);
return (
<div className={styles.container}>
{showError && (
<div>
<Alert title="">
<VerticalGroup>
<div>Sorry, I was unable to complete your request. Please try again.</div>
</VerticalGroup>
</Alert>
</div>
)}
<Input
placeholder="Tell AI what to do next..."
suffix={
<IconButton
name="corner-down-right-alt"
variant="secondary"
aria-label="Send custom feedback"
onClick={onClickSubmitCustomFeedback}
disabled={customFeedback === ''}
/>
}
value={customFeedback}
onChange={onChangeCustomFeedback}
onKeyDown={onKeyDownCustomFeedbackInput}
/>
<div className={styles.actions}>
<QuickFeedback onSuggestionClick={onGenerateWithFeedback} isGenerating={isStreamGenerating} />
<GenerationHistoryCarousel
history={history}
index={currentIndex}
onNavigate={onNavigate}
reply={reply.replace(/^"|"$/g, '')}
streamStatus={streamStatus}
/>
</div>
<div className={styles.applySuggestion}>
<HorizontalGroup justify={'flex-end'}>
{isStreamGenerating && <Spinner />}
<Button onClick={onApply} disabled={isStreamGenerating}>
Apply
</Button>
</HorizontalGroup>
</div>
<div className={styles.footer}>
<Icon name="exclamation-circle" aria-label="exclamation-circle" className={styles.infoColor} />
<Text variant="bodySmall" color="secondary">
This content is AI-generated.{' '}
<TextLink variant="bodySmall" href="https://grafana.com/grafana/dashboards/" external onClick={onClickDocs}>
Learn more
</TextLink>
</Text>
</div>
</div>
);
};
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,
}),
});

View File

@ -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 (
<GenAIButton messages={messages} onClick={onClick} onGenerate={onGenerate} loadingText={'Generating description'} />
<GenAIButton
messages={messages}
onGenerate={onGenerate}
loadingText={'Generating description'}
eventTrackingSrc={EventTrackingSrc.panelDescription}
toggleTipTitle={'Improve your panel description'}
/>
);
};

View File

@ -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 <GenAIButton messages={messages} onClick={onClick} onGenerate={onGenerate} loadingText={'Generating title'} />;
return (
<GenAIButton
messages={messages}
onGenerate={onGenerate}
loadingText={'Generating title'}
eventTrackingSrc={EventTrackingSrc.panelTitle}
toggleTipTitle={'Improve your panel title'}
/>
);
};
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.`,
},
];
};

View File

@ -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 (
<>
<MinimalisticPagination
currentPage={index}
numberOfPages={historySize}
onNavigate={onNavigate}
hideWhenSinglePage={true}
className={styles.paginationWrapper}
/>
<div className={styles.contentWrapper}>
<Text element="p" color="secondary">
{getHistoryText()}
</Text>
</div>
</>
);
};
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,
}),
});

View File

@ -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 (
<div className={cx(styles.wrapper, className)}>
<IconButton
name="angle-left"
size="md"
tooltip="Previous"
onClick={() => onNavigate(currentPage - 1)}
disabled={currentPage === 1}
/>
{currentPage} of {numberOfPages}
<IconButton
name="angle-right"
size="md"
tooltip="Next"
onClick={() => onNavigate(currentPage + 1)}
disabled={currentPage === numberOfPages}
/>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'row',
gap: 16,
}),
});

View File

@ -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 (
<div className={styles.quickSuggestionsWrapper}>
<Button
onClick={() => onSuggestionClick(QuickFeedbackType.Shorter)}
size="sm"
variant="secondary"
disabled={isGenerating}
>
{QuickFeedbackType.Shorter}
</Button>
<Button
onClick={() => onSuggestionClick(QuickFeedbackType.MoreDescriptive)}
size="sm"
variant="secondary"
disabled={isGenerating}
>
{QuickFeedbackType.MoreDescriptive}
</Button>
<Button
onClick={() => onSuggestionClick(QuickFeedbackType.Regenerate)}
size="sm"
variant="secondary"
disabled={isGenerating}
>
{QuickFeedbackType.Regenerate}
</Button>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
quickSuggestionsWrapper: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexGrow: 1,
gap: 8,
paddingTop: 10,
}),
});

View File

@ -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<React.SetStateAction<Message[]>>;
reply: string;
isGenerating: boolean;
streamStatus: StreamStatus;
error: Error | undefined;
value:
| {
@ -36,7 +42,7 @@ export function useOpenAIStream(
const [messages, setMessages] = useState<Message[]>([]);
// The latest reply from the LLM.
const [reply, setReply] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [streamStatus, setStreamStatus] = useState<StreamStatus>(StreamStatus.IDLE);
const [error, setError] = useState<Error>();
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,
};

View File

@ -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 });
}

View File

@ -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.
*/