Alerting: Auto-generate summary button

pull/102574/head
Alexander Akhmetov 4 months ago
parent e216c2f29d
commit e816303323
No known key found for this signature in database
GPG Key ID: A5A8947133B1B31B
  1. 80
      public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx
  2. 92
      public/app/features/alerting/unified/components/rule-editor/GenAIAlertDescriptionButton.test.tsx
  3. 85
      public/app/features/alerting/unified/components/rule-editor/GenAIAlertDescriptionButton.tsx
  4. 92
      public/app/features/alerting/unified/components/rule-editor/GenAIAlertSummaryButton.test.tsx
  5. 81
      public/app/features/alerting/unified/components/rule-editor/GenAIAlertSummaryButton.tsx
  6. 359
      public/app/features/alerting/unified/components/rule-editor/GenAIButton.test.tsx
  7. 226
      public/app/features/alerting/unified/components/rule-editor/GenAIButton.tsx
  8. 2
      public/app/features/dashboard/components/GenAI/tracking.ts
  9. 8
      public/locales/en-US/grafana.json

@ -16,6 +16,8 @@ import { isGrafanaManagedRuleByType } from '../../utils/rules';
import AnnotationHeaderField from './AnnotationHeaderField';
import DashboardAnnotationField from './DashboardAnnotationField';
import { DashboardPicker, PanelDTO, getVisualPanels } from './DashboardPicker';
import { GenAIAlertDescriptionButton } from './GenAIAlertDescriptionButton';
import { GenAIAlertSummaryButton } from './GenAIAlertSummaryButton';
import { NeedHelpInfo } from './NeedHelpInfo';
import { RuleEditorSection } from './RuleEditorSection';
import { useDashboardQuery } from './useDashboardQuery';
@ -91,6 +93,36 @@ const AnnotationsStep = () => {
setShowPanelSelector(true);
};
const handleGenerateDescription = (description: string) => {
// Find if description annotation already exists
const descIndex = annotations.findIndex((a) => a.key === Annotation.description);
if (descIndex >= 0) {
// Update existing description annotation
const updatedAnnotations = [...annotations];
updatedAnnotations[descIndex] = { ...updatedAnnotations[descIndex], value: description };
setValue('annotations', updatedAnnotations);
} else {
// Add new description annotation
append({ key: Annotation.description, value: description });
}
};
const handleGenerateSummary = (summary: string) => {
// Find if summary annotation already exists
const summaryIndex = annotations.findIndex((a) => a.key === Annotation.summary);
if (summaryIndex >= 0) {
// Update existing summary annotation
const updatedAnnotations = [...annotations];
updatedAnnotations[summaryIndex] = { ...updatedAnnotations[summaryIndex], value: summary };
setValue('annotations', updatedAnnotations);
} else {
// Add new summary annotation
append({ key: Annotation.summary, value: summary });
}
};
function getAnnotationsSectionDescription() {
return (
<Stack direction="row" gap={0.5} alignItems="center">
@ -173,18 +205,30 @@ const AnnotationsStep = () => {
invalid={!!errors.annotations?.[index]?.value?.message}
error={errors.annotations?.[index]?.value?.message}
>
<ValueInputComponent
data-testid={`annotation-value-${index}`}
className={cx(styles.annotationValueInput, { [styles.textarea]: !isUrl })}
{...register(`annotations.${index}.value`)}
placeholder={
isUrl
? 'https://'
: (annotationField.key && `Enter a ${annotationField.key}...`) ||
'Enter custom annotation content...'
}
defaultValue={annotationField.value}
/>
<div className={styles.inputWithButton}>
<ValueInputComponent
data-testid={`annotation-value-${index}`}
className={cx(styles.annotationValueInput, { [styles.textarea]: !isUrl })}
{...register(`annotations.${index}.value`)}
placeholder={
isUrl
? 'https://'
: (annotationField.key && `Enter a ${annotationField.key}...`) ||
'Enter custom annotation content...'
}
defaultValue={annotationField.value}
/>
{annotationField.key === Annotation.description && (
<div className={styles.aiButtonContainer}>
<GenAIAlertDescriptionButton onGenerate={handleGenerateDescription} />
</div>
)}
{annotationField.key === Annotation.summary && (
<div className={styles.aiButtonContainer}>
<GenAIAlertSummaryButton onGenerate={handleGenerateSummary} />
</div>
)}
</div>
</Field>
{!annotationLabels[annotation] && (
<Button
@ -280,6 +324,18 @@ const getStyles = (theme: GrafanaTheme2) => ({
annotationValueContainer: css({
display: 'flex',
}),
inputWithButton: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
position: 'relative',
}),
aiButtonContainer: css({
marginLeft: theme.spacing(1),
alignSelf: 'flex-start',
}),
});
export default AnnotationsStep;

@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form';
import { Provider } from 'react-redux';
import { configureStore } from 'app/store/configureStore';
import { GenAIAlertDescriptionButton } from './GenAIAlertDescriptionButton';
import { GenAIButton } from './GenAIButton';
// Mock the GenAIButton component
jest.mock('./GenAIButton', () => ({
GenAIButton: jest.fn(() => <div data-testid="gen-ai-button">GenAIButton</div>),
Role: {
system: 'system',
user: 'user',
},
}));
// Mock react-hook-form
const mockWatch = jest.fn();
const FormProviderWrapper: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
const methods = useForm({
defaultValues: {
name: 'Test Alert Rule',
type: 'test',
annotations: [{ key: 'description', value: 'Test description' }],
labels: [{ key: 'test', value: 'label' }],
queries: [{ model: { test: 'query' } }],
},
});
// Track when watch method is used
mockWatch.mockImplementation(() => {});
// No need to replace methods.watch
return <FormProvider {...methods}>{children}</FormProvider>;
};
const mockStore = configureStore();
describe('GenAIAlertDescriptionButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render GenAIButton', () => {
render(
<Provider store={mockStore}>
<FormProviderWrapper>
<GenAIAlertDescriptionButton onGenerate={jest.fn()} />
</FormProviderWrapper>
</Provider>
);
expect(screen.getByTestId('gen-ai-button')).toBeInTheDocument();
});
it('should call GenAIButton with proper props', () => {
const onGenerate = jest.fn();
render(
<Provider store={mockStore}>
<FormProviderWrapper>
<GenAIAlertDescriptionButton onGenerate={onGenerate} />
</FormProviderWrapper>
</Provider>
);
// Check that form values are used
expect(GenAIButton).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.any(Array),
}),
expect.anything()
);
// Check that the messages array exists
const props = jest.mocked(GenAIButton).mock.calls[0][0];
expect(props.messages).toBeDefined();
// Since messages can be a function or array, we'd need runtime type checking
// to safely test its contents, which would be better in an integration test
// Verify GenAIButton was called with expected props
expect(GenAIButton).toHaveBeenCalledWith(
expect.objectContaining({
onGenerate,
toggleTipTitle: 'Improve your alert rule description',
tooltip: 'Generate a description for this alert rule',
}),
expect.anything()
);
});
});

@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { t } from 'app/core/internationalization';
import { RuleFormValues } from '../../types/rule-form';
import { Annotation } from '../../utils/constants';
import { GenAIButton, Message, Role } from './GenAIButton';
interface GenAIAlertDescriptionButtonProps {
onGenerate: (description: string) => void;
}
const DESCRIPTION_GENERATION_STANDARD_PROMPT =
'You are an expert in creating Grafana Alert Rules.\n' +
'Your goal is to write a descriptive and concise alert rule description.\n' +
'The description should explain the purpose of the alert rule, what it monitors, and why someone should care about it.\n' +
'The description should be clear and understandable by someone who is not familiar with the system.\n' +
'IMPORTANT: Only use official Grafana alerting template variables: $labels, $values, $value, $expr, $humanizeValue, $humanizeDuration.\n' +
'DO NOT create variables that do not exist like $valueInt, $valueString, etc.\n' +
'For labels, always use the format {{ $labels.labelname }} with double curly braces.\n' +
'For values, use {{ $value }} or {{ $value | formatter }} with double curly braces,' +
'or use {{ .Value.<refID>.Value }} where redID is the refID from the queries.\n' +
'Use only existing labels that you can see in the provided label information.\n' +
'Keep the description under 300 characters.\n' +
'Respond with only the description of the alert rule.';
export const GenAIAlertDescriptionButton = ({ onGenerate }: GenAIAlertDescriptionButtonProps) => {
const { watch } = useFormContext<RuleFormValues>();
const name = watch('name');
const type = watch('type');
const annotations = watch('annotations');
const queries = watch('queries');
// Get the summary annotation if it exists
const summary = annotations.find((annotation) => annotation.key === Annotation.summary)?.value || '';
const messages = useMemo(() => {
const queryModels = queries.map((q) => q.model);
// Filter out expression queries which have refId property
const dataQueries = queryModels.filter((q) => !q.refId);
// Get other important context
const labels = watch('labels') || [];
// Format labels and annotations for the prompt
const labelsText = labels.length > 0 ? `Labels:\n${labels.map((l) => `- ${l.key}: ${l.value}`).join('\n')}\n` : '';
const annotationsText =
annotations.length > 0
? `Annotations:\n${annotations
.filter((a) => a.key !== Annotation.description)
.map((a) => `- ${a.key}: ${a.value}`)
.join('\n')}\n`
: '';
const messages: Message[] = [
{
content: DESCRIPTION_GENERATION_STANDARD_PROMPT,
role: Role.system,
},
{
content:
`Alert Rule Name: ${name}\n` +
`Alert Rule Type: ${type}\n` +
`Alert Rule Summary: ${summary}\n` +
`${labelsText}` +
`${annotationsText}` +
`Queries: ${JSON.stringify(dataQueries, null, 2)}\n`,
role: Role.user,
},
];
return messages;
}, [name, type, summary, annotations, queries, watch]);
return (
<GenAIButton
messages={messages}
onGenerate={onGenerate}
toggleTipTitle={t('alerting.rule-editor.gen-ai.description-tip-title', 'Improve your alert rule description')}
tooltip={t('alerting.rule-editor.gen-ai.description-tooltip', 'Generate a description for this alert rule')}
/>
);
};

@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form';
import { Provider } from 'react-redux';
import { configureStore } from 'app/store/configureStore';
import { GenAIAlertSummaryButton } from './GenAIAlertSummaryButton';
import { GenAIButton } from './GenAIButton';
// Mock the GenAIButton component
jest.mock('./GenAIButton', () => ({
GenAIButton: jest.fn(() => <div data-testid="gen-ai-button">GenAIButton</div>),
Role: {
system: 'system',
user: 'user',
},
}));
// Mock react-hook-form
const mockWatch = jest.fn();
const FormProviderWrapper: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
const methods = useForm({
defaultValues: {
name: 'Test Alert Rule',
type: 'test',
annotations: [{ key: 'description', value: 'Test description' }],
labels: [{ key: 'test', value: 'label' }],
queries: [{ model: { test: 'query' } }],
},
});
// Track when watch method is used
mockWatch.mockImplementation(() => {});
// No need to replace methods.watch
return <FormProvider {...methods}>{children}</FormProvider>;
};
const mockStore = configureStore();
describe('GenAIAlertSummaryButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render GenAIButton', () => {
render(
<Provider store={mockStore}>
<FormProviderWrapper>
<GenAIAlertSummaryButton onGenerate={jest.fn()} />
</FormProviderWrapper>
</Provider>
);
expect(screen.getByTestId('gen-ai-button')).toBeInTheDocument();
});
it('should call GenAIButton with proper props', () => {
const onGenerate = jest.fn();
render(
<Provider store={mockStore}>
<FormProviderWrapper>
<GenAIAlertSummaryButton onGenerate={onGenerate} />
</FormProviderWrapper>
</Provider>
);
// Check that form values are used
expect(GenAIButton).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.any(Array),
}),
expect.anything()
);
// Check that the messages array exists
const props = jest.mocked(GenAIButton).mock.calls[0][0];
expect(props.messages).toBeDefined();
// Since messages can be a function or array, we'd need runtime type checking
// to safely test its contents, which would be better in an integration test
// Verify GenAIButton was called with expected props
expect(GenAIButton).toHaveBeenCalledWith(
expect.objectContaining({
onGenerate,
toggleTipTitle: 'Improve your alert rule summary',
tooltip: 'Generate a summary for this alert rule',
}),
expect.anything()
);
});
});

@ -0,0 +1,81 @@
import { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { t } from 'app/core/internationalization';
import { RuleFormValues } from '../../types/rule-form';
import { GenAIButton, Message, Role } from './GenAIButton';
interface GenAIAlertSummaryButtonProps {
onGenerate: (summary: string) => void;
}
const SUMMARY_GENERATION_STANDARD_PROMPT =
'You are an expert in creating Grafana Alert Rules.\n' +
'Your goal is to write a short, concise summary for an alert rule.\n' +
'The summary should briefly explain what happened and why someone should care about it.\n' +
'IMPORTANT: Only use official Grafana alerting template variables: $labels, $values, $value, $expr, $humanizeValue, $humanizeDuration.\n' +
'DO NOT create variables that do not exist like $valueInt, $valueString, etc.\n' +
'For labels, always use the format {{ $labels.labelname }} with double curly braces.\n' +
'For values, use {{ $value }} or {{ $value | formatter }} with double curly braces,' +
'or use {{ .Value.<refID>.Value }} where redID is the refID from the queries.\n' +
'Use only existing labels that you can see in the provided label information.\n' +
'Keep the summary under 100 characters and make it actionable.\n' +
'Respond with only the summary text.';
export const GenAIAlertSummaryButton = ({ onGenerate }: GenAIAlertSummaryButtonProps) => {
const { watch } = useFormContext<RuleFormValues>();
const name = watch('name');
const type = watch('type');
const queries = watch('queries');
const messages = useMemo(() => {
const queryModels = queries.map((q) => q.model);
// Filter out expression queries which have refId property
const dataQueries = queryModels.filter((q) => !q.refId);
// Get other important context
const annotations = watch('annotations') || [];
const labels = watch('labels') || [];
const description = annotations.find((a) => a.key === 'description')?.value || '';
// Format labels and annotations for the prompt
const labelsText = labels.length > 0 ? `Labels:\n${labels.map((l) => `- ${l.key}: ${l.value}`).join('\n')}\n` : '';
const annotationsText =
annotations.length > 0 ? `Annotations:\n${annotations.map((a) => `- ${a.key}: ${a.value}`).join('\n')}\n` : '';
const messages: Message[] = [
{
content: SUMMARY_GENERATION_STANDARD_PROMPT,
role: Role.system,
},
{
content:
`Alert Rule Name: ${name}\n` +
`Alert Rule Type: ${type}\n` +
`${description}\n` +
`${labelsText}` +
`${annotationsText}` +
`Queries: ${JSON.stringify(dataQueries, null, 2)}\n` +
`Examples of VALID Grafana template usage:\n` +
`1. CPU usage on {{ $labels.instance }} is high ({{ $value }}%)\n` +
`2. Memory usage exceeds threshold: {{ $value | humanizePercentage }}\n` +
`3. Disk I/O on {{ $labels.device }} is {{ $value | humanize1024 }}/s\n` +
`4. Service {{ $labels.service }} has high error rate: {{ $value }}\n` +
`5. Network usage above threshold: {{ $value | humanizeBytes }}\n`,
role: Role.user,
},
];
return messages;
}, [name, type, queries, watch]);
return (
<GenAIButton
messages={messages}
onGenerate={onGenerate}
toggleTipTitle={t('alerting.rule-editor.gen-ai.summary-tip-title', 'Improve your alert rule summary')}
tooltip={t('alerting.rule-editor.gen-ai.summary-tooltip', 'Generate a summary for this alert rule')}
/>
);
};

@ -0,0 +1,359 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Observable } from 'rxjs';
import { render } from 'test/test-utils';
import { selectors } from '@grafana/e2e-selectors';
import { GenAIButton, GenAIButtonProps, Role } from './GenAIButton';
import { StreamStatus, useLLMStream } from '../../../../dashboard/components/GenAI/hooks';
const mockedUseLLMStreamState = {
messages: [],
setMessages: jest.fn(),
reply: 'I am a robot',
streamStatus: StreamStatus.IDLE,
error: null,
value: null,
};
jest.mock('../../../../dashboard/components/GenAI/hooks', () => ({
useLLMStream: jest.fn(() => mockedUseLLMStreamState),
StreamStatus: {
IDLE: 'idle',
GENERATING: 'generating',
},
}));
describe('GenAIButton', () => {
const onGenerate = jest.fn();
function setup(props: GenAIButtonProps = { onGenerate, messages: [] }) {
return render(<GenAIButton text="Auto-generate" {...props} />);
}
describe('when LLM plugin is not configured', () => {
beforeAll(() => {
jest.mocked(useLLMStream).mockReturnValue({
messages: [],
error: undefined,
streamStatus: StreamStatus.IDLE,
reply: 'Some completed genereated text',
setMessages: jest.fn(),
stopGeneration: jest.fn(),
value: {
enabled: false,
stream: new Observable().subscribe(),
},
});
});
it('should not render anything', async () => {
setup();
waitFor(async () => expect(await screen.findByText('Auto-generate')).not.toBeInTheDocument());
});
});
describe('when LLM plugin is properly configured, so it is enabled', () => {
const setMessagesMock = jest.fn();
const setShouldStopMock = jest.fn();
beforeEach(() => {
setMessagesMock.mockClear();
setShouldStopMock.mockClear();
jest.mocked(useLLMStream).mockReturnValue({
messages: [],
error: undefined,
streamStatus: StreamStatus.IDLE,
reply: 'Some completed generated text',
setMessages: setMessagesMock,
stopGeneration: setShouldStopMock,
value: {
enabled: true,
stream: new Observable().subscribe(),
},
});
});
it('should render text', async () => {
setup();
waitFor(async () => expect(await screen.findByText('Auto-generate')).toBeInTheDocument());
});
it('should enable the button', async () => {
setup();
waitFor(async () => expect(await screen.findByRole('button')).toBeEnabled());
});
it('should send the configured messages', async () => {
setup({ onGenerate, messages: [{ content: 'Generate X', role: 'system' as Role }] });
const generateButton = await screen.findByRole('button');
// Click the button
await fireEvent.click(generateButton);
await waitFor(() => expect(generateButton).toBeEnabled());
// Wait for the loading state to be resolved
expect(setMessagesMock).toHaveBeenCalledTimes(1);
expect(setMessagesMock).toHaveBeenCalledWith([{ content: 'Generate X', role: 'system' as Role }]);
});
it('should call the messages when they are provided as callback', async () => {
const onGenerate = jest.fn();
const messages = jest.fn().mockReturnValue([{ content: 'Generate X', role: 'system' as Role }]);
const onClick = jest.fn();
setup({ onGenerate, messages, temperature: 3, onClick });
const generateButton = await screen.findByRole('button');
await fireEvent.click(generateButton);
expect(messages).toHaveBeenCalledTimes(1);
expect(setMessagesMock).toHaveBeenCalledTimes(1);
expect(setMessagesMock).toHaveBeenCalledWith([{ content: 'Generate X', role: 'system' as Role }]);
});
it('should call the onClick callback', async () => {
const onGenerate = jest.fn();
const onClick = jest.fn();
const messages = [{ content: 'Generate X', role: 'system' as Role }];
setup({ onGenerate, messages, temperature: 3, onClick });
const generateButton = await screen.findByRole('button');
await fireEvent.click(generateButton);
await waitFor(() => expect(onClick).toHaveBeenCalledTimes(1));
});
it('should display the tooltip if provided', async () => {
const { getByRole, getByTestId } = setup({
tooltip: 'This is a tooltip',
onGenerate,
messages: [],
});
// Wait for the check to be completed
const button = getByRole('button');
await userEvent.hover(button);
const tooltip = await waitFor(() => getByTestId(selectors.components.Tooltip.container));
expect(tooltip).toBeVisible();
expect(tooltip).toHaveTextContent('This is a tooltip');
});
});
describe('when it is generating data', () => {
const setShouldStopMock = jest.fn();
beforeEach(() => {
jest.mocked(useLLMStream).mockReturnValue({
messages: [],
error: undefined,
streamStatus: StreamStatus.GENERATING,
reply: 'Some incomplete generated text',
setMessages: jest.fn(),
stopGeneration: setShouldStopMock,
value: {
enabled: true,
stream: new Observable().subscribe(),
},
});
});
it('should render loading text', async () => {
setup();
waitFor(async () => expect(await screen.findByText('Auto-generate')).toBeInTheDocument());
});
it('should enable the button', async () => {
setup();
waitFor(async () => expect(await screen.findByRole('button')).toBeEnabled());
});
it('shows the stop button while generating', async () => {
const { getByText, getByRole } = setup();
const generateButton = getByText('Stop generating');
expect(generateButton).toBeVisible();
await waitFor(() => expect(getByRole('button')).toBeEnabled());
});
it('should not call onGenerate when the text is generating', async () => {
const onGenerate = jest.fn();
setup({ onGenerate, messages: [] });
await waitFor(() => expect(onGenerate).not.toHaveBeenCalledTimes(1));
});
it('should stop generating when clicking the button', async () => {
const onGenerate = jest.fn();
const { getByText } = setup({ onGenerate, messages: [] });
const generateButton = getByText('Stop generating');
await fireEvent.click(generateButton);
expect(setShouldStopMock).toHaveBeenCalledTimes(1);
expect(onGenerate).not.toHaveBeenCalled();
});
});
describe('when it is completed from generating data', () => {
const setShouldStopMock = jest.fn();
beforeEach(() => {
const reply = 'Some completed generated text';
const returnValue = {
messages: [],
error: undefined,
streamStatus: StreamStatus.COMPLETED,
reply,
setMessages: jest.fn(),
stopGeneration: setShouldStopMock,
value: {
enabled: true,
stream: new Observable().subscribe(),
},
};
jest
.mocked(useLLMStream)
.mockImplementationOnce((options) => {
options?.onResponse?.(reply);
return returnValue;
})
.mockImplementation(() => returnValue);
});
it('should render improve text ', async () => {
setup();
waitFor(async () => expect(await screen.findByText('Improve')).toBeInTheDocument());
});
it('should enable the button', async () => {
setup();
waitFor(async () => expect(await screen.findByRole('button')).toBeEnabled());
});
it('should call onGenerate when the text is completed', async () => {
const onGenerate = jest.fn();
setup({ onGenerate, messages: [] });
await waitFor(() => expect(onGenerate).toHaveBeenCalledTimes(1));
expect(onGenerate).toHaveBeenCalledWith('Some completed generated text');
});
});
describe('when there is an error generating data', () => {
const setMessagesMock = jest.fn();
const setShouldStopMock = jest.fn();
beforeEach(() => {
setMessagesMock.mockClear();
setShouldStopMock.mockClear();
jest.mocked(useLLMStream).mockReturnValue({
messages: [],
error: new Error('Something went wrong'),
streamStatus: StreamStatus.IDLE,
reply: '',
setMessages: setMessagesMock,
stopGeneration: setShouldStopMock,
value: {
enabled: true,
stream: new Observable().subscribe(),
},
});
});
it('should render error state text', async () => {
setup();
waitFor(async () => expect(await screen.findByText('Retry')).toBeInTheDocument());
});
it('should enable the button', async () => {
setup();
waitFor(async () => expect(await screen.findByRole('button')).toBeEnabled());
});
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 generateButton = getByText('Retry');
await fireEvent.click(generateButton);
expect(setMessagesMock).toHaveBeenCalledTimes(1);
expect(setMessagesMock).toHaveBeenCalledWith(messages);
});
it('should display the error message as tooltip', async () => {
const { getByRole, getByTestId } = setup();
// Wait for the check to be completed
const button = getByRole('button');
await userEvent.hover(button);
const tooltip = await waitFor(() => getByTestId(selectors.components.Tooltip.container));
expect(tooltip).toBeVisible();
// The tooltip keeps interactive to be able to click the link
await userEvent.hover(tooltip);
expect(tooltip).toBeVisible();
expect(tooltip).toHaveTextContent(
'Failed to generate content using LLM. Please try again or if the problem persists, contact your organization admin.'
);
});
it('error message should overwrite the tooltip content passed in tooltip prop', async () => {
const { getByRole, getByTestId } = setup({
tooltip: 'This is a tooltip',
onGenerate,
messages: [],
});
// Wait for the check to be completed
const button = getByRole('button');
await userEvent.hover(button);
const tooltip = await waitFor(() => getByTestId(selectors.components.Tooltip.container));
expect(tooltip).toBeVisible();
// The tooltip keeps interactive to be able to click the link
await userEvent.hover(tooltip);
expect(tooltip).toBeVisible();
expect(tooltip).toHaveTextContent(
'Failed to generate content using LLM. Please try again or if the problem persists, contact your organization admin.'
);
});
it('should call the onClick callback', async () => {
const onGenerate = jest.fn();
const onClick = jest.fn();
const messages = [{ content: 'Generate X', role: 'system' as Role }];
setup({ onGenerate, messages, temperature: 3, onClick });
const generateButton = await screen.findByRole('button');
await fireEvent.click(generateButton);
await waitFor(() => expect(onClick).toHaveBeenCalledTimes(1));
});
it('should call the messages when they are provided as callback', async () => {
const onGenerate = jest.fn();
const messages = jest.fn().mockReturnValue([{ content: 'Generate X', role: 'system' as Role }]);
const onClick = jest.fn();
setup({ onGenerate, messages, temperature: 3, onClick });
const generateButton = await screen.findByRole('button');
await fireEvent.click(generateButton);
expect(messages).toHaveBeenCalledTimes(1);
expect(setMessagesMock).toHaveBeenCalledTimes(1);
expect(setMessagesMock).toHaveBeenCalledWith([{ content: 'Generate X', role: 'system' as Role }]);
});
});
});

@ -0,0 +1,226 @@
import { css } from '@emotion/css';
import { useCallback, useState } from 'react';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { llm } from '@grafana/llm';
import { Button, Spinner, Text, Toggletip, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { StreamStatus, useLLMStream } from '../../../../dashboard/components/GenAI/hooks';
import { DEFAULT_LLM_MODEL, sanitizeReply } from '../../../../dashboard/components/GenAI/utils';
/**
* Message roles for LLM interactions
*/
export enum Role {
// System content cannot be overwritten by user prompts.
'system' = 'system',
// User content is the content that the user has entered.
// This content can be overwritten by following prompt.
'user' = 'user',
}
const GenAIHistory = ({
history,
onApplySuggestion,
}: {
history: string[];
onApplySuggestion: (suggestion: string) => void;
}) => {
return (
<div>
{history.map((suggestion, i) => (
<div key={i} style={{ marginBottom: '8px' }}>
<Text>{suggestion}</Text>
<Button size="sm" variant="secondary" onClick={() => onApplySuggestion(suggestion)}>
<Trans>Apply</Trans>
</Button>
</div>
))}
</div>
);
};
export type Message = llm.Message;
export interface GenAIButtonProps {
text?: string;
toggleTipTitle?: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
// Messages to send to the LLM plugin
messages: Message[] | (() => Message[]);
// Callback function that the LLM plugin streams responses to
onGenerate: (response: string) => void;
// Temperature for the LLM plugin. Default is 1.
// Closer to 0 means more conservative, closer to 1 means more creative.
temperature?: number;
model?: llm.Model;
disabled?: boolean;
tooltip?: string;
}
export const STOP_GENERATION_TEXT = <Trans>Stop generating</Trans>;
export const GenAIButton = ({
text = <Trans>Auto-generate</Trans>,
toggleTipTitle = '',
onClick: onClickProp,
model = DEFAULT_LLM_MODEL,
messages,
onGenerate,
temperature = 1,
disabled,
tooltip,
}: GenAIButtonProps) => {
const styles = useStyles2(getStyles);
const [history, setHistory] = useState<string[]>([]);
const unshiftHistoryEntry = useCallback((historyEntry: string) => {
setHistory((h) => [historyEntry, ...h]);
}, []);
const onResponse = useCallback(
(reply: string) => {
const sanitizedReply = sanitizeReply(reply);
onGenerate(sanitizedReply);
unshiftHistoryEntry(sanitizedReply);
},
[onGenerate, unshiftHistoryEntry]
);
const { setMessages, stopGeneration, value, error, streamStatus } = useLLMStream({
model,
temperature,
onResponse,
});
const [showHistory, setShowHistory] = useState(false);
const hasHistory = history.length > 0;
const isFirstHistoryEntry = !hasHistory;
const isGenerating = streamStatus === StreamStatus.GENERATING;
const isButtonDisabled = disabled || (value && !value.enabled && !error);
const showTooltip = error || tooltip ? undefined : false;
const tooltipContent = error ? (
<Trans>
Failed to generate content using LLM. Please try again or if the problem persists, contact your organization
admin.
</Trans>
) : (
tooltip || ''
);
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (streamStatus === StreamStatus.GENERATING) {
stopGeneration();
} else {
if (!hasHistory) {
onClickProp?.(e);
setMessages(getMessages());
} else {
setShowHistory(true);
}
}
};
// The button is disabled if the plugin is not installed or enabled
if (!value?.enabled) {
return null;
}
const onApplySuggestion = (suggestion: string) => {
onGenerate(suggestion);
setShowHistory(false);
};
const getIcon = () => {
if (isGenerating) {
return undefined;
}
if (error || (value && !value.enabled)) {
return 'exclamation-circle';
}
return 'ai';
};
const getText = () => {
let buttonText = text;
if (error) {
buttonText = <Trans>Retry</Trans>;
}
if (isGenerating) {
buttonText = STOP_GENERATION_TEXT;
}
if (hasHistory) {
buttonText = <Trans>Improve</Trans>;
}
return buttonText;
};
const button = (
<Button
icon={getIcon()}
onClick={onClick}
fill="text"
size="sm"
disabled={isButtonDisabled}
variant={error ? 'destructive' : 'primary'}
>
{getText()}
</Button>
);
const getMessages = () => (typeof messages === 'function' ? messages() : messages);
const renderButtonWithToggletip = () => {
if (hasHistory) {
const title = <Text element="p">{toggleTipTitle}</Text>;
return (
<Toggletip
title={title}
content={<GenAIHistory history={history} onApplySuggestion={onApplySuggestion} />}
placement="left-start"
fitContent={true}
show={showHistory}
onClose={() => setShowHistory(false)}
onOpen={() => setShowHistory(true)}
>
{button}
</Toggletip>
);
}
return button;
};
return (
<div className={styles.wrapper}>
{isGenerating && <Spinner size="sm" className={styles.spinner} />}
{isFirstHistoryEntry ? (
<Tooltip show={showTooltip} interactive content={tooltipContent}>
{button}
</Tooltip>
) : (
renderButtonWithToggletip()
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
}),
spinner: css({
color: theme.colors.text.link,
}),
});

@ -9,6 +9,8 @@ export enum EventTrackingSrc {
dashboardChanges = 'dashboard-changes',
dashboardTitle = 'dashboard-title',
dashboardDescription = 'dashboard-description',
alertRuleDescription = 'alert-rule-description',
alertRuleSummary = 'alert-rule-summary',
unknown = 'unknown',
}

@ -672,6 +672,14 @@
"description-target-data-source": "The Prometheus data source to store the recording rule in",
"label-target-data-source": "Target data source"
},
"rule-editor": {
"gen-ai": {
"description-tip-title": "Improve your alert rule description",
"description-tooltip": "Generate a description for this alert rule",
"summary-tip-title": "Improve your alert rule summary",
"summary-tooltip": "Generate a summary for this alert rule"
}
},
"rule-form": {
"annotations": {
"description1": "Annotations add additional information to alerts, helping alert responders identify and address potential issues.",

Loading…
Cancel
Save