Spaces:
Running on Zero
Running on Zero
Download web/static/app.js from Aniruddha7/QueryLensV2b: direct link, hf CLI and curl.
- Browser
- Download file 42.9 kB
-
https://huggingface.co/spaces/Aniruddha7/QueryLensV2b/resolve/main/web/static/app.js
- Command line
-
hf download hf://spaces/Aniruddha7/QueryLensV2b/web/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Aniruddha7/QueryLensV2b/resolve/main/web/static/app.js
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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| // 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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(); | |