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 { 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<string[]>([]);
|
||||
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 (
|
||||
<div className={styles.wrapper}>
|
||||
<Stack direction="row" gap={0.5} alignItems="center">
|
||||
{isGenerating && <Spinner size="sm" className={styles.spinner} />}
|
||||
{isFirstHistoryEntry ? (
|
||||
<Tooltip show={showTooltip} interactive content={tooltipContent}>
|
||||
|
|
@ -207,14 +222,11 @@ export const GenAIButton = ({
|
|||
) : (
|
||||
renderButtonWithToggletip()
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
spinner: css({
|
||||
color: theme.colors.text.link,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
)}
|
||||
|
||||
<GenerationHistoryCarousel history={history} index={currentIndex} onNavigate={onNavigate} />
|
||||
|
||||
<div className={styles.actionButtons}>
|
||||
<QuickFeedback onSuggestionClick={onGenerateWithFeedback} isGenerating={isStreamGenerating} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export enum EventTrackingSrc {
|
|||
dashboardChanges = 'dashboard-changes',
|
||||
dashboardTitle = 'dashboard-title',
|
||||
dashboardDescription = 'dashboard-description',
|
||||
sqlExpressions = 'sql-expressions',
|
||||
unknown = 'unknown',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { Threshold } from './components/Threshold';
|
|||
import { ExpressionQuery, ExpressionQueryType, expressionTypes } from './types';
|
||||
import { getDefaults } from './utils/expressionTypes';
|
||||
|
||||
type Props = QueryEditorProps<DataSourceApi<ExpressionQuery>, ExpressionQuery>;
|
||||
export type ExpressionQueryEditorProps = QueryEditorProps<DataSourceApi<ExpressionQuery>, 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 <Threshold onChange={onChange} query={query} labelWidth={labelWidth} refIds={refIds} />;
|
||||
|
||||
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.requireActual('@grafana/ui'),
|
||||
|
|
@ -13,13 +13,54 @@ jest.mock('@grafana/plugin-ui', () => ({
|
|||
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', () => {
|
||||
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(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
||||
await act(async () => {
|
||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
||||
});
|
||||
|
||||
// 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(<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
|
||||
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(<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];
|
||||
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 { 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<SelectableValue<string>>;
|
||||
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<HTMLDivElement>(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 (
|
||||
<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 className={styles.sqlContainer}>
|
||||
<div className={styles.sqlButtons}>
|
||||
<Stack direction="row" gap={1} alignItems="center" justifyContent="end">
|
||||
<Suspense fallback={null}>
|
||||
{shouldShowViewExplanation ? (
|
||||
<Button
|
||||
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({
|
||||
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[]) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue