From 0e11d5ee145e0e6ec4f0d996bb92cf2a98a3a9bf Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Fri, 23 Aug 2024 20:50:01 +0200 Subject: [PATCH 1/6] fix(Ai): Fix "Explain Flame Graph" feature --- .../components/SceneAiPanel/SceneAiPanel.tsx | 143 ++++++++++++++++ .../components/AiButton/AIButton.tsx | 53 ++++++ .../infrastructure/useFetchLlmPluginStatus.ts | 16 ++ .../SceneAiPanel/components/AiReply.tsx | 113 +++++++++++++ .../SceneAiPanel/components/FollowUpForm.tsx | 70 ++++++++ .../SceneAiPanel/domain/buildLlmPrompts.ts | 119 ++++++++++++++ .../domain/useOpenAiChatCompletions.ts | 153 ++++++++++++++++++ .../infrastructure/ProfileApiClient.ts | 38 +++++ .../infrastructure/cleanupDotResponse.ts | 12 ++ .../infrastructure/useFetchDotProfiles.ts | 40 +++++ .../SceneDiffFlameGraph.tsx | 84 +++++----- .../infrastructure/useFetchDiffProfile.ts | 2 +- .../SceneFlameGraph.tsx | 59 ++++--- .../FiltersVariable/FiltersVariable.tsx | 12 +- 14 files changed, 846 insertions(+), 68 deletions(-) create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/SceneAiPanel.tsx create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiButton/AIButton.tsx create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiButton/infrastructure/useFetchLlmPluginStatus.ts create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiReply.tsx create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/components/FollowUpForm.tsx create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/domain/buildLlmPrompts.ts create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/domain/useOpenAiChatCompletions.ts create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/infrastructure/ProfileApiClient.ts create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/infrastructure/cleanupDotResponse.ts create mode 100644 src/pages/ProfilesExplorerView/components/SceneAiPanel/infrastructure/useFetchDotProfiles.ts diff --git a/src/pages/ProfilesExplorerView/components/SceneAiPanel/SceneAiPanel.tsx b/src/pages/ProfilesExplorerView/components/SceneAiPanel/SceneAiPanel.tsx new file mode 100644 index 00000000..23ae6ff8 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneAiPanel/SceneAiPanel.tsx @@ -0,0 +1,143 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { Alert, Button, IconButton, Spinner, useStyles2 } from '@grafana/ui'; +import { getProfileMetric, ProfileMetricId } from '@shared/infrastructure/profile-metrics/getProfileMetric'; +import { DomainHookReturnValue } from '@shared/types/DomainHookReturnValue'; +import { InlineBanner } from '@shared/ui/InlineBanner'; +import { Panel } from '@shared/ui/Panel/Panel'; +import React from 'react'; + +import { ProfilesDataSourceVariable } from '../../domain/variables/ProfilesDataSourceVariable'; +import { getSceneVariableValue } from '../../helpers/getSceneVariableValue'; +import { AiReply } from './components/AiReply'; +import { FollowUpForm } from './components/FollowUpForm'; +import { useOpenAiChatCompletions } from './domain/useOpenAiChatCompletions'; +import { FetchParams, useFetchDotProfiles } from './infrastructure/useFetchDotProfiles'; + +interface SceneAiPanelState extends SceneObjectState { + isDiff: boolean; +} + +export class SceneAiPanel extends SceneObjectBase { + constructor({ isDiff }: { isDiff: SceneAiPanelState['isDiff'] }) { + super({ + key: 'ai-panel', + isDiff, + }); + } + + useSceneAiPanel = (params: FetchParams): DomainHookReturnValue => { + const dataSourceUid = sceneGraph.findByKeyAndType(this, 'dataSource', ProfilesDataSourceVariable).useState() + .value as string; + + const { error: fetchError, isFetching, profiles } = useFetchDotProfiles(dataSourceUid, params); + + const profileMetricId = getSceneVariableValue(this, 'profileMetricId'); + const profileType = getProfileMetric(profileMetricId as ProfileMetricId).type; + + const { reply, error: llmError, retry } = useOpenAiChatCompletions(profileType, profiles); + + return { + data: { + isLoading: isFetching || (!isFetching && !fetchError && !llmError && !reply.text.trim()), + fetchError, + llmError, + reply, + shouldDisplayReply: Boolean(reply?.hasStarted || reply?.hasFinished), + shouldDisplayFollowUpForm: !fetchError && !llmError && Boolean(reply?.hasFinished), + }, + actions: { + retry, + submitFollowupQuestion(question: string) { + reply.askFollowupQuestion(question); + }, + }, + }; + }; + + static Component = ({ + model, + params, + onClose, + }: SceneComponentProps & { + params: FetchParams; + onClose: () => void; + }) => { + const styles = useStyles2(getStyles); + const { data, actions } = model.useSceneAiPanel(params); + + return ( + + } + dataTestId="ai-panel" + > +
+ {data.fetchError && ( + + )} + + {data.shouldDisplayReply && } + + {data.isLoading && ( + <> + +  Analyzing... + + )} + + {data.llmError && ( + +
+
+

{data.llmError.message}

+

+ Sorry for any inconvenience, please retry or if the problem persists, contact your organization + admin. +

+
+
+ +
+ )} + + {data.shouldDisplayFollowUpForm && } +
+
+ ); + }; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + sidePanel: css` + flex: 1 0 50%; + margin-left: 8px; + max-width: calc(50% - 4px); + `, + title: css` + margin: -4px 0 4px 0; + `, + content: css` + padding: ${theme.spacing(1)}; + `, + retryButton: css` + float: right; + `, +}); diff --git a/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiButton/AIButton.tsx b/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiButton/AIButton.tsx new file mode 100644 index 00000000..49d129e0 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiButton/AIButton.tsx @@ -0,0 +1,53 @@ +import { css } from '@emotion/css'; +import { IconName } from '@grafana/data'; +import { Button, useStyles2 } from '@grafana/ui'; +import { reportInteraction } from '@shared/domain/reportInteraction'; +import React, { ReactNode } from 'react'; + +import { useFetchLlmPluginStatus } from './infrastructure/useFetchLlmPluginStatus'; + +type AIButtonProps = { + children: ReactNode; + onClick: (event: React.MouseEvent) => void; + disabled?: boolean; + interactionName: string; +}; + +export function AIButton({ children, onClick, disabled, interactionName }: AIButtonProps) { + const styles = useStyles2(getStyles); + const { isEnabled, error, isFetching } = useFetchLlmPluginStatus(); + + let icon: IconName = 'ai'; + let title = ''; + + if (error) { + icon = 'shield-exclamation'; + title = 'Grafana LLM plugin missing or not configured!'; + } else if (isFetching) { + icon = 'fa fa-spinner'; + title = 'Checking the status of the Grafana LLM plugin...'; + } + + return ( + + ); +} + +const getStyles = () => ({ + aiButton: css` + padding: 0 4px; + `, +}); diff --git a/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiButton/infrastructure/useFetchLlmPluginStatus.ts b/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiButton/infrastructure/useFetchLlmPluginStatus.ts new file mode 100644 index 00000000..53d94fc4 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiButton/infrastructure/useFetchLlmPluginStatus.ts @@ -0,0 +1,16 @@ +import { llms } from '@grafana/experimental'; +import { useQuery } from '@tanstack/react-query'; + +export function useFetchLlmPluginStatus() { + const { data, isFetching, error } = useQuery({ + queryKey: ['llm'], + queryFn: () => llms.openai.enabled(), + }); + + if (error) { + console.error('Error while checking the status of the Grafana LLM plugin!'); + console.error(error); + } + + return { isEnabled: Boolean(data), isFetching, error }; +} diff --git a/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiReply.tsx b/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiReply.tsx new file mode 100644 index 00000000..433791b3 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/AiReply.tsx @@ -0,0 +1,113 @@ +import { css } from '@emotion/css'; +import { useStyles2 } from '@grafana/ui'; +import Markdown from 'markdown-to-jsx'; +import React, { ReactNode } from 'react'; + +import { OpenAiReply } from '../domain/useOpenAiChatCompletions'; + +// yeah, I know... +const setNativeValue = (element: Element, value: string) => { + const valueSetter = Object!.getOwnPropertyDescriptor(element, 'value')!.set; + const prototypeValueSetter = Object!.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value')!.set; + + if (valueSetter && valueSetter !== prototypeValueSetter) { + prototypeValueSetter!.call(element, value); + } else { + valueSetter!.call(element, value); + } +}; + +const onClickSearchTerm = (event: any) => { + const searchInputElement = document.querySelector('[placeholder^="Search"]'); + + if (searchInputElement === null) { + console.error('Cannot find search input element!'); + return; + } + + const value = event.target.textContent.trim(); + + setNativeValue(searchInputElement, value); + + searchInputElement.dispatchEvent(new Event('input', { bubbles: true })); +}; + +const SearchTerm = ({ children }: { children: ReactNode }) => { + const styles = useStyles2(getStyles); + + // If the code block contains newlines, don't make it a search link + if (typeof children === 'string' && children.includes('\n')) { + return {children}; + } + + return ( + + {children} + + ); +}; + +const MARKDOWN_OPTIONS = { + overrides: { + code: { + component: SearchTerm, + }, + }, +}; + +type AiReplyProps = { + reply: OpenAiReply['reply']; +}; + +export function AiReply({ reply }: AiReplyProps) { + const styles = useStyles2(getStyles); + + return ( +
+ {reply?.messages + ?.filter((message) => message.role !== 'system') + .map((message) => ( + <> +
+ {message.content} +
+
+ + ))} + +
+ {reply.text} +
+
+ ); +} + +const getStyles = () => ({ + container: css` + width: 100%; + height: 100%; + `, + reply: css` + font-size: 13px; + + & ol, + & ul { + margin: 0 0 16px 24px; + } + `, + searchLink: css` + color: rgb(255, 136, 51); + border: 1px solid transparent; + padding: 2px 4px; + cursor: pointer; + font-size: 13px; + + &:hover, + &:focus, + &:active { + box-sizing: border-box; + border: 1px solid rgb(255, 136, 51, 0.8); + border-radius: 4px; + } + `, +}); diff --git a/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/FollowUpForm.tsx b/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/FollowUpForm.tsx new file mode 100644 index 00000000..1e81fb92 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneAiPanel/components/FollowUpForm.tsx @@ -0,0 +1,70 @@ +import { css } from '@emotion/css'; +import { Button, TextArea, useStyles2 } from '@grafana/ui'; +import React, { KeyboardEvent, useCallback, useState } from 'react'; + +const getStyles = () => ({ + textarea: css` + margin-bottom: 8px; + `, + sendButton: css` + float: right; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + `, +}); + +type FollowUpFormProps = { + onSubmit: (question: string) => void; +}; + +function useFollowUpForm(onSubmit: FollowUpFormProps['onSubmit']) { + const [question, setQuestion] = useState(''); + + const onChangeInput = useCallback((event: any) => { + setQuestion(event.target.value); + }, []); + + const onClickSend = useCallback(() => { + const questionToSend = question.trim(); + if (!questionToSend) { + return; + } + + onSubmit(questionToSend); + + setQuestion(''); + }, [question, onSubmit]); + + return { + question, + onChangeInput, + onClickSend, + }; +} + +export function FollowUpForm({ onSubmit }: FollowUpFormProps) { + const styles = useStyles2(getStyles); + const { question, onChangeInput, onClickSend } = useFollowUpForm(onSubmit); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Enter' && !event.shiftKey) { + onClickSend(); + } + }; + + return ( +
+