prazy1208/text2sql
0
1const LEGACY_SESSION_KEY = 'text2sql_session_id';2const LEGACY_CHAT_HISTORY_KEY = 'text2sql_chat_history';3const CLIENT_ID_KEY = 'text2sql_client_id';4const ACTIVE_SESSION_KEY = 'text2sql_active_session';5const API_BASE = '';6 7const form = document.getElementById('query-form');8const useCaseSelect = document.getElementById('use-case');9const messageInput = document.getElementById('message');10const submitBtn = document.getElementById('submit-btn');11const outputPlaceholder = document.getElementById('output-placeholder');12const outputContent = document.getElementById('output-content');13const outputError = document.getElementById('output-error');14const chatScroll = document.getElementById('chat-scroll');15const chatThread = document.getElementById('chat-thread');16const composerStatus = document.getElementById('composer-status');17const intentActions = document.getElementById('intent-actions');18const intentActionsLabel = document.getElementById('intent-actions-label');19const intentYesBtn = document.getElementById('intent-yes-btn');20const intentNoBtn = document.getElementById('intent-no-btn');21const chatListEl = document.getElementById('chat-list');22const newChatBtn = document.getElementById('new-chat-btn');23const deleteConfirmModal = document.getElementById('delete-confirm-modal');24const deleteConfirmCancelBtn = document.getElementById('delete-confirm-cancel');25const deleteConfirmDeleteBtn = document.getElementById('delete-confirm-delete');26const domainSelectorEl = document.getElementById('domain-selector');27const pipelineProgressEl = document.getElementById('pipeline-progress');28const welcomePanel = document.getElementById('welcome-panel');29 30let activeSessionId = null;31let cachedSessions = [];32let pendingDeleteSessionId = null;33let pendingIntentState = null;34let domainInfo = null;35let pipelineInterval = null;36 37const PROMPT_CORRECTED_QUESTION = 'Please provide a corrected question.';38 39const COPY_SQL_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>`;40 41const DOMAIN_ICONS = {42 healthcare: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>`,43 retail: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/></svg>`,44 finance: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>`,45};46 47const PIPELINE_STEPS = ['intent', 'tables', 'columns', 'fewshot', 'sql'];48 49// ===== Utility =====50 51function removeLegacyKeys() {52 try {53 localStorage.removeItem(LEGACY_CHAT_HISTORY_KEY);54 localStorage.removeItem(LEGACY_SESSION_KEY);55 } catch (_e) { /* ignore */ }56}57 58function randomUUID() {59 if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();60 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {61 const r = (Math.random() * 16) | 0;62 const v = c === 'x' ? r : (r & 0x3) | 0x8;63 return v.toString(16);64 });65}66 67function ensureClientId() {68 try {69 let id = localStorage.getItem(CLIENT_ID_KEY);70 if (!id) { id = randomUUID(); localStorage.setItem(CLIENT_ID_KEY, id); }71 return id;72 } catch (_e) { return randomUUID(); }73}74 75function getClientHeaders() {76 return { 'X-Client-Id': ensureClientId() };77}78 79function persistActiveSession(id) {80 try { if (id) localStorage.setItem(ACTIVE_SESSION_KEY, id); else localStorage.removeItem(ACTIVE_SESSION_KEY); }81 catch (_e) { /* ignore */ }82}83 84function getRememberedSessionId() {85 try { return localStorage.getItem(ACTIVE_SESSION_KEY); } catch (_e) { return null; }86}87 88function updateSessionHeader(id) { /* no-op: session id removed from header */ }89 90function getActiveSessionId() { return activeSessionId; }91 92function setActiveSessionId(id) {93 const sid = id != null && String(id).trim() ? String(id).trim() : null;94 activeSessionId = sid;95 persistActiveSession(sid);96}97 98function sidebarLabelForSession(s) {99 const t = s.title && String(s.title).trim();100 if (t) return t;101 const raw = String(s.session_id || '').replace(/-/g, '');102 const tail = raw.slice(-6) || raw.slice(0, 6) || '…';103 return `New chat (${tail})`;104}105 106function escapeHtml(s) {107 const div = document.createElement('div');108 div.textContent = s;109 return div.innerHTML;110}111 112// ===== Domain selector =====113 114function resetDomainSelect() {115 useCaseSelect.value = '';116 domainSelectorEl.querySelectorAll('.domain-btn').forEach(b => b.classList.remove('is-selected'));117 updatePlaceholderForDomain('');118}119 120function selectDomain(name, opts = {}) {121 const previousDomain = useCaseSelect.value;122 useCaseSelect.value = name;123 domainSelectorEl.querySelectorAll('.domain-btn').forEach(b => {124 b.classList.toggle('is-selected', b.dataset.domain === name);125 });126 updatePlaceholderForDomain(name);127 128 if (opts.silent) return;129 130 if (previousDomain && previousDomain !== name && chatThread.children.length > 0) {131 setActiveSessionId(null);132 clearChatThread();133 pendingIntentState = null;134 hideIntentActions();135 showError('');136 showContextualSuggestions();137 void refreshSidebar();138 } else if (chatThread.children.length === 0) {139 showContextualSuggestions();140 }141}142 143function renderDomainButtons(domains) {144 if (!domainSelectorEl) return;145 domainSelectorEl.innerHTML = '';146 (domains || []).forEach(d => {147 const btn = document.createElement('button');148 btn.type = 'button';149 btn.className = 'domain-btn';150 btn.dataset.domain = d.name;151 btn.innerHTML = `<span class="domain-btn-icon">${DOMAIN_ICONS[d.name] || ''}</span>${d.display_name}`;152 btn.addEventListener('click', () => selectDomain(d.name));153 domainSelectorEl.appendChild(btn);154 });155}156 157function updatePlaceholderForDomain(domain) {158 if (!messageInput) return;159 if (domain) {160 const name = domain.charAt(0).toUpperCase() + domain.slice(1);161 messageInput.placeholder = `Ask a question about ${name} data...`;162 } else {163 messageInput.placeholder = 'Select a domain, then ask a question...';164 }165}166 167// ===== Welcome screen =====168 169async function loadDomainInfo() {170 try {171 const res = await fetch(`${API_BASE}/domain-info`);172 if (!res.ok) return null;173 const data = await res.json();174 domainInfo = data.domains || [];175 return domainInfo;176 } catch (e) {177 console.warn('domain-info failed', e);178 return null;179 }180}181 182function renderWelcomePanel(domains) {183 if (!welcomePanel) return;184 if (!domains || !domains.length) {185 welcomePanel.innerHTML = '<p style="color:var(--text-muted)">Loading...</p>';186 return;187 }188 189 const cardsHtml = domains.map(d => {190 const icon = DOMAIN_ICONS[d.name] || '';191 const tablePills = d.tables.slice(0, 6).map(t =>192 `<span class="table-tag">${escapeHtml(t)}</span>`193 ).join('') + (d.tables.length > 6 ? `<span class="table-tag table-tag--more">+${d.tables.length - 6}</span>` : '');194 const examplesHtml = (d.example_questions || []).map(q =>195 `<li><button type="button" class="example-question-btn" data-domain="${d.name}" data-question="${escapeHtml(q)}">${escapeHtml(q)}</button></li>`196 ).join('');197 return `198 <div class="domain-card">199 <div class="domain-card-header">200 <div class="domain-card-icon domain-card-icon--${d.name}">${icon}</div>201 <h3 class="domain-card-title">${d.display_name}</h3>202 </div>203 <p class="domain-card-desc">${d.description}</p>204 <div class="domain-card-tables">${tablePills}</div>205 <ul class="domain-card-examples">${examplesHtml}</ul>206 </div>207 `;208 }).join('');209 210 const pipelineHtml = `211 <div class="pipeline-diagram">212 <div class="pipeline-diagram-step"><div class="pipeline-diagram-icon">1</div><span class="pipeline-diagram-label">Intent</span></div>213 <div class="pipeline-diagram-arrow"></div>214 <div class="pipeline-diagram-step"><div class="pipeline-diagram-icon">2</div><span class="pipeline-diagram-label">Tables</span></div>215 <div class="pipeline-diagram-arrow"></div>216 <div class="pipeline-diagram-step"><div class="pipeline-diagram-icon">3</div><span class="pipeline-diagram-label">Columns</span></div>217 <div class="pipeline-diagram-arrow"></div>218 <div class="pipeline-diagram-step"><div class="pipeline-diagram-icon">4</div><span class="pipeline-diagram-label">Few-Shot</span></div>219 <div class="pipeline-diagram-arrow"></div>220 <div class="pipeline-diagram-step"><div class="pipeline-diagram-icon">5</div><span class="pipeline-diagram-label">Gen SQL</span></div>221 </div>222 `;223 224 welcomePanel.innerHTML = `225 <div class="welcome-hero">226 <h2>Text2SQL</h2>227 <p>Convert natural language into SQL — powered by a multi-agent AI pipeline.</p>228 </div>229 <div class="welcome-how">230 ${pipelineHtml}231 </div>232 <div class="welcome-domains">${cardsHtml}</div>233 `;234 235 if (!welcomePanel.dataset.bound) {236 welcomePanel.dataset.bound = '1';237 welcomePanel.addEventListener('click', (e) => {238 const btn = e.target.closest('.example-question-btn');239 if (!btn) return;240 const domain = btn.dataset.domain;241 const question = btn.dataset.question;242 if (domain && question) {243 selectDomain(domain, { silent: true });244 messageInput.value = question;245 form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));246 }247 });248 }249}250 251function showWelcome() {252 outputPlaceholder.classList.remove('hidden');253 outputContent.classList.add('hidden');254 if (domainInfo) renderWelcomePanel(domainInfo);255}256 257// ===== Contextual placeholder (in-chat suggestions) =====258 259function showContextualSuggestions() {260 const domain = useCaseSelect.value;261 if (!domain || !domainInfo) {262 showWelcome();263 return;264 }265 const info = domainInfo.find(d => d.name === domain);266 if (!info) { showWelcome(); return; }267 268 outputPlaceholder.classList.remove('hidden');269 outputContent.classList.add('hidden');270 271 const pills = (info.example_questions || []).map(q =>272 `<button type="button" class="suggestion-pill" data-question="${escapeHtml(q)}">${escapeHtml(q)}</button>`273 ).join('');274 275 welcomePanel.innerHTML = `276 <div class="chat-suggestions">277 <h3>Ask about ${info.display_name}</h3>278 <p>${info.description} — ${info.table_count} tables available</p>279 <div class="suggestion-pills">${pills}</div>280 </div>281 `;282 283 welcomePanel.onclick = (e) => {284 const pill = e.target.closest('.suggestion-pill');285 if (!pill) return;286 messageInput.value = pill.dataset.question;287 form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));288 };289}290 291// ===== Pipeline progress =====292 293function showPipelineProgress() {294 if (!pipelineProgressEl) return;295 pipelineProgressEl.classList.remove('hidden');296 let stepIndex = 0;297 updatePipelineStep(stepIndex);298 pipelineInterval = setInterval(() => {299 stepIndex++;300 if (stepIndex >= PIPELINE_STEPS.length) {301 stepIndex = PIPELINE_STEPS.length - 1;302 }303 updatePipelineStep(stepIndex);304 }, 3000);305}306 307function updatePipelineStep(activeIdx) {308 const steps = pipelineProgressEl.querySelectorAll('.pipeline-step');309 const connectors = pipelineProgressEl.querySelectorAll('.pipeline-connector');310 steps.forEach((el, i) => {311 el.classList.remove('is-active', 'is-done');312 if (i < activeIdx) el.classList.add('is-done');313 else if (i === activeIdx) el.classList.add('is-active');314 });315 connectors.forEach((el, i) => {316 el.style.background = i < activeIdx ? 'var(--green-500)' : 'var(--border)';317 });318}319 320function hidePipelineProgress() {321 if (pipelineInterval) { clearInterval(pipelineInterval); pipelineInterval = null; }322 if (!pipelineProgressEl) return;323 const steps = pipelineProgressEl.querySelectorAll('.pipeline-step');324 steps.forEach(el => { el.classList.remove('is-active'); el.classList.add('is-done'); });325 const connectors = pipelineProgressEl.querySelectorAll('.pipeline-connector');326 connectors.forEach(el => { el.style.background = 'var(--green-500)'; });327 setTimeout(() => {328 pipelineProgressEl.classList.add('hidden');329 steps.forEach(el => el.classList.remove('is-done', 'is-active'));330 connectors.forEach(el => { el.style.background = ''; });331 }, 800);332}333 334// ===== SQL syntax highlighting =====335 336function highlightSQL(sql) {337 const keywords = /\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|FULL|CROSS|ON|AND|OR|NOT|IN|IS|NULL|AS|ORDER|BY|GROUP|HAVING|LIMIT|OFFSET|UNION|ALL|DISTINCT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|TABLE|INDEX|VIEW|SET|VALUES|INTO|BETWEEN|LIKE|ILIKE|EXISTS|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE|ASC|DESC|COUNT|SUM|AVG|MIN|MAX|COALESCE|CAST|EXTRACT|DATE_TRUNC|GENERATE_SERIES|OVER|PARTITION|ROW_NUMBER|RANK|DENSE_RANK|LAG|LEAD|FILTER|LATERAL|FETCH|NEXT|ROWS|ONLY|NULLS|FIRST|LAST)\b/gi;338 const functions = /\b(COUNT|SUM|AVG|MIN|MAX|COALESCE|CAST|EXTRACT|DATE_TRUNC|GENERATE_SERIES|ROW_NUMBER|RANK|DENSE_RANK|LAG|LEAD|NOW|CURRENT_DATE|CURRENT_TIMESTAMP|ROUND|FLOOR|CEIL|ABS|LENGTH|UPPER|LOWER|TRIM|SUBSTRING|CONCAT|TO_CHAR|TO_DATE|TO_NUMBER)\s*(?=\()/gi;339 const strings = /('[^']*')/g;340 const numbers = /\b(\d+\.?\d*)\b/g;341 const comments = /(--[^\n]*)/g;342 343 let result = escapeHtml(sql);344 result = result.replace(comments, '<span class="sql-comment">$1</span>');345 result = result.replace(strings, '<span class="sql-string">$1</span>');346 result = result.replace(functions, '<span class="sql-function">$1</span>');347 result = result.replace(keywords, '<span class="sql-keyword">$1</span>');348 result = result.replace(numbers, (match, num, offset, str) => {349 const before = str.substring(Math.max(0, offset - 20), offset);350 if (before.includes('sql-')) return match;351 return `<span class="sql-number">${num}</span>`;352 });353 return result;354}355 356// ===== Use cases / sessions =====357 358async function loadUseCases() {359 try {360 const res = await fetch(`${API_BASE}/use-cases`);361 if (!res.ok) throw new Error('Failed to load use cases');362 const list = await res.json();363 useCaseSelect.innerHTML =364 '<option value="">Select Domain</option>' +365 list.map((u) => `<option value="${u}">${u.charAt(0).toUpperCase() + u.slice(1)}</option>`).join('');366 } catch (e) {367 useCaseSelect.innerHTML = '<option value="">Failed to load</option>';368 console.error(e);369 }370}371 372async function fetchSessionsList() {373 const cid = ensureClientId();374 const res = await fetch(`${API_BASE}/sessions?client_id=${encodeURIComponent(cid)}&limit=200`, {375 headers: getClientHeaders(),376 });377 if (!res.ok) {378 const err = await res.json().catch(() => ({}));379 const detail = err.detail != null ? err.detail : res.statusText;380 throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));381 }382 return res.json();383}384 385async function refreshSidebar() {386 try {387 const sessions = await fetchSessionsList();388 renderChatList(sessions);389 } catch (e) {390 console.warn('refreshSidebar failed', e);391 }392}393 394const CHAT_DELETE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;395 396function renderChatList(sessions) {397 if (!chatListEl) return;398 cachedSessions = Array.isArray(sessions) ? sessions.slice() : [];399 chatListEl.innerHTML = '';400 cachedSessions.forEach((s) => {401 const row = document.createElement('div');402 row.className = 'chat-list-row' + (s.session_id === activeSessionId ? ' is-active' : '');403 row.dataset.sessionId = s.session_id;404 row.setAttribute('role', 'listitem');405 406 const btn = document.createElement('button');407 btn.type = 'button';408 btn.className = 'chat-list-item';409 btn.dataset.sessionId = s.session_id;410 btn.textContent = sidebarLabelForSession(s);411 if (s.use_case) btn.dataset.useCase = s.use_case;412 413 const del = document.createElement('button');414 del.type = 'button';415 del.className = 'chat-list-delete';416 del.dataset.sessionId = s.session_id;417 del.setAttribute('aria-label', 'Delete chat');418 del.title = 'Delete chat';419 del.innerHTML = CHAT_DELETE_SVG;420 421 row.appendChild(btn);422 row.appendChild(del);423 chatListEl.appendChild(row);424 });425}426 427function setSidebarActiveHighlight(sessionId) {428 if (!chatListEl) return;429 chatListEl.querySelectorAll('.chat-list-row').forEach((row) => {430 row.classList.toggle('is-active', Boolean(sessionId) && row.dataset.sessionId === sessionId);431 });432}433 434// ===== Session management =====435 436async function deleteSessionRemote(sessionId) {437 const cid = ensureClientId();438 const res = await fetch(439 `${API_BASE}/sessions/${encodeURIComponent(sessionId)}?client_id=${encodeURIComponent(cid)}`,440 { method: 'DELETE', headers: getClientHeaders() }441 );442 if (res.status === 204) return;443 const err = await res.json().catch(() => ({}));444 const detail = err.detail != null ? err.detail : res.statusText;445 throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));446}447 448function openDeleteConfirmModal(sessionId) {449 if (!deleteConfirmModal || !sessionId) return;450 pendingDeleteSessionId = sessionId;451 deleteConfirmModal.classList.remove('hidden');452 deleteConfirmModal.setAttribute('aria-hidden', 'false');453 if (deleteConfirmCancelBtn) deleteConfirmCancelBtn.focus();454}455 456function closeDeleteConfirmModal() {457 if (!deleteConfirmModal) return;458 pendingDeleteSessionId = null;459 deleteConfirmModal.classList.add('hidden');460 deleteConfirmModal.setAttribute('aria-hidden', 'true');461}462 463function handleDeleteChat(sessionId) {464 if (!sessionId) return;465 openDeleteConfirmModal(sessionId);466}467 468async function executeConfirmedDeleteChat() {469 const sessionId = pendingDeleteSessionId;470 if (!sessionId) return;471 closeDeleteConfirmModal();472 showError('');473 try {474 await deleteSessionRemote(sessionId);475 const wasActive = sessionId === activeSessionId;476 await refreshSidebar();477 if (wasActive) {478 if (cachedSessions.length > 0 && cachedSessions[0].session_id) {479 await loadChatSession(cachedSessions[0].session_id);480 } else {481 setActiveSessionId(null);482 resetDomainSelect();483 clearChatThread();484 pendingIntentState = null;485 hideIntentActions();486 showWelcome();487 setSidebarActiveHighlight(null);488 }489 }490 } catch (e) {491 showError(e?.message || 'Failed to delete chat');492 }493}494 495function applyUseCaseFromSession(sessionId) {496 const row = cachedSessions.find((x) => x.session_id === sessionId);497 const uc = row?.use_case && String(row.use_case).trim();498 if (!uc) return;499 selectDomain(uc, { silent: true });500}501 502async function fetchSessionMessages(sessionId) {503 const res = await fetch(504 `${API_BASE}/sessions/${encodeURIComponent(sessionId)}/messages`,505 { headers: getClientHeaders() }506 );507 if (!res.ok) {508 const err = await res.json().catch(() => ({}));509 const detail = err.detail != null ? err.detail : res.statusText;510 throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));511 }512 return res.json();513}514 515async function fetchSessionPipelineTurns(sessionId) {516 const res = await fetch(517 `${API_BASE}/sessions/${encodeURIComponent(sessionId)}/pipeline-turns`,518 { headers: getClientHeaders() }519 );520 if (!res.ok) {521 const err = await res.json().catch(() => ({}));522 const detail = err.detail != null ? err.detail : res.statusText;523 throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));524 }525 return res.json();526}527 528function turnToAssistantData(turn) {529 return {530 conversation_state: turn.conversation_state || 'completed',531 rephrased_question: turn.rephrased_question || '',532 resolved_question: turn.resolved_question || turn.rephrased_question || '',533 keywords: Array.isArray(turn.keywords) ? turn.keywords : [],534 business_insights: Array.isArray(turn.business_insights) ? turn.business_insights : [],535 intent_confidence: typeof turn.intent_confidence === 'number' ? turn.intent_confidence : 0,536 selected_tables: Array.isArray(turn.selected_tables) ? turn.selected_tables : [],537 selected_columns:538 turn.selected_columns && typeof turn.selected_columns === 'object' && !Array.isArray(turn.selected_columns)539 ? turn.selected_columns540 : {},541 few_shot_examples: Array.isArray(turn.few_shot_examples) ? turn.few_shot_examples : [],542 generated_sql: turn.generated_sql || '',543 error: turn.error != null ? turn.error : null,544 };545}546 547function turnHasPipelineArtifacts(turn) {548 const sql = (turn.generated_sql || '').trim();549 const tables = Array.isArray(turn.selected_tables) && turn.selected_tables.length > 0;550 return Boolean(sql || tables);551}552 553function renderReloadedThread(msgs, turns) {554 const list = Array.isArray(msgs) ? msgs : [];555 const pipelineTurns = Array.isArray(turns) ? turns : [];556 const consumedTurnIds = new Set();557 558 function consumeNextRichTurn() {559 for (const t of pipelineTurns) {560 const id = t.intent_output_id;561 if (id == null || consumedTurnIds.has(id)) continue;562 if (!turnHasPipelineArtifacts(t)) continue;563 consumedTurnIds.add(id);564 return t;565 }566 return null;567 }568 569 const defer = { deferScroll: true };570 for (const m of list) {571 const role = (m.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';572 const mt = (m.message_type || '').toLowerCase();573 if (role === 'assistant' && mt === 'pipeline_completed') {574 const t = consumeNextRichTurn();575 if (t) {576 appendChatBubble('assistant', 'SQL generated successfully.', turnToAssistantData(t), defer);577 } else {578 appendPlainBubble(role, m.content || '', defer);579 }580 } else {581 appendPlainBubble(role, m.content || '', defer);582 }583 }584 585 for (const t of pipelineTurns) {586 const id = t.intent_output_id;587 if (id == null || consumedTurnIds.has(id) || !turnHasPipelineArtifacts(t)) continue;588 consumedTurnIds.add(id);589 appendChatBubble('user', t.user_input || '', null, defer);590 appendChatBubble('assistant', 'SQL generated successfully.', turnToAssistantData(t), defer);591 }592 scrollChatToBottom();593}594 595function clearChatThread() {596 chatThread.innerHTML = '';597 dockIntentActionsDefault();598}599 600async function bootstrapFreshSession() {601 const cid = ensureClientId();602 const res = await fetch(`${API_BASE}/session?client_id=${encodeURIComponent(cid)}`, {603 method: 'POST',604 headers: { 'Content-Type': 'application/json' },605 });606 const data = await res.json();607 if (!res.ok || !data?.session_id) {608 const detail = data?.detail != null ? data.detail : res.statusText;609 throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));610 }611 setActiveSessionId(String(data.session_id));612 return String(data.session_id);613}614 615async function loadChatSession(sessionId) {616 if (!sessionId) return;617 setLoadingState(true, 'Loading chat…');618 showError('');619 hideIntentActions();620 pendingIntentState = null;621 622 try {623 const [msgs, turns] = await Promise.all([624 fetchSessionMessages(sessionId),625 fetchSessionPipelineTurns(sessionId).catch((e) => { console.warn('pipeline-turns', e); return []; }),626 ]);627 setActiveSessionId(sessionId);628 clearChatThread();629 630 if (!msgs.length) {631 showContextualSuggestions();632 setSidebarActiveHighlight(sessionId);633 void refreshSidebar().then(() => applyUseCaseFromSession(sessionId));634 return;635 }636 637 showChatArea();638 renderReloadedThread(msgs, turns);639 setSidebarActiveHighlight(sessionId);640 void refreshSidebar().then(() => applyUseCaseFromSession(sessionId));641 } catch (e) {642 showError(e?.message || 'Failed to load chat');643 } finally {644 setLoadingState(false);645 }646}647 648// ===== Chat rendering =====649 650function appendPlainBubble(role, text, options = {}) {651 showChatArea();652 const bubble = document.createElement('div');653 bubble.className = `chat-bubble ${role === 'user' ? 'chat-bubble-user' : 'chat-bubble-assistant'}`;654 const title = role === 'user' ? 'You' : 'Assistant';655 bubble.innerHTML = `656 <p class="chat-bubble-meta">${title}</p>657 <p class="chat-bubble-text">${escapeHtml(text || '')}</p>658 `;659 chatThread.appendChild(bubble);660 if (!options.deferScroll) scrollChatToBottom();661}662 663function scrollChatToBottom() {664 if (!chatScroll) return;665 chatScroll.scrollTop = chatScroll.scrollHeight;666}667 668function bindSqlCopyDelegation() {669 if (!chatThread || chatThread.dataset.sqlCopyBound === '1') return;670 chatThread.dataset.sqlCopyBound = '1';671 chatThread.addEventListener('click', async (e) => {672 const btn = e.target.closest('.copy-sql-btn');673 if (!btn || !chatThread.contains(btn)) return;674 e.preventDefault();675 const wrap = btn.closest('.out-sql-wrap');676 const codeEl = wrap && wrap.querySelector('.out-sql code');677 const text = codeEl ? codeEl.textContent : '';678 if (!text || !String(text).trim()) return;679 680 const revert = () => {681 btn.classList.remove('copy-sql-btn--done');682 btn.setAttribute('aria-label', 'Copy SQL');683 btn.title = 'Copy SQL';684 window.clearTimeout(btn._copyResetTid);685 };686 687 try {688 if (navigator.clipboard && navigator.clipboard.writeText) {689 await navigator.clipboard.writeText(text);690 } else {691 const ta = document.createElement('textarea');692 ta.value = text; ta.setAttribute('readonly', '');693 ta.style.position = 'fixed'; ta.style.left = '-9999px';694 document.body.appendChild(ta); ta.select();695 document.execCommand('copy'); document.body.removeChild(ta);696 }697 btn.classList.add('copy-sql-btn--done');698 btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied';699 window.clearTimeout(btn._copyResetTid);700 btn._copyResetTid = window.setTimeout(revert, 1600);701 } catch (_err) { revert(); }702 });703}704 705function showTypingIndicator() {706 removeTypingIndicator();707 const typing = document.createElement('div');708 typing.className = 'chat-bubble chat-bubble-assistant typing-indicator';709 typing.id = 'typing-indicator';710 typing.innerHTML = `<div class="typing-dots"><span></span><span></span><span></span></div>`;711 chatThread.appendChild(typing);712 scrollChatToBottom();713}714 715function removeTypingIndicator() {716 const el = document.getElementById('typing-indicator');717 if (el) el.remove();718}719 720function setLoadingState(loading, text, showPipeline = false) {721 submitBtn.disabled = loading;722 newChatBtn.disabled = loading;723 if (loading) {724 outputError.classList.add('hidden');725 outputError.textContent = '';726 if (showPipeline) {727 showPipelineProgress();728 showTypingIndicator();729 }730 const hasThread = chatThread.children.length > 0;731 if (hasThread) {732 outputContent.classList.remove('hidden');733 outputPlaceholder.classList.add('hidden');734 }735 if (composerStatus) {736 composerStatus.textContent = text || 'Processing...';737 composerStatus.classList.remove('hidden');738 }739 } else {740 hidePipelineProgress();741 removeTypingIndicator();742 if (composerStatus) { composerStatus.classList.add('hidden'); composerStatus.textContent = ''; }743 if (!chatThread.children.length) {744 showContextualSuggestions();745 }746 }747}748 749function showChatArea() {750 outputPlaceholder.classList.add('hidden');751 outputContent.classList.remove('hidden');752}753 754function buildAssistantDetails(data) {755 if (!data || typeof data !== 'object') return '';756 const state = data.conversation_state || '';757 if (state !== 'completed') return '';758 759 const parts = [];760 const interpreted = (data.resolved_question || data.rephrased_question || '').trim();761 if (interpreted) {762 parts.push(`<div class="detail-interpreted"><span class="detail-label">Interpreted as:</span> <span class="detail-value">${escapeHtml(interpreted)}</span></div>`);763 }764 765 const sql = (data.generated_sql && String(data.generated_sql).trim()) || '';766 if (sql) {767 parts.push(`<div class="out-sql-wrap">768<pre class="out-sql"><code>${highlightSQL(sql)}</code></pre>769<button type="button" class="copy-sql-btn" aria-label="Copy SQL" title="Copy SQL">${COPY_SQL_ICON}</button>770</div>`);771 } else if (data.error != null && String(data.error).trim()) {772 parts.push(`<p class="out-text out-error">${escapeHtml(String(data.error).trim())}</p>`);773 }774 775 if (parts.length === 0) return '';776 return `<div class="chat-details">${parts.join('')}</div>`;777}778 779function dockIntentActionsDefault() {780 intentActions.classList.add('hidden');781 intentActions.setAttribute('hidden', '');782 intentActionsLabel.classList.remove('sr-only');783 intentActionsLabel.textContent = 'Is this understanding correct?';784 outputContent.append(chatThread, intentActions);785}786 787function appendChatBubble(role, text, data = null, options = {}) {788 showChatArea();789 const bubble = document.createElement('div');790 bubble.className = `chat-bubble ${role === 'user' ? 'chat-bubble-user' : 'chat-bubble-assistant'}`;791 792 const title = role === 'user' ? 'You' : 'Assistant';793 let html = `794 <p class="chat-bubble-meta">${title}</p>795 <p class="chat-bubble-text">${escapeHtml(text || '')}</p>796 `;797 798 if (role === 'assistant' && data) {799 html += buildAssistantDetails(data);800 }801 802 bubble.innerHTML = html;803 804 if (options.intentInline && role === 'assistant') {805 const wrap = document.createElement('div');806 wrap.className = 'chat-item chat-item--intent-prompt';807 wrap.appendChild(bubble);808 intentActionsLabel.classList.add('sr-only');809 intentActionsLabel.textContent = 'Confirm with Yes or No';810 wrap.appendChild(intentActions);811 intentActions.classList.remove('hidden');812 intentActions.removeAttribute('hidden');813 chatThread.appendChild(wrap);814 } else {815 chatThread.appendChild(bubble);816 }817 if (!options.deferScroll) scrollChatToBottom();818}819 820function showError(errorText) {821 outputError.textContent = errorText || '';822 outputError.classList.toggle('hidden', !errorText);823}824 825function hideIntentActions() {826 dockIntentActionsDefault();827}828 829// ===== Query submission =====830 831async function callQuery(payload) {832 const body = { ...payload, client_id: ensureClientId() };833 let res = await fetch(`${API_BASE}/query`, {834 method: 'POST',835 headers: { 'Content-Type': 'application/json' },836 body: JSON.stringify(body),837 });838 let data = await res.json();839 840 const invalidSession =841 !res.ok && data?.detail && String(data.detail).toLowerCase().includes('invalid or unknown session_id');842 if (invalidSession) {843 await bootstrapFreshSession();844 const retryPayload = { ...body, session_id: getActiveSessionId() };845 res = await fetch(`${API_BASE}/query`, {846 method: 'POST',847 headers: { 'Content-Type': 'application/json' },848 body: JSON.stringify(retryPayload),849 });850 data = await res.json();851 }852 853 if (data.session_id) setActiveSessionId(String(data.session_id));854 return { res, data };855}856 857form.addEventListener('submit', async (e) => {858 e.preventDefault();859 const useCase = useCaseSelect.value?.trim();860 const message = messageInput.value?.trim();861 if (!useCase) {862 showError('Please select a domain first (Healthcare, Retail, or Finance)');863 return;864 }865 if (!message) return;866 867 showError('');868 hideIntentActions();869 appendChatBubble('user', message);870 messageInput.value = '';871 setLoadingState(true, 'Running multi-agent pipeline...', true);872 873 try {874 const body = {875 message,876 use_case: useCase,877 session_id: getActiveSessionId(),878 message_type: pendingIntentState ? 'intent_correction' : 'new_query',879 };880 const { res, data } = await callQuery(body);881 882 if (!res.ok) {883 const detail = data.detail != null ? data.detail : res.statusText;884 const errText = typeof detail === 'string' ? detail : JSON.stringify(detail);885 showError(errText);886 appendChatBubble('assistant', `Error: ${errText}`);887 await refreshSidebar();888 return;889 }890 891 if (data.conversation_state === 'waiting_intent_confirmation') {892 pendingIntentState = { useCase, pendingIntentId: data.pending_intent_id || null };893 const prompt = data.clarification_question ||894 `Did I understand correctly: ${data.resolved_question || data.rephrased_question}?`;895 appendChatBubble('assistant', prompt, data, { intentInline: true });896 } else if (data.conversation_state === 'waiting_user_rephrase') {897 pendingIntentState = { useCase, pendingIntentId: data.pending_intent_id || null };898 appendChatBubble('assistant', PROMPT_CORRECTED_QUESTION, { conversation_state: 'waiting_user_rephrase' });899 hideIntentActions();900 } else if (data.conversation_state === 'waiting_analytical_query') {901 pendingIntentState = null;902 hideIntentActions();903 appendChatBubble('assistant', data.clarification_question || 'Please type your analytical question.', data);904 } else if (data.conversation_state === 'conversation_ended') {905 pendingIntentState = null;906 hideIntentActions();907 appendChatBubble('assistant', data.clarification_question || 'Ok, thank you!', data);908 } else {909 pendingIntentState = null;910 hideIntentActions();911 appendChatBubble('assistant', 'SQL generated successfully.', data);912 if (data.error) showError(data.error);913 }914 await refreshSidebar();915 } catch (err) {916 const errText = err?.message || 'Request failed';917 showError(errText);918 appendChatBubble('assistant', `Error: ${errText}`);919 await refreshSidebar();920 } finally {921 setLoadingState(false);922 }923});924 925// ===== Intent confirmation =====926 927async function submitIntentConfirmation(answer) {928 if (!pendingIntentState) return;929 hideIntentActions();930 appendChatBubble('user', answer === 'yes' ? 'Yes' : 'No');931 setLoadingState(true, 'Confirming intent and generating SQL...', true);932 try {933 const payload = {934 message: answer,935 use_case: pendingIntentState.useCase,936 session_id: getActiveSessionId(),937 message_type: 'intent_confirmation',938 confirmation: answer,939 };940 const { res, data } = await callQuery(payload);941 if (!res.ok) {942 const detail = data.detail != null ? data.detail : res.statusText;943 const errText = typeof detail === 'string' ? detail : JSON.stringify(detail);944 showError(errText);945 appendChatBubble('assistant', `Error: ${errText}`);946 await refreshSidebar();947 return;948 }949 950 if (data.conversation_state === 'waiting_user_rephrase') {951 pendingIntentState = { useCase: pendingIntentState.useCase, pendingIntentId: data.pending_intent_id || null };952 appendChatBubble('assistant', PROMPT_CORRECTED_QUESTION, { conversation_state: 'waiting_user_rephrase' });953 } else if (data.conversation_state === 'waiting_analytical_query') {954 pendingIntentState = null;955 appendChatBubble('assistant', data.clarification_question || 'Please type your analytical question.', data);956 } else if (data.conversation_state === 'conversation_ended') {957 pendingIntentState = null;958 appendChatBubble('assistant', data.clarification_question || 'Ok, thank you!', data);959 } else {960 pendingIntentState = null;961 const done = data.conversation_state === 'completed';962 appendChatBubble('assistant', done ? 'SQL generated successfully.' : 'Thanks — generating SQL now.', data);963 if (data.error) showError(data.error);964 }965 await refreshSidebar();966 } catch (err) {967 const errText = err?.message || 'Request failed';968 showError(errText);969 appendChatBubble('assistant', `Error: ${errText}`);970 await refreshSidebar();971 } finally {972 setLoadingState(false);973 hideIntentActions();974 }975}976 977intentYesBtn.addEventListener('click', () => submitIntentConfirmation('yes'));978intentNoBtn.addEventListener('click', () => submitIntentConfirmation('no'));979 980// ===== Modal =====981 982if (deleteConfirmModal) {983 deleteConfirmModal.addEventListener('click', (e) => { if (e.target === deleteConfirmModal) closeDeleteConfirmModal(); });984}985if (deleteConfirmCancelBtn) {986 deleteConfirmCancelBtn.addEventListener('click', () => closeDeleteConfirmModal());987}988if (deleteConfirmDeleteBtn) {989 deleteConfirmDeleteBtn.addEventListener('click', () => void executeConfirmedDeleteChat());990}991 992document.addEventListener('keydown', (e) => {993 if (e.key !== 'Escape') return;994 if (!deleteConfirmModal || deleteConfirmModal.classList.contains('hidden')) return;995 closeDeleteConfirmModal();996});997 998// ===== Sidebar events =====999 1000if (chatListEl) {1001 chatListEl.addEventListener('click', (e) => {1002 const delBtn = e.target.closest('.chat-list-delete');1003 if (delBtn && delBtn.dataset.sessionId) {1004 e.preventDefault(); e.stopPropagation();1005 void handleDeleteChat(delBtn.dataset.sessionId);1006 return;1007 }1008 const btn = e.target.closest('.chat-list-item');1009 if (!btn || !btn.dataset.sessionId) return;1010 const id = btn.dataset.sessionId;1011 if (id === activeSessionId) return;1012 if (btn.dataset.useCase) selectDomain(btn.dataset.useCase.trim(), { silent: true });1013 loadChatSession(id);1014 });1015}1016 1017if (newChatBtn) {1018 newChatBtn.addEventListener('click', async () => {1019 showError('');1020 setActiveSessionId(null);1021 resetDomainSelect();1022 clearChatThread();1023 pendingIntentState = null;1024 hideIntentActions();1025 showWelcome();1026 setSidebarActiveHighlight(null);1027 await refreshSidebar();1028 });1029}1030 1031// ===== Init =====1032 1033async function init() {1034 bindSqlCopyDelegation();1035 submitBtn.disabled = true;1036 newChatBtn.disabled = true;1037 removeLegacyKeys();1038 ensureClientId();1039 1040 const [, domains] = await Promise.all([loadUseCases(), loadDomainInfo()]);1041 if (domains) renderDomainButtons(domains);1042 1043 showError('');1044 hideIntentActions();1045 showWelcome();1046 1047 try {1048 let sessions = [];1049 try { sessions = await fetchSessionsList(); } catch (e) { console.warn('Could not list sessions', e); }1050 renderChatList(sessions);1051 setActiveSessionId(null);1052 resetDomainSelect();1053 showWelcome();1054 submitBtn.disabled = false;1055 newChatBtn.disabled = false;1056 } catch (e) {1057 showError(e.message || 'Failed to initialize');1058 submitBtn.disabled = false;1059 newChatBtn.disabled = false;1060 }1061}1062 1063init();1064 