QueryLensV2b / web /static /app.js
Aniruddha7's picture
Full deploy of QueryLensV2 (code + demo db) to a fresh Space
19dfd32 verified
Raw History Blame Contribute Delete
42.9 kB
const askBtn = document.getElementById('ask');
const questionEl = document.getElementById('question');
const answerEl = document.getElementById('answer');
const sqlEl = document.getElementById('sql');
const memoryEl = document.getElementById('memory');
const querycraftEl = document.getElementById('querycraft');
const presenterEl = document.getElementById('presenter');
const uploadInput = document.getElementById('upload');
const uploadBtn = document.getElementById('uploadBtn');
const uploadStatus = document.getElementById('uploadStatus');
const uploadControls = document.getElementById('uploadControls');
const modeToggle = document.getElementById('modeToggle');
let currentDocId = null;
let currentMode = 'text2sql';
const statusEl = document.getElementById('status');
const timeEl = document.getElementById('time');
const recalcBtn = document.getElementById('recalc');
const spinner = document.getElementById('spinner');
const spinnerText = document.getElementById('spinnerText');
const askBtnEl = askBtn;
// Mode toggle logic
modeToggle.addEventListener('change', function() {
currentMode = modeToggle.value;
console.log('[DEBUG] Mode changed to:', currentMode); // Debug log
if(currentMode === 'ocr_qa') {
uploadControls.classList.remove('hidden');
console.log('[DEBUG] Upload controls shown'); // Debug log
} else {
uploadControls.classList.add('hidden');
currentDocId = null;
uploadStatus.textContent = 'No upload';
uploadStatus.className = 'status-text';
console.log('[DEBUG] Upload controls hidden'); // Debug log
}
});
// Initialize UI state
console.log('[DEBUG] Initial mode:', currentMode); // Debug log
if(currentMode === 'ocr_qa') {
uploadControls.classList.remove('hidden');
} else {
uploadControls.classList.add('hidden');
}
// Helper to programmatically switch to OCR Q&A mode
function switchToOcrQaMode() {
if (modeToggle.value !== 'ocr_qa') {
modeToggle.value = 'ocr_qa';
currentMode = 'ocr_qa';
uploadControls.classList.remove('hidden');
// Fire a change event so any listeners update state consistently
const ev = new Event('change', { bubbles: true });
modeToggle.dispatchEvent(ev);
console.log('[DEBUG] Switched to OCR Q&A mode');
}
}
// Extract mode change logic into a function so it can be called directly
function onModeChange() {
currentMode = modeToggle.value;
console.log('[DEBUG] onModeChange called, mode:', currentMode); // Debug log
if(currentMode === 'ocr_qa') {
uploadControls.classList.remove('hidden');
console.log('[DEBUG] Upload controls shown via onModeChange'); // Debug log
} else {
uploadControls.classList.add('hidden');
currentDocId = null;
uploadStatus.textContent = 'No upload';
uploadStatus.className = 'status-text';
console.log('[DEBUG] Upload controls hidden via onModeChange'); // Debug log
}
}
// Mode toggle logic
modeToggle.addEventListener('change', onModeChange);
// Upload handler: create a temporary file input on demand to avoid issues with a permanently hidden input.
if (uploadBtn) {
uploadBtn.addEventListener('click', () => {
// If not in OCR Q&A mode, switch and highlight, then open file picker after a delay
if (modeToggle.value !== 'ocr_qa') {
switchToOcrQaMode();
// Visually highlight the dropdown to show the switch
modeToggle.style.boxShadow = '0 0 0 3px rgba(15, 98, 254, 0.2)';
modeToggle.style.transition = 'box-shadow 0.2s';
setTimeout(() => {
modeToggle.style.boxShadow = '';
// Now open the file picker
const tmp = document.createElement('input');
tmp.type = 'file';
tmp.accept = 'image/*';
tmp.style.position = 'absolute';
tmp.style.left = '-9999px';
document.body.appendChild(tmp);
tmp.addEventListener('change', async () => {
const files = tmp.files;
if (!files || files.length === 0) {
uploadStatus.textContent = 'No file selected';
document.body.removeChild(tmp);
return;
}
switchToOcrQaMode();
uploadStatus.textContent = 'Uploading...';
uploadStatus.className = 'status-text text-warning';
const fd = new FormData();
fd.append('file', files[0]);
try {
const r = await fetch('/upload-image', { method: 'POST', body: fd });
if (!r.ok) {
const j = await r.json().catch(()=>({}));
uploadStatus.textContent = 'Upload failed: ' + (j.detail || r.statusText);
uploadStatus.className = 'status-text text-error';
document.body.removeChild(tmp);
return;
}
const j = await r.json();
currentDocId = j.doc_id;
uploadStatus.textContent = `Uploaded (doc_id=${currentDocId})`;
uploadStatus.className = 'status-text text-success';
} catch (e) {
uploadStatus.textContent = 'Upload error: ' + String(e);
uploadStatus.className = 'status-text text-error';
}
document.body.removeChild(tmp);
});
tmp.click();
}, 350); // 350ms to allow UI to update and user to see the switch
return;
}
// Already in OCR Q&A mode: open file picker immediately
const tmp = document.createElement('input');
tmp.type = 'file';
tmp.accept = 'image/*';
tmp.style.position = 'absolute';
tmp.style.left = '-9999px';
document.body.appendChild(tmp);
tmp.addEventListener('change', async () => {
const files = tmp.files;
if (!files || files.length === 0) {
uploadStatus.textContent = 'No file selected';
uploadStatus.className = 'status-text';
document.body.removeChild(tmp);
return;
}
switchToOcrQaMode();
uploadStatus.textContent = 'Uploading...';
uploadStatus.className = 'status-text text-warning';
const fd = new FormData();
fd.append('file', files[0]);
try {
const r = await fetch('/upload-image', { method: 'POST', body: fd });
if (!r.ok) {
const j = await r.json().catch(()=>({}));
uploadStatus.textContent = 'Upload failed: ' + (j.detail || r.statusText);
uploadStatus.className = 'status-text text-error';
document.body.removeChild(tmp);
return;
}
const j = await r.json();
currentDocId = j.doc_id;
uploadStatus.textContent = `Uploaded (doc_id=${currentDocId})`;
uploadStatus.className = 'status-text text-success';
} catch (e) {
uploadStatus.textContent = 'Upload error: ' + String(e);
uploadStatus.className = 'status-text text-error';
}
document.body.removeChild(tmp);
});
tmp.click();
});
}
function setRunning(running){
if(running){
askBtnEl.disabled = true;
recalcBtn.disabled = true;
questionEl.disabled = true;
spinner.classList.remove('hidden');
spinnerText.classList.remove('hidden');
spinnerText.textContent = 'Processing...';
// hide the small status text while spinner is active
statusEl.textContent = '';
console.log('[DEBUG] Loading state: ON, spinner should be visible'); // Debug log
} else {
askBtnEl.disabled = false;
recalcBtn.disabled = false;
questionEl.disabled = false;
spinner.classList.add('hidden');
spinnerText.classList.add('hidden');
// restore default status
statusEl.textContent = 'Ready';
console.log('[DEBUG] Loading state: OFF, spinner should be hidden'); // Debug log
}
}
function renderPresenterTrace(text){
if(!text) return '—';
// Split traces into chunks separated by blank lines and render as collapsible
const parts = String(text).split(/\n\s*\n/).filter(Boolean);
const container = document.createElement('div');
parts.forEach((p, i) => {
const d = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = p.split('\n')[0].slice(0,80);
summary.style.cursor = 'pointer';
summary.style.padding = '0.5rem';
summary.style.borderRadius = '0.375rem';
summary.style.marginBottom = '0.5rem';
summary.style.backgroundColor = 'var(--bg-raised)';
const pre = document.createElement('pre');
pre.style.whiteSpace = 'pre-wrap';
pre.style.fontSize = '0.75rem';
pre.style.padding = '0.75rem';
pre.style.backgroundColor = 'var(--panel-inset)';
pre.style.borderRadius = '0.375rem';
pre.style.margin = '0';
pre.textContent = p;
d.appendChild(summary);
d.appendChild(pre);
if(i===0) d.open = true;
container.appendChild(d);
});
return container;
}
// small helper to escape HTML
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// Detect if text contains a pipe/markdown table and return true
function isMarkdownTable(text){
if(!text) return false;
const lines = String(text).split(/\r?\n/).map(l=>l.trim()).filter(Boolean);
if(lines.length < 2) return false;
// Count lines that contain pipe separators
const pipeLines = lines.filter(l => l.includes('|'));
if(pipeLines.length < 2) return false;
// If the second line is a markdown separator like | --- | --- | or ---|---
const second = lines[1];
if(/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(second)) return true;
// Otherwise, if majority of non-empty lines contain pipes, treat as table
return (pipeLines.length / lines.length) >= 0.5;
}
// Parse a simple markdown/pipe table into a DOM <table>
function parseMarkdownTable(text){
const lines = String(text).split(/\r?\n/).map(l=>l.trim()).filter(Boolean);
// find the first contiguous block of pipe lines
let start = -1, end = -1;
for(let i=0;i<lines.length;i++){
if(lines[i].includes('|')){ start = i; break; }
}
if(start === -1) return null;
for(let i=start;i<lines.length;i++){
if(!lines[i].includes('|')){ end = i; break; }
}
if(end === -1) end = lines.length;
const block = lines.slice(start, end);
const rows = block.map(r => {
let s = r;
if(s.startsWith('|')) s = s.slice(1);
if(s.endsWith('|')) s = s.slice(0,-1);
return s.split('|').map(c => c.trim());
});
// If second row is separator, treat first row as header
let header = null;
if(rows.length >= 2 && rows[1].every(c => /^:?-{3,}:?$/.test(c))) {
header = rows[0];
rows.splice(0,2);
}
const table = document.createElement('table');
table.className = 'answer-table';
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
if(header){
const tr = document.createElement('tr');
header.forEach(h => { const th = document.createElement('th'); th.textContent = h || ''; tr.appendChild(th); });
thead.appendChild(tr);
} else if(rows.length>0){
// use first row as header if no separator present
const tr = document.createElement('tr');
rows[0].forEach((_,i) => { const th = document.createElement('th'); th.textContent = `col ${i+1}`; tr.appendChild(th); });
thead.appendChild(tr);
}
rows.forEach(r => {
const tr = document.createElement('tr');
r.forEach(c => { const td = document.createElement('td'); td.textContent = c; tr.appendChild(td); });
tbody.appendChild(tr);
});
if(thead.childElementCount) table.appendChild(thead);
table.appendChild(tbody);
// Create enhanced table wrapper with controls
const wrapper = document.createElement('div');
wrapper.className = 'table-wrapper';
// Create table controls
const controls = document.createElement('div');
controls.className = 'table-controls';
const controlsLeft = document.createElement('div');
controlsLeft.className = 'table-controls-left';
const tableInfo = document.createElement('div');
tableInfo.className = 'table-info';
const rowCount = tbody.children.length;
const colCount = header ? header.length : (rows.length > 0 ? rows[0].length : 0);
tableInfo.textContent = `${rowCount} rows × ${colCount} columns`;
controlsLeft.appendChild(tableInfo);
const controlsRight = document.createElement('div');
controlsRight.className = 'table-controls-right';
const resizeControls = document.createElement('div');
resizeControls.className = 'table-resize-controls';
// Size control buttons
const sizes = [
{ name: 'Compact', class: 'compact' },
{ name: 'Comfortable', class: 'comfortable' },
{ name: 'Spacious', class: 'spacious' }
];
sizes.forEach((size, index) => {
const btn = document.createElement('button');
btn.textContent = size.name;
btn.className = 'table-resize-btn';
if (index === 1) btn.classList.add('active'); // Default to comfortable
btn.addEventListener('click', () => {
// Remove all size classes
table.classList.remove('compact', 'comfortable', 'spacious');
// Add selected size class
table.classList.add(size.class);
// Update active button
resizeControls.querySelectorAll('.table-resize-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
resizeControls.appendChild(btn);
});
controlsRight.appendChild(resizeControls);
controls.appendChild(controlsLeft);
controls.appendChild(controlsRight);
// Create table container
const container = document.createElement('div');
container.className = 'table-container';
container.appendChild(table);
wrapper.appendChild(controls);
wrapper.appendChild(container);
// Set default size
table.classList.add('comfortable');
return wrapper;
}
// heuristics: split logs into agent sections
function splitLogs(logs) {
const sections = {memory: '', querycraft: '', presenter: ''};
if (!logs) {
console.log('[DEBUG] splitLogs: No logs provided');
return sections;
}
console.log('[DEBUG] splitLogs: Input logs length:', logs.length);
console.log('[DEBUG] splitLogs: First 500 chars:', logs.substring(0, 500));
console.log('[DEBUG] splitLogs: Last 500 chars:', logs.substring(Math.max(0, logs.length - 500)));
// Normalize line endings
const lines = logs.replace(/\r/g,'').split('\n');
console.log('[DEBUG] splitLogs: Total lines:', lines.length);
// Look for explicit markers first
let mode = null;
let memoryMarkerFound = false;
let querycraftMarkerFound = false;
let presenterMarkerFound = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const l = line.trim();
// Match the exact agent section headers from agentic_workflow.py
if (/=== MEMORY AGENT ===/i.test(l) || /INITIAL MEMORY/i.test(l)) {
mode = 'memory';
memoryMarkerFound = true;
console.log(`[DEBUG] splitLogs: Found MEMORY marker at line ${i}: "${l}"`);
continue;
}
if (/=== QUERY CRAFT AGENT ===/i.test(l) || /QUERY_CRAFT/i.test(l) || /MICRO-HINT/i.test(l)) {
mode = 'querycraft';
querycraftMarkerFound = true;
console.log(`[DEBUG] splitLogs: Found QUERYCRAFT marker at line ${i}: "${l}"`);
continue;
}
if (/=== RESULT PRESENTER AGENT ===/i.test(l) || /PRESENTER/i.test(l)) {
mode = 'presenter';
presenterMarkerFound = true;
console.log(`[DEBUG] splitLogs: Found PRESENTER marker at line ${i}: "${l}"`);
continue;
}
// Additional patterns to catch query craft related logs
if (/\[FAST\]/i.test(l) || /\[GEN\]/i.test(l) || /\[MICRO-HINT\]/i.test(l)) {
mode = 'querycraft';
console.log(`[DEBUG] splitLogs: Found QUERYCRAFT pattern at line ${i}: "${l}"`);
}
// Heuristic: SQL lines go to querycraft
if (/^(SELECT|WITH|INSERT|UPDATE|DELETE)\b/i.test(l)) {
mode = 'querycraft';
sections.querycraft += l + '\n';
console.log(`[DEBUG] splitLogs: Found SQL pattern at line ${i}, mode set to querycraft`);
continue;
}
if (!mode) {
// Seed with memory until a clearer marker appears
sections.memory += line + '\n';
} else {
sections[mode] += line + '\n';
}
}
console.log('[DEBUG] splitLogs: Markers found - Memory:', memoryMarkerFound, 'QueryCraft:', querycraftMarkerFound, 'Presenter:', presenterMarkerFound);
console.log('[DEBUG] splitLogs: Section lengths - Memory:', sections.memory.length, 'QueryCraft:', sections.querycraft.length, 'Presenter:', sections.presenter.length);
console.log('[DEBUG] splitLogs: Memory section preview:', sections.memory.substring(0, 200));
console.log('[DEBUG] splitLogs: QueryCraft section preview:', sections.querycraft.substring(0, 200));
console.log('[DEBUG] splitLogs: Presenter section preview:', sections.presenter.substring(0, 200));
return sections;
}
function findSQL(logs) {
if (!logs) return '';
// Primary: explicit markers
const markerRe = /<<<SQL_REVISED_START>>>([\s\S]*?)<<<SQL_REVISED_END>>>/m;
let m = logs.match(markerRe);
if (m) return m[1].trim();
const markerRe2 = /<<<SQL_START>>>([\s\S]*?)<<<SQL_END>>>/m;
m = logs.match(markerRe2);
if (m) return m[1].trim();
// Fallback: first standalone SQL-ish block up to semicolon
const fallback = logs.match(/^(?:.*?)(SELECT|WITH)[^;]{0,4000};/im);
if (fallback) {
const startIdx = fallback.index;
if (startIdx != null) {
const slice = logs.slice(startIdx).match(/([\s\S]*?;)/);
if (slice) return slice[1].trim();
}
}
return '';
}
async function runQuery(q){
if (!q) return alert('Please enter a question');
answerEl.textContent = 'Thinking...';
setRunning(true);
timeEl.textContent = '';
sqlEl.textContent = '-- SQL will appear here --';
memoryEl.textContent = querycraftEl.textContent = presenterEl.textContent = '—';
const startTime = Date.now();
try {
const body = { question: q, mode: currentMode };
if (currentMode === 'ocr_qa' && currentDocId) body.doc_id = currentDocId;
const res = await fetch('/api/query', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
let j = null;
if (!res.ok) {
// Read the body EXACTLY once, then parse — reading .json() then .text()
// on the same Response throws "body stream already read".
const raw = await res.text().catch(() => '');
try { j = raw ? JSON.parse(raw) : null; } catch (err) { j = null; }
if (!j) {
answerEl.textContent = 'Error ' + res.status + ': ' + (raw || res.statusText || 'request failed');
setRunning(false);
return;
}
if (j.error === 'worker_timeout') { answerEl.textContent = 'Worker timeout: ' + (j.message || 'The worker did not respond in time.'); setRunning(false); return; }
answerEl.textContent = j.error || j.message || JSON.stringify(j);
if (j.trace) presenterEl.textContent = j.trace;
setRunning(false);
return;
}
j = await res.json();
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (j.answer) {
// Render markdown/pipe tables as HTML tables for readability
if(isMarkdownTable(j.answer)){
const node = parseMarkdownTable(j.answer);
answerEl.innerHTML = '';
if(node) answerEl.appendChild(node);
else answerEl.textContent = j.answer;
} else {
// Non-table answers: preserve plain text
answerEl.textContent = j.answer;
}
} else answerEl.textContent = JSON.stringify(j, null, 2);
// Chart contract: render ECharts visualization if the worker attached one
renderChart(j.chart);
// A query round-tripped: the empty-state hint is no longer needed
if (!j.chart) {
const pending = document.getElementById('chartPending');
if (pending) pending.classList.add('hidden');
}
// Prefer direct sql field from worker, else derive from logs
if (j.sql) {
const codeEl = document.getElementById('sql');
codeEl.textContent = j.sql;
if(window.Prism && Prism.highlightElement) Prism.highlightElement(codeEl);
}
if (j.logs) {
console.log('[DEBUG] runQuery: Processing logs of length:', j.logs.length);
const sections = splitLogs(j.logs);
console.log('[DEBUG] runQuery: Sections received:', {
memory: sections.memory.length,
querycraft: sections.querycraft.length,
presenter: sections.presenter.length
});
const memoryContent = sections.memory.trim() || '—';
const querycraftContent = sections.querycraft.trim() || '—';
const presenterContent = sections.presenter.trim() || '';
console.log('[DEBUG] runQuery: Setting memory content length:', memoryContent.length);
console.log('[DEBUG] runQuery: Setting querycraft content length:', querycraftContent.length);
console.log('[DEBUG] runQuery: Setting presenter content length:', presenterContent.length);
memoryEl.textContent = memoryContent;
querycraftEl.textContent = querycraftContent;
if(presenterContent){
console.log('[DEBUG] runQuery: Rendering presenter trace');
const node = renderPresenterTrace(presenterContent);
presenterEl.innerHTML = '';
presenterEl.appendChild(node);
} else {
console.log('[DEBUG] runQuery: No presenter content, setting dash');
presenterEl.textContent = '—';
}
if (!j.sql) {
const sql = findSQL(j.logs) || '';
if (sql) {
const codeEl = document.getElementById('sql');
codeEl.textContent = sql;
if(window.Prism && Prism.highlightElement) Prism.highlightElement(codeEl);
}
}
} else {
console.log('[DEBUG] runQuery: No logs in response');
}
statusEl.textContent = 'Done';
timeEl.textContent = `${elapsed}s`;
setRunning(false);
} catch (e) {
answerEl.textContent = 'Error: ' + String(e);
setRunning(false);
}
}
askBtn.onclick = () => runQuery(questionEl.value.trim());
recalcBtn.onclick = () => {
const q = questionEl.value.trim();
if (!q) return alert('Please enter a question');
const normalized = q.replace(/^(?:re-execute\s+)+/i, '');
const prefixed = 're-execute ' + normalized;
// do not modify the visible textarea; send the prefixed query directly
runQuery(prefixed);
};
// Enter key support
questionEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
runQuery(questionEl.value.trim());
}
});
/* ============================================================================
* Chart rendering (ECharts, multi-hue palette + 3D-style finishes)
* ========================================================================== */
// Palette: emerald first so the canonical "battery green" bars lead the system
const SEPIA_PALETTE = [
'#34D399', '#22D3EE', '#A78BFA', '#FBBF24', '#F472B6',
'#60A5FA', '#2DD4BF', '#FB923C', '#C084FC', '#94A3B8',
];
// Chart text/line colors track the active theme (dark Graphite Halo | paper light)
let CHART_INK = '#9DAFC4';
let CHART_INK_FADED = '#64788F';
let CHART_LINE = '#2A3A50';
let CHART_SPLIT = 'rgba(42, 58, 80, 0.55)';
let CHART_PANEL = '#0B101A';
let TOOLTIP_BG = 'rgba(13, 19, 29, 0.95)';
let TOOLTIP_INK = '#E8EEF7';
// Drop-shadow strengths: charts keep their 3D lift on paper, just softer
let SHADOW_BAR = 'rgba(0, 0, 0, 0.35)';
let SHADOW_LIFT = 'rgba(0, 0, 0, 0.45)';
let SHADOW_EMP = 'rgba(0, 0, 0, 0.55)';
function applyChartTheme() {
const light = document.documentElement.classList.contains('light');
if (light) {
CHART_INK = '#4C5D75';
CHART_INK_FADED = '#8494A9';
CHART_LINE = '#C9D4E2';
CHART_SPLIT = 'rgba(201, 212, 226, 0.7)';
CHART_PANEL = '#FFFFFF';
TOOLTIP_BG = 'rgba(255, 255, 255, 0.97)';
TOOLTIP_INK = '#172231';
SHADOW_BAR = 'rgba(16, 24, 40, 0.14)';
SHADOW_LIFT = 'rgba(16, 24, 40, 0.18)';
SHADOW_EMP = 'rgba(16, 24, 40, 0.28)';
} else {
CHART_INK = '#9DAFC4';
CHART_INK_FADED = '#64788F';
CHART_LINE = '#2A3A50';
CHART_SPLIT = 'rgba(42, 58, 80, 0.55)';
CHART_PANEL = '#0B101A';
TOOLTIP_BG = 'rgba(13, 19, 29, 0.95)';
TOOLTIP_INK = '#E8EEF7';
SHADOW_BAR = 'rgba(0, 0, 0, 0.35)';
SHADOW_LIFT = 'rgba(0, 0, 0, 0.45)';
SHADOW_EMP = 'rgba(0, 0, 0, 0.55)';
}
}
applyChartTheme();
// ── tiny color helpers: blend with white/dark, keep the hue family intact ──
const _DARK_RGB = [11, 16, 26]; // --panel-inset, so depth tones stay on-theme
function hexToRgb(hex) {
const h = hex.replace('#', '');
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function mixHex(hex, rgbTarget, amount) {
const c = hexToRgb(hex);
const s = rgbTarget;
const r = c.map((v, i) => Math.round(v + (s[i] - v) * amount));
return '#' + r.map(v => v.toString(16).padStart(2, '0')).join('');
}
const lightenHex = (hex, f = 0.5) => mixHex(hex, [255, 255, 255], f);
const deepenHex = (hex, f = 0.35) => mixHex(hex, _DARK_RGB, f);
const withAlpha = (hex, a) => {
const [r, g, b] = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${a})`;
};
// Battery-icon style bar body: light specular cap -> pure hue -> deep base
function barGradient(hex, echarts) {
return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: lightenHex(hex, 0.55) },
{ offset: 0.10, color: lightenHex(hex, 0.30) },
{ offset: 0.38, color: hex },
{ offset: 1, color: deepenHex(hex, 0.40) },
]);
}
// Soft rising area fill under a line
function areaGradient(hex, echarts) {
return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: withAlpha(hex, 0.38) },
{ offset: 1, color: withAlpha(hex, 0.02) },
]);
}
let chartInstance = null;
let chartResizeObserver = null;
let lastChartSpec = null; // kept so a theme switch can re-render the chart in the new ink
function mountChart(el) {
// HiDPI-crisp + fluid: render at device pixel ratio (min 2x) and resize the
// bitmap whenever the container's CSS size changes. Without this the canvas
// bitmap is stretched by CSS and charts look blurry.
if (chartInstance) { chartInstance.dispose(); chartInstance = null; }
if (chartResizeObserver) { chartResizeObserver.disconnect(); chartResizeObserver = null; }
const dpr = Math.max(window.devicePixelRatio || 1, 2);
chartInstance = echarts.init(el, null, { renderer: 'canvas', devicePixelRatio: dpr });
if (typeof ResizeObserver !== 'undefined') {
chartResizeObserver = new ResizeObserver(() => {
if (!chartInstance || chartInstance.isDisposed()) return;
chartInstance.resize({ animation: { duration: 120 } });
});
chartResizeObserver.observe(el);
}
return chartInstance;
}
function darkTooltip(trigger) {
return {
trigger,
backgroundColor: TOOLTIP_BG,
borderColor: CHART_LINE,
textStyle: { color: TOOLTIP_INK, fontSize: 12 },
};
}
function renderChart(spec) {
lastChartSpec = spec || null;
const box = document.getElementById('chartBox');
const canvas = document.getElementById('chartCanvas');
const caption = document.getElementById('chartCaption');
const pending = document.getElementById('chartPending');
if (!box || !canvas) return;
if (!spec || !spec.type) { box.classList.add('hidden'); if (chartInstance) { chartInstance.dispose(); chartInstance = null; } return; }
if (typeof echarts === 'undefined') { box.classList.add('hidden'); return; }
if (pending) pending.classList.add('hidden');
box.classList.remove('hidden');
mountChart(canvas);
const base = {
backgroundColor: 'transparent',
color: SEPIA_PALETTE,
tooltip: darkTooltip(spec.type === 'pie' ? 'item' : 'axis'),
grid: { left: 12, right: 18, top: 42, bottom: 12, containLabel: true },
textStyle: { color: CHART_INK, fontFamily: 'Inter, sans-serif' },
};
let option = base;
const xAxis = {
type: 'category',
data: spec.x || [],
axisLine: { lineStyle: { color: CHART_LINE } },
axisTick: { lineStyle: { color: CHART_LINE } },
axisLabel: { color: CHART_INK, rotate: (spec.x || []).length > 8 ? 30 : 0, hideOverlap: true, fontSize: 11 },
};
const yAxis = {
type: 'value',
splitLine: { lineStyle: { color: CHART_SPLIT } },
axisLabel: { color: CHART_INK_FADED, fontSize: 11 },
};
const seriesData = (spec.series || []).map(s => ({ name: s.name, values: s.values || [] }));
if (spec.type === 'pie') {
const data = (spec.x || []).map((lab, i) => ({
name: lab,
value: (seriesData[0] && seriesData[0].values[i]) || 0,
}));
option = {
...base, tooltip: darkTooltip('item'),
legend: { bottom: 0, textStyle: { color: CHART_INK, fontSize: 11 }, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 8 },
series: [{
type: 'pie', radius: ['40%', '70%'], center: ['50%', '46%'],
data,
itemStyle: {
borderColor: CHART_PANEL, borderWidth: 2, borderRadius: 8,
shadowBlur: 14, shadowColor: SHADOW_LIFT, shadowOffsetY: 3,
},
label: { color: CHART_INK, fontSize: 11 },
emphasis: {
itemStyle: {
shadowBlur: 22, shadowColor: SHADOW_EMP,
shadowOffsetY: 5, scale: true, scaleSize: 4,
},
label: { fontSize: 13, fontWeight: 600 },
},
}],
};
} else if (spec.type === 'line') {
const single = seriesData.length === 1;
option = {
...base, xAxis, yAxis,
legend: seriesData.length > 1 ? { top: 4, textStyle: { color: CHART_INK, fontSize: 11 }, icon: 'roundRect', itemWidth: 14, itemHeight: 8 } : undefined,
series: seriesData.map((s, i) => {
const tint = SEPIA_PALETTE[i % SEPIA_PALETTE.length];
return {
type: 'line', name: s.name, data: s.values,
smooth: true, symbol: 'circle', symbolSize: 7,
lineStyle: {
width: 3,
shadowColor: withAlpha(tint, 0.45), shadowBlur: 10, shadowOffsetY: 5,
},
itemStyle: { color: tint, borderColor: lightenHex(tint, 0.6), borderWidth: 2 },
areaStyle: single ? { color: areaGradient(tint, echarts) } : undefined,
};
}),
};
} else {
// Bars: vertical gradient ("battery" body) + rounded top + soft drop shadow
option = {
...base, xAxis, yAxis,
legend: seriesData.length > 1 ? { top: 4, textStyle: { color: CHART_INK, fontSize: 11 }, icon: 'roundRect', itemWidth: 14, itemHeight: 8 } : undefined,
series: seriesData.map((s, i) => {
const tint = SEPIA_PALETTE[i % SEPIA_PALETTE.length];
return {
type: 'bar', name: s.name, data: s.values,
itemStyle: {
color: barGradient(tint, echarts),
borderRadius: [6, 6, 2, 2],
shadowBlur: 7, shadowColor: SHADOW_BAR, shadowOffsetY: 3,
},
emphasis: {
itemStyle: {
color: barGradient(lightenHex(tint, 0.18), echarts),
shadowBlur: 12, shadowColor: withAlpha(tint, 0.4), shadowOffsetY: 4,
},
},
barMaxWidth: 56,
};
}),
};
}
chartInstance.setOption(option);
// Flush one resize after layout settles so nothing was measured mid-show.
requestAnimationFrame(() => { if (chartInstance && !chartInstance.isDisposed()) chartInstance.resize(); });
if (caption) {
caption.textContent = (spec.title || '') + (spec.rows_in_result ? ` · ${spec.rows_in_result} data points` : '');
}
}
window.addEventListener('resize', () => { if (chartInstance) chartInstance.resize(); });
/* ============================================================================
* Dynamic database profile manager (Phase 1 API + Phase 4 UI)
* ========================================================================== */
const schemaProfilesEl = document.getElementById('schemaProfiles');
const schemaTablesEl = document.getElementById('schemaTables');
const schemaNameEl = document.getElementById('schemaName');
const schemaUrlEl = document.getElementById('schemaUrl');
const schemaAddBtn = document.getElementById('schemaAddBtn');
const schemaNotesEl = document.getElementById('schemaNotes');
const schemaFileBtn = document.getElementById('schemaFileBtn');
const dbBadge = document.getElementById('dbBadge');
const dbTag = document.getElementById('dbTag');
async function apiSchemas(method, path, body, isForm) {
const opts = { method, headers: {} };
if (body !== undefined) {
if (isForm) { opts.body = body; }
else { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
}
const r = await fetch(path, opts);
let j = null;
try { j = await r.json(); } catch (e) { j = null; }
if (!r.ok) throw new Error((j && (j.error || j.detail)) || r.statusText);
return j;
}
function setActiveProfileName(name) {
if (dbBadge) dbBadge.textContent = name || '—';
if (dbTag) dbTag.textContent = name || 'demo_employee';
}
async function refreshSchemaPanel() {
let data;
try { data = await apiSchemas('GET', '/api/schemas'); }
catch (e) {
if (schemaProfilesEl) schemaProfilesEl.innerHTML = '<div class="profile-item"><span class="profile-name">(schema service offline)</span></div>';
return;
}
const profiles = (data.profiles || []);
setActiveProfileName((profiles.find(p => p.active) || {}).name || '—');
if (schemaProfilesEl) {
schemaProfilesEl.innerHTML = '';
if (!profiles.length) {
schemaProfilesEl.innerHTML = '<div class="profile-item"><span class="profile-name">No profiles yet — add one below</span></div>';
}
for (const p of profiles) {
const item = document.createElement('div');
item.className = 'profile-item' + (p.active ? ' active' : '');
const dot = document.createElement('span');
dot.className = 'profile-dot' + (p.active ? ' on' : '');
const nm = document.createElement('span');
nm.className = 'profile-name'; nm.textContent = p.name; nm.title = p.db_url || '';
const kind = document.createElement('span');
kind.className = 'profile-kind'; kind.textContent = p.kind || 'user';
const actions = document.createElement('span'); actions.className = 'profile-actions';
if (!p.active) {
const sw = document.createElement('button');
sw.className = 'profile-btn'; sw.textContent = 'Switch';
sw.onclick = async () => {
sw.disabled = true; sw.textContent = '…';
try { await apiSchemas('POST', '/api/schemas/switch/' + p.id); await refreshSchemaPanel(); }
catch (e) { alert('Switch failed: ' + e.message); sw.disabled = false; sw.textContent = 'Switch'; }
};
actions.appendChild(sw);
}
const nt = document.createElement('button');
nt.className = 'profile-btn';
nt.textContent = ((p.notes || '').trim()) ? 'Notes •' : 'Notes';
nt.title = 'Schema notes — domain hints injected into every prompt for this database';
nt.onclick = () => toggleSchemaNotesEditor(item, p);
actions.appendChild(nt);
const del = document.createElement('button');
del.className = 'profile-btn danger'; del.textContent = '×';
del.title = 'Remove profile';
del.onclick = async () => {
if (!confirm(`Remove profile "${p.name}"?`)) return;
try { await apiSchemas('DELETE', '/api/schemas/' + p.id); await refreshSchemaPanel(); }
catch (e) { alert('Delete failed: ' + e.message); }
};
actions.appendChild(del);
item.append(dot, nm, kind, actions);
schemaProfilesEl.appendChild(item);
}
}
// Current schema tables (best-effort)
try {
const cur = await apiSchemas('GET', '/api/schema/current');
if (schemaTablesEl) {
schemaTablesEl.innerHTML = '';
const tables = cur.tables || [];
if (!tables.length) { schemaTablesEl.innerHTML = ''; return; }
for (const t of tables.slice(0, 10)) {
const d = document.createElement('div');
d.className = 'tbl';
d.innerHTML = `<b>${escapeHtml(t.name)}</b> <span class="rc">(${t.row_count ?? '?'} rows · ${t.columns.length} cols)</span>`;
d.title = t.columns.join(', ');
schemaTablesEl.appendChild(d);
}
}
} catch (e) { /* schema detail endpoint optional */ }
}
function toggleSchemaNotesEditor(item, p) {
// Toggle this row's notes editor; only one open at a time.
const prev = item.nextElementSibling;
if (prev && prev.classList && prev.classList.contains('notes-editor')) { prev.remove(); return; }
document.querySelectorAll('.notes-editor').forEach(el => el.remove());
const editor = document.createElement('div');
editor.className = 'notes-editor';
const ta = document.createElement('textarea');
ta.value = p.notes || '';
ta.maxLength = 4000;
ta.rows = 3;
ta.placeholder = "What your columns mean, e.g. 'sold_date IS NULL = still in inventory', 'msrp = list price, sale_price = actual', revenue rules, aliases";
const row = document.createElement('div');
row.className = 'notes-editor-actions';
const save = document.createElement('button');
save.className = 'profile-btn primary'; save.textContent = 'Save notes';
const cancel = document.createElement('button');
cancel.className = 'profile-btn'; cancel.textContent = 'Cancel';
save.onclick = async () => {
save.disabled = true; save.textContent = 'Saving…';
try {
const res = await apiSchemas('POST', '/api/schemas/' + p.id + '/notes', { notes: ta.value });
p.notes = (res && res.notes) || '';
await refreshSchemaPanel();
if (statusEl) statusEl.textContent = 'Schema notes updated for "' + p.name + '"';
} catch (e) {
alert('Saving notes failed: ' + e.message);
save.disabled = false; save.textContent = 'Save notes';
}
};
cancel.onclick = () => editor.remove();
row.append(save, cancel);
editor.append(ta, row);
item.after(editor);
ta.focus();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function addSchemaFromUrl() {
const name = (schemaNameEl && schemaNameEl.value.trim()) || '';
const url = (schemaUrlEl && schemaUrlEl.value.trim()) || '';
const notes = (schemaNotesEl && schemaNotesEl.value.trim()) || '';
if (!name || !url) { alert('Provide both a profile name and a connection URL (or use file upload).'); return; }
schemaAddBtn.disabled = true; schemaAddBtn.textContent = 'Connecting…';
try {
await apiSchemas('POST', '/api/schemas/add', { name, connection_url: url, notes });
schemaNameEl.value = ''; schemaUrlEl.value = '';
if (schemaNotesEl) schemaNotesEl.value = '';
await refreshSchemaPanel();
statusEl.textContent = `Connected to "${name}"`;
} catch (e) {
alert('Connect failed: ' + e.message);
} finally {
schemaAddBtn.disabled = false; schemaAddBtn.textContent = 'Connect';
}
}
function addSchemaFromFile() {
const tmp = document.createElement('input');
tmp.type = 'file';
tmp.accept = '.sqlite,.db,.sqlite3,.csv';
tmp.style.position = 'absolute'; tmp.style.left = '-9999px';
document.body.appendChild(tmp);
tmp.addEventListener('change', async () => {
const f = tmp.files && tmp.files[0];
document.body.removeChild(tmp);
if (!f) return;
if (!schemaNameEl.value.trim()) schemaNameEl.value = f.name.replace(/\.[^.]+$/, '');
const fd = new FormData();
fd.append('file', f);
fd.append('name', schemaNameEl.value.trim());
fd.append('activate', 'true');
if (schemaNotesEl && schemaNotesEl.value.trim()) fd.append('notes', schemaNotesEl.value.trim());
schemaFileBtn.disabled = true; schemaFileBtn.textContent = 'Importing…';
try {
await fetch('/api/schemas/add-file', { method: 'POST', body: fd }).then(async r => {
if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.detail || r.statusText); }
return r.json();
});
await refreshSchemaPanel();
statusEl.textContent = `Imported "${schemaNameEl.value.trim()}"`;
schemaNameEl.value = '';
if (schemaNotesEl) schemaNotesEl.value = '';
} catch (e) {
alert('Import failed: ' + e.message);
} finally {
schemaFileBtn.disabled = false; schemaFileBtn.textContent = 'Upload .sqlite / .csv';
}
});
tmp.click();
}
if (schemaAddBtn) schemaAddBtn.onclick = addSchemaFromUrl;
if (schemaFileBtn) schemaFileBtn.onclick = addSchemaFromFile;
/* ── Theme toggle (dark Graphite Halo default ↔ light paper) ───────── */
const themeToggle = document.getElementById('themeToggle');
const isLightTheme = () => document.documentElement.classList.contains('light');
function refreshChartsForTheme() {
applyChartTheme();
const box = document.getElementById('chartBox');
if (lastChartSpec && box && !box.classList.contains('hidden') && typeof echarts !== 'undefined') {
renderChart(lastChartSpec);
}
}
function applyTheme(light) {
document.documentElement.classList.toggle('light', light);
try { localStorage.setItem('ql.theme', light ? 'light' : 'dark'); } catch (e) { /* private mode */ }
if (themeToggle) {
const label = light ? 'Switch to dark mode' : 'Switch to light mode';
themeToggle.title = label;
themeToggle.setAttribute('aria-label', label);
}
refreshChartsForTheme();
}
if (themeToggle) {
let savedTheme = null;
try { savedTheme = localStorage.getItem('ql.theme'); } catch (e) { /* ignore */ }
applyTheme(savedTheme === 'light'); // dark stays the brand default
themeToggle.addEventListener('click', () => applyTheme(!isLightTheme()));
}
// Initial load
refreshSchemaPanel();