mirror of https://github.com/grafana/grafana.git
SQL Expressions: LLM plugin integration - SQL suggestions and SQL explanations (#107545)
* init: first pass at AI generated SQL expressions
* chore: fixes to tests
* i18n
* chore: small GenAIButton style update
* lazy load the genAI button - circular dependency issue in our test mocks...?
* chore: polish
* fix: i18n
* feat: make it a whole lot more capable
* chore: extract prompt logic to common file
* chore: consolidate state management to custom hooks
* chore: clean up, update GenAIButton API, etc.
* chore: major sql prompt improvement + plan for future + genAIButton api update
* chore: polish for now
* chore: clean up folder structure
* chore: conditionally use hooks + improve prompt
* chore: betterer....
* chore: polish
* feat: testing 🚀
* chore: polish polish polish
* chore: remove startWithPrompt
* chore: timeout 30
* chore: add experimental badge
* i18n + 60 sec timeout
* chore: MOAR POLISH
* chore: clean up explanation drawer
* chore: update tests
* chore: update FF checks
* chore: get the rendering w/FF correct
* chore: fix tests
* chore: re-work ai tooltips
* chore: cleanup
* chore: handle sql button styling differently
* chore: fix styling last time
This commit is contained in:
parent
50a3aa3137
commit
31fc7d5d7a
|
|
@ -4,10 +4,10 @@ import * as React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { llm } from '@grafana/llm';
|
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 { GenAIHistory } from './GenAIHistory';
|
||||||
import { StreamStatus, useLLMStream } from './hooks';
|
import { StreamStatus, TIMEOUT, useLLMStream } from './hooks';
|
||||||
import { AutoGenerateItem, EventTrackingSrc, reportAutoGenerateInteraction } from './tracking';
|
import { AutoGenerateItem, EventTrackingSrc, reportAutoGenerateInteraction } from './tracking';
|
||||||
import { DEFAULT_LLM_MODEL, Message, sanitizeReply } from './utils';
|
import { DEFAULT_LLM_MODEL, Message, sanitizeReply } from './utils';
|
||||||
|
|
||||||
|
|
@ -36,6 +36,10 @@ export interface GenAIButtonProps {
|
||||||
toggletip will be enabled.
|
toggletip will be enabled.
|
||||||
*/
|
*/
|
||||||
tooltip?: string;
|
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';
|
export const STOP_GENERATION_TEXT = 'Stop generating';
|
||||||
|
|
||||||
|
|
@ -50,13 +54,22 @@ export const GenAIButton = ({
|
||||||
eventTrackingSrc,
|
eventTrackingSrc,
|
||||||
disabled,
|
disabled,
|
||||||
tooltip,
|
tooltip,
|
||||||
|
onHistoryChange,
|
||||||
|
timeout = TIMEOUT,
|
||||||
}: GenAIButtonProps) => {
|
}: GenAIButtonProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const [history, setHistory] = useState<string[]>([]);
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
const unshiftHistoryEntry = useCallback((historyEntry: string) => {
|
const unshiftHistoryEntry = useCallback(
|
||||||
setHistory((h) => [historyEntry, ...h]);
|
(historyEntry: string) => {
|
||||||
}, []);
|
setHistory((h) => {
|
||||||
|
const newHistory = [historyEntry, ...h];
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
onHistoryChange?.([historyEntry, ...history]);
|
||||||
|
},
|
||||||
|
[onHistoryChange, history]
|
||||||
|
);
|
||||||
|
|
||||||
const onResponse = useCallback(
|
const onResponse = useCallback(
|
||||||
(reply: string) => {
|
(reply: string) => {
|
||||||
|
|
@ -71,6 +84,7 @@ export const GenAIButton = ({
|
||||||
model,
|
model,
|
||||||
temperature,
|
temperature,
|
||||||
onResponse,
|
onResponse,
|
||||||
|
timeout,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
|
@ -181,6 +195,7 @@ export const GenAIButton = ({
|
||||||
onApplySuggestion={onApplySuggestion}
|
onApplySuggestion={onApplySuggestion}
|
||||||
updateHistory={unshiftHistoryEntry}
|
updateHistory={unshiftHistoryEntry}
|
||||||
eventTrackingSrc={eventTrackingSrc}
|
eventTrackingSrc={eventTrackingSrc}
|
||||||
|
timeout={timeout}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
placement="left-start"
|
placement="left-start"
|
||||||
|
|
@ -198,7 +213,7 @@ export const GenAIButton = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<Stack direction="row" gap={0.5} alignItems="center">
|
||||||
{isGenerating && <Spinner size="sm" className={styles.spinner} />}
|
{isGenerating && <Spinner size="sm" className={styles.spinner} />}
|
||||||
{isFirstHistoryEntry ? (
|
{isFirstHistoryEntry ? (
|
||||||
<Tooltip show={showTooltip} interactive content={tooltipContent}>
|
<Tooltip show={showTooltip} interactive content={tooltipContent}>
|
||||||
|
|
@ -207,14 +222,11 @@ export const GenAIButton = ({
|
||||||
) : (
|
) : (
|
||||||
renderButtonWithToggletip()
|
renderButtonWithToggletip()
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
wrapper: css({
|
|
||||||
display: 'flex',
|
|
||||||
}),
|
|
||||||
spinner: css({
|
spinner: css({
|
||||||
color: theme.colors.text.link,
|
color: theme.colors.text.link,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
|
@ -18,6 +18,7 @@ export interface GenAIHistoryProps {
|
||||||
onApplySuggestion: (suggestion: string) => void;
|
onApplySuggestion: (suggestion: string) => void;
|
||||||
updateHistory: (historyEntry: string) => void;
|
updateHistory: (historyEntry: string) => void;
|
||||||
eventTrackingSrc: EventTrackingSrc;
|
eventTrackingSrc: EventTrackingSrc;
|
||||||
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const temperature = 0.5;
|
const temperature = 0.5;
|
||||||
|
|
@ -28,12 +29,17 @@ export const GenAIHistory = ({
|
||||||
messages,
|
messages,
|
||||||
onApplySuggestion,
|
onApplySuggestion,
|
||||||
updateHistory,
|
updateHistory,
|
||||||
|
timeout,
|
||||||
}: GenAIHistoryProps) => {
|
}: GenAIHistoryProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(1);
|
const [currentIndex, setCurrentIndex] = useState(1);
|
||||||
const [customFeedback, setCustomPrompt] = useState('');
|
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(
|
const onResponse = useCallback(
|
||||||
(response: string) => {
|
(response: string) => {
|
||||||
updateHistory(sanitizeReply(response));
|
updateHistory(sanitizeReply(response));
|
||||||
|
|
@ -45,6 +51,7 @@ export const GenAIHistory = ({
|
||||||
model: DEFAULT_LLM_MODEL,
|
model: DEFAULT_LLM_MODEL,
|
||||||
temperature,
|
temperature,
|
||||||
onResponse,
|
onResponse,
|
||||||
|
timeout,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reportInteraction = (item: AutoGenerateItem, otherMetadata?: object) =>
|
const reportInteraction = (item: AutoGenerateItem, otherMetadata?: object) =>
|
||||||
|
|
@ -70,7 +77,7 @@ export const GenAIHistory = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const onGenerateWithFeedback = (suggestion: string) => {
|
const onGenerateWithFeedback = (suggestion: string) => {
|
||||||
setMessages((messages) => [...messages, ...getFeedbackMessage(history[currentIndex - 1], suggestion)]);
|
setMessages(() => [...messagesRef.current, ...getFeedbackMessage(history[currentIndex - 1], suggestion)]);
|
||||||
|
|
||||||
if (suggestion in QuickFeedbackType) {
|
if (suggestion in QuickFeedbackType) {
|
||||||
reportInteraction(AutoGenerateItem.quickFeedback, { quickFeedbackItem: suggestion });
|
reportInteraction(AutoGenerateItem.quickFeedback, { quickFeedbackItem: suggestion });
|
||||||
|
|
@ -104,7 +111,6 @@ export const GenAIHistory = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<GenerationHistoryCarousel history={history} index={currentIndex} onNavigate={onNavigate} />
|
<GenerationHistoryCarousel history={history} index={currentIndex} onNavigate={onNavigate} />
|
||||||
|
|
||||||
<div className={styles.actionButtons}>
|
<div className={styles.actionButtons}>
|
||||||
<QuickFeedback onSuggestionClick={onGenerateWithFeedback} isGenerating={isStreamGenerating} />
|
<QuickFeedback onSuggestionClick={onGenerateWithFeedback} isGenerating={isStreamGenerating} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,19 @@ export enum StreamStatus {
|
||||||
COMPLETED = 'completed',
|
COMPLETED = 'completed',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TIMEOUT = 10000;
|
export const TIMEOUT = 10000; // 10 seconds
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
model: string;
|
model: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
onResponse?: (response: string) => void;
|
onResponse?: (response: string) => void;
|
||||||
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
model: DEFAULT_LLM_MODEL,
|
model: DEFAULT_LLM_MODEL,
|
||||||
temperature: 1,
|
temperature: 1,
|
||||||
|
timeout: TIMEOUT,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UseLLMStreamResponse {
|
interface UseLLMStreamResponse {
|
||||||
|
|
@ -47,7 +49,8 @@ interface UseLLMStreamResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add tests
|
// 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.
|
// The messages array to send to the LLM, updated when the button is clicked.
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
|
||||||
|
|
@ -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.
|
// If the stream is generating and we haven't received a reply, it times out.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeout: NodeJS.Timeout | undefined;
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
if (streamStatus === StreamStatus.GENERATING && reply === '') {
|
if (streamStatus === StreamStatus.GENERATING && reply === '') {
|
||||||
timeout = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
onError(new Error(`LLM stream timed out after ${TIMEOUT}ms`));
|
onError(new Error(`LLM stream timed out after ${timeout}ms`));
|
||||||
}, TIMEOUT);
|
}, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [streamStatus, reply, onError]);
|
}, [streamStatus, reply, onError, timeout]);
|
||||||
|
|
||||||
if (asyncError || enabledError) {
|
if (asyncError || enabledError) {
|
||||||
setError(asyncError || enabledError);
|
setError(asyncError || enabledError);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export enum EventTrackingSrc {
|
||||||
dashboardChanges = 'dashboard-changes',
|
dashboardChanges = 'dashboard-changes',
|
||||||
dashboardTitle = 'dashboard-title',
|
dashboardTitle = 'dashboard-title',
|
||||||
dashboardDescription = 'dashboard-description',
|
dashboardDescription = 'dashboard-description',
|
||||||
|
sqlExpressions = 'sql-expressions',
|
||||||
unknown = 'unknown',
|
unknown = 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { Threshold } from './components/Threshold';
|
||||||
import { ExpressionQuery, ExpressionQueryType, expressionTypes } from './types';
|
import { ExpressionQuery, ExpressionQueryType, expressionTypes } from './types';
|
||||||
import { getDefaults } from './utils/expressionTypes';
|
import { getDefaults } from './utils/expressionTypes';
|
||||||
|
|
||||||
type Props = QueryEditorProps<DataSourceApi<ExpressionQuery>, ExpressionQuery>;
|
export type ExpressionQueryEditorProps = QueryEditorProps<DataSourceApi<ExpressionQuery>, ExpressionQuery>;
|
||||||
|
|
||||||
const labelWidth = 15;
|
const labelWidth = 15;
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ function useExpressionsCache() {
|
||||||
return { getCachedExpression, setCachedExpression };
|
return { getCachedExpression, setCachedExpression };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpressionQueryEditor(props: Props) {
|
export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) {
|
||||||
const { query, queries, onRunQuery, onChange, app } = props;
|
const { query, queries, onRunQuery, onChange, app } = props;
|
||||||
const { getCachedExpression, setCachedExpression } = useExpressionsCache();
|
const { getCachedExpression, setCachedExpression } = useExpressionsCache();
|
||||||
|
|
||||||
|
|
@ -118,7 +118,7 @@ export function ExpressionQueryEditor(props: Props) {
|
||||||
return <Threshold onChange={onChange} query={query} labelWidth={labelWidth} refIds={refIds} />;
|
return <Threshold onChange={onChange} query={query} labelWidth={labelWidth} refIds={refIds} />;
|
||||||
|
|
||||||
case ExpressionQueryType.sql:
|
case ExpressionQueryType.sql:
|
||||||
return <SqlExpr onChange={onChange} query={query} refIds={refIds} queries={queries} />;
|
return <SqlExpr onChange={onChange} query={query} refIds={refIds} queries={queries} metadata={props} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<Drawer
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
title={<Trans i18nKey="sql-expressions.explanation-modal-title">SQL Query Explanation</Trans>}
|
||||||
|
>
|
||||||
|
<Stack direction="column" data-testid="explanation-drawer">
|
||||||
|
<Card noMargin>
|
||||||
|
<div className="markdown-html" dangerouslySetInnerHTML={{ __html: renderMarkdown(explanation) }} />
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 (
|
||||||
|
<GenAIButton
|
||||||
|
disabled={!hasQuery}
|
||||||
|
eventTrackingSrc={EventTrackingSrc.sqlExpressions}
|
||||||
|
messages={messages}
|
||||||
|
onGenerate={onExplain}
|
||||||
|
temperature={0.3}
|
||||||
|
text={t('sql-expressions.explain-query', 'Explain query')}
|
||||||
|
timeout={60000} // 60 seconds
|
||||||
|
toggleTipTitle={t('sql-expressions.ai-explain-title', 'AI-powered SQL expression explanation')}
|
||||||
|
tooltip={
|
||||||
|
!hasQuery
|
||||||
|
? t('sql-expressions.explain-empty-query-tooltip', 'Enter a SQL expression to get an explanation')
|
||||||
|
: t(
|
||||||
|
'expressions.sql-expr.tooltip-experimental',
|
||||||
|
'SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 (
|
||||||
|
<GenAIButton
|
||||||
|
disabled={refIds.length === 0}
|
||||||
|
eventTrackingSrc={EventTrackingSrc.sqlExpressions}
|
||||||
|
messages={messages}
|
||||||
|
onGenerate={onGenerate}
|
||||||
|
onHistoryChange={onHistoryUpdate}
|
||||||
|
temperature={0.3}
|
||||||
|
text={t('sql-expressions.sql-ai-interaction', `{{text}}`, { text })}
|
||||||
|
timeout={60000} // 60 seconds
|
||||||
|
toggleTipTitle={t('sql-expressions.ai-suggestions-title', 'AI-powered SQL expression suggestions')}
|
||||||
|
tooltip={
|
||||||
|
refIds.length === 0
|
||||||
|
? t('sql-expressions.add-query-tooltip', 'Add at least one data query to generate SQL suggestions')
|
||||||
|
: t(
|
||||||
|
'expressions.sql-expr.tooltip-experimental',
|
||||||
|
'SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 (
|
||||||
|
<Drawer
|
||||||
|
onClose={onClose}
|
||||||
|
size="lg"
|
||||||
|
title={<Trans i18nKey="sql-expressions.sql-suggestion-history">SQL Suggestion History</Trans>}
|
||||||
|
>
|
||||||
|
<div className={styles.content} data-testid="suggestions-drawer">
|
||||||
|
<Stack direction="column" gap={3}>
|
||||||
|
<div className={styles.timelineContainer}>
|
||||||
|
{/* Vertical timeline line */}
|
||||||
|
<div className={styles.timelineLine} />
|
||||||
|
|
||||||
|
<div className={styles.suggestionsList}>
|
||||||
|
{suggestions.map((suggestion, index) => {
|
||||||
|
const parsedSuggestion = parseSuggestion(suggestion);
|
||||||
|
const isLatest = index === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className={styles.timelineItem}>
|
||||||
|
{/* Timeline node */}
|
||||||
|
<div
|
||||||
|
className={`${styles.timelineNode} ${isLatest ? styles.timelineNodeActive : styles.timelineNodeInactive}`}
|
||||||
|
/>
|
||||||
|
<Card noMargin key={index} className={isLatest ? styles.latestSuggestion : ''}>
|
||||||
|
<div className={styles.suggestionContent}>
|
||||||
|
{parsedSuggestion.map(({ type, content, language }, partIndex) => (
|
||||||
|
<div key={partIndex} className={styles.suggestionPart}>
|
||||||
|
{type === 'code' ? (
|
||||||
|
<div className={styles.codeBlock}>
|
||||||
|
<div className={styles.codeHeader}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Text variant="bodySmall" weight="bold">
|
||||||
|
<Trans
|
||||||
|
i18nKey="sql-expressions.code-label"
|
||||||
|
values={{ language: language?.toUpperCase() || 'CODE' }}
|
||||||
|
>
|
||||||
|
{'{{ language }}'}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Stack direction="row" gap={1}>
|
||||||
|
<ClipboardButton
|
||||||
|
size="sm"
|
||||||
|
icon="copy"
|
||||||
|
variant="secondary"
|
||||||
|
getText={() => content}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="sql-expressions.copy">Copy</Trans>
|
||||||
|
</ClipboardButton>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
icon="ai-sparkle"
|
||||||
|
onClick={() => onApplySuggestion(content)}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="sql-expressions.apply">Apply</Trans>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
value={content}
|
||||||
|
language={language === 'sql' || language === 'mysql' ? 'mysql' : 'sql'}
|
||||||
|
width="100%"
|
||||||
|
height={Math.max(80, Math.min(300, (content.split('\n').length + 1) * 20))}
|
||||||
|
readOnly={true}
|
||||||
|
showMiniMap={false}
|
||||||
|
showLineNumbers={true}
|
||||||
|
monacoOptions={{
|
||||||
|
lineNumbers: 'on',
|
||||||
|
folding: false,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
renderLineHighlight: 'none',
|
||||||
|
wordWrap: 'on',
|
||||||
|
readOnly: true,
|
||||||
|
contextmenu: false,
|
||||||
|
padding: { top: 8, bottom: 8 },
|
||||||
|
automaticLayout: true,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="markdown-html"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -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 (
|
||||||
|
<div className={styles.buttonWrapper} data-testid="suggestions-badge">
|
||||||
|
<Button variant="secondary" fill="outline" size="sm" onClick={handleOpenDrawer} icon="list-ol">
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
<Trans i18nKey="sql-expressions.suggestions">Suggestions</Trans>
|
||||||
|
<span className={styles.countBadge}>
|
||||||
|
<Text variant="bodySmall" weight="bold">
|
||||||
|
{suggestions.length}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
</Stack>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
countBadge: css({
|
||||||
|
color: theme.colors.primary.text,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}),
|
||||||
|
buttonWrapper: css({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
export const useSQLExplanations = (currentExpression: string) => {
|
||||||
|
const [explanation, setExplanation] = useState<string>('');
|
||||||
|
const [isExplanationOpen, setIsExplanationOpen] = useState(false);
|
||||||
|
const [prevExpression, setPrevExpression] = useState<string>(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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
|
||||||
|
export const useSQLSuggestions = () => {
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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<SQLPromptVariables, 'queryInstruction'>): 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);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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.mock('@grafana/ui', () => ({
|
||||||
...jest.requireActual('@grafana/ui'),
|
...jest.requireActual('@grafana/ui'),
|
||||||
|
|
@ -13,13 +13,54 @@ jest.mock('@grafana/plugin-ui', () => ({
|
||||||
SQLEditor: () => <div data-testid="sql-editor">SQL Editor Mock</div>,
|
SQLEditor: () => <div data-testid="sql-editor">SQL Editor Mock</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 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 <div data-testid="suggestions-button">{text}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./GenAI/GenAISQLExplainButton', () => ({
|
||||||
|
GenAISQLExplainButton: () => <div data-testid="explain-button">Explain query</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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', () => {
|
describe('SqlExpr', () => {
|
||||||
it('initializes new expressions with default query', () => {
|
it('initializes new expressions with default query', async () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
const refIds = [{ value: 'A' }];
|
const refIds = [{ value: 'A' }];
|
||||||
const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery;
|
const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery;
|
||||||
|
|
||||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
await act(async () => {
|
||||||
|
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
||||||
|
});
|
||||||
|
|
||||||
// Verify onChange was called
|
// Verify onChange was called
|
||||||
expect(onChange).toHaveBeenCalled();
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
|
@ -29,13 +70,15 @@ describe('SqlExpr', () => {
|
||||||
expect(updatedQuery.expression.toUpperCase()).toContain('SELECT');
|
expect(updatedQuery.expression.toUpperCase()).toContain('SELECT');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves existing expressions when mounted', () => {
|
it('preserves existing expressions when mounted', async () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
const refIds = [{ value: 'A' }];
|
const refIds = [{ value: 'A' }];
|
||||||
const existingExpression = 'SELECT 1 AS foo';
|
const existingExpression = 'SELECT 1 AS foo';
|
||||||
const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery;
|
const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery;
|
||||||
|
|
||||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
await act(async () => {
|
||||||
|
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
||||||
|
});
|
||||||
|
|
||||||
// Check if onChange was called
|
// Check if onChange was called
|
||||||
if (onChange.mock.calls.length > 0) {
|
if (onChange.mock.calls.length > 0) {
|
||||||
|
|
@ -48,14 +91,116 @@ describe('SqlExpr', () => {
|
||||||
expect(query.expression).toBe(existingExpression);
|
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 onChange = jest.fn();
|
||||||
const refIds = [{ value: 'A' }];
|
const refIds = [{ value: 'A' }];
|
||||||
const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery;
|
const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery;
|
||||||
|
|
||||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting queries={[]} />);
|
await act(async () => {
|
||||||
|
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting queries={[]} />);
|
||||||
|
});
|
||||||
|
|
||||||
const updatedQuery = onChange.mock.calls[0][0];
|
const updatedQuery = onChange.mock.calls[0][0];
|
||||||
expect(updatedQuery.format).toBe('alerting');
|
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(<SqlExpr {...customProps} />);
|
||||||
|
expect(await findByText('Generate suggestion')).toBeInTheDocument();
|
||||||
|
expect(await findByText('Explain query')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders GenAI buttons with non-empty expression', async () => {
|
||||||
|
const { findByText } = render(<SqlExpr {...defaultProps} />);
|
||||||
|
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(<SqlExpr {...customProps} />);
|
||||||
|
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(<SqlExpr {...defaultProps} />);
|
||||||
|
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(<SqlExpr {...defaultProps} />);
|
||||||
|
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(<SqlExpr {...defaultProps} />);
|
||||||
|
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(<SqlExpr {...defaultProps} />);
|
||||||
|
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(<SqlExpr {...defaultProps} />);
|
||||||
|
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(<SqlExpr {...defaultProps} />);
|
||||||
|
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(<SqlExpr {...defaultProps} />);
|
||||||
|
expect(await findByTestId('explanation-drawer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,65 @@
|
||||||
import { css } from '@emotion/css';
|
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 { SQLEditor, CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui';
|
||||||
import { DataQuery } from '@grafana/schema/dist/esm/index';
|
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 { SqlExpressionQuery } from '../types';
|
||||||
import { fetchSQLFields } from '../utils/metaSqlExpr';
|
import { fetchSQLFields } from '../utils/metaSqlExpr';
|
||||||
|
|
||||||
|
import { useSQLExplanations } from './GenAI/hooks/useSQLExplanations';
|
||||||
|
import { useSQLSuggestions } from './GenAI/hooks/useSQLSuggestions';
|
||||||
import { getSqlCompletionProvider } from './sqlCompletionProvider';
|
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
|
// Account for Monaco editor's border to prevent clipping
|
||||||
const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom
|
const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom
|
||||||
|
|
||||||
interface Props {
|
export interface SqlExprProps {
|
||||||
refIds: Array<SelectableValue<string>>;
|
refIds: Array<SelectableValue<string>>;
|
||||||
query: SqlExpressionQuery;
|
query: SqlExpressionQuery;
|
||||||
queries: DataQuery[] | undefined;
|
queries: DataQuery[] | undefined;
|
||||||
onChange: (query: SqlExpressionQuery) => void;
|
onChange: (query: SqlExpressionQuery) => void;
|
||||||
/** Should the `format` property be set to `alerting`? */
|
/** Should the `format` property be set to `alerting`? */
|
||||||
alerting?: boolean;
|
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 vars = useMemo(() => refIds.map((v) => v.value!), [refIds]);
|
||||||
const completionProvider = useMemo(
|
const completionProvider = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -48,12 +84,70 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries }:
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [dimensions, setDimensions] = useState({ height: 0 });
|
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) => {
|
const onEditorChange = (expression: string) => {
|
||||||
onChange({
|
onChange({
|
||||||
...query,
|
...query,
|
||||||
expression,
|
expression,
|
||||||
format: alerting ? 'alerting' : undefined,
|
format: alerting ? 'alerting' : undefined,
|
||||||
});
|
});
|
||||||
|
updatePrevExpression(expression);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onApplySuggestion = (suggestion: string) => {
|
||||||
|
onEditorChange(suggestion);
|
||||||
|
handleApplySuggestion(suggestion);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set up resize observer to handle container resizing
|
// Set up resize observer to handle container resizing
|
||||||
|
|
@ -81,24 +175,112 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries }:
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={styles.editorContainer}>
|
<>
|
||||||
<SQLEditor
|
<div className={styles.sqlContainer}>
|
||||||
query={query.expression || initialQuery}
|
<div className={styles.sqlButtons}>
|
||||||
onChange={onEditorChange}
|
<Stack direction="row" gap={1} alignItems="center" justifyContent="end">
|
||||||
height={dimensions.height - EDITOR_BORDER_ADJUSTMENT}
|
<Suspense fallback={null}>
|
||||||
language={EDITOR_LANGUAGE_DEFINITION}
|
{shouldShowViewExplanation ? (
|
||||||
/>
|
<Button
|
||||||
</div>
|
fill="outline"
|
||||||
|
icon="gf-movepane-right"
|
||||||
|
onClick={handleOpenExplanation}
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<Trans i18nKey="sql-expressions.view-explanation">View explanation</Trans>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<GenAISQLExplainButton
|
||||||
|
currentQuery={query.expression || ''}
|
||||||
|
onExplain={handleExplain}
|
||||||
|
queryContext={queryContext}
|
||||||
|
refIds={vars}
|
||||||
|
// schemas={schemas} // Will be added when schema extraction is implemented
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<GenAISQLSuggestionsButton
|
||||||
|
currentQuery={query.expression || ''}
|
||||||
|
initialQuery={initialQuery}
|
||||||
|
onGenerate={() => {}} // 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
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Stack>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SuggestionsDrawerButton handleOpenDrawer={handleOpenDrawer} suggestions={suggestions} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={containerRef} className={styles.editorContainer}>
|
||||||
|
<SQLEditor
|
||||||
|
query={query.expression || initialQuery}
|
||||||
|
onChange={onEditorChange}
|
||||||
|
height={dimensions.height - EDITOR_BORDER_ADJUSTMENT}
|
||||||
|
language={EDITOR_LANGUAGE_DEFINITION}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<GenAISuggestionsDrawer
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
onApplySuggestion={onApplySuggestion}
|
||||||
|
onClose={handleCloseDrawer}
|
||||||
|
suggestions={suggestions}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<GenAIExplanationDrawer
|
||||||
|
isOpen={isExplanationOpen}
|
||||||
|
onClose={handleCloseExplanation}
|
||||||
|
explanation={explanation}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStyles = () => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
sqlContainer: css({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: 'auto 1fr',
|
||||||
|
gridTemplateAreas: `
|
||||||
|
"buttons"
|
||||||
|
"editor"
|
||||||
|
`,
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
}),
|
||||||
editorContainer: css({
|
editorContainer: css({
|
||||||
|
gridArea: 'editor',
|
||||||
height: '240px',
|
height: '240px',
|
||||||
resize: 'vertical',
|
resize: 'vertical',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
minHeight: '100px',
|
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[]) {
|
async function fetchFields(identifier: TableIdentifier, queries: DataQuery[]) {
|
||||||
|
|
|
||||||
|
|
@ -7490,6 +7490,9 @@
|
||||||
"label-upsample": "Upsample",
|
"label-upsample": "Upsample",
|
||||||
"tooltip-s-m-h": "10s, 1m, 30m, 1h"
|
"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": {
|
"threshold": {
|
||||||
"label-input": "Input"
|
"label-input": "Input"
|
||||||
}
|
}
|
||||||
|
|
@ -12495,6 +12498,21 @@
|
||||||
"sort-picker": {
|
"sort-picker": {
|
||||||
"select-aria-label": "Sort"
|
"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": {
|
"stat": {
|
||||||
"add-orientation-option": {
|
"add-orientation-option": {
|
||||||
"description-orientation": "Layout orientation",
|
"description-orientation": "Layout orientation",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue