mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
## Summary Fixes - https://github.com/openobserve/o2-enterprise/issues/1892 - Added explicit permission check in `chat` and `chat_stream` handlers that returns `MetaHttpResponse::forbidden("Unauthorized Access")` (HTTP 403) when the user lacks AI access, ensuring consistent 403 behavior regardless of OpenFGA state - Updated all 4 SSE error event handlers in `O2AIChat.vue` to recognize authorization-related error messages and display the standard "Unauthorized Access" message ## Why When a user without AI permissions used the query editor, the auth middleware could pass the request through (e.g., when OpenFGA was disabled), and the agent service error would arrive as an SSE event within a 200 response — bypassing the standard 403 flow. This fix ensures the backend returns proper HTTP 403 and the frontend displays the standard unauthorized message consistently. ## Test plan - [ ] Verify non-root user without AI permission gets HTTP 403 from `/api/{org}/ai/chat_stream` - [ ] Verify non-root user without AI permission gets HTTP 403 from `/api/{org}/ai/chat` - [ ] Verify root users are unaffected (bypass permission check) - [ ] Verify the frontend displays "Unauthorized Access: You are not authorized to perform this operation..." for auth-related SSE errors - [ ] Verify non-auth SSE errors continue to display normally 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1506 lines
45 KiB
Vue
1506 lines
45 KiB
Vue
<!-- Copyright 2026 OpenObserve Inc.
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
-->
|
|
|
|
<template>
|
|
<div class="code-query-editor-container" v-bind="$attrs">
|
|
<div
|
|
data-test="query-editor"
|
|
class="logs-query-editor"
|
|
ref="editorRef"
|
|
:id="editorId"
|
|
/>
|
|
<!-- AI Icon Button -->
|
|
<OButton
|
|
v-if="showAiIcon && !disableAi"
|
|
variant="sidebar-toggle"
|
|
size="icon-toolbar"
|
|
class="ai-icon-button"
|
|
:class="nlpMode ? 'ai-icon-active' : ''"
|
|
@click="toggleNlpMode"
|
|
data-test="query-editor-ai-icon-btn"
|
|
>
|
|
<OIcon size="md">
|
|
<img :src="aiIcon" alt="AI" class="ai-icon-img" />
|
|
</OIcon>
|
|
<OTooltip side="top" align="center">
|
|
<template #content>{{ disableAiReason || t(nlpMode ? 'search.nlpModeEnabled' : 'search.nlpModeLabel') }}</template>
|
|
</OTooltip>
|
|
</OButton>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import {
|
|
defineComponent,
|
|
ref,
|
|
onMounted,
|
|
nextTick,
|
|
type Ref,
|
|
onDeactivated,
|
|
onUnmounted,
|
|
onActivated,
|
|
watch,
|
|
computed,
|
|
} from "vue";
|
|
|
|
// Lazy load Monaco Editor - only loaded when this component is rendered
|
|
// This reduces initial bundle size by ~3.1MB
|
|
let monaco: any = null;
|
|
const loadMonaco = async () => {
|
|
if (!monaco) {
|
|
await import("monaco-editor/esm/vs/editor/editor.all.js");
|
|
monaco = await import("monaco-editor/esm/vs/editor/editor.api");
|
|
}
|
|
return monaco;
|
|
};
|
|
|
|
import { vrlLanguageDefinition } from "@/utils/query/vrlLanguageDefinition";
|
|
|
|
import { useStore } from "vuex";
|
|
import { debounce } from "lodash-es";
|
|
import searchState from "@/composables/useLogs/searchState";
|
|
import { useNLQuery } from "@/composables/useNLQuery";
|
|
import { useI18n } from "vue-i18n";
|
|
import useNotifications from "@/composables/useNotifications";
|
|
import { getImageURL } from "@/utils/zincutils";
|
|
import { isAuthError } from "@/utils/authErrors";
|
|
import OButton from "@/lib/core/Button/OButton.vue";
|
|
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
|
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
|
export default defineComponent({
|
|
inheritAttrs: false,
|
|
components: { OButton, OIcon, OTooltip },
|
|
props: {
|
|
editorId: {
|
|
type: String,
|
|
default: "editor",
|
|
},
|
|
query: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
showAutoComplete: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
keywords: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
suggestions: {
|
|
type: Array,
|
|
default: null,
|
|
},
|
|
debounceTime: {
|
|
type: Number,
|
|
default: 500,
|
|
},
|
|
readOnly: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
showLineNumbers: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
stickyScroll: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
language: {
|
|
type: String,
|
|
default: "sql",
|
|
},
|
|
functions: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
fields: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
nlpMode: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
showAiIcon: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
disableAi: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
disableAiReason: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
},
|
|
emits: [
|
|
"update-query",
|
|
"run-query",
|
|
"update:query",
|
|
"focus",
|
|
"blur",
|
|
"nlpModeDetected",
|
|
"generation-start",
|
|
"generation-end",
|
|
"generation-success",
|
|
"toggle-nlp-mode",
|
|
],
|
|
setup(props, { emit }) {
|
|
const store = useStore();
|
|
const { t } = useI18n();
|
|
const { showErrorNotification } = useNotifications();
|
|
const editorRef: any = ref();
|
|
// editor object is used to interact with the monaco editor instance
|
|
let editorObj: any = null;
|
|
const { searchObj } = searchState();
|
|
const {
|
|
detectNaturalLanguage,
|
|
generateSQL,
|
|
transformToSQL,
|
|
isGenerating,
|
|
streamingResponse,
|
|
} = useNLQuery();
|
|
|
|
let provider: Ref<any | null> = ref(null);
|
|
const currentEditorText = ref("");
|
|
|
|
// These will be initialized when Monaco loads
|
|
let CompletionKind: any = null;
|
|
let insertTextRules: any = null;
|
|
|
|
const initializeMonacoConstants = () => {
|
|
if (!monaco || CompletionKind) return;
|
|
|
|
CompletionKind = {
|
|
Keyword: monaco.languages.CompletionItemKind.Keyword,
|
|
Operator: monaco.languages.CompletionItemKind.Operator,
|
|
Text: monaco.languages.CompletionItemKind.Text,
|
|
Value: monaco.languages.CompletionItemKind.Value,
|
|
Method: monaco.languages.CompletionItemKind.Method,
|
|
Function: monaco.languages.CompletionItemKind.Function,
|
|
Constructor: monaco.languages.CompletionItemKind.Constructor,
|
|
Field: monaco.languages.CompletionItemKind.Field,
|
|
Variable: monaco.languages.CompletionItemKind.Variable,
|
|
};
|
|
|
|
insertTextRules = {
|
|
InsertAsSnippet:
|
|
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
KeepWhitespace:
|
|
monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
|
|
None: monaco.languages.CompletionItemInsertTextRule.None,
|
|
};
|
|
};
|
|
|
|
const defaultKeywords = [
|
|
{
|
|
label: "and",
|
|
kind: "Keyword",
|
|
insertText: "and ",
|
|
},
|
|
{
|
|
label: "or",
|
|
kind: "Keyword",
|
|
insertText: "or ",
|
|
},
|
|
{
|
|
label: "like",
|
|
kind: "Keyword",
|
|
insertText: "like '%${1:params}%' ",
|
|
insertTextRules: "InsertAsSnippet",
|
|
},
|
|
{
|
|
label: "in",
|
|
kind: "Keyword",
|
|
insertText: "in ('${1:params}') ",
|
|
insertTextRules: "InsertAsSnippet",
|
|
},
|
|
{
|
|
label: "not in",
|
|
kind: "Keyword",
|
|
insertText: "not in ('${1:params}') ",
|
|
insertTextRules: "InsertAsSnippet",
|
|
},
|
|
{
|
|
label: "between",
|
|
kind: "Keyword",
|
|
insertText: "between '${1:params}' and '${1:params}' ",
|
|
insertTextRules: "InsertAsSnippet",
|
|
},
|
|
{
|
|
label: "not between",
|
|
kind: "Keyword",
|
|
insertText: "not between '${1:params}' and '${1:params}' ",
|
|
insertTextRules: "InsertAsSnippet",
|
|
},
|
|
{
|
|
label: "is null",
|
|
kind: "Keyword",
|
|
insertText: "is null ",
|
|
},
|
|
{
|
|
label: "is not null",
|
|
kind: "Keyword",
|
|
insertText: "is not null ",
|
|
},
|
|
{
|
|
label: ">",
|
|
kind: "Operator",
|
|
insertText: "> ",
|
|
},
|
|
{
|
|
label: "<",
|
|
kind: "Operator",
|
|
insertText: "< ",
|
|
},
|
|
{
|
|
label: ">=",
|
|
kind: "Operator",
|
|
insertText: ">= ",
|
|
},
|
|
{
|
|
label: "<=",
|
|
kind: "Operator",
|
|
insertText: "<= ",
|
|
},
|
|
{
|
|
label: "<>",
|
|
kind: "Operator",
|
|
insertText: "<> ",
|
|
},
|
|
{
|
|
label: "=",
|
|
kind: "Operator",
|
|
insertText: "= ",
|
|
},
|
|
{
|
|
label: "!=",
|
|
kind: "Operator",
|
|
insertText: "!= ",
|
|
},
|
|
{
|
|
label: "()",
|
|
kind: "Keyword",
|
|
insertText: "(${1:condition}) ",
|
|
insertTextRules: "InsertAsSnippet",
|
|
},
|
|
];
|
|
const defaultSuggestions = [
|
|
{
|
|
label: (_keyword: string) => `match_all('${_keyword}')`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) => `match_all('${_keyword}')`,
|
|
},
|
|
{
|
|
label: (_keyword: string) => `match_all_raw('${_keyword}')`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) => `match_all_raw('${_keyword}')`,
|
|
},
|
|
{
|
|
label: (_keyword: string) => `match_all_raw_ignore_case('${_keyword}')`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) =>
|
|
`match_all_raw_ignore_case('${_keyword}')`,
|
|
},
|
|
{
|
|
label: (_keyword: string) =>
|
|
`re_match(fieldname: string, regular_expression: string)`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) => `re_match(fieldname, '')`,
|
|
},
|
|
{
|
|
label: (_keyword: string) =>
|
|
`re_not_match(fieldname: string, regular_expression: string)`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) => `re_not_match(fieldname, '')`,
|
|
},
|
|
{
|
|
label: (_keyword: string) => `str_match(fieldname, '${_keyword}')`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) => `str_match(fieldname, '${_keyword}')`,
|
|
},
|
|
{
|
|
label: (_keyword: string) =>
|
|
`str_match_ignore_case(fieldname, '${_keyword}')`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) =>
|
|
`str_match_ignore_case(fieldname, '${_keyword}')`,
|
|
},
|
|
{
|
|
label: (_keyword: string) => `fuzzy_match(fieldname, '${_keyword}', 1)`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) =>
|
|
`fuzzy_match(fieldname, '${_keyword}', 1)`,
|
|
},
|
|
{
|
|
label: (_keyword: string) => `fuzzy_match_all('${_keyword}', 1)`,
|
|
kind: "Text",
|
|
insertText: (_keyword: string) => `fuzzy_match_all('${_keyword}', 1)`,
|
|
},
|
|
];
|
|
|
|
watch(
|
|
() => store.state.theme,
|
|
() => {
|
|
if (!monaco) return;
|
|
monaco.editor.setTheme(
|
|
store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
|
|
);
|
|
},
|
|
);
|
|
|
|
const keywords = computed(() => {
|
|
if (props.language === "sql" && !props.keywords?.length) {
|
|
return defaultKeywords;
|
|
}
|
|
return props.keywords;
|
|
});
|
|
|
|
const suggestions = computed(() => {
|
|
if (props.language === "sql" && props.suggestions == null) {
|
|
return defaultSuggestions;
|
|
}
|
|
return props.suggestions ?? [];
|
|
});
|
|
|
|
/**
|
|
* Debounced function to detect natural language and auto-toggle NLP mode
|
|
* Waits 500ms after user stops typing before checking
|
|
*
|
|
* CRITICAL BEHAVIOR:
|
|
* - If NOT in NLP mode: Auto-detect and emit event to turn ON NLP mode for natural language
|
|
* - If ALREADY in NLP mode: Do NOT emit events (keep NLP mode ON regardless of what user types)
|
|
* - NLP mode only turns OFF when AI successfully generates SQL query
|
|
*/
|
|
const checkForNaturalLanguage = debounce((text: string) => {
|
|
currentEditorText.value = text;
|
|
const isNL = detectNaturalLanguage(text, props.language);
|
|
|
|
console.log("[NL2Q-Detection]", {
|
|
text: text.substring(0, 50),
|
|
language: props.language,
|
|
isNaturalLanguage: isNL,
|
|
currentNlpMode: props.nlpMode,
|
|
});
|
|
|
|
// ONLY emit events if NOT already in NLP mode (auto-detection feature)
|
|
// If already in NLP mode (user toggled it), don't change anything
|
|
if (!props.nlpMode) {
|
|
if (isNL) {
|
|
console.log(
|
|
"[NL2Q-Detection] Natural language detected, emitting nlpModeDetected: true",
|
|
);
|
|
emit("nlpModeDetected", true);
|
|
} else {
|
|
console.log(
|
|
"[NL2Q-Detection] Query syntax detected, emitting nlpModeDetected: false",
|
|
);
|
|
emit("nlpModeDetected", false);
|
|
}
|
|
} else {
|
|
console.log(
|
|
"[NL2Q-Detection] Already in NLP mode, not emitting auto-detection events",
|
|
);
|
|
}
|
|
}, 500);
|
|
|
|
/**
|
|
* Handles Generate SQL button click
|
|
* Calls AI to generate query based on current language (SQL, PromQL, VRL, JavaScript)
|
|
* @param customText - Optional custom text to use instead of editor content
|
|
*/
|
|
const handleGenerateSQL = async (
|
|
customText?: string,
|
|
abortSignal?: AbortSignal,
|
|
sessionId?: string,
|
|
) => {
|
|
const currentText = customText || currentEditorText.value;
|
|
if (!currentText.trim()) return;
|
|
|
|
const currentLanguage = props.language?.toLowerCase() || "sql";
|
|
console.log(
|
|
"[NL2Q-UI] Starting query generation for language:",
|
|
currentLanguage,
|
|
"text:",
|
|
currentText,
|
|
);
|
|
|
|
try {
|
|
// Get organization ID from store
|
|
const orgId = store.state.selectedOrganization?.identifier || "default";
|
|
console.log("[NL2Q-UI] Organization ID:", orgId);
|
|
|
|
// Create language-appropriate prompt
|
|
let promptPrefix = "";
|
|
switch (currentLanguage) {
|
|
case "promql":
|
|
promptPrefix = "Generate PromQL query";
|
|
break;
|
|
case "vrl":
|
|
promptPrefix = "Generate VRL function";
|
|
break;
|
|
case "javascript":
|
|
promptPrefix = "Generate JavaScript function";
|
|
break;
|
|
case "sql":
|
|
default:
|
|
promptPrefix = "Generate SQL query";
|
|
break;
|
|
}
|
|
|
|
const prompt = `${promptPrefix} : ${currentText}`;
|
|
console.log("[NL2Q-UI] Generated prompt:", prompt);
|
|
|
|
// Generate query from natural language
|
|
console.log("[NL2Q-UI] Calling generateSQL...");
|
|
const generatedSQL = await generateSQL(
|
|
prompt,
|
|
orgId,
|
|
abortSignal,
|
|
sessionId,
|
|
);
|
|
console.log("[NL2Q-UI] generateSQL returned:", {
|
|
value: generatedSQL,
|
|
type: typeof generatedSQL,
|
|
isNull: generatedSQL === null,
|
|
isEmpty: generatedSQL === "",
|
|
isFalsy: !generatedSQL,
|
|
});
|
|
|
|
if (!generatedSQL || generatedSQL.trim() === "") {
|
|
// Show error notification - use streaming error message if available (e.g. Unauthorized Access)
|
|
console.log(
|
|
"[NL2Q-UI] Showing error notification - query generation failed or empty",
|
|
);
|
|
const errorMsg = isAuthError(streamingResponse.value)
|
|
? streamingResponse.value
|
|
: t("search.nlQueryGenerationFailed");
|
|
showErrorNotification(errorMsg);
|
|
throw new Error("Query generation failed");
|
|
}
|
|
|
|
// Check if this is a special action completion (dashboard/alert)
|
|
if (generatedSQL.startsWith("✓ DASHBOARD_CREATED:")) {
|
|
console.log("[NL2Q-UI] Dashboard created successfully");
|
|
const responseText = generatedSQL
|
|
.replace("✓ DASHBOARD_CREATED:", "")
|
|
.trim();
|
|
emit("generation-success", {
|
|
type: "dashboard",
|
|
message: responseText,
|
|
});
|
|
// Don't emit nlpModeDetected - keep user in current mode
|
|
return; // Success without SQL
|
|
}
|
|
|
|
if (generatedSQL.startsWith("✓ ALERT_CREATED:")) {
|
|
console.log("[NL2Q-UI] Alert created successfully");
|
|
const responseText = generatedSQL
|
|
.replace("✓ ALERT_CREATED:", "")
|
|
.trim();
|
|
emit("generation-success", { type: "alert", message: responseText });
|
|
// Don't emit nlpModeDetected - keep user in current mode
|
|
return; // Success without SQL
|
|
}
|
|
|
|
if (generatedSQL.startsWith("✓ ACTION_COMPLETED:")) {
|
|
console.log("[NL2Q-UI] Action completed successfully");
|
|
const responseText = generatedSQL
|
|
.replace("✓ ACTION_COMPLETED:", "")
|
|
.trim();
|
|
emit("generation-success", { type: "action", message: responseText });
|
|
// Don't emit nlpModeDetected - keep user in current mode
|
|
return; // Success without SQL
|
|
}
|
|
|
|
// Normal query generation - transform and update editor with language-specific comments
|
|
const transformedText = transformToSQL(
|
|
currentText,
|
|
generatedSQL,
|
|
props.language,
|
|
);
|
|
console.log("[NL2Q-UI] Transformed text:", transformedText);
|
|
|
|
// Update editor value
|
|
setValue(transformedText);
|
|
|
|
// Emit update events
|
|
emit("update-query", transformedText);
|
|
emit("update:query", transformedText);
|
|
|
|
// Turn off NLP mode after generating SQL (we're now in SQL mode)
|
|
emit("nlpModeDetected", false);
|
|
console.log(
|
|
"[NL2Q-UI] Emitted nlpModeDetected: false to turn off NLP mode",
|
|
);
|
|
|
|
// Emit SQL generation success
|
|
emit("generation-success", { type: "sql", message: generatedSQL });
|
|
|
|
console.log("[NL2Q-UI] SQL generation completed successfully");
|
|
} catch (error) {
|
|
console.error("[NL2Q-UI] Exception during SQL generation:", error);
|
|
showErrorNotification(t("search.nlQueryGenerationFailed"));
|
|
throw error; // Re-throw so SearchBar can handle it
|
|
}
|
|
};
|
|
|
|
const createDependencyProposals = (range: any) => {
|
|
if (!CompletionKind || !insertTextRules) return [];
|
|
return keywords.value.map((keyword: any) => {
|
|
const itemObj: any = {
|
|
...keyword,
|
|
label: keyword["label"],
|
|
kind: CompletionKind[keyword["kind"]],
|
|
insertText: keyword["insertText"],
|
|
range: range,
|
|
};
|
|
if (insertTextRules[keyword["insertTextRule"]]) {
|
|
itemObj["insertTextRules"] =
|
|
insertTextRules[keyword["insertTextRule"]];
|
|
}
|
|
return itemObj;
|
|
});
|
|
};
|
|
|
|
const setupEditor = async () => {
|
|
// Lazy load Monaco Editor on first use
|
|
const monacoModule = await loadMonaco();
|
|
monaco = monacoModule;
|
|
|
|
// Expose Monaco on window for e2e tests (read-only assertions against editor model).
|
|
// Tests use: window.monaco.editor.getModels()[0].getValue()
|
|
if (typeof window !== "undefined") {
|
|
(window as any).monaco = monacoModule;
|
|
}
|
|
|
|
// Initialize Monaco constants after loading
|
|
initializeMonacoConstants();
|
|
|
|
// Register custom languages after Monaco is loaded
|
|
if (props.language === "promql") {
|
|
monaco.languages.register({ id: "promql" });
|
|
}
|
|
if (props.language === "vrl") {
|
|
monaco.languages.register({ id: "vrl" });
|
|
|
|
// Register a tokens provider for the language
|
|
monaco.languages.setMonarchTokensProvider(
|
|
"vrl",
|
|
vrlLanguageDefinition as any,
|
|
);
|
|
}
|
|
|
|
monaco.editor.defineTheme("myCustomTheme", {
|
|
base: "vs", // can also be vs-dark or hc-black
|
|
inherit: true, // can also be false to completely replace the builtin rules
|
|
rules: [{ token: "comment", background: "FFFFFF" }],
|
|
colors: {
|
|
"editor.foreground": "#000000",
|
|
"editor.background": "#fafafa",
|
|
"editorCursor.foreground": "#000000",
|
|
"editor.lineHighlightBackground": "#FFFFFF",
|
|
"editorLineNumber.foreground": "#000000",
|
|
"editor.border": "#000000",
|
|
},
|
|
});
|
|
|
|
monaco.editor.defineTheme("myCustomDarkTheme", {
|
|
base: "vs-dark",
|
|
inherit: true,
|
|
rules: [
|
|
{ token: "string", foreground: "CE9178" },
|
|
{ token: "string.sql", foreground: "CE9178" },
|
|
{ token: "string.vrl", foreground: "CE9178" },
|
|
],
|
|
colors: {},
|
|
});
|
|
|
|
// Dispose the provider if it already exists before registering a new one
|
|
provider.value?.dispose();
|
|
registerAutoCompleteProvider();
|
|
|
|
let editorElement = document.getElementById(props.editorId);
|
|
let retryCount = 0;
|
|
const maxRetries = 5;
|
|
|
|
// Retry mechanism to ensure the editor element is found
|
|
while (!editorElement && retryCount < maxRetries) {
|
|
await new Promise((resolve) => setTimeout(resolve, 100)); // Wait for 100ms
|
|
editorElement = document.getElementById(props.editorId);
|
|
retryCount++;
|
|
}
|
|
|
|
if (!editorElement) {
|
|
return;
|
|
}
|
|
|
|
// If editor already exists (hasChildNodes), update its value and options instead of returning
|
|
if (editorElement && editorElement?.hasChildNodes()) {
|
|
if (editorObj) {
|
|
// Update editor value if different from current
|
|
const currentValue = editorObj?.getValue();
|
|
if (currentValue !== props.query?.trim()) {
|
|
editorObj.setValue(props.query?.trim() || "");
|
|
}
|
|
|
|
// Update readonly option if different
|
|
const currentReadOnly = editorObj.getRawOptions().readOnly;
|
|
if (currentReadOnly !== props.readOnly) {
|
|
editorObj.updateOptions({ readOnly: props.readOnly });
|
|
}
|
|
return;
|
|
} else {
|
|
// editorObj is null but element has children - stale DOM
|
|
// Don't recreate if new props are empty/readonly (likely a stale mount with default props)
|
|
// The existing editor in the DOM is probably correct
|
|
if (!props.query?.trim() && props.readOnly) {
|
|
return;
|
|
}
|
|
|
|
// New props have actual data - safe to recreate
|
|
editorElement.innerHTML = "";
|
|
// Fall through to create new editor below
|
|
}
|
|
}
|
|
editorObj = monaco.editor.create(editorElement as HTMLElement, {
|
|
value: props.query?.trim(),
|
|
language: props.language,
|
|
theme: store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
|
|
showFoldingControls: enableCodeFolding.value ? "always" : "never",
|
|
folding: enableCodeFolding.value,
|
|
wordWrap: "on",
|
|
automaticLayout: true,
|
|
lineNumbers: props.showLineNumbers ? "on" : "off",
|
|
// Reserve a couple of gutter chars so the right-aligned line numbers get
|
|
// left breathing room instead of sitting flush against the editor edge
|
|
// (and so the gutter width doesn't visibly jump as digit count grows).
|
|
lineNumbersMinChars: 2,
|
|
overviewRulerLanes: 0,
|
|
fixedOverflowWidgets: true,
|
|
overviewRulerBorder: false,
|
|
// Gap between the (right-aligned) line numbers and the code text. 3px was
|
|
// too tight and made the digit visually collide with the first character.
|
|
lineDecorationsWidth: 10,
|
|
hideCursorInOverviewRuler: true,
|
|
renderLineHighlight: "none",
|
|
glyphMargin: false,
|
|
scrollBeyondLastColumn: 0,
|
|
scrollBeyondLastLine: false,
|
|
// Small top/bottom breathing room so line 1 (and the cursor) doesn't
|
|
// hug the top edge of the editor.
|
|
padding: { top: 3, bottom: 3 },
|
|
smoothScrolling: true,
|
|
mouseWheelScrollSensitivity: 1,
|
|
fastScrollSensitivity: 1,
|
|
scrollbar: { horizontal: "auto", vertical: "visible" },
|
|
find: {
|
|
addExtraSpaceOnTop: false,
|
|
autoFindInSelection: "never",
|
|
seedSearchStringFromSelection: "never",
|
|
},
|
|
minimap: { enabled: false },
|
|
readOnly: props.readOnly,
|
|
renderValidationDecorations: "on",
|
|
stickyScroll: {
|
|
enabled: props.stickyScroll,
|
|
},
|
|
});
|
|
|
|
editorObj.onDidChangeModelContent(
|
|
debounce((e: any) => {
|
|
const newValue = editorObj?.getValue()?.trim();
|
|
emit("update-query", newValue, e);
|
|
emit("update:query", newValue, e);
|
|
|
|
// Check for natural language after user stops typing (debounced)
|
|
if (newValue) checkForNaturalLanguage(newValue);
|
|
|
|
validateDoubleQuotes();
|
|
}, props.debounceTime),
|
|
);
|
|
|
|
editorObj.createContextKey("ctrlenter", true);
|
|
editorObj.addCommand(
|
|
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
|
|
function () {
|
|
// Emit run-query event when Ctrl/Cmd+Enter is pressed
|
|
emit("run-query");
|
|
},
|
|
"ctrlenter",
|
|
);
|
|
editorObj.onDidFocusEditorWidget(() => {
|
|
emit("focus");
|
|
|
|
// added hack to handle case where ctrl+enter / cmd+enter stops working after
|
|
// user click on the result row and open sidebase or opensidebar from schedule search
|
|
// This is because the editor loses focus and the context key "ctrlenter" is not active anymore, so we need to re-add the command on focus
|
|
editorObj.addCommand(
|
|
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
|
|
function () {
|
|
// Emit run-query event when Ctrl/Cmd+Enter is pressed
|
|
emit("run-query");
|
|
},
|
|
"ctrlenter",
|
|
);
|
|
});
|
|
|
|
editorObj.onDidBlurEditorWidget(() => {
|
|
const model = editorObj?.getModel();
|
|
const value = model?.getValue();
|
|
const trimmedValue = value?.trim();
|
|
|
|
// Only apply trim if there are actually tailing and leading spaces to trim
|
|
if (value !== trimmedValue) {
|
|
const lastLine = model.getLineCount();
|
|
const lastLineLength = model.getLineLength(lastLine);
|
|
|
|
// Create an edit operation that replaces the entire content
|
|
// This preserves undo history becuase it treats this as a single edit operation
|
|
//and it will be in the undo stack as one operation
|
|
model.pushEditOperations(
|
|
[],
|
|
[
|
|
{
|
|
range: new monaco.Range(1, 1, lastLine, lastLineLength + 1),
|
|
text: trimmedValue,
|
|
},
|
|
],
|
|
() => null,
|
|
);
|
|
}
|
|
emit("blur");
|
|
});
|
|
|
|
const handleWindowClick = () => {
|
|
editorObj?.layout();
|
|
};
|
|
|
|
const handleWindowResize = async () => {
|
|
await nextTick();
|
|
editorObj?.layout();
|
|
// queryEditorRef.value.resetEditorLayout();
|
|
};
|
|
|
|
window.addEventListener("click", handleWindowClick);
|
|
window.addEventListener("resize", handleWindowResize);
|
|
|
|
// Store references for cleanup
|
|
editorObj._windowClickHandler = handleWindowClick;
|
|
editorObj._windowResizeHandler = handleWindowResize;
|
|
|
|
// Validate the initial query value on mount
|
|
validateDoubleQuotes();
|
|
};
|
|
|
|
onMounted(async () => {
|
|
provider.value?.dispose();
|
|
|
|
if (props.language === "sql") {
|
|
await import("monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js");
|
|
}
|
|
|
|
if (props.language === "json") {
|
|
await import("monaco-editor/esm/vs/language/json/monaco.contribution.js");
|
|
}
|
|
|
|
if (props.language === "html") {
|
|
await import("monaco-editor/esm/vs/language/html/monaco.contribution.js");
|
|
}
|
|
|
|
if (props.language === "markdown") {
|
|
await import("monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js");
|
|
}
|
|
|
|
if (props.language === "python") {
|
|
await import("monaco-editor/esm/vs/basic-languages/python/python.contribution.js");
|
|
}
|
|
if (props.language === "javascript") {
|
|
await import("monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js");
|
|
}
|
|
|
|
setupEditor();
|
|
});
|
|
|
|
onActivated(async () => {
|
|
if (!editorObj) {
|
|
setupEditor();
|
|
editorObj?.layout();
|
|
} else {
|
|
provider.value?.dispose();
|
|
registerAutoCompleteProvider();
|
|
}
|
|
});
|
|
|
|
onDeactivated(() => {
|
|
provider.value?.dispose();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
provider.value?.dispose();
|
|
|
|
// Clean up global event listeners
|
|
if (editorObj) {
|
|
if (editorObj._windowClickHandler) {
|
|
window.removeEventListener("click", editorObj._windowClickHandler);
|
|
}
|
|
if (editorObj._windowResizeHandler) {
|
|
window.removeEventListener("resize", editorObj._windowResizeHandler);
|
|
}
|
|
|
|
// Dispose the editor
|
|
editorObj.dispose();
|
|
editorObj = null;
|
|
}
|
|
});
|
|
|
|
const enableCodeFolding = computed(() => {
|
|
return ["json", "html", "javascript"].includes(props.language);
|
|
});
|
|
|
|
// update readonly when prop value changes
|
|
watch(
|
|
() => props.readOnly,
|
|
() => {
|
|
editorObj?.updateOptions({ readOnly: props.readOnly });
|
|
},
|
|
);
|
|
|
|
// update lineNumbers when prop value changes
|
|
watch(
|
|
() => props.showLineNumbers,
|
|
() => {
|
|
editorObj?.updateOptions({
|
|
lineNumbers: props.showLineNumbers ? "on" : "off",
|
|
});
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => store.state.theme,
|
|
() => {
|
|
if (!monaco) return;
|
|
monaco.editor.setTheme(
|
|
store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
|
|
);
|
|
},
|
|
);
|
|
|
|
// update readonly when prop value changes
|
|
watch(
|
|
() => props.query,
|
|
(newQuery, oldQuery) => {
|
|
if (!editorObj) return;
|
|
|
|
const currentValue = editorObj?.getValue();
|
|
const newValue = props.query || "";
|
|
const hasFocus = editorObj.hasWidgetFocus();
|
|
|
|
// Only update if:
|
|
// 1. Editor doesn't have focus (external update), OR
|
|
// 2. It's readonly AND values are actually different
|
|
// 3. Compare trimmed values to avoid cursor jumps from trailing spaces
|
|
const shouldUpdate =
|
|
(props.readOnly || !hasFocus) &&
|
|
currentValue?.trim() !== newValue?.trim();
|
|
|
|
if (shouldUpdate) {
|
|
editorObj.getModel()?.setValue(newValue);
|
|
}
|
|
},
|
|
);
|
|
|
|
const setValue = (value: string) => {
|
|
if (editorObj?.setValue) {
|
|
editorObj.setValue(value);
|
|
editorObj?.layout();
|
|
}
|
|
};
|
|
|
|
const registerAutoCompleteProvider = () => {
|
|
if (!props.showAutoComplete || !monaco) return;
|
|
provider.value = monaco.languages.registerCompletionItemProvider(
|
|
props.language,
|
|
{
|
|
provideCompletionItems: function (model, position) {
|
|
// find out if we are completing a property in the 'dependencies' object.
|
|
var textUntilPosition = model.getValueInRange({
|
|
startLineNumber: 1,
|
|
startColumn: 1,
|
|
endLineNumber: position.lineNumber,
|
|
endColumn: position.column,
|
|
});
|
|
|
|
var word = model.getWordUntilPosition(position);
|
|
var range = {
|
|
startLineNumber: position.lineNumber,
|
|
endLineNumber: position.lineNumber,
|
|
startColumn: word.startColumn,
|
|
endColumn: word.endColumn,
|
|
};
|
|
|
|
let arr = textUntilPosition.trim().split(" ");
|
|
let filteredSuggestions = [];
|
|
filteredSuggestions = createDependencyProposals(range);
|
|
filteredSuggestions = filteredSuggestions.filter((item) => {
|
|
return item.label.toLowerCase().includes(word.word.toLowerCase());
|
|
});
|
|
|
|
const lastElement = arr.pop();
|
|
suggestions.value.forEach((suggestion: any) => {
|
|
filteredSuggestions.push({
|
|
label: suggestion.label(lastElement),
|
|
kind: monaco.languages.CompletionItemKind[
|
|
suggestion.kind || "Text"
|
|
],
|
|
insertText: suggestion.insertText(lastElement),
|
|
range: range,
|
|
});
|
|
});
|
|
|
|
return {
|
|
suggestions: filteredSuggestions,
|
|
};
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const resetEditorLayout = () => {
|
|
editorObj?.layout();
|
|
};
|
|
|
|
const triggerAutoComplete = async (value: string) => {
|
|
// Close any currently-open suggestion popup before retriggering.
|
|
// Monaco's model.trigger() calls cancel() internally, but if a natural
|
|
// popup is already in "Showing" state (e.g. opened by typing quote after
|
|
// an operator, or from word-based suggest while typing NOT LIKE), the
|
|
// widget may not refresh its item list. hideSuggestWidget transitions
|
|
// state to Idle cleanly (no "user dismissed" flag) so the following
|
|
// triggerSuggest always opens a fresh popup with the latest keywords.
|
|
editorObj.trigger(value, "hideSuggestWidget", {});
|
|
await nextTick();
|
|
editorObj.trigger(value, "editor.action.triggerSuggest", {});
|
|
};
|
|
|
|
const disableSuggestionPopup = () => {
|
|
const escEvent = new KeyboardEvent("keydown", {
|
|
keyCode: 27,
|
|
code: "Escape",
|
|
key: "Escape",
|
|
bubbles: true,
|
|
});
|
|
editorRef.value.dispatchEvent(escEvent);
|
|
};
|
|
|
|
const formatDocument = async () => {
|
|
// As Monaco editor does not support formatting in read-only mode, we need to temporarily disable it while formatting
|
|
return new Promise((resolve) => {
|
|
editorObj.updateOptions({ readOnly: false });
|
|
editorObj
|
|
.getAction("editor.action.formatDocument")
|
|
.run()
|
|
.then(() => {
|
|
editorObj.updateOptions({ readOnly: props.readOnly });
|
|
resolve(true);
|
|
})
|
|
.catch(() => {
|
|
editorObj.updateOptions({ readOnly: props.readOnly });
|
|
resolve(false);
|
|
});
|
|
});
|
|
};
|
|
|
|
const getCursorIndex = () => {
|
|
const currentPosition = editorObj.getPosition();
|
|
const cursorIndex =
|
|
editorObj?.getModel().getOffsetAt(currentPosition) - 1;
|
|
return cursorIndex || null;
|
|
};
|
|
|
|
const getModel = () => {
|
|
return editorObj?.getModel();
|
|
};
|
|
|
|
const getValue = () => {
|
|
return editorObj?.getValue();
|
|
};
|
|
|
|
const decorateRanges = (ranges: any[]) => {
|
|
if (!monaco) return;
|
|
// Highlight the ranges
|
|
const decorations = ranges.map((range) => {
|
|
return {
|
|
range: new monaco.Range(range.startLine, 1, range.endLine, 1),
|
|
options: {
|
|
isWholeLine: true,
|
|
className: "highlight-error", // Add this class to style the highlighted lines
|
|
glyphMarginClassName: "error-glyph", // Optional: add a custom icon to the gutter
|
|
},
|
|
};
|
|
});
|
|
|
|
const decorationIds = editorObj.deltaDecorations([], decorations);
|
|
};
|
|
|
|
function addErrorDiagnostics(ranges: any) {
|
|
if (!monaco) return;
|
|
// const markers = [
|
|
// {
|
|
// resource: {
|
|
// $mid: 1,
|
|
// external: "inmemory://model/4",
|
|
// path: "/4",
|
|
// scheme: "inmemory",
|
|
// authority: "model",
|
|
// },
|
|
// owner: "owner",
|
|
// code: "MY_ERROR_CODE",
|
|
// severity: monaco.MarkerSeverity.Error,
|
|
// message: "Error: Something went wrong",
|
|
// startLineNumber: 2,
|
|
// startColumn: 1,
|
|
// endLineNumber: 7,
|
|
// endColumn: 1,
|
|
// },
|
|
// {
|
|
// resource: {
|
|
// $mid: 1,
|
|
// external: "inmemory://model/4",
|
|
// path: "/4",
|
|
// scheme: "inmemory",
|
|
// authority: "model",
|
|
// },
|
|
// owner: "owner",
|
|
// code: "MY_ERROR_CODE",
|
|
// severity: monaco.MarkerSeverity.Error,
|
|
// message: "Error: Something went wrong",
|
|
// startLineNumber: 8,
|
|
// startColumn: 1,
|
|
// endLineNumber: 13,
|
|
// endColumn: 1,
|
|
// },
|
|
// ];
|
|
|
|
// Set markers to the model
|
|
// monaco.editor.setModelMarkers(getModel(), "owner", markers);
|
|
const model = getModel();
|
|
const markers = ranges.map((range: any) => {
|
|
const startLine = range.startLine;
|
|
const endLine = range.endLine;
|
|
const startCol = range.column ?? 1;
|
|
// Highlight to end-of-line so the squiggle is visible
|
|
const lineContent = model?.getLineContent?.(endLine) ?? "";
|
|
const endCol = lineContent.length + 1 || startCol + 1;
|
|
return {
|
|
severity: monaco.MarkerSeverity.Error,
|
|
startLineNumber: startLine,
|
|
startColumn: startCol,
|
|
endLineNumber: endLine,
|
|
endColumn: endCol,
|
|
message: range.error,
|
|
code: "",
|
|
};
|
|
});
|
|
|
|
monaco.editor.setModelMarkers(getModel(), "owner", []);
|
|
monaco.editor.setModelMarkers(getModel(), "owner", markers);
|
|
}
|
|
|
|
// Scan the current SQL query for double-quoted string values and mark them
|
|
// with a warning squiggle + hover tooltip. Double quotes are only valid for
|
|
// identifiers (table/column names) in SQL; string literals must use single
|
|
// quotes. Operates independently of addErrorDiagnostics ("owner" markers) so
|
|
// server-side error markers and these client-side warnings never interfere.
|
|
const validateDoubleQuotes = () => {
|
|
if (!editorObj || !monaco || props.language !== "sql") return;
|
|
const model = editorObj.getModel();
|
|
if (!model) return;
|
|
|
|
const text = model.getValue();
|
|
const markers: any[] = [];
|
|
|
|
// Two patterns are flagged — both only within value position (after a
|
|
// SQL comparison/membership operator). FROM "table" / SELECT "col" are
|
|
// intentionally NOT matched.
|
|
//
|
|
// Pattern A — fully double-quoted: field = "value"
|
|
// Pattern B — mismatched quotes: field = "value' or field = 'value"
|
|
// Capture group 1: the invalid quoted token
|
|
const regex =
|
|
/(?:NOT\s+LIKE|NOT\s+IN\s*\(|!=|<>|>=|<=|=|>|<|LIKE|IN\s*\()\s*("[^'"]*'|'[^'"]*"|"[^"]*")/gi;
|
|
|
|
let match;
|
|
while ((match = regex.exec(text)) !== null) {
|
|
const quotedStr = match[1]; // the invalid quoted token
|
|
const startOffset = match.index + match[0].length - quotedStr.length;
|
|
const endOffset = startOffset + quotedStr.length;
|
|
|
|
const startPos = model.getPositionAt(startOffset);
|
|
const endPos = model.getPositionAt(endOffset);
|
|
|
|
const isMixed =
|
|
(quotedStr.startsWith('"') && quotedStr.endsWith("'")) ||
|
|
(quotedStr.startsWith("'") && quotedStr.endsWith('"'));
|
|
|
|
markers.push({
|
|
severity: monaco.MarkerSeverity.Warning,
|
|
startLineNumber: startPos.lineNumber,
|
|
startColumn: startPos.column,
|
|
endLineNumber: endPos.lineNumber,
|
|
endColumn: endPos.column,
|
|
message: isMixed
|
|
? "Mismatched quotes. Use matching single quotes for string values."
|
|
: "Double quotes are not valid for string values. Use single quotes instead.",
|
|
});
|
|
}
|
|
|
|
monaco.editor.setModelMarkers(model, "dq-validation", markers);
|
|
};
|
|
|
|
// Watch isGenerating and emit events to parent
|
|
watch(isGenerating, (newValue) => {
|
|
console.log("[CodeQueryEditor] isGenerating changed to:", newValue);
|
|
if (newValue) {
|
|
console.log("[CodeQueryEditor] Emitting generation-start");
|
|
emit("generation-start");
|
|
} else {
|
|
console.log("[CodeQueryEditor] Emitting generation-end");
|
|
emit("generation-end");
|
|
}
|
|
});
|
|
|
|
// Computed property for AI icon based on theme
|
|
const aiIcon = computed(() => {
|
|
return store.state.theme === "dark"
|
|
? getImageURL("images/common/ai_icon_dark.svg")
|
|
: getImageURL("images/common/ai_icon_gradient.svg");
|
|
});
|
|
|
|
// Toggle NLP mode
|
|
const toggleNlpMode = () => {
|
|
emit("toggle-nlp-mode");
|
|
};
|
|
|
|
return {
|
|
editorRef,
|
|
editorObj,
|
|
setValue,
|
|
resetEditorLayout,
|
|
disableSuggestionPopup,
|
|
triggerAutoComplete,
|
|
getCursorIndex,
|
|
searchObj,
|
|
formatDocument,
|
|
getModel,
|
|
getValue,
|
|
decorateRanges,
|
|
addErrorDiagnostics,
|
|
isGenerating,
|
|
handleGenerateSQL,
|
|
streamingResponse,
|
|
t,
|
|
aiIcon,
|
|
toggleNlpMode,
|
|
};
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
#editor {
|
|
width: 100%;
|
|
height: 78%;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.code-query-editor-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* AI Icon Button Styling */
|
|
.ai-icon-button {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
z-index: 10;
|
|
background-color: var(--o2-bg-primary);
|
|
border: 1px solid var(--o2-border-color);
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.ai-icon-button:hover {
|
|
background-color: var(--o2-hover-accent);
|
|
border-color: var(--o2-color-primary);
|
|
}
|
|
|
|
.ai-icon-button.ai-icon-active {
|
|
background-color: var(--o2-color-primary-light);
|
|
border-color: var(--o2-color-primary);
|
|
}
|
|
|
|
.ai-icon-img {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.q-dark .ai-icon-img {
|
|
filter: brightness(1.2);
|
|
}
|
|
.monaco-editor,
|
|
.monaco-diff-editor .synthetic-focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor [tabindex="0"]:focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor [tabindex="-1"]:focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor button:focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor input[type="button"]:focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor input[type="checkbox"]:focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor input[type="search"]:focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor input[type="text"]:focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor select:focus,
|
|
.monaco-editor,
|
|
.monaco-diff-editor textarea:focus {
|
|
outline-width: 0px;
|
|
}
|
|
|
|
/* Generate SQL button - O2 AI Assistant gradient style (matches send-button) */
|
|
.generate-sql-button {
|
|
position: absolute;
|
|
bottom: 0.5rem;
|
|
right: 0.5rem;
|
|
z-index: 100;
|
|
background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%) !important;
|
|
color: white !important;
|
|
box-shadow: 0 0.25rem 0.9375rem 0 rgba(139, 92, 246, 0.3) !important;
|
|
transition: all 0.3s ease !important;
|
|
border: none !important;
|
|
}
|
|
|
|
.generate-sql-button:hover:not(.disabled):not([disabled]):not(:disabled) {
|
|
background: linear-gradient(135deg, #7c3aed 0%, #db2777 100%) !important;
|
|
box-shadow: 0 0.375rem 1.25rem 0 rgba(139, 92, 246, 0.4) !important;
|
|
transform: translateY(-0.0625rem) !important;
|
|
}
|
|
|
|
.generate-sql-button:active:not(.disabled):not([disabled]):not(:disabled) {
|
|
transform: translateY(0) !important;
|
|
box-shadow: 0 0.125rem 0.625rem 0 rgba(139, 92, 246, 0.3) !important;
|
|
}
|
|
|
|
/* Fade transition for button appearance */
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* Streaming preview card - O2 AI Assistant message style with purple border */
|
|
.streaming-preview-card {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 56.25rem; /* 900px - matches O2 AI Assistant max-width */
|
|
max-width: calc(100% - 2rem);
|
|
max-height: 31.25rem;
|
|
background: var(--o2-card-bg);
|
|
border-radius: 0.5rem; /* 8px - matches O2 message border-radius */
|
|
border: 2px solid #8b5cf6; /* O2 AI Assistant purple border */
|
|
padding: 0.75rem; /* 12px - matches O2 message padding */
|
|
z-index: 99;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Light mode shadow - matches O2 AI Assistant with purple glow */
|
|
.light-mode .streaming-preview-card {
|
|
box-shadow: 0 0.25rem 1rem 0 rgba(139, 92, 246, 0.2);
|
|
}
|
|
|
|
/* Dark mode shadow - matches O2 AI Assistant with purple glow */
|
|
.dark-mode .streaming-preview-card {
|
|
box-shadow: 0 0.25rem 1rem 0 rgba(139, 92, 246, 0.3);
|
|
}
|
|
|
|
/* Streaming preview content - O2 AI Assistant text-block style */
|
|
.streaming-preview-content {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
|
Arial, sans-serif;
|
|
font-size: 0.875rem;
|
|
line-height: 1.6; /* Better readability for text content */
|
|
color: var(--o2-text-primary);
|
|
margin: 0;
|
|
padding: 1rem;
|
|
overflow-y: auto;
|
|
max-height: 30rem;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* Generating text with dynamic message */
|
|
.generating-text {
|
|
font-size: 0.9375rem; /* 15px */
|
|
font-weight: 500;
|
|
color: var(--o2-text-primary);
|
|
}
|
|
|
|
/* Animated dots - ellipsis animation using pseudo-element */
|
|
.animated-dots::after {
|
|
content: "";
|
|
animation: ellipsis 1.5s infinite;
|
|
display: inline-block;
|
|
width: 1.5em;
|
|
text-align: left;
|
|
font-size: inherit;
|
|
font-weight: inherit;
|
|
font-family: inherit;
|
|
color: inherit;
|
|
line-height: inherit;
|
|
}
|
|
|
|
@keyframes ellipsis {
|
|
0% {
|
|
content: "";
|
|
}
|
|
25% {
|
|
content: ".";
|
|
}
|
|
50% {
|
|
content: "..";
|
|
}
|
|
75%,
|
|
100% {
|
|
content: "...";
|
|
}
|
|
}
|
|
|
|
/* Code blocks within streaming preview */
|
|
.streaming-preview-content :deep(pre),
|
|
.streaming-preview-content :deep(code) {
|
|
font-family: "Monaco", "Menlo", "Ubuntu Mono", "Courier New", monospace;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow-wrap: break-word;
|
|
line-height: 1.4;
|
|
padding: 0.5rem;
|
|
margin: 0.25rem 0;
|
|
border-radius: 0.25rem;
|
|
display: block;
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
/* Lists within streaming preview */
|
|
.streaming-preview-content :deep(ol) {
|
|
list-style-type: decimal;
|
|
padding-left: 1.5em;
|
|
margin: 0.5em 0;
|
|
}
|
|
|
|
.streaming-preview-content :deep(ul) {
|
|
list-style-type: disc;
|
|
padding-left: 1.5em;
|
|
margin: 0.5em 0;
|
|
}
|
|
|
|
.streaming-preview-content :deep(li) {
|
|
margin: 0.25em 0;
|
|
}
|
|
|
|
/* Paragraphs within streaming preview */
|
|
.streaming-preview-content :deep(p) {
|
|
margin: 0.5em 0;
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
word-break: break-word;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* Slide up transition for streaming preview - centered */
|
|
.slide-up-enter-active,
|
|
.slide-up-leave-active {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.slide-up-enter-from {
|
|
opacity: 0;
|
|
transform: translate(-50%, -50%) scale(0.95);
|
|
}
|
|
|
|
.slide-up-leave-to {
|
|
opacity: 0;
|
|
transform: translate(-50%, -50%) scale(0.98);
|
|
}
|
|
|
|
@keyframes typing-cursor {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.85;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss">
|
|
.logs-query-editor {
|
|
flex: 1;
|
|
min-height: 0;
|
|
/* Fixed left breathing room for the whole editor. Monaco mounts as a child of
|
|
this host, so padding here insets the line-number gutter (and code) by a
|
|
constant amount regardless of the line-number digit count — unlike
|
|
lineNumbersMinChars, which only pads while digits stay below the reserved
|
|
width and goes flush again at 2+ digits. automaticLayout measures the
|
|
content box, so the editor adapts to the reduced width. */
|
|
padding-left: 0.5rem;
|
|
.monaco-editor,
|
|
.monaco-editor .monaco-editor {
|
|
padding: 0px 0px 0px 0px !important;
|
|
|
|
.editor-widget .suggest-widget {
|
|
z-index: 9999;
|
|
display: flex !important;
|
|
visibility: visible !important;
|
|
}
|
|
--vscode-focusBorder: transparent !important;
|
|
}
|
|
}
|
|
|
|
.highlight-error {
|
|
background-color: rgba(255, 0, 0, 0.1);
|
|
text-decoration: underline;
|
|
text-decoration-color: red;
|
|
}
|
|
</style>
|