/* =============================================================
MESSAGES
============================================================= */
function appendMessage(role, content, opts = {}) {
const el = document.createElement('div');
el.className = `msg ${role}`;
const bubble = document.createElement('div');
bubble.className = 'msg-bubble' + (opts.error ? ' error-bubble' : '');
if (role === 'assistant' && !opts.error) {
bubble.innerHTML = renderMarkdown(content);
attachTableExportButtons(bubble);
} else {
bubble.textContent = content;
}
el.appendChild(bubble);
if (opts.time) {
const meta = document.createElement('div');
meta.className = 'msg-meta';
meta.textContent = fmtTime(opts.time);
el.appendChild(meta);
}
dom.messages.appendChild(el);
scrollBottom();
return el;
}
function appendTypingIndicator() {
const el = document.createElement('div');
el.className = 'msg assistant';
el.id = 'typing-indicator';
el.innerHTML = '
';
dom.messages.appendChild(el);
scrollBottom();
return el;
}
/* =============================================================
PER-TABLE EXCEL EXPORT
Tables in assistant answers are real elements (rendered by
marked.js). Attach a small floating export icon to each one so the
user can save just that table, not the whole chat.
============================================================= */
function attachTableExportButtons(container) {
container.querySelectorAll('table').forEach(table => {
if (table.dataset.exportAttached) return;
table.dataset.exportAttached = '1';
const wrap = document.createElement('div');
wrap.className = 'table-export-wrap';
table.parentNode.insertBefore(wrap, table);
wrap.appendChild(table);
const btn = document.createElement('button');
btn.className = 'table-export-btn';
btn.title = 'Export table to Excel';
btn.innerHTML = '';
btn.addEventListener('click', async (e) => {
e.stopPropagation();
try { await exportTableToExcel(table); }
catch (err) { alert('Export failed: ' + err.message); }
});
wrap.appendChild(btn);
});
}
async function exportTableToExcel(table) {
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
const rows = Array.from(table.querySelectorAll('tbody tr')).map(tr =>
Array.from(tr.querySelectorAll('td')).map(td => td.textContent.trim())
);
if (!headers.length) { alert('Could not read this table.'); return; }
const filename = `table-${Date.now()}.xlsx`;
if (window.pywebview && window.pywebview.api) {
const folder = await window.pywebview.api.pick_folder();
if (!folder) return; // user cancelled
await api('/export-table-xlsx', {
method: 'POST',
body: JSON.stringify({ folder, filename, headers, rows }),
});
return;
}
// No pywebview bridge -- plain browser tab (RDP session on the server or
// a genuinely separate machine, indistinguishable from here). Ask the
// backend for the bytes and let the browser's own download deliver them.
await _downloadFileFromBackend('/export-table-xlsx', filename, { filename, headers, rows });
}
/* =============================================================
DRAWER
============================================================= */
function clearDrawer() {
dom.drawerContent.innerHTML = '';
dom.drawerContent.appendChild(dom.drawerEmpty);
dom.drawerEmpty.style.display = 'flex';
state.activeToolCards = {};
}
function addThinkingCard(text) {
dom.drawerEmpty.style.display = 'none';
const card = document.createElement('div');
card.className = 'thinking-card'; // collapsed by default
card.innerHTML = `
`;
card.querySelector('.thinking-card-header').addEventListener('click', () => card.classList.toggle('open'));
dom.drawerContent.appendChild(card); // append at bottom
}
function addToolStartCard(tool, input, callId) {
dom.drawerEmpty.style.display = 'none';
const color = toolColor(tool);
const card = document.createElement('div');
card.className = 'tool-card'; // collapsed by default
card.dataset.callId = callId;
card.innerHTML = `
Input
${escHtml(JSON.stringify(input, null, 2))}
`;
card.querySelector('.tool-card-header').addEventListener('click', () => card.classList.toggle('open'));
dom.drawerContent.appendChild(card); // append at bottom
dom.drawerContent.scrollTop = dom.drawerContent.scrollHeight;
state.activeToolCards[callId] = card;
}
function updateToolEndCard(tool, output, durationMs, callId) {
const card = state.activeToolCards[callId];
if (!card) return;
const header = card.querySelector('.tool-card-header');
const spinner = header.querySelector('.tool-spinner');
if (spinner) {
const dur = document.createElement('span');
dur.className = 'tool-duration';
dur.textContent = `${durationMs}ms`;
header.replaceChild(dur, spinner);
}
const body = card.querySelector('.tool-card-body');
const outSection = document.createElement('div');
outSection.innerHTML = `
Output
${escHtml(output)}
`;
body.appendChild(outSection);
}
/* =============================================================
SOURCE EXTRACTION & LINK HANDLING
============================================================= */
/**
* Open a URL in the system default app.
* In pywebview, window.open() opens the OS browser/file handler.
*/
function openExternalUrl(url) {
try {
if (window.pywebview && window.pywebview.api && window.pywebview.api.open_url) {
window.pywebview.api.open_url(url);
} else {
window.open(url, '_blank');
}
} catch { window.open(url, '_blank'); }
}
// Global click handler to intercept all clicks inside the messages area
document.addEventListener('click', e => {
const a = e.target.closest('a');
if (!a) return;
const href = a.getAttribute('href');
if (!href) return;
// Skip doc preview links — handled by the preview panel interceptors below
if (href.match(/\/text\/[a-f0-9]+/) || href.match(/\/docs\/[a-f0-9]+/)) return;
// Only intercept http/https and local file URLs
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('file://')) {
e.preventDefault();
openExternalUrl(href);
}
});