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:
co-authored by
nmarrs
Ivan Ortega
parent
f2bf066ad2
commit
6614eb0a6e
@@ -8,25 +8,31 @@ import { selectors } from '@grafana/e2e-selectors';
|
|||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
|
|
||||||
import { GenAIButton, GenAIButtonProps } from './GenAIButton';
|
import { GenAIButton, GenAIButtonProps } from './GenAIButton';
|
||||||
import { useOpenAIStream } from './hooks';
|
import { StreamStatus, useOpenAIStream } from './hooks';
|
||||||
|
import { EventTrackingSrc } from './tracking';
|
||||||
import { Role } from './utils';
|
import { Role } from './utils';
|
||||||
|
|
||||||
const mockedUseOpenAiStreamState = {
|
const mockedUseOpenAiStreamState = {
|
||||||
setMessages: jest.fn(),
|
setMessages: jest.fn(),
|
||||||
reply: 'I am a robot',
|
reply: 'I am a robot',
|
||||||
isGenerationResponse: false,
|
streamStatus: StreamStatus.IDLE,
|
||||||
error: null,
|
error: null,
|
||||||
value: null,
|
value: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('./hooks', () => ({
|
jest.mock('./hooks', () => ({
|
||||||
useOpenAIStream: jest.fn(() => mockedUseOpenAiStreamState),
|
useOpenAIStream: jest.fn(() => mockedUseOpenAiStreamState),
|
||||||
|
StreamStatus: {
|
||||||
|
IDLE: 'idle',
|
||||||
|
GENERATING: 'generating',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('GenAIButton', () => {
|
describe('GenAIButton', () => {
|
||||||
const onGenerate = jest.fn();
|
const onGenerate = jest.fn();
|
||||||
|
const eventTrackingSrc = EventTrackingSrc.unknown;
|
||||||
|
|
||||||
function setup(props: GenAIButtonProps = { onGenerate, messages: [] }) {
|
function setup(props: GenAIButtonProps = { onGenerate, messages: [], eventTrackingSrc }) {
|
||||||
return render(
|
return render(
|
||||||
<Router history={locationService.getHistory()}>
|
<Router history={locationService.getHistory()}>
|
||||||
<GenAIButton text="Auto-generate" {...props} />
|
<GenAIButton text="Auto-generate" {...props} />
|
||||||
@@ -38,7 +44,7 @@ describe('GenAIButton', () => {
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.mocked(useOpenAIStream).mockReturnValue({
|
jest.mocked(useOpenAIStream).mockReturnValue({
|
||||||
error: undefined,
|
error: undefined,
|
||||||
isGenerating: false,
|
streamStatus: StreamStatus.IDLE,
|
||||||
reply: 'Some completed genereated text',
|
reply: 'Some completed genereated text',
|
||||||
setMessages: jest.fn(),
|
setMessages: jest.fn(),
|
||||||
value: {
|
value: {
|
||||||
@@ -60,7 +66,7 @@ describe('GenAIButton', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.mocked(useOpenAIStream).mockReturnValue({
|
jest.mocked(useOpenAIStream).mockReturnValue({
|
||||||
error: undefined,
|
error: undefined,
|
||||||
isGenerating: false,
|
streamStatus: StreamStatus.IDLE,
|
||||||
reply: 'Some completed genereated text',
|
reply: 'Some completed genereated text',
|
||||||
setMessages: setMessagesMock,
|
setMessages: setMessagesMock,
|
||||||
value: {
|
value: {
|
||||||
@@ -82,7 +88,7 @@ describe('GenAIButton', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should send the configured messages', async () => {
|
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');
|
const generateButton = await screen.findByRole('button');
|
||||||
|
|
||||||
// Click the button
|
// Click the button
|
||||||
@@ -98,7 +104,7 @@ describe('GenAIButton', () => {
|
|||||||
const onGenerate = jest.fn();
|
const onGenerate = jest.fn();
|
||||||
const onClick = jest.fn();
|
const onClick = jest.fn();
|
||||||
const messages = [{ content: 'Generate X', role: 'system' as Role }];
|
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');
|
const generateButton = await screen.findByRole('button');
|
||||||
await fireEvent.click(generateButton);
|
await fireEvent.click(generateButton);
|
||||||
@@ -111,8 +117,8 @@ describe('GenAIButton', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.mocked(useOpenAIStream).mockReturnValue({
|
jest.mocked(useOpenAIStream).mockReturnValue({
|
||||||
error: undefined,
|
error: undefined,
|
||||||
isGenerating: true,
|
streamStatus: StreamStatus.GENERATING,
|
||||||
reply: 'Some incompleted generated text',
|
reply: 'Some incomplete generated text',
|
||||||
setMessages: jest.fn(),
|
setMessages: jest.fn(),
|
||||||
value: {
|
value: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -143,11 +149,11 @@ describe('GenAIButton', () => {
|
|||||||
|
|
||||||
it('should call onGenerate when the text is generating', async () => {
|
it('should call onGenerate when the text is generating', async () => {
|
||||||
const onGenerate = jest.fn();
|
const onGenerate = jest.fn();
|
||||||
setup({ onGenerate, messages: [] });
|
setup({ onGenerate, messages: [], eventTrackingSrc: eventTrackingSrc });
|
||||||
|
|
||||||
await waitFor(() => expect(onGenerate).toHaveBeenCalledTimes(1));
|
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(() => {
|
beforeEach(() => {
|
||||||
jest.mocked(useOpenAIStream).mockReturnValue({
|
jest.mocked(useOpenAIStream).mockReturnValue({
|
||||||
error: new Error('Something went wrong'),
|
error: new Error('Something went wrong'),
|
||||||
isGenerating: false,
|
streamStatus: StreamStatus.IDLE,
|
||||||
reply: '',
|
reply: '',
|
||||||
setMessages: setMessagesMock,
|
setMessages: setMessagesMock,
|
||||||
value: {
|
value: {
|
||||||
@@ -180,7 +186,7 @@ describe('GenAIButton', () => {
|
|||||||
it('should retry when clicking', async () => {
|
it('should retry when clicking', async () => {
|
||||||
const onGenerate = jest.fn();
|
const onGenerate = jest.fn();
|
||||||
const messages = [{ content: 'Generate X', role: 'system' as Role }];
|
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');
|
const generateButton = getByText('Retry');
|
||||||
|
|
||||||
await fireEvent.click(generateButton);
|
await fireEvent.click(generateButton);
|
||||||
@@ -209,7 +215,7 @@ describe('GenAIButton', () => {
|
|||||||
const onGenerate = jest.fn();
|
const onGenerate = jest.fn();
|
||||||
const onClick = jest.fn();
|
const onClick = jest.fn();
|
||||||
const messages = [{ content: 'Generate X', role: 'system' as Role }];
|
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');
|
const generateButton = await screen.findByRole('button');
|
||||||
await fireEvent.click(generateButton);
|
await fireEvent.click(generateButton);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
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';
|
import { OPEN_AI_MODEL, Message } from './utils';
|
||||||
|
|
||||||
export interface GenAIButtonProps {
|
export interface GenAIButtonProps {
|
||||||
@@ -12,6 +14,7 @@ export interface GenAIButtonProps {
|
|||||||
text?: string;
|
text?: string;
|
||||||
// Button label text when loading
|
// Button label text when loading
|
||||||
loadingText?: string;
|
loadingText?: string;
|
||||||
|
toggleTipTitle?: string;
|
||||||
// Button click handler
|
// Button click handler
|
||||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
// Messages to send to the LLM plugin
|
// Messages to send to the LLM plugin
|
||||||
@@ -21,70 +24,160 @@ export interface GenAIButtonProps {
|
|||||||
// Temperature for the LLM plugin. Default is 1.
|
// Temperature for the LLM plugin. Default is 1.
|
||||||
// Closer to 0 means more conservative, closer to 1 means more creative.
|
// Closer to 0 means more conservative, closer to 1 means more creative.
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
// Event tracking source. Send as `src` to Rudderstack event
|
||||||
|
eventTrackingSrc: EventTrackingSrc;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GenAIButton = ({
|
export const GenAIButton = ({
|
||||||
text = 'Auto-generate',
|
text = 'Auto-generate',
|
||||||
loadingText = 'Generating',
|
loadingText = 'Generating',
|
||||||
|
toggleTipTitle = '',
|
||||||
onClick: onClickProp,
|
onClick: onClickProp,
|
||||||
messages,
|
messages,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
temperature = 1,
|
temperature = 1,
|
||||||
|
eventTrackingSrc,
|
||||||
}: GenAIButtonProps) => {
|
}: GenAIButtonProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
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(() => {
|
useEffect(() => {
|
||||||
// Todo: Consider other options for `"` sanitation
|
// Todo: Consider other options for `"` sanitation
|
||||||
if (isGenerating && reply) {
|
if (isFirstHistoryEntry && reply) {
|
||||||
onGenerate(reply.replace(/^"|"$/g, ''));
|
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
|
// The button is disabled if the plugin is not installed or enabled
|
||||||
if (!value?.enabled) {
|
if (!value?.enabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const onApplySuggestion = (suggestion: string) => {
|
||||||
onClickProp?.(e);
|
reportInteraction(AutoGenerateItem.applySuggestion);
|
||||||
setMessages(messages);
|
onGenerate(suggestion);
|
||||||
|
setShowHistory(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
if (error || !value?.enabled) {
|
if (isFirstHistoryEntry) {
|
||||||
return 'exclamation-circle';
|
|
||||||
}
|
|
||||||
if (isGenerating) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
if (error || (value && !value?.enabled)) {
|
||||||
|
return 'exclamation-circle';
|
||||||
|
}
|
||||||
return 'ai';
|
return 'ai';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getText = () => {
|
const getText = () => {
|
||||||
|
let buttonText = text;
|
||||||
|
|
||||||
if (error) {
|
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 (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
{isGenerating && <Spinner size={14} />}
|
{isFirstHistoryEntry && <Spinner size={14} />}
|
||||||
<Tooltip show={error ? undefined : false} interactive content={`OpenAI error: ${error?.message}`}>
|
{!hasHistory && (
|
||||||
<Button
|
<Tooltip show={error ? undefined : false} interactive content={`OpenAI error: ${error?.message}`}>
|
||||||
icon={getIcon()}
|
{button}
|
||||||
onClick={onClick}
|
</Tooltip>
|
||||||
fill="text"
|
)}
|
||||||
size="sm"
|
{hasHistory && renderButtonWithToggletip()}
|
||||||
disabled={isGenerating || (!value?.enabled && !error)}
|
|
||||||
variant={error ? 'destructive' : 'primary'}
|
|
||||||
>
|
|
||||||
{getText()}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react';
|
|||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
|
|
||||||
import { GenAIButton } from './GenAIButton';
|
import { GenAIButton } from './GenAIButton';
|
||||||
import { EventSource, reportGenerateAIButtonClicked } from './tracking';
|
import { EventTrackingSrc } from './tracking';
|
||||||
import { getDashboardPanelPrompt, Message, Role } from './utils';
|
import { getDashboardPanelPrompt, Message, Role } from './utils';
|
||||||
|
|
||||||
interface GenAIDashDescriptionButtonProps {
|
interface GenAIDashDescriptionButtonProps {
|
||||||
@@ -24,10 +24,15 @@ const DESCRIPTION_GENERATION_STANDARD_PROMPT =
|
|||||||
|
|
||||||
export const GenAIDashDescriptionButton = ({ onGenerate, dashboard }: GenAIDashDescriptionButtonProps) => {
|
export const GenAIDashDescriptionButton = ({ onGenerate, dashboard }: GenAIDashDescriptionButtonProps) => {
|
||||||
const messages = React.useMemo(() => getMessages(dashboard), [dashboard]);
|
const messages = React.useMemo(() => getMessages(dashboard), [dashboard]);
|
||||||
const onClick = React.useCallback(() => reportGenerateAIButtonClicked(EventSource.dashboardDescription), []);
|
|
||||||
|
|
||||||
return (
|
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 { DashboardModel } from '../../state';
|
||||||
|
|
||||||
import { GenAIButton } from './GenAIButton';
|
import { GenAIButton } from './GenAIButton';
|
||||||
import { EventSource, reportGenerateAIButtonClicked } from './tracking';
|
import { EventTrackingSrc } from './tracking';
|
||||||
import { getDashboardPanelPrompt, Message, Role } from './utils';
|
import { getDashboardPanelPrompt, Message, Role } from './utils';
|
||||||
|
|
||||||
interface GenAIDashTitleButtonProps {
|
interface GenAIDashTitleButtonProps {
|
||||||
@@ -24,9 +24,16 @@ const TITLE_GENERATION_STANDARD_PROMPT =
|
|||||||
|
|
||||||
export const GenAIDashTitleButton = ({ onGenerate, dashboard }: GenAIDashTitleButtonProps) => {
|
export const GenAIDashTitleButton = ({ onGenerate, dashboard }: GenAIDashTitleButtonProps) => {
|
||||||
const messages = React.useMemo(() => getMessages(dashboard), [dashboard]);
|
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[] {
|
function getMessages(dashboard: DashboardModel): Message[] {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useMemo } from 'react';
|
|||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
|
|
||||||
import { GenAIButton } from './GenAIButton';
|
import { GenAIButton } from './GenAIButton';
|
||||||
import { EventSource, reportGenerateAIButtonClicked } from './tracking';
|
import { EventTrackingSrc } from './tracking';
|
||||||
import { getDashboardChanges, Message, Role } from './utils';
|
import { getDashboardChanges, Message, Role } from './utils';
|
||||||
|
|
||||||
interface GenAIDashboardChangesButtonProps {
|
interface GenAIDashboardChangesButtonProps {
|
||||||
@@ -27,15 +27,15 @@ const CHANGES_GENERATION_STANDARD_PROMPT = [
|
|||||||
|
|
||||||
export const GenAIDashboardChangesButton = ({ dashboard, onGenerate }: GenAIDashboardChangesButtonProps) => {
|
export const GenAIDashboardChangesButton = ({ dashboard, onGenerate }: GenAIDashboardChangesButtonProps) => {
|
||||||
const messages = useMemo(() => getMessages(dashboard), [dashboard]);
|
const messages = useMemo(() => getMessages(dashboard), [dashboard]);
|
||||||
const onClick = React.useCallback(() => reportGenerateAIButtonClicked(EventSource.dashboardChanges), []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenAIButton
|
<GenAIButton
|
||||||
messages={messages}
|
messages={messages}
|
||||||
onGenerate={onGenerate}
|
onGenerate={onGenerate}
|
||||||
onClick={onClick}
|
|
||||||
loadingText={'Generating changes summary'}
|
loadingText={'Generating changes summary'}
|
||||||
temperature={0}
|
temperature={0}
|
||||||
|
eventTrackingSrc={EventTrackingSrc.dashboardChanges}
|
||||||
|
toggleTipTitle={'Improve your dashboard changes summary'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { PanelModel } from '../../state';
|
||||||
|
|
||||||
import { GenAIButton } from './GenAIButton';
|
import { GenAIButton } from './GenAIButton';
|
||||||
import { EventSource, reportGenerateAIButtonClicked } from './tracking';
|
import { EventTrackingSrc } from './tracking';
|
||||||
import { Message, Role } from './utils';
|
import { Message, Role } from './utils';
|
||||||
|
|
||||||
interface GenAIPanelDescriptionButtonProps {
|
interface GenAIPanelDescriptionButtonProps {
|
||||||
@@ -22,10 +22,15 @@ const DESCRIPTION_GENERATION_STANDARD_PROMPT =
|
|||||||
|
|
||||||
export const GenAIPanelDescriptionButton = ({ onGenerate, panel }: GenAIPanelDescriptionButtonProps) => {
|
export const GenAIPanelDescriptionButton = ({ onGenerate, panel }: GenAIPanelDescriptionButtonProps) => {
|
||||||
const messages = React.useMemo(() => getMessages(panel), [panel]);
|
const messages = React.useMemo(() => getMessages(panel), [panel]);
|
||||||
const onClick = React.useCallback(() => reportGenerateAIButtonClicked(EventSource.panelDescription), []);
|
|
||||||
|
|
||||||
return (
|
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 { PanelModel } from '../../state';
|
||||||
|
|
||||||
import { GenAIButton } from './GenAIButton';
|
import { GenAIButton } from './GenAIButton';
|
||||||
import { EventSource, reportGenerateAIButtonClicked } from './tracking';
|
import { EventTrackingSrc } from './tracking';
|
||||||
import { Message, Role } from './utils';
|
import { Message, QuickFeedbackType, Role } from './utils';
|
||||||
|
|
||||||
interface GenAIPanelTitleButtonProps {
|
interface GenAIPanelTitleButtonProps {
|
||||||
onGenerate: (title: string) => void;
|
onGenerate: (title: string) => void;
|
||||||
@@ -19,9 +19,16 @@ const TITLE_GENERATION_STANDARD_PROMPT =
|
|||||||
|
|
||||||
export const GenAIPanelTitleButton = ({ onGenerate, panel }: GenAIPanelTitleButtonProps) => {
|
export const GenAIPanelTitleButton = ({ onGenerate, panel }: GenAIPanelTitleButtonProps) => {
|
||||||
const messages = React.useMemo(() => getMessages(panel), [panel]);
|
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[] {
|
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.
|
// Ideally we will want to move the hook itself to a different scope later.
|
||||||
type Message = openai.Message;
|
type Message = openai.Message;
|
||||||
|
|
||||||
|
export enum StreamStatus {
|
||||||
|
IDLE = 'idle',
|
||||||
|
GENERATING = 'generating',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Add tests
|
// TODO: Add tests
|
||||||
export function useOpenAIStream(
|
export function useOpenAIStream(
|
||||||
model = OPEN_AI_MODEL,
|
model = OPEN_AI_MODEL,
|
||||||
@@ -19,7 +25,7 @@ export function useOpenAIStream(
|
|||||||
): {
|
): {
|
||||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||||
reply: string;
|
reply: string;
|
||||||
isGenerating: boolean;
|
streamStatus: StreamStatus;
|
||||||
error: Error | undefined;
|
error: Error | undefined;
|
||||||
value:
|
value:
|
||||||
| {
|
| {
|
||||||
@@ -36,7 +42,7 @@ export function useOpenAIStream(
|
|||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
// The latest reply from the LLM.
|
// The latest reply from the LLM.
|
||||||
const [reply, setReply] = useState('');
|
const [reply, setReply] = useState('');
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [streamStatus, setStreamStatus] = useState<StreamStatus>(StreamStatus.IDLE);
|
||||||
const [error, setError] = useState<Error>();
|
const [error, setError] = useState<Error>();
|
||||||
const { error: notifyError } = useAppNotification();
|
const { error: notifyError } = useAppNotification();
|
||||||
|
|
||||||
@@ -50,7 +56,7 @@ export function useOpenAIStream(
|
|||||||
return { enabled };
|
return { enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsGenerating(true);
|
setStreamStatus(StreamStatus.GENERATING);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
// Stream the completions. Each element is the next stream chunk.
|
// Stream the completions. Each element is the next stream chunk.
|
||||||
const stream = openai
|
const stream = openai
|
||||||
@@ -75,14 +81,17 @@ export function useOpenAIStream(
|
|||||||
stream: stream.subscribe({
|
stream: stream.subscribe({
|
||||||
next: setReply,
|
next: setReply,
|
||||||
error: (e: Error) => {
|
error: (e: Error) => {
|
||||||
setIsGenerating(false);
|
setStreamStatus(StreamStatus.IDLE);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setError(e);
|
setError(e);
|
||||||
notifyError('OpenAI Error', `${e.message}`);
|
notifyError('OpenAI Error', `${e.message}`);
|
||||||
logError(e, { messages: JSON.stringify(messages), model, temperature: String(temperature) });
|
logError(e, { messages: JSON.stringify(messages), model, temperature: String(temperature) });
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
setIsGenerating(false);
|
setStreamStatus(StreamStatus.COMPLETED);
|
||||||
|
setTimeout(() => {
|
||||||
|
setStreamStatus(StreamStatus.IDLE);
|
||||||
|
});
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
},
|
},
|
||||||
@@ -97,7 +106,7 @@ export function useOpenAIStream(
|
|||||||
return {
|
return {
|
||||||
setMessages,
|
setMessages,
|
||||||
reply,
|
reply,
|
||||||
isGenerating,
|
streamStatus,
|
||||||
error,
|
error,
|
||||||
value,
|
value,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import { reportInteraction } from '@grafana/runtime';
|
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',
|
panelDescription = 'panel-description',
|
||||||
panelTitle = 'panel-title',
|
panelTitle = 'panel-title',
|
||||||
dashboardChanges = 'dashboard-changes',
|
dashboardChanges = 'dashboard-changes',
|
||||||
dashboardTitle = 'dashboard-title',
|
dashboardTitle = 'dashboard-title',
|
||||||
dashboardDescription = 'dashboard-description',
|
dashboardDescription = 'dashboard-description',
|
||||||
|
unknown = 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reportGenerateAIButtonClicked(src: EventSource) {
|
// Item of the interaction for the improve button and history poppover
|
||||||
reportInteraction('dashboards_autogenerate_clicked', { src });
|
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 type Message = openai.Message;
|
||||||
|
|
||||||
|
export enum QuickFeedbackType {
|
||||||
|
Shorter = 'Even shorter',
|
||||||
|
MoreDescriptive = 'More descriptive',
|
||||||
|
Regenerate = 'Regenerate',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The OpenAI model to be used.
|
* The OpenAI model to be used.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user