This commit is contained in:
Lizandro Guarnizo
2026-06-02 13:13:29 -05:00
parent ba76754331
commit 28d0eae715
7 changed files with 434 additions and 77 deletions
+275 -56
View File
@@ -181,6 +181,108 @@
<div class="flex-1"></div>
<span x-show="selectedDb" class="text-xs text-[#4ade80] font-mono" x-text="selectedDb"></span>
</div>
<div class="relative">
<textarea id="sql-editor" x-model="sqlText"
@input.debounce.150ms="onSqlInput()"
@keydown="onSqlKeydown($event)"
@keydown.ctrl.enter.prevent="runQuery()"
@keydown.meta.enter.prevent="runQuery()"
@scroll="closeAutocomplete()"
@click="closeAutocomplete()"
:placeholder="isMongo ? 'db.coleccion.find({})\n// Ctrl+Enter para ejecutar\n// db.coleccion.insertOne({campo: \'valor\'})' : isRedis ? '# Comandos Redis (uno por línea)\nKEYS *\nGET mi_clave\nHGETALL mi_hash\n# Ctrl+Enter para ejecutar' : '-- Escribe tu consulta SQL aquí\n-- Ctrl+Enter para ejecutar\nSELECT * FROM tabla LIMIT 100;'"
class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 resize-none outline-none leading-relaxed"
style="height:180px; tab-size:2;"></textarea>
<!-- Autocomplete dropdown -->
<div x-show="autocompleteVisible" x-cloak
:style="'position:absolute;left:'+autocompletePos.left+'px;top:'+autocompletePos.top+'px;z-index:100;'"
class="bg-white border border-gray-300 rounded-lg shadow-xl max-h-48 overflow-y-auto min-w-[180px]">
<template x-for="(item, i) in autocompleteItems" :key="i">
<div @mousedown.prevent="selectAutocomplete(i)"
class="px-3 py-1.5 text-xs font-mono cursor-pointer flex items-center gap-2"
:class="i === autocompleteIdx ? 'bg-[#8eb02f] text-white' : 'hover:bg-gray-100 text-gray-700'">
<!-- Icono por tipo -->
<span class="text-[9px] shrink-0 w-12 truncate opacity-60 font-sans"
:class="{
'text-blue-500': item.type === 'keyword',
'text-amber-600': item.type === 'table',
'text-green-600': item.type.startsWith('col')
}"
x-text="item.type === 'keyword' ? 'KW' : item.type === 'table' ? 'tabla' : item.type.replace('col·','')">
</span>
<span class="flex-1 truncate" x-text="item.label"></span>
</div>
</template>
</div>
<!-- Typo suggestions -->
<div x-show="typoSuggestions.length > 0" x-cloak
class="absolute bottom-0 left-0 right-0 px-3 py-1.5 bg-yellow-900 bg-opacity-90 flex flex-wrap gap-1.5 items-center">
<span class="text-yellow-300 text-[10px] shrink-0">¿Quisiste decir?</span>
<template x-for="(s, i) in typoSuggestions" :key="i">
<button @mousedown.prevent="applyTypoFix(s)"
class="text-[10px] px-2 py-0.5 rounded bg-yellow-400 text-yellow-900 hover:bg-yellow-300 transition font-mono font-semibold">
<span x-text="s"></span>
</button>
</template>
</div>
</div>
</div>
<!-- ═══ BARRA NL2SQL (cool) ═══ -->
<div x-show="!isMongo && !isRedis && selectedConxId" class="mt-2 rounded-xl overflow-hidden"
style="background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #1e3a5f 100%); border: 1px solid #4c1d95">
<div class="flex items-center gap-2 px-3 py-2">
<!-- Icono mágico animado -->
<div class="shrink-0 flex items-center justify-center w-7 h-7 rounded-lg"
style="background: linear-gradient(135deg,#7c3aed,#2563eb)"
:class="nl2sqlLoading ? 'animate-pulse' : ''">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/>
</svg>
</div>
<input id="nl2sql-input" x-model="nl2sqlText"
@keydown.enter="generateNL2SQL()"
:disabled="nl2sqlLoading"
type="text"
placeholder="Describe en español lo que quieres consultar... ej: trae los usuarios activos creados este mes"
class="flex-1 bg-transparent text-white text-xs placeholder-indigo-300 outline-none font-sans">
<button @click="generateNL2SQL()" :disabled="nl2sqlLoading || !nl2sqlText.trim()"
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition disabled:opacity-40"
style="background: linear-gradient(135deg,#7c3aed,#2563eb); color:white"
onmouseover="this.style.opacity='0.85'"
onmouseout="this.style.opacity='1'">
<svg x-show="!nl2sqlLoading" class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
<svg x-show="nl2sqlLoading" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="nl2sqlLoading ? 'Generando…' : 'Generar SQL'"></span>
</button>
</div>
<!-- Respuesta inline -->
<div x-show="nl2sqlResult" x-cloak class="px-3 pb-2">
<div class="rounded-lg overflow-hidden border border-indigo-600">
<div class="flex items-center justify-between px-3 py-1.5 bg-indigo-900 bg-opacity-60">
<span class="text-[10px] text-indigo-200 font-semibold">SQL generado</span>
<div class="flex gap-1.5">
<button @click="applyNL2SQL()"
class="text-[10px] px-2 py-0.5 rounded font-semibold transition"
style="background:#8eb02f;color:white"
onmouseover="this.style.background='#6d8c24'"
onmouseout="this.style.background='#8eb02f'">
Aplicar al editor
</button>
<button @click="nl2sqlResult=''"
class="text-[10px] px-1.5 text-indigo-300 hover:text-white transition"></button>
</div>
</div>
<pre class="px-3 py-2 text-xs font-mono text-green-300 bg-gray-950 whitespace-pre-wrap max-h-24 overflow-y-auto" x-text="nl2sqlResult"></pre>
</div>
</div>
</div>
<div class="relative">
<textarea id="sql-editor" x-model="sqlText"
@input.debounce.150ms="onSqlInput()"
@@ -679,10 +781,13 @@ document.addEventListener('alpine:init', () => {
editValue: '',
editSaving: false,
// ── NL2SQL ──
nl2sqlText: '',
nl2sqlLoading: false,
nl2sqlResult: '',
// ── Typo detection ──
typoSuggestions: [],
// ── AI ──
aiLoading: false,
aiModalOpen: false,
aiModalSql: '',
@@ -763,7 +868,7 @@ document.addEventListener('alpine:init', () => {
},
// ══════════════════════════════════════════════════════════════
// AUTOCOMPLETE
// AUTOCOMPLETE — con contexto WHERE/SET/ON/HAVING
// ══════════════════════════════════════════════════════════════
async onSqlInput() {
if (!this.sqlText || this.isMongo || this.isRedis) {
@@ -774,71 +879,127 @@ document.addEventListener('alpine:init', () => {
const ta = document.getElementById('sql-editor');
if (!ta) return;
const cursorPos = ta.selectionStart;
const text = this.sqlText.substring(0, cursorPos);
const words = text.split(/[\s,;()\n]+/);
const currentWord = words[words.length - 1] || '';
const fullText = this.sqlText;
const textBefore = this.sqlText.substring(0, cursorPos);
const wordMatch = textBefore.match(/[\w.]+$/);
const currentWord = wordMatch ? wordMatch[0] : '';
// ── Autocomplete ──
if (currentWord.length >= 1) {
const upper = currentWord.toUpperCase();
const suggestions = [];
// Prefetch columnas de TODAS las tablas de la query en paralelo
this.prefetchTableColumns(this.sqlText);
// Keywords
for (const kw of this.sqlKeywords) {
if (kw.startsWith(upper) && kw !== upper) {
suggestions.push({ label: kw, type: 'keyword' });
}
}
if (currentWord.length < 1) {
this.closeAutocomplete();
this.typoSuggestions = [];
return;
}
// Table names
for (const t of this.tables) {
const tLower = t.toLowerCase();
const cLower = currentWord.toLowerCase();
if (tLower.startsWith(cLower) && t !== currentWord) {
suggestions.push({ label: t, type: 'table' });
}
}
const upper = currentWord.toUpperCase();
// Column names from loaded columns
const tableContext = this.detectTableContext(fullText, cursorPos);
if (tableContext && this.columnCache[tableContext]) {
for (const col of this.columnCache[tableContext]) {
// ── Detectar contexto del cursor ──
// Contexto columna: cursor después de WHERE, AND, OR, ON, SET, HAVING, SELECT campo
const contextUpper = textBefore.replace(/\s+/g, ' ').toUpperCase();
const columnContextRe = /(?:WHERE|AND|OR|HAVING|ON|SET|,)\s+[\w.]*$/;
const selectContextRe = /SELECT\s+(?:[\w.,\s]+,\s*)?[\w.]*$/;
const isColumnContext = columnContextRe.test(contextUpper) || selectContextRe.test(contextUpper);
const suggestions = [];
if (isColumnContext) {
// Prioridad 1: columnas de TODAS las tablas en el query
const allTables = this.extractAllTablesFromQuery(this.sqlText);
for (const tableName of allTables) {
const cols = this.columnCache[tableName] || [];
for (const col of cols) {
const colLower = col.name.toLowerCase();
const cLower2 = currentWord.toLowerCase();
if (colLower.startsWith(cLower2) && col.name !== currentWord) {
suggestions.push({ label: col.name, type: 'col·' + (col.type || '').substring(0, 12) });
if (colLower.startsWith(currentWord.toLowerCase()) && col.name !== currentWord) {
suggestions.push({
label: col.name,
type: 'col·' + (col.type || '').substring(0, 10),
priority: 0
});
}
}
}
if (suggestions.length > 0) {
this.autocompleteItems = suggestions.slice(0, 12);
this.autocompleteIdx = 0;
this.autocompleteVisible = true;
// Position below cursor
const lineHeight = 24;
const charWidth = 8.4;
const lines = text.split('\n');
const currentLine = lines.length;
const currentCol = lines[lines.length - 1].length;
this.autocompletePos = {
left: Math.min(currentCol * charWidth, 600),
top: currentLine * lineHeight + 4
};
} else {
this.closeAutocomplete();
// Prioridad 2: también keywords que aplican en este contexto
const whereKws = ['IS', 'NOT', 'NULL', 'IN', 'LIKE', 'ILIKE', 'BETWEEN', 'AND', 'OR', 'EXISTS', 'TRUE', 'FALSE'];
for (const kw of whereKws) {
if (kw.startsWith(upper) && kw !== upper) {
suggestions.push({ label: kw, type: 'keyword', priority: 1 });
}
}
// ── Typo detection ──
this.detectTypos(currentWord);
} else {
this.closeAutocomplete();
this.typoSuggestions = [];
// Contexto general: keywords primero
for (const kw of this.sqlKeywords) {
if (kw.startsWith(upper) && kw !== upper) {
suggestions.push({ label: kw, type: 'keyword', priority: 0 });
}
}
// Luego tablas (contexto FROM / JOIN / UPDATE / INTO)
const tableContextRe = /(?:FROM|JOIN|UPDATE|INTO|TABLE)\s+[\w.]*$/i;
if (tableContextRe.test(textBefore)) {
for (const t of this.tables) {
if (t.toLowerCase().startsWith(currentWord.toLowerCase()) && t !== currentWord) {
suggestions.push({ label: t, type: 'table', priority: 0 });
}
}
} else {
// Fuera de contexto claro: tablas de menor prioridad
for (const t of this.tables) {
if (t.toLowerCase().startsWith(currentWord.toLowerCase()) && t !== currentWord) {
suggestions.push({ label: t, type: 'table', priority: 1 });
}
}
}
// Columnas de tabla detectada en contexto
const tableCtx = this.detectTableContext(this.sqlText, cursorPos);
if (tableCtx && this.columnCache[tableCtx]) {
for (const col of this.columnCache[tableCtx]) {
if (col.name.toLowerCase().startsWith(currentWord.toLowerCase()) && col.name !== currentWord) {
suggestions.push({ label: col.name, type: 'col·' + (col.type || '').substring(0, 10), priority: 1 });
}
}
}
}
// Fetch columns for tables mentioned in the query
this.prefetchTableColumns(fullText);
// Deduplicar y limitar
const seen = new Set();
const unique = suggestions.filter(s => {
if (seen.has(s.label)) return false;
seen.add(s.label);
return true;
}).slice(0, 14);
if (unique.length > 0) {
this.autocompleteItems = unique;
this.autocompleteIdx = 0;
this.autocompleteVisible = true;
// Calcular posición debajo del cursor
const lineHeight = 24;
const charWidth = 8.4;
const lines = textBefore.split('\n');
const lineNum = lines.length;
const colNum = lines[lines.length - 1].length;
this.autocompletePos = {
left: Math.min(colNum * charWidth, 560),
top: lineNum * lineHeight + 4
};
} else {
this.closeAutocomplete();
}
// Typo detection
this.detectTypos(currentWord);
},
// Extrae TODAS las tablas mencionadas en FROM, JOIN, UPDATE, INTO del query
extractAllTablesFromQuery(sql) {
const found = [];
const re = /(?:FROM|JOIN|UPDATE|INTO|TABLE)\s+`?(\w[\w\d_]*)`?/gi;
let m;
while ((m = re.exec(sql)) !== null) {
const t = m[1];
if (!found.includes(t) && this.tables.includes(t)) found.push(t);
}
return found;
},
onSqlKeydown(e) {
@@ -1129,6 +1290,64 @@ document.addEventListener('alpine:init', () => {
}
},
// ══════════════════════════════════════════════════════════════
// NL2SQL — Lenguaje natural a SQL
// ══════════════════════════════════════════════════════════════
async generateNL2SQL() {
if (!this.nl2sqlText.trim() || this.nl2sqlLoading) return;
this.nl2sqlLoading = true;
this.nl2sqlResult = '';
// Construir schema de tablas disponibles para dar contexto a la IA
const schemaPayload = [];
for (const tableName of this.tables) {
const cols = this.columnCache[tableName] || [];
schemaPayload.push({
table: tableName,
columns: cols.map(c => ({ name: c.name, type: c.type || '' }))
});
// Si no tenemos columnas cargadas aún, intentar cargarlas
if (cols.length === 0 && this.selectedConxId && this.selectedDb) {
try {
const res = await axios.get('/app/query-runner/columns', {
params: { conx_db_id: this.selectedConxId, db: this.selectedDb, table: tableName }
});
const loaded = res.data.data || [];
this.columnCache[tableName] = loaded;
schemaPayload[schemaPayload.length - 1].columns = loaded.map(c => ({ name: c.name, type: c.type || '' }));
} catch (_) {}
}
}
try {
const res = await axios.post('/app/query-runner/nl2sql', {
conx_db_id: parseInt(this.selectedConxId) || 0,
database: this.selectedDb,
text: this.nl2sqlText,
schema: schemaPayload
});
if (res.data.suggestion) {
this.nl2sqlResult = res.data.suggestion;
} else if (res.data.error) {
this.showToast(res.data.error, 'error');
}
} catch (e) {
const msg = e.response?.data?.error || e.message;
this.showToast('Error NL2SQL: ' + msg, 'error');
}
this.nl2sqlLoading = false;
},
applyNL2SQL() {
if (this.nl2sqlResult) {
this.sqlText = this.nl2sqlResult;
this.nl2sqlResult = '';
this.nl2sqlText = '';
document.getElementById('sql-editor')?.focus();
this.showToast('SQL aplicado al editor');
}
},
// ══════════════════════════════════════════════════════════════
// CORE QUERY RUNNER
// ══════════════════════════════════════════════════════════════