Files
openobserve/web/src/components/NLModeQueryBar.vue
T

428 lines
14 KiB
Vue

<template>
<div class="tw:w-full tw:flex tw:flex-col tw:gap-2">
<!-- Compact Layout: Editor only (toggle/button provided by parent) -->
<template v-if="layout === 'editor-only'">
<CodeQueryEditor
:ref="(el) => (editorRef = el)"
:editor-id="`${dataTestPrefix}-editor`"
:language="language"
:query="query"
:nlp-mode="nlpMode"
:read-only="readOnly"
:show-auto-complete="showAutoComplete"
@update:query="handleQueryUpdate"
@nlpModeDetected="handleNlpModeDetected"
@generation-start="handleGenerationStart"
@generation-end="handleGenerationEnd"
@generation-success="handleGenerationSuccess"
/>
</template>
<!-- Full Layout: Toggle + Editor + Button -->
<template v-else>
<!-- NL Mode Toggle Bar -->
<div class="tw:flex tw:items-center tw:gap-2 tw:mb-2">
<!-- NL Mode Toggle (always visible when AI enabled and enterprise) -->
<div
v-if="config.isEnterprise == 'true' && store.state.zoConfig.ai_enabled"
class="tw:flex tw:items-center tw:gap-2 tw:py-1 tw:px-2 tw:rounded element-box-shadow"
style="background-color: var(--q-dark-page)"
>
<OSwitch
:data-test="`${dataTestPrefix}-nlp-mode-toggle`"
v-model="nlpMode"
/>
<img :src="nlpIcon" alt="NL Mode" class="tw:w-5 tw:h-5" />
<OTooltip :content="t('nlMode.toggle')" side="top" />
</div>
<!-- Action Buttons (always present) -->
<div class="tw:flex tw:gap-2 tw:ml-auto">
<!-- Main Action Button: Run Query / Ask AI -->
<OButton
:data-test="`${dataTestPrefix}-action-btn`"
:variant="isAIMode ? 'ghost-primary' : 'ghost'"
size="sm"
:title="isAIMode ? aiButtonTooltip : normalButtonTooltip"
:class="buttonClasses"
:loading="loading"
:disabled="disabled || (isAIMode && isGenerating)"
@click="handleButtonClick"
>
<OIcon
v-if="showIcon"
:name="isAIMode ? 'auto-awesome' : 'search'" size="sm"
class="tw:mr-1"
/>
{{ isAIMode ? aiButtonLabel : normalButtonLabel }}
</OButton>
<!-- Dropdown (optional - for enterprise features) -->
<template v-if="showDropdown">
<OSeparator class="tw:h-7.25 tw:w-px" />
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
variant="ghost"
size="icon-xs"
class="tw:h-7.25 search-button-dropdown"
:class="dropdownClasses"
>
<OIcon name="arrow-drop-down" size="sm" />
</OButton>
</template>
<!-- Normal Mode: Refresh option -->
<template v-if="!isAIMode">
<ODropdownItem
:disabled="disabled"
@select="$emit('refresh')"
>
<template #icon-left>
<OIcon name="refresh" size="sm" />
</template>
{{ t('search.refreshCacheAndRunQuery') }}
</ODropdownItem>
</template>
<!-- AI Mode: Custom actions (slot) -->
<template v-else>
<slot name="dropdown-actions">
<div class="tw:min-w-35 tw:p-2 tw:text-xs tw:text-center tw:text-dropdown-item-text">
{{ t('nlMode.noAdditionalOptions') }}
</div>
</slot>
</template>
</ODropdown>
</template>
</div>
</div>
<!-- Query Editor with AI Input Bar -->
<div class="tw:w-full">
<!-- AI Input Bar (shown in NL Mode) -->
<div
v-if="isAIMode"
class="tw:p-3 tw:bg-[linear-gradient(135deg,rgba(139,92,246,0.05)_0%,rgba(236,72,153,0.05)_100%)] tw:border-b tw:border-(--o2-border-color)"
>
<!-- Show streaming status with spinner -->
<div v-if="isGenerating" class="ai-bar-streaming tw:flex tw:items-center tw:gap-2 tw:bg-white tw:rounded-lg tw:py-2 tw:px-3 tw:text-(--q-primary)">
<img :src="nlpIcon" alt="AI" class="tw:w-5 tw:h-5" />
<OSpinner variant="dots" size="xs" />
<span class="tw:text-sm tw:text-[#666]">{{ aiStatusText || t('search.analyzingQuery') }}</span>
</div>
<!-- Normal input when not generating -->
<OInput
v-else
v-model="aiInputText"
:placeholder="t('search.askAIPlaceholder')"
class="ai-input-field"
:data-test="`${dataTestPrefix}-ai-input-field`"
@keydown.enter="handleAIInputEnter"
>
<template v-slot:icon-left>
<img :src="nlpIcon" alt="AI" class="tw:w-5 tw:h-5" />
</template>
</OInput>
</div>
<!-- Code Editor -->
<CodeQueryEditor
:ref="(el) => (editorRef = el)"
:editor-id="`${dataTestPrefix}-editor`"
:language="language"
:query="query"
:nlp-mode="nlpMode"
:read-only="readOnly"
:show-auto-complete="showAutoComplete"
@update:query="handleQueryUpdate"
@nlpModeDetected="handleNlpModeDetected"
@generation-start="handleGenerationStart"
@generation-end="handleGenerationEnd"
@generation-success="handleGenerationSuccess"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import CodeQueryEditor from '@/components/CodeQueryEditor.vue';
import OButton from '@/lib/core/Button/OButton.vue';
import ODropdown from '@/lib/overlay/Dropdown/ODropdown.vue';
import ODropdownItem from '@/lib/overlay/Dropdown/ODropdownItem.vue';
import { getImageURL } from '@/utils/zincutils';
import config from '@/aws-exports';
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
interface Props {
// Query props
language: 'sql' | 'promql' | 'vrl' | 'javascript';
query: string;
readOnly?: boolean;
showAutoComplete?: boolean;
// Context-aware button labels (ONLY thing that changes per page)
normalButtonLabel?: string; // "Run Query", "Test Query", "Apply Query", "Validate VRL"
aiButtonLabel?: string; // Always "Ask AI" (default)
normalButtonTooltip?: string;
aiButtonTooltip?: string;
// State
loading?: boolean;
disabled?: boolean;
// UI customization
layout?: 'full' | 'editor-only'; // full: toggle + editor + button, editor-only: just editor
showIcon?: boolean;
showDropdown?: boolean;
borderRadius?: 'normal' | 'enterprise';
height?: string;
// Testing
dataTestPrefix?: string;
}
const props = withDefaults(defineProps<Props>(), {
readOnly: false,
showAutoComplete: true,
normalButtonLabel: 'Run Query',
aiButtonLabel: 'Ask AI',
normalButtonTooltip: 'Execute action',
aiButtonTooltip: 'Generate query from natural language using AI',
layout: 'full',
showIcon: true,
showDropdown: false,
borderRadius: 'normal',
height: '30px',
dataTestPrefix: 'nl-mode',
});
const emit = defineEmits<{
'update:query': [query: string];
'run-query': []; // Normal mode action
'ask-ai': [naturalLanguage: string]; // AI mode action
refresh: []; // Refresh cache action
}>();
const store = useStore();
const { t } = useI18n();
const nlpMode = ref(false);
const isNaturalLanguageDetected = ref(false);
const isGenerating = ref(false);
const editorRef = ref<any>(null);
// AI Input Bar state
const aiInputText = ref('');
const aiStatusText = ref('');
const nlpIcon = computed(() => {
return store.state.theme === 'dark'
? getImageURL('images/common/ai_icon_dark.svg')
: getImageURL('images/common/ai_icon_gradient.svg');
});
// Computed: Is in AI mode?
const isAIMode = computed(() => nlpMode.value || isNaturalLanguageDetected.value);
// Computed: Button classes
const buttonClasses = computed(() => {
const classes = [
'tw:p-0',
`tw:h-[${props.height}]`,
'element-box-shadow',
];
if (isAIMode.value) {
classes.push('tw:bg-[linear-gradient(135deg,#8B5CF6_0%,#EC4899_100%)]! tw:text-white! tw:border-none! tw:text-[0.6875rem]! tw:font-semibold! tw:leading-[1rem]! tw:transition-all! tw:duration-300! tw:ease-[ease]! tw:shadow-[0_0.25rem_0.9375rem_0_rgba(139,92,246,0.3)]! tw:px-3! tw:w-[92px]! tw:hover:shadow-[0_0.375rem_1.25rem_0_rgba(139,92,246,0.5)]! tw:hover:-translate-y-px tw:active:translate-y-0');
} else {
classes.push('tw:w-[92px]!', 'o2-color-primary');
}
if (props.borderRadius === 'enterprise') {
classes.push('search-button-enterprise-border-radius');
} else {
classes.push('search-button-normal-border-radius');
}
return classes;
});
// Computed: Dropdown classes
const dropdownClasses = computed(() => {
const classes = [];
if (isAIMode.value) {
classes.push('o2-ai-dropdown-button');
} else {
classes.push('o2-color-primary');
}
if (props.borderRadius === 'enterprise') {
classes.push('search-button-dropdown-enterprise-border-radius');
} else {
classes.push('search-button-normal-border-radius');
}
return classes;
});
// Handle query update from editor
const handleQueryUpdate = (newQuery: string) => {
emit('update:query', newQuery);
};
// Handle auto-detection from editor
const handleNlpModeDetected = (isNL: boolean) => {
isNaturalLanguageDetected.value = isNL;
};
// Detect if user wants to execute the query instead of generating a new one
const isExecutionIntent = (input: string): boolean => {
const normalized = input.toLowerCase().trim();
const executionKeywords = [
'run', 'run query', 'execute', 'execute query', 'search', 'go',
'submit', 'apply', 'show results', 'get results', 'fetch',
'run it', 'execute it', 'do it', 'run this', 'execute this'
];
return executionKeywords.includes(normalized);
};
// Handle AI input field Enter key
const handleAIInputEnter = async () => {
if (!aiInputText.value.trim()) {
return;
}
const naturalLanguage = aiInputText.value.trim();
const currentQuery = editorRef.value?.getValue ? editorRef.value.getValue() : props.query;
// Check if user wants to execute the query instead of generating a new one
if (currentQuery && currentQuery.trim() && isExecutionIntent(naturalLanguage)) {
aiInputText.value = ''; // Clear input
emit('run-query'); // Trigger query execution
return;
}
// Call the CodeQueryEditor's handleGenerateSQL method directly
if (editorRef.value && typeof editorRef.value.handleGenerateSQL === 'function') {
try {
aiStatusText.value = t('search.generatingQuery');
await editorRef.value.handleGenerateSQL(naturalLanguage);
// Success is handled by handleGenerationSuccess event
} catch (error) {
console.error('[NLModeQueryBar] Query generation failed:', error);
aiStatusText.value = '';
}
} else {
console.error('[NLModeQueryBar] Editor ref not found or handleGenerateSQL not available');
}
// Still emit event for parent components that may need to react
emit('ask-ai', naturalLanguage);
};
// Handle button click
const handleButtonClick = async () => {
if (isAIMode.value) {
// Ask AI mode - generate query using streaming API
// Use AI input text if available, otherwise use query from editor
const naturalLanguage = aiInputText.value.trim() || props.query;
if (!naturalLanguage) {
console.warn('[NLModeQueryBar] No input provided for AI generation');
return;
}
const currentQuery = editorRef.value?.getValue ? editorRef.value.getValue() : props.query;
// Check if user wants to execute the query instead of generating a new one
if (currentQuery && currentQuery.trim() && isExecutionIntent(naturalLanguage)) {
aiInputText.value = ''; // Clear input
emit('run-query'); // Trigger query execution
return;
}
// Turn on NLP mode if not already on
if (!nlpMode.value) {
nlpMode.value = true;
}
// Call the CodeQueryEditor's handleGenerateSQL method directly
if (editorRef.value && typeof editorRef.value.handleGenerateSQL === 'function') {
try {
aiStatusText.value = t('search.generatingQuery');
await editorRef.value.handleGenerateSQL(naturalLanguage);
// Success is handled by handleGenerationSuccess event
} catch (error) {
console.error('[NLModeQueryBar] Query generation failed:', error);
aiStatusText.value = '';
}
} else {
console.error('[NLModeQueryBar] Editor ref not found or handleGenerateSQL not available');
}
// Still emit event for parent components that may need to react
emit('ask-ai', naturalLanguage);
} else {
// Normal mode - run query
emit('run-query');
}
};
// Handle generation lifecycle events
const handleGenerationStart = () => {
isGenerating.value = true;
};
const handleGenerationEnd = () => {
isGenerating.value = false;
};
const handleGenerationSuccess = ({ type, message }: any) => {
// Show success message in AI status
aiStatusText.value = '✓ ' + t('search.queryGeneratedSuccess');
// Clear AI input text after successful generation
setTimeout(() => {
aiInputText.value = '';
aiStatusText.value = '';
}, 2000);
// After successful generation, turn off NLP mode
if (type === 'sql' || type === 'promql' || type === 'vrl') {
nlpMode.value = false;
isNaturalLanguageDetected.value = false;
}
};
// Expose methods for parent components
defineExpose({
generateQuery: async () => {
if (editorRef.value?.handleGenerateSQL) {
await editorRef.value.handleGenerateSQL();
}
},
getValue: () => {
return editorRef.value?.getValue();
},
setValue: (value: string) => {
if (editorRef.value?.setValue) {
editorRef.value.setValue(value);
}
},
toggleNlpMode: (enabled: boolean) => {
nlpMode.value = enabled;
},
});
</script>