diff --git a/public/app/features/dashboard/components/GenAI/GenAIButton.tsx b/public/app/features/dashboard/components/GenAI/GenAIButton.tsx index ec51fc91275..8adda16af3a 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIButton.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIButton.tsx @@ -4,10 +4,10 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { llm } from '@grafana/llm'; -import { Button, Spinner, useStyles2, Tooltip, Toggletip, Text } from '@grafana/ui'; +import { Button, Spinner, useStyles2, Tooltip, Toggletip, Text, Stack } from '@grafana/ui'; import { GenAIHistory } from './GenAIHistory'; -import { StreamStatus, useLLMStream } from './hooks'; +import { StreamStatus, TIMEOUT, useLLMStream } from './hooks'; import { AutoGenerateItem, EventTrackingSrc, reportAutoGenerateInteraction } from './tracking'; import { DEFAULT_LLM_MODEL, Message, sanitizeReply } from './utils'; @@ -36,6 +36,10 @@ export interface GenAIButtonProps { toggletip will be enabled. */ tooltip?: string; + // Optional callback to receive history updates + onHistoryChange?: (history: string[]) => void; + // Optional timeout for the LLM stream. Default is 10 seconds + timeout?: number; } export const STOP_GENERATION_TEXT = 'Stop generating'; @@ -50,13 +54,22 @@ export const GenAIButton = ({ eventTrackingSrc, disabled, tooltip, + onHistoryChange, + timeout = TIMEOUT, }: GenAIButtonProps) => { const styles = useStyles2(getStyles); const [history, setHistory] = useState([]); - const unshiftHistoryEntry = useCallback((historyEntry: string) => { - setHistory((h) => [historyEntry, ...h]); - }, []); + const unshiftHistoryEntry = useCallback( + (historyEntry: string) => { + setHistory((h) => { + const newHistory = [historyEntry, ...h]; + return newHistory; + }); + onHistoryChange?.([historyEntry, ...history]); + }, + [onHistoryChange, history] + ); const onResponse = useCallback( (reply: string) => { @@ -71,6 +84,7 @@ export const GenAIButton = ({ model, temperature, onResponse, + timeout, }); const [showHistory, setShowHistory] = useState(false); @@ -181,6 +195,7 @@ export const GenAIButton = ({ onApplySuggestion={onApplySuggestion} updateHistory={unshiftHistoryEntry} eventTrackingSrc={eventTrackingSrc} + timeout={timeout} /> } placement="left-start" @@ -198,7 +213,7 @@ export const GenAIButton = ({ }; return ( -
+ {isGenerating && } {isFirstHistoryEntry ? ( @@ -207,14 +222,11 @@ export const GenAIButton = ({ ) : ( renderButtonWithToggletip() )} -
+ ); }; const getStyles = (theme: GrafanaTheme2) => ({ - wrapper: css({ - display: 'flex', - }), spinner: css({ color: theme.colors.text.link, }), diff --git a/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx b/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx index ccac627b42c..e908fe5b611 100644 --- a/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx +++ b/public/app/features/dashboard/components/GenAI/GenAIHistory.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; @@ -18,6 +18,7 @@ export interface GenAIHistoryProps { onApplySuggestion: (suggestion: string) => void; updateHistory: (historyEntry: string) => void; eventTrackingSrc: EventTrackingSrc; + timeout?: number; } const temperature = 0.5; @@ -28,12 +29,17 @@ export const GenAIHistory = ({ messages, onApplySuggestion, updateHistory, + timeout, }: GenAIHistoryProps) => { const styles = useStyles2(getStyles); const [currentIndex, setCurrentIndex] = useState(1); const [customFeedback, setCustomPrompt] = useState(''); + // Keep ref in sync with messages prop to avoid stale closure issues + const messagesRef = useRef(messages); + messagesRef.current = messages; + const onResponse = useCallback( (response: string) => { updateHistory(sanitizeReply(response)); @@ -45,6 +51,7 @@ export const GenAIHistory = ({ model: DEFAULT_LLM_MODEL, temperature, onResponse, + timeout, }); const reportInteraction = (item: AutoGenerateItem, otherMetadata?: object) => @@ -70,7 +77,7 @@ export const GenAIHistory = ({ }; const onGenerateWithFeedback = (suggestion: string) => { - setMessages((messages) => [...messages, ...getFeedbackMessage(history[currentIndex - 1], suggestion)]); + setMessages(() => [...messagesRef.current, ...getFeedbackMessage(history[currentIndex - 1], suggestion)]); if (suggestion in QuickFeedbackType) { reportInteraction(AutoGenerateItem.quickFeedback, { quickFeedbackItem: suggestion }); @@ -104,7 +111,6 @@ export const GenAIHistory = ({ )} -
diff --git a/public/app/features/dashboard/components/GenAI/hooks.ts b/public/app/features/dashboard/components/GenAI/hooks.ts index 5809245bf1c..0255336da4a 100644 --- a/public/app/features/dashboard/components/GenAI/hooks.ts +++ b/public/app/features/dashboard/components/GenAI/hooks.ts @@ -20,17 +20,19 @@ export enum StreamStatus { COMPLETED = 'completed', } -export const TIMEOUT = 10000; +export const TIMEOUT = 10000; // 10 seconds interface Options { model: string; temperature: number; onResponse?: (response: string) => void; + timeout?: number; } const defaultOptions = { model: DEFAULT_LLM_MODEL, temperature: 1, + timeout: TIMEOUT, }; interface UseLLMStreamResponse { @@ -47,7 +49,8 @@ interface UseLLMStreamResponse { } // TODO: Add tests -export function useLLMStream({ model, temperature, onResponse }: Options = defaultOptions): UseLLMStreamResponse { +export function useLLMStream(options: Options = defaultOptions): UseLLMStreamResponse { + const { model, temperature, onResponse, timeout } = { ...defaultOptions, ...options }; // The messages array to send to the LLM, updated when the button is clicked. const [messages, setMessages] = useState([]); @@ -145,17 +148,17 @@ export function useLLMStream({ model, temperature, onResponse }: Options = defau // If the stream is generating and we haven't received a reply, it times out. useEffect(() => { - let timeout: NodeJS.Timeout | undefined; + let timeoutId: NodeJS.Timeout | undefined; if (streamStatus === StreamStatus.GENERATING && reply === '') { - timeout = setTimeout(() => { - onError(new Error(`LLM stream timed out after ${TIMEOUT}ms`)); - }, TIMEOUT); + timeoutId = setTimeout(() => { + onError(new Error(`LLM stream timed out after ${timeout}ms`)); + }, timeout); } return () => { - clearTimeout(timeout); + clearTimeout(timeoutId); }; - }, [streamStatus, reply, onError]); + }, [streamStatus, reply, onError, timeout]); if (asyncError || enabledError) { setError(asyncError || enabledError); diff --git a/public/app/features/dashboard/components/GenAI/tracking.ts b/public/app/features/dashboard/components/GenAI/tracking.ts index 8327fdf430e..f3b0b25bfeb 100644 --- a/public/app/features/dashboard/components/GenAI/tracking.ts +++ b/public/app/features/dashboard/components/GenAI/tracking.ts @@ -9,6 +9,7 @@ export enum EventTrackingSrc { dashboardChanges = 'dashboard-changes', dashboardTitle = 'dashboard-title', dashboardDescription = 'dashboard-description', + sqlExpressions = 'sql-expressions', unknown = 'unknown', } diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index e49d3932c48..de0d59c2920 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -15,7 +15,7 @@ import { Threshold } from './components/Threshold'; import { ExpressionQuery, ExpressionQueryType, expressionTypes } from './types'; import { getDefaults } from './utils/expressionTypes'; -type Props = QueryEditorProps, ExpressionQuery>; +export type ExpressionQueryEditorProps = QueryEditorProps, ExpressionQuery>; const labelWidth = 15; @@ -78,7 +78,7 @@ function useExpressionsCache() { return { getCachedExpression, setCachedExpression }; } -export function ExpressionQueryEditor(props: Props) { +export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) { const { query, queries, onRunQuery, onChange, app } = props; const { getCachedExpression, setCachedExpression } = useExpressionsCache(); @@ -118,7 +118,7 @@ export function ExpressionQueryEditor(props: Props) { return ; case ExpressionQueryType.sql: - return ; + return ; } }; diff --git a/public/app/features/expressions/components/GenAI/GenAIExplanationDrawer.tsx b/public/app/features/expressions/components/GenAI/GenAIExplanationDrawer.tsx new file mode 100644 index 00000000000..c42bf3d2ad7 --- /dev/null +++ b/public/app/features/expressions/components/GenAI/GenAIExplanationDrawer.tsx @@ -0,0 +1,29 @@ +import { renderMarkdown } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { Drawer, Stack, Card } from '@grafana/ui'; + +interface AIExplanationDrawerProps { + isOpen: boolean; + onClose: () => void; + explanation: string; +} + +export const GenAIExplanationDrawer = ({ isOpen, onClose, explanation }: AIExplanationDrawerProps) => { + if (!isOpen) { + return null; + } + + return ( + SQL Query Explanation} + > + + +
+ + + + ); +}; diff --git a/public/app/features/expressions/components/GenAI/GenAISQLExplainButton.tsx b/public/app/features/expressions/components/GenAI/GenAISQLExplainButton.tsx new file mode 100644 index 00000000000..86c5953025a --- /dev/null +++ b/public/app/features/expressions/components/GenAI/GenAISQLExplainButton.tsx @@ -0,0 +1,100 @@ +import { useCallback } from 'react'; + +import { t } from '@grafana/i18n'; + +import { GenAIButton } from '../../../dashboard/components/GenAI/GenAIButton'; +import { EventTrackingSrc } from '../../../dashboard/components/GenAI/tracking'; +import { Message, Role } from '../../../dashboard/components/GenAI/utils'; + +import { getSQLExplanationSystemPrompt, QueryUsageContext } from './sqlPromptConfig'; + +interface GenAISQLExplainButtonProps { + currentQuery: string; + onExplain: (explanation: string) => void; + refIds: string[]; + schemas?: unknown; // Reserved for future schema implementation + queryContext?: QueryUsageContext; +} + +// AI prompt for explaining SQL expressions + +const getExplanationPrompt = (currentQuery: string): string => { + if (!currentQuery || currentQuery.trim() === '') { + return 'There is no SQL query to explain. Please enter a SQL expression first.'; + } + + return `${currentQuery} + +Explain what this query does in simple terms.`; +}; + +/** + * Creates messages for the LLM to explain SQL queries + * + * @param refIds - The list of RefIDs available in the current context + * @param currentQuery - The current SQL query to explain + * @param schemas - Optional schema information (planned for future implementation) + * @param queryContext - Optional query usage context + * @returns A list of messages to be sent to the LLM for explaining the SQL query + */ +const getSQLExplanationMessages = ( + refIds: string[], + currentQuery: string, + schemas?: unknown, + queryContext?: QueryUsageContext +): Message[] => { + const systemPrompt = getSQLExplanationSystemPrompt({ + refIds: refIds.length > 0 ? refIds.join(', ') : 'A', + currentQuery: currentQuery.trim() || 'No current query provided', + schemas, // Will be utilized once schema extraction is implemented + queryContext, + }); + + const userPrompt = getExplanationPrompt(currentQuery); + + return [ + { + role: Role.system, + content: systemPrompt, + }, + { + role: Role.user, + content: userPrompt, + }, + ]; +}; + +export const GenAISQLExplainButton = ({ + currentQuery, + onExplain, + queryContext, + refIds, + schemas, // Future implementation will use this for enhanced context +}: GenAISQLExplainButtonProps) => { + const messages = useCallback(() => { + return getSQLExplanationMessages(refIds, currentQuery, schemas, queryContext); + }, [refIds, currentQuery, schemas, queryContext]); + + const hasQuery = currentQuery && currentQuery.trim() !== ''; + + return ( + + ); +}; diff --git a/public/app/features/expressions/components/GenAI/GenAISQLSuggestionsButton.tsx b/public/app/features/expressions/components/GenAI/GenAISQLSuggestionsButton.tsx new file mode 100644 index 00000000000..3b386879354 --- /dev/null +++ b/public/app/features/expressions/components/GenAI/GenAISQLSuggestionsButton.tsx @@ -0,0 +1,122 @@ +import { useCallback } from 'react'; + +import { t } from '@grafana/i18n'; + +import { GenAIButton } from '../../../dashboard/components/GenAI/GenAIButton'; +import { EventTrackingSrc } from '../../../dashboard/components/GenAI/tracking'; +import { Message, Role } from '../../../dashboard/components/GenAI/utils'; + +import { getSQLSuggestionSystemPrompt, QueryUsageContext } from './sqlPromptConfig'; + +interface GenAISQLSuggestionsButtonProps { + currentQuery: string; + onGenerate: (suggestion: string) => void; + onHistoryUpdate?: (history: string[]) => void; + refIds: string[]; + initialQuery: string; + schemas?: unknown; // Reserved for future schema implementation + errorContext?: string[]; + queryContext?: QueryUsageContext; +} + +// AI prompts for different SQL use cases + +const getContextualPrompts = (refIds: string[], currentQuery: string): string[] => { + const trimmedQuery = currentQuery.trim(); + + // If there's a current query, focus more on fixing/improving it + if (trimmedQuery) { + return [`Improve, fix syntax errors, or optimize this SQL query: ${trimmedQuery}`]; + } + + // If no current query, focus on suggestions + return [ + `Join, aggregate, filter, calculate percentiles, create time-based + window functions, or generally just make common SQL pattern queries for data from ${refIds.join(', ')}`, + ]; +}; + +/** + * Creates messages for the LLM to generate SQL suggestions + * + * @param refIds - The list of RefIDs available in the current context + * @param currentQuery - The current SQL query being edited + * @param schemas - Optional schema information (planned for future implementation) + * @param errorContext - Optional error context for targeted fixes (planned for future implementation) + * @param queryContext - Optional query usage context + * @returns A list of messages to be sent to the LLM for generating SQL suggestions + */ +const getSQLSuggestionMessages = ( + refIds: string[], + currentQuery: string, + schemas?: unknown, + errorContext?: string[], + queryContext?: QueryUsageContext +): Message[] => { + const trimmedQuery = currentQuery.trim(); + const queryInstruction = trimmedQuery + ? 'Focus on fixing, improving, or enhancing the current query provided above.' + : 'Generate a new SQL query based on the available RefIDs and common use cases.'; + + const systemPrompt = getSQLSuggestionSystemPrompt({ + refIds: refIds.length > 0 ? refIds.join(', ') : 'A', + currentQuery: trimmedQuery || 'No current query provided', + queryInstruction: queryInstruction, + schemas, // Will be utilized once schema extraction is implemented + errorContext, + queryContext, + }); + + const contextualPrompts = getContextualPrompts(refIds, currentQuery); + const selectedPrompt = contextualPrompts[Math.floor(Math.random() * contextualPrompts.length)]; + + return [ + { + role: Role.system, + content: systemPrompt, + }, + { + role: Role.user, + content: selectedPrompt, + }, + ]; +}; + +export const GenAISQLSuggestionsButton = ({ + currentQuery, + onGenerate, + onHistoryUpdate, + refIds, + initialQuery, + schemas, // Future implementation will use this for enhanced context + errorContext, + queryContext, +}: GenAISQLSuggestionsButtonProps) => { + const messages = useCallback(() => { + return getSQLSuggestionMessages(refIds, currentQuery, schemas, errorContext, queryContext); + }, [refIds, currentQuery, schemas, errorContext, queryContext]); + + const text = !currentQuery || currentQuery === initialQuery ? 'Generate suggestion' : 'Improve query'; + + return ( + + ); +}; diff --git a/public/app/features/expressions/components/GenAI/GenAISuggestionsDrawer.tsx b/public/app/features/expressions/components/GenAI/GenAISuggestionsDrawer.tsx new file mode 100644 index 00000000000..f552febc335 --- /dev/null +++ b/public/app/features/expressions/components/GenAI/GenAISuggestionsDrawer.tsx @@ -0,0 +1,237 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { CodeEditor, Drawer, useStyles2, Stack, Button, Card, Text, ClipboardButton } from '@grafana/ui'; + +import { parseSuggestion } from './utils'; + +interface AISuggestionsDrawerProps { + isOpen: boolean; + onApplySuggestion: (suggestion: string) => void; + onClose: () => void; + suggestions: string[]; +} + +export const GenAISuggestionsDrawer = ({ + isOpen, + onApplySuggestion, + onClose, + suggestions, +}: AISuggestionsDrawerProps) => { + const styles = useStyles2(getStyles); + + if (!isOpen) { + return null; + } + + return ( + SQL Suggestion History} + > +
+ +
+ {/* Vertical timeline line */} +
+ +
+ {suggestions.map((suggestion, index) => { + const parsedSuggestion = parseSuggestion(suggestion); + const isLatest = index === 0; + + return ( +
+ {/* Timeline node */} +
+ +
+ {parsedSuggestion.map(({ type, content, language }, partIndex) => ( +
+ {type === 'code' ? ( +
+
+ + + + {'{{ language }}'} + + + + content} + > + Copy + + + + +
+ +
+ ) : ( +
+ )} +
+ ))} +
+ +
+ ); + })} +
+
+ +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + content: css({ + height: '100%', + display: 'flex', + flexDirection: 'column', + }), + emptyState: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + textAlign: 'center', + gap: theme.spacing(2), + }), + timelineContainer: css({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + flex: 1, + paddingLeft: theme.spacing(4.5), // Space for timeline line and nodes + }), + timelineLine: css({ + position: 'absolute', + // Offset the 2px width of the timeline line + left: `calc(${theme.spacing(1)} + 2px)`, + top: theme.spacing(1), + bottom: 0, + width: '2px', + backgroundColor: theme.colors.border.strong, + zIndex: 1, + }), + suggestionsList: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + position: 'relative', + }), + timelineItem: css({ + position: 'relative', + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(2), + }), + timelineNode: css({ + position: 'absolute', + left: theme.spacing(-4.5), // Position on the timeline line + top: theme.spacing(1), // Align with card content + width: theme.spacing(3), + height: theme.spacing(3), + borderRadius: theme.shape.radius.pill, + border: `2px solid ${theme.colors.primary.main}`, + backgroundColor: theme.colors.background.primary, + zIndex: 2, + flexShrink: 0, + }), + timelineNodeActive: css({ + backgroundColor: theme.colors.primary.main, // Filled circle for current/latest + boxShadow: `0 0 0 4px ${theme.colors.background.primary}`, // White ring around filled circle + }), + timelineNodeInactive: css({ + backgroundColor: theme.colors.background.primary, // Empty circle for others + boxShadow: `0 0 0 4px ${theme.colors.background.primary}`, // White ring around filled circle + }), + latestSuggestion: css({ + border: `2px solid ${theme.colors.primary.main}`, + position: 'relative', + '&::before': { + content: '"Latest"', + position: 'absolute', + top: theme.spacing(-0.5), + right: theme.spacing(1), + backgroundColor: theme.colors.primary.main, + color: theme.colors.primary.contrastText, + padding: theme.spacing(0.25, 1), + borderRadius: theme.shape.radius.default, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + }, + }), + suggestionContent: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), + width: '100%', + overflowX: 'auto', + }), + suggestionPart: css({ + display: 'block', + width: '100%', + }), + codeBlock: css({ + border: `1px solid ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.default, + overflow: 'hidden', + marginBottom: theme.spacing(1), + width: '100%', + minWidth: '600px', + }), + codeHeader: css({ + backgroundColor: theme.colors.background.secondary, + padding: theme.spacing(1, 1.5), + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), +}); diff --git a/public/app/features/expressions/components/GenAI/SuggestionsDrawerButton.tsx b/public/app/features/expressions/components/GenAI/SuggestionsDrawerButton.tsx new file mode 100644 index 00000000000..49abd0a20d4 --- /dev/null +++ b/public/app/features/expressions/components/GenAI/SuggestionsDrawerButton.tsx @@ -0,0 +1,40 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { Button, Stack, Text, useStyles2 } from '@grafana/ui'; + +interface SuggestionsBadgeProps { + suggestions: string[]; + handleOpenDrawer: () => void; +} + +export const SuggestionsDrawerButton = ({ suggestions, handleOpenDrawer }: SuggestionsBadgeProps) => { + const styles = useStyles2(getStyles); + + return ( +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + countBadge: css({ + color: theme.colors.primary.text, + fontWeight: 'bold', + }), + buttonWrapper: css({ + position: 'relative', + display: 'inline-block', + }), +}); diff --git a/public/app/features/expressions/components/GenAI/hooks/useSQLExplanations.ts b/public/app/features/expressions/components/GenAI/hooks/useSQLExplanations.ts new file mode 100644 index 00000000000..4435fbc1909 --- /dev/null +++ b/public/app/features/expressions/components/GenAI/hooks/useSQLExplanations.ts @@ -0,0 +1,60 @@ +import { useState, useMemo } from 'react'; + +export const useSQLExplanations = (currentExpression: string) => { + const [explanation, setExplanation] = useState(''); + const [isExplanationOpen, setIsExplanationOpen] = useState(false); + const [prevExpression, setPrevExpression] = useState(currentExpression); + + /** + * Handle new explanation from the AI system + * Sets the explanation and opens the modal + */ + const handleExplain = (newExplanation: string) => { + setExplanation(newExplanation); + setIsExplanationOpen(true); + }; + + const handleOpenExplanation = () => setIsExplanationOpen(true); + + const handleCloseExplanation = () => setIsExplanationOpen(false); + + /** + * Update the previous expression tracker + * Should be called when the query expression changes + */ + const updatePrevExpression = (newExpression: string) => { + setPrevExpression(newExpression); + // Clear explanation when expression changes + setExplanation(''); + }; + + const clearExplanation = () => { + setExplanation(''); + setIsExplanationOpen(false); + }; + + /** + * Determine if we should show "View explanation" button vs "Explain query" button + * Returns true if there's an existing explanation OR if the expression has changed + */ + const shouldShowViewExplanation = useMemo(() => { + return Boolean(explanation) || prevExpression !== currentExpression; + }, [explanation, prevExpression, currentExpression]); + + return { + // State + explanation, + isExplanationOpen, + prevExpression, + + // Computed state + shouldShowViewExplanation, + + // Actions + handleExplain, + handleOpenExplanation, + handleCloseExplanation, + updatePrevExpression, + clearExplanation, + }; +}; diff --git a/public/app/features/expressions/components/GenAI/hooks/useSQLSuggestions.ts b/public/app/features/expressions/components/GenAI/hooks/useSQLSuggestions.ts new file mode 100644 index 00000000000..4b0eedfdf1a --- /dev/null +++ b/public/app/features/expressions/components/GenAI/hooks/useSQLSuggestions.ts @@ -0,0 +1,55 @@ +import { useState, useRef } from 'react'; + +export const useSQLSuggestions = () => { + const [suggestions, setSuggestions] = useState([]); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const prevSuggestionsLengthRef = useRef(0); + + /** + * Handle new suggestions from the AI system + * Updates the suggestions list and manages unseen suggestions state + */ + const handleHistoryUpdate = (history: string[]) => { + setSuggestions(history); + + // Auto-open drawer when new suggestions are received during feedback loop + if (history.length > prevSuggestionsLengthRef.current) { + setIsDrawerOpen(true); + } + + // Update the reference to track suggestion count for next time + prevSuggestionsLengthRef.current = history.length; + }; + + /** + * Handle applying a suggestion to the editor + * Returns the suggestion for the parent component to handle + * i.e., the parent component can then use the suggestions for the editor drawer + */ + const handleApplySuggestion = (suggestion: string) => { + setIsDrawerOpen(false); + return suggestion; + }; + + const handleOpenDrawer = () => setIsDrawerOpen(true); + const handleCloseDrawer = () => setIsDrawerOpen(false); + + const clearSuggestions = () => { + setSuggestions([]); + setIsDrawerOpen(false); + prevSuggestionsLengthRef.current = 0; + }; + + return { + // state + suggestions, + isDrawerOpen, + + // actions + handleHistoryUpdate, + handleApplySuggestion, + handleOpenDrawer, + handleCloseDrawer, + clearSuggestions, + }; +}; diff --git a/public/app/features/expressions/components/GenAI/sqlPromptConfig.ts b/public/app/features/expressions/components/GenAI/sqlPromptConfig.ts new file mode 100644 index 00000000000..5c7d6238334 --- /dev/null +++ b/public/app/features/expressions/components/GenAI/sqlPromptConfig.ts @@ -0,0 +1,209 @@ +/** + * Configuration file for SQL AI prompts used across expression components + * NOTE: Schema and error context information integration is planned for future implementation + */ + +import { DataQuery } from '@grafana/schema'; + +// Common SQL context information shared across all prompts +const COMMON_SQL_CONTEXT = { + engineInfo: 'MySQL dialectic based on dolthub go-mysql-server. The tables are all in memory', + refIdExplanation: 'RefIDs (A, B, C, etc.) represent data from other queries', + columnInfo: 'value should always be represented as __value__', +} as const; + +// Template placeholders used in prompts +const TEMPLATE_PLACEHOLDERS = { + refIds: '{refIds}', + currentQuery: '{currentQuery}', + queryInstruction: '{queryInstruction}', + schemaInfo: '{schemaInfo}', // Note: Schema information will be implemented in future updates + errorContext: '{errorContext}', // Note: Error context will be implemented in future updates + queryContext: '{queryContext}', +} as const; + +export interface QueryUsageContext { + panelId?: string; + alerting?: boolean; + queries?: DataQuery[]; + dashboardContext?: { + dashboardTitle?: string; + panelName?: string; + }; + datasources?: string[]; + totalRows?: number; + requestTime?: number; + numberOfQueries?: number; + seriesData?: unknown; +} + +/** + * System prompt for SQL suggestion generation with enhanced context + */ +const SQL_SUGGESTION_SYSTEM_PROMPT = `You are a SQL expert for Grafana expressions specializing in time series data analysis. +IMPORTANT - Current SQL Errors (if any): ${TEMPLATE_PLACEHOLDERS.errorContext} + +SQL dialect required by Grafana expressions: ${COMMON_SQL_CONTEXT.engineInfo} + +RefIDs context: ${COMMON_SQL_CONTEXT.refIdExplanation} +Grafana specific context: ${COMMON_SQL_CONTEXT.columnInfo} + +Available RefIDs to use in composable queries: ${TEMPLATE_PLACEHOLDERS.refIds} + +Current query to be improved: ${TEMPLATE_PLACEHOLDERS.currentQuery} + +Schema information to use in composable queries: ${TEMPLATE_PLACEHOLDERS.schemaInfo} + +${TEMPLATE_PLACEHOLDERS.queryContext} + +Query instruction: ${TEMPLATE_PLACEHOLDERS.queryInstruction} + +You may be able to derive schema information from the series data in queryContext. + +Given the above data, help users with their SQL query by: +- **PRIORITY: If there are errors listed above, focus on fixing them first** +- Fixing syntax errors using available field and data type information +- Suggesting optimal queries based on actual data schema and patterns. +- Look at query context stats: totalRows, requestTime, numberOfQueries, and if it looks like performance should be part of the conversation, suggest optimizing for performance. Note indexing is not supported in Grafana expressions. +- Leveraging time series patterns and Grafana-specific use cases + +Guidelines: +- Use proper field names and types based on schema information +- Include LIMIT clauses for performance unless aggregating +- Consider time-based filtering and grouping for time series data +- Suggest meaningful aggregations for metric data +- Use appropriate JOIN conditions when correlating multiple RefIDs +`; + +/** + * System prompt for SQL explanation generation with enhanced context + */ +const SQL_EXPLANATION_SYSTEM_PROMPT = `You are an expert in SQL and Grafana SQL expressions with deep knowledge of time series data. + +SQL dialect: ${COMMON_SQL_CONTEXT.engineInfo} + +RefIDs: ${COMMON_SQL_CONTEXT.refIdExplanation} + +Grafana specific context: ${COMMON_SQL_CONTEXT.columnInfo} + +Available RefIDs: ${TEMPLATE_PLACEHOLDERS.refIds} + +Schema: ${TEMPLATE_PLACEHOLDERS.schemaInfo} + +${TEMPLATE_PLACEHOLDERS.queryContext} + +Explain SQL queries clearly and concisely, focusing on: +- What data is being selected and from which RefIDs +- How the data is being transformed or aggregated +- The purpose and business meaning of the query using dashboard and panel name from query context if relevant +- Performance implications and optimization opportunities. Database columns can not be indexed in context of Grafana sql expressions. Don't focus on + performance unless the query context has a requestTime or totalRows that looks like it could benefit from it. +- Time series specific patterns and their significance + +Provide a clear explanation of what this SQL query does:`; + +/** + * Generate query context text for prompts + */ +const generateQueryContext = (queryContext?: QueryUsageContext): string => { + if (!queryContext) { + return ''; + } + + const contextParts = []; + if (queryContext.panelId) { + contextParts.push( + `Panel Type: ${queryContext.panelId}. Please use this to generate suggestions that are relevant to the panel type.` + ); + } + if (queryContext.alerting) { + contextParts.push( + 'Context: Alerting rule (focus on boolean/threshold results). Please use this to generate suggestions that are relevant to the alerting rule.' + ); + } + if (queryContext.queries) { + const queriesText = Array.isArray(queryContext.queries) + ? JSON.stringify(queryContext.queries, null, 2) + : String(queryContext.queries); + contextParts.push(`Queries available to use in the SQL Expression: ${queriesText}`); + } + if (queryContext.dashboardContext) { + const dashboardText = + typeof queryContext.dashboardContext === 'object' + ? JSON.stringify(queryContext.dashboardContext, null, 2) + : String(queryContext.dashboardContext); + contextParts.push(`Dashboard context (dashboard title and panel name): ${dashboardText}`); + } + if (queryContext.datasources) { + const datasourcesText = Array.isArray(queryContext.datasources) + ? JSON.stringify(queryContext.datasources, null, 2) + : String(queryContext.datasources); + contextParts.push(`Datasources available to use in the SQL Expression: ${datasourcesText}`); + } + if (queryContext.totalRows) { + contextParts.push(`Total rows in the query: ${queryContext.totalRows}`); + } + if (queryContext.requestTime) { + contextParts.push(`Request time: ${queryContext.requestTime}`); + } + if (queryContext.numberOfQueries) { + contextParts.push(`Number of queries: ${queryContext.numberOfQueries}`); + } + if (queryContext.seriesData) { + const seriesDataText = + typeof queryContext.seriesData === 'object' + ? JSON.stringify(queryContext.seriesData, null, 2) + : String(queryContext.seriesData); + contextParts.push(`Series data: ${seriesDataText}`); + } + + return contextParts.length + ? `Query Context: +${contextParts.join('\n')}` + : ''; +}; + +/** + * Enhanced interface for prompt generation variables + */ +export interface SQLPromptVariables { + refIds: string; + currentQuery: string; + queryInstruction: string; + schemas?: unknown; // Reserved for future schema implementation + errorContext?: string[]; + queryContext?: QueryUsageContext; +} + +/** + * Generate the complete system prompt for SQL suggestions with enhanced context + * + * Note: Schema information integration is planned for future implementation + */ +export const getSQLSuggestionSystemPrompt = (variables: SQLPromptVariables): string => { + const queryContext = generateQueryContext(variables.queryContext); + const schemaInfo = ''; // Placeholder for future schema information + const errorContext = variables.errorContext?.length + ? variables.errorContext.join('\n') + : 'No current errors detected.'; + + return SQL_SUGGESTION_SYSTEM_PROMPT.replaceAll(TEMPLATE_PLACEHOLDERS.refIds, variables.refIds) + .replaceAll(TEMPLATE_PLACEHOLDERS.currentQuery, variables.currentQuery) + .replaceAll(TEMPLATE_PLACEHOLDERS.queryInstruction, variables.queryInstruction) + .replaceAll(TEMPLATE_PLACEHOLDERS.schemaInfo, schemaInfo) + .replaceAll(TEMPLATE_PLACEHOLDERS.errorContext, errorContext) + .replaceAll(TEMPLATE_PLACEHOLDERS.queryContext, queryContext); +}; + +/** + * Generate the complete system prompt for SQL explanations with enhanced context + */ +export const getSQLExplanationSystemPrompt = (variables: Omit): string => { + const queryContext = generateQueryContext(variables.queryContext); + + const schemaInfo = ''; // Placeholder for future schema information + + return SQL_EXPLANATION_SYSTEM_PROMPT.replaceAll(TEMPLATE_PLACEHOLDERS.refIds, variables.refIds) + .replaceAll(TEMPLATE_PLACEHOLDERS.schemaInfo, schemaInfo) + .replaceAll(TEMPLATE_PLACEHOLDERS.queryContext, queryContext); +}; diff --git a/public/app/features/expressions/components/GenAI/utils.ts b/public/app/features/expressions/components/GenAI/utils.ts new file mode 100644 index 00000000000..a51f9e2aeaf --- /dev/null +++ b/public/app/features/expressions/components/GenAI/utils.ts @@ -0,0 +1,63 @@ +/** + * Parses AI-generated suggestions that contain mixed content (text and code blocks). + * + * AI responses often come in markdown format with explanatory text and code blocks + * delimited by triple backticks (```). This function separates and structures that + * content for proper rendering in the UI. + * + * @param suggestion - Raw AI suggestion string potentially containing markdown code blocks + * @returns Array of parsed parts, each with: + * - type: 'text' for explanatory content, 'code' for SQL queries + * - content: The actual text or code content + * - language: For code blocks, the detected language (defaults to 'sql', normalized to 'mysql') + * + * Example input: "Here's a query:\n```sql\nSELECT * FROM A\n```\nThis joins data." + * Example output: [ + * { type: 'text', content: "Here's a query:" }, + * { type: 'code', content: 'SELECT * FROM A', language: 'mysql' }, + * { type: 'text', content: 'This joins data.' } + * ] + */ +export const parseSuggestion = (suggestion: string) => { + if (!suggestion) { + return []; + } + const parts: Array<{ type: 'text' | 'code'; content: string; language?: string }> = []; + + // Split by triple backticks to find code blocks + const segments = suggestion.split(/```/); + + segments.forEach((segment, index) => { + if (index % 2 === 0) { + // Even indices are text (outside code blocks) + if (segment.trim()) { + parts.push({ type: 'text', content: segment.trim() }); + } + } else { + // Odd indices are code blocks + const lines = segment.split('\n'); + let language = 'sql'; // default language + let codeContent = segment; + + // Check if first line specifies a language + if (lines[0] && lines[0].trim() && !lines[0].includes(' ')) { + language = lines[0].trim().toLowerCase(); + codeContent = lines.slice(1).join('\n'); + } + + // Remove trailing newlines + codeContent = codeContent.replace(/\n+$/, ''); + + if (codeContent.trim()) { + const finalLanguage = language === 'mysql' ? 'mysql' : language === 'sql' ? 'mysql' : language; + parts.push({ + type: 'code', + content: codeContent.trim(), + language: finalLanguage, + }); + } + } + }); + + return parts; +}; diff --git a/public/app/features/expressions/components/SqlExpr.test.tsx b/public/app/features/expressions/components/SqlExpr.test.tsx index e63a80133d6..53931e785db 100644 --- a/public/app/features/expressions/components/SqlExpr.test.tsx +++ b/public/app/features/expressions/components/SqlExpr.test.tsx @@ -1,8 +1,8 @@ -import { render } from '@testing-library/react'; +import { render, waitFor, fireEvent, act } from 'test/test-utils'; -import { ExpressionQuery } from '../types'; +import { ExpressionQuery, ExpressionQueryType } from '../types'; -import { SqlExpr } from './SqlExpr'; +import { SqlExpr, SqlExprProps } from './SqlExpr'; jest.mock('@grafana/ui', () => ({ ...jest.requireActual('@grafana/ui'), @@ -13,13 +13,54 @@ jest.mock('@grafana/plugin-ui', () => ({ SQLEditor: () =>
SQL Editor Mock
, })); +// Mock lazy loaded GenAI components +jest.mock('./GenAI/GenAISQLSuggestionsButton', () => ({ + GenAISQLSuggestionsButton: ({ currentQuery, initialQuery }: { currentQuery: string; initialQuery: string }) => { + const text = !currentQuery || currentQuery === initialQuery ? 'Generate suggestion' : 'Improve query'; + return
{text}
; + }, +})); + +jest.mock('./GenAI/GenAISQLExplainButton', () => ({ + GenAISQLExplainButton: () =>
Explain query
, +})); + +// Mock custom hooks for GenAI features +jest.mock('./GenAI/hooks/useSQLSuggestions', () => ({ + useSQLSuggestions: jest.fn(() => ({ + handleApplySuggestion: jest.fn(), + handleHistoryUpdate: jest.fn(), + handleCloseDrawer: jest.fn(), + handleOpenDrawer: jest.fn(), + isDrawerOpen: false, + suggestions: [], + })), +})); + +jest.mock('./GenAI/hooks/useSQLExplanations', () => ({ + useSQLExplanations: jest.fn((currentExpression: string) => ({ + explanation: '', + handleCloseExplanation: jest.fn(), + handleOpenExplanation: jest.fn(), + handleExplain: jest.fn(), + isExplanationOpen: false, + shouldShowViewExplanation: false, + updatePrevExpression: jest.fn(), + prevExpression: currentExpression, + })), +})); + +// Note: Add more mocks if needed for other lazy components + describe('SqlExpr', () => { - it('initializes new expressions with default query', () => { + it('initializes new expressions with default query', async () => { const onChange = jest.fn(); const refIds = [{ value: 'A' }]; const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery; - render(); + await act(async () => { + render(); + }); // Verify onChange was called expect(onChange).toHaveBeenCalled(); @@ -29,13 +70,15 @@ describe('SqlExpr', () => { expect(updatedQuery.expression.toUpperCase()).toContain('SELECT'); }); - it('preserves existing expressions when mounted', () => { + it('preserves existing expressions when mounted', async () => { const onChange = jest.fn(); const refIds = [{ value: 'A' }]; const existingExpression = 'SELECT 1 AS foo'; const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery; - render(); + await act(async () => { + render(); + }); // Check if onChange was called if (onChange.mock.calls.length > 0) { @@ -48,14 +91,116 @@ describe('SqlExpr', () => { expect(query.expression).toBe(existingExpression); }); - it('adds alerting format when alerting prop is true', () => { + it('adds alerting format when alerting prop is true', async () => { const onChange = jest.fn(); const refIds = [{ value: 'A' }]; const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery; - render(); + await act(async () => { + render(); + }); const updatedQuery = onChange.mock.calls[0][0]; expect(updatedQuery.format).toBe('alerting'); }); }); + +describe('SqlExpr with GenAI features', () => { + const defaultProps: SqlExprProps = { + onChange: jest.fn(), + refIds: [{ value: 'A' }], + query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` }, + queries: [], + }; + + it('renders GenAI buttons with empty expression', async () => { + const customProps = { ...defaultProps, query: { ...defaultProps.query, expression: '' } }; + const { findByText } = render(); + expect(await findByText('Generate suggestion')).toBeInTheDocument(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders GenAI buttons with non-empty expression', async () => { + const { findByText } = render(); + expect(await findByText('Improve query')).toBeInTheDocument(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders "Improve query" when currentQuery differs from initialQuery', async () => { + const customProps = { + ...defaultProps, + query: { ...defaultProps.query, expression: 'SELECT * FROM A WHERE value > 10' }, + }; + const { findByText } = render(); + expect(await findByText('Improve query')).toBeInTheDocument(); + }); + + it('renders View explanation button when shouldShowViewExplanation is true', async () => { + const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); + useSQLExplanations.mockImplementation((currentExpression: string) => ({ + shouldShowViewExplanation: true, + })); + + const { findByText } = render(); + expect(await findByText('View explanation')).toBeInTheDocument(); + }); + + it('renders Explain query button when shouldShowViewExplanation is false', async () => { + const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); + useSQLExplanations.mockImplementation((currentExpression: string) => ({ + shouldShowViewExplanation: false, + })); + + const { findByText } = render(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders SuggestionsDrawerButton when there are suggestions', async () => { + const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); + useSQLSuggestions.mockImplementation(() => ({ suggestions: ['suggestion1', 'suggestion2'] })); + + const { findByTestId } = render(); + expect(await findByTestId('suggestions-badge')).toBeInTheDocument(); + }); + + it('does not render SuggestionsDrawerButton when there are no suggestions', async () => { + const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); + useSQLSuggestions.mockImplementation(() => ({ suggestions: [] })); + + const { queryByTestId } = render(); + expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument(); + }); + + it('calls handleOpenExplanation when View explanation is clicked', async () => { + const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); + const mockHandleOpen = jest.fn(); + useSQLExplanations.mockImplementation(() => ({ + shouldShowViewExplanation: true, + handleOpenExplanation: mockHandleOpen, + })); + + const { findByText } = render(); + const button = await findByText('View explanation'); + fireEvent.click(button); + expect(mockHandleOpen).toHaveBeenCalled(); + }); + + it('renders suggestions drawer when isDrawerOpen is true', async () => { + const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); + useSQLSuggestions.mockImplementation(() => ({ + isDrawerOpen: true, + suggestions: ['suggestion1', 'suggestion2'], + })); + + const { findByTestId } = render(); + expect(await findByTestId('suggestions-drawer')).toBeInTheDocument(); + }); + + it('renders explanation drawer when isExplanationOpen is true', async () => { + const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); + useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true })); + + const { findByTestId } = render(); + expect(await findByTestId('explanation-drawer')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpr.tsx b/public/app/features/expressions/components/SqlExpr.tsx index ac2917b1c40..6b1c467168a 100644 --- a/public/app/features/expressions/components/SqlExpr.tsx +++ b/public/app/features/expressions/components/SqlExpr.tsx @@ -1,29 +1,65 @@ import { css } from '@emotion/css'; -import { useMemo, useRef, useEffect, useState } from 'react'; +import { useMemo, useRef, useEffect, useState, lazy, Suspense } from 'react'; -import { SelectableValue } from '@grafana/data'; +import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { SQLEditor, CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui'; import { DataQuery } from '@grafana/schema/dist/esm/index'; -import { useStyles2 } from '@grafana/ui'; +import { useStyles2, Stack, Button } from '@grafana/ui'; +import { ExpressionQueryEditorProps } from '../ExpressionQueryEditor'; import { SqlExpressionQuery } from '../types'; import { fetchSQLFields } from '../utils/metaSqlExpr'; +import { useSQLExplanations } from './GenAI/hooks/useSQLExplanations'; +import { useSQLSuggestions } from './GenAI/hooks/useSQLSuggestions'; import { getSqlCompletionProvider } from './sqlCompletionProvider'; +// Lazy load the GenAI components to avoid circular dependencies +const GenAISQLSuggestionsButton = lazy(() => + import('./GenAI/GenAISQLSuggestionsButton').then((module) => ({ + default: module.GenAISQLSuggestionsButton, + })) +); + +const GenAISQLExplainButton = lazy(() => + import('./GenAI/GenAISQLExplainButton').then((module) => ({ + default: module.GenAISQLExplainButton, + })) +); + +const SuggestionsDrawerButton = lazy(() => + import('./GenAI/SuggestionsDrawerButton').then((module) => ({ + default: module.SuggestionsDrawerButton, + })) +); + +const GenAISuggestionsDrawer = lazy(() => + import('./GenAI/GenAISuggestionsDrawer').then((module) => ({ + default: module.GenAISuggestionsDrawer, + })) +); + +const GenAIExplanationDrawer = lazy(() => + import('./GenAI/GenAIExplanationDrawer').then((module) => ({ + default: module.GenAIExplanationDrawer, + })) +); + // Account for Monaco editor's border to prevent clipping const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom -interface Props { +export interface SqlExprProps { refIds: Array>; query: SqlExpressionQuery; queries: DataQuery[] | undefined; onChange: (query: SqlExpressionQuery) => void; /** Should the `format` property be set to `alerting`? */ alerting?: boolean; + metadata?: ExpressionQueryEditorProps; } -export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries }: Props) => { +export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries, metadata }: SqlExprProps) => { const vars = useMemo(() => refIds.map((v) => v.value!), [refIds]); const completionProvider = useMemo( () => @@ -48,12 +84,70 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries }: const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ height: 0 }); + const { handleApplySuggestion, handleHistoryUpdate, handleCloseDrawer, handleOpenDrawer, isDrawerOpen, suggestions } = + useSQLSuggestions(); + + const { + explanation, + handleCloseExplanation, + handleOpenExplanation, + handleExplain, + isExplanationOpen, + shouldShowViewExplanation, + updatePrevExpression, + } = useSQLExplanations(query.expression || ''); + + const queryContext = useMemo( + () => ({ + alerting, + panelId: metadata?.data?.request?.panelPluginId, + queries: metadata?.queries, + dashboardContext: { + dashboardTitle: metadata?.data?.request?.dashboardTitle ?? '', + panelName: metadata?.data?.request?.panelName ?? '', + }, + datasources: metadata?.queries?.map((query) => query.datasource?.type ?? '') ?? [], + totalRows: metadata?.data?.series.reduce((sum, frame) => sum + frame.length, 0), + requestTime: metadata?.data?.request?.endTime + ? metadata?.data?.request?.endTime - metadata?.data?.request?.startTime + : -1, + numberOfQueries: metadata?.data?.request?.targets?.length ?? 0, + seriesData: metadata?.data?.series, + }), + [alerting, metadata] + ); + + const errorContext = useMemo(() => { + if (!metadata?.data) { + return []; + } + + const errors: string[] = []; + + // Handle multiple errors (preferred) + if (metadata.data.errors?.length) { + errors.push(...metadata.data.errors.map((err) => err.message).filter((msg): msg is string => Boolean(msg))); + } + // Handle legacy single error + else if (metadata.data.error?.message) { + errors.push(metadata.data.error.message); + } + + return errors; + }, [metadata?.data]); + const onEditorChange = (expression: string) => { onChange({ ...query, expression, format: alerting ? 'alerting' : undefined, }); + updatePrevExpression(expression); + }; + + const onApplySuggestion = (suggestion: string) => { + onEditorChange(suggestion); + handleApplySuggestion(suggestion); }; // Set up resize observer to handle container resizing @@ -81,24 +175,112 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries }: }, []); return ( -
- -
+ <> +
+
+ + + {shouldShowViewExplanation ? ( + + ) : ( + + )} + + + {}} // Noop - history is managed via onHistoryUpdate + onHistoryUpdate={handleHistoryUpdate} + queryContext={queryContext} + refIds={vars} + errorContext={errorContext} // Will be added when error tracking is implemented + // schemas={schemas} // Will be added when schema extraction is implemented + /> + + + {suggestions.length > 0 && ( + + + + )} +
+ +
+ +
+
+ <> + + + + + + + + ); }; -const getStyles = () => ({ +const getStyles = (theme: GrafanaTheme2) => ({ + sqlContainer: css({ + display: 'grid', + gridTemplateRows: 'auto 1fr', + gridTemplateAreas: ` + "buttons" + "editor" + `, + gap: theme.spacing(0.5), + }), editorContainer: css({ + gridArea: 'editor', height: '240px', resize: 'vertical', overflow: 'auto', minHeight: '100px', }), + // This is NOT ideal. The alternative is to expose SQL buttons as a separate component, + // Then consume them in ExpressionQueryEditor. This requires a lot of refactoring and + // can be prioritized later. + sqlButtons: css({ + gridArea: 'buttons', + justifySelf: 'end', + transform: `translateY(${theme.spacing(-4)})`, + marginBottom: theme.spacing(-4), // Prevent affecting editor position + zIndex: 10, // Ensure buttons appear above other elements + position: 'relative', // Required for z-index to work + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), }); async function fetchFields(identifier: TableIdentifier, queries: DataQuery[]) { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c0631410c8e..13445a18190 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7490,6 +7490,9 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, + "sql-expr": { + "tooltip-experimental": "SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team." + }, "threshold": { "label-input": "Input" } @@ -12495,6 +12498,21 @@ "sort-picker": { "select-aria-label": "Sort" }, + "sql-expressions": { + "add-query-tooltip": "Add at least one data query to generate SQL suggestions", + "ai-explain-title": "AI-powered SQL expression explanation", + "ai-suggestions-title": "AI-powered SQL expression suggestions", + "apply": "Apply", + "code-label": "{{ language }}", + "copy": "Copy", + "explain-empty-query-tooltip": "Enter a SQL expression to get an explanation", + "explain-query": "Explain query", + "explanation-modal-title": "SQL Query Explanation", + "sql-ai-interaction": "{{text}}", + "sql-suggestion-history": "SQL Suggestion History", + "suggestions": "Suggestions", + "view-explanation": "View explanation" + }, "stat": { "add-orientation-option": { "description-orientation": "Layout orientation",