mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 16:15:42 -06:00
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:
parent
f2bf066ad2
commit
6614eb0a6e
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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[] {
|
||||
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
207
public/app/features/dashboard/components/GenAI/GenAIHistory.tsx
Normal file
207
public/app/features/dashboard/components/GenAI/GenAIHistory.tsx
Normal 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,
|
||||
}),
|
||||
});
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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.`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
@ -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,
|
||||
}),
|
||||
});
|
@ -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,
|
||||
}),
|
||||
});
|
@ -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,
|
||||
}),
|
||||
});
|
@ -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,
|
||||
};
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user