CoolFace
Apppublic

prazy1208/text2sql

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
app.js1064 linesDownload Raw Back to frontend
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 &mdash; 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} &mdash; ${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