/** * Sources, Request History, and Artifacts rendering module. * Provides bounded lazy expansion of huge tool results, sanitized textContent rendering, * and distinct display of repeated/cache-hit calls. */ const MAX_HISTORY_RESULT_DISPLAY_BYTES = 32 * 1024; // 32 KiB display limit per entry /** * Formats byte counts into human-readable strings. * @param {number} bytes * @returns {string} */ export function formatBytes(bytes) { if (typeof bytes !== 'number' || isNaN(bytes) || bytes < 0) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** * Bounded JSON serializer that traverses data structures lazily and terminates * immediately when maxBytes is exceeded, avoiding full serialization memory cost. * @param {any} value * @param {number} [maxBytes] * @returns {string} */ export function serializeBounded(value, maxBytes = MAX_HISTORY_RESULT_DISPLAY_BYTES) { if (value === undefined) return 'undefined'; if (value === null) return 'null'; if (typeof value !== 'object') { const s = String(value); if (s.length > maxBytes) { return s.slice(0, maxBytes) + `\n\n... [Truncated: ${formatBytes(s.length)} total]`; } return JSON.stringify(value); } let totalChars = 0; let truncated = false; const parts = []; function append(str) { if (!str) return; if (totalChars + str.length > maxBytes) { const allowed = Math.max(0, maxBytes - totalChars); if (allowed > 0) { parts.push(str.slice(0, allowed)); totalChars += allowed; } truncated = true; } else { parts.push(str); totalChars += str.length; } } function walk(val, indent) { if (totalChars >= maxBytes) { truncated = true; return; } if (val === null) { append('null'); return; } const type = typeof val; if (type !== 'object') { if (type === 'string') { const remaining = maxBytes - totalChars; if (val.length > remaining) { truncated = true; const sliced = val.slice(0, Math.max(0, remaining - 15)); append(JSON.stringify(sliced).slice(0, -1) + '... [truncated]"'); } else { append(JSON.stringify(val)); } } else { append(JSON.stringify(val)); } return; } const spaces = ' '.repeat(indent); const nextSpaces = ' '.repeat(indent + 1); if (Array.isArray(val)) { if (val.length === 0) { append('[]'); return; } append('[\n'); for (let i = 0; i < val.length; i++) { if (totalChars >= maxBytes) { truncated = true; append(`${nextSpaces}... [${val.length - i} more items truncated]\n`); break; } append(nextSpaces); walk(val[i], indent + 1); if (i < val.length - 1) append(','); append('\n'); } append(`${spaces}]`); return; } // Object const keys = Object.keys(val); if (keys.length === 0) { append('{}'); return; } append('{\n'); for (let i = 0; i < keys.length; i++) { if (totalChars >= maxBytes) { truncated = true; append(`${nextSpaces}... [${keys.length - i} more properties truncated]\n`); break; } const key = keys[i]; append(`${nextSpaces}${JSON.stringify(key)}: `); walk(val[key], indent + 1); if (i < keys.length - 1) append(','); append('\n'); } append(`${spaces}}`); } walk(value, 0); let result = parts.join(''); if (truncated) { result += `\n\n... [Result display bounded to ${formatBytes(maxBytes)}; click Export to MD for full output]`; } return result; } /** * Formats ISO timestamp into readable UTC string. * @param {string} isoString * @returns {string} */ export function formatTimestamp(isoString) { if (!isoString) return 'Unknown'; try { const d = new Date(isoString); if (isNaN(d.getTime())) return isoString; return d.toISOString().replace('T', ' ').replace('Z', ' UTC'); } catch { return isoString; } } /** * Renders the list of accessed Confluence pages. * @param {HTMLElement} container * @param {Array<{ page_id: string, title: string, space: string, url: string, accessed_at: string }>} pages */ export function renderPagesAccessed(container, pages) { container.replaceChildren(); if (!Array.isArray(pages) || pages.length === 0) { const emptyMsg = document.createElement('p'); emptyMsg.className = 'page-card-meta'; emptyMsg.textContent = 'No Confluence pages were read during this query.'; container.appendChild(emptyMsg); return; } pages.forEach((page) => { const card = document.createElement('div'); card.className = 'page-card'; const left = document.createElement('div'); left.className = 'page-card-left'; const spaceBadge = document.createElement('span'); spaceBadge.className = 'badge-space'; spaceBadge.textContent = page.space || 'PAGE'; const link = document.createElement('a'); link.className = 'page-title-link'; link.textContent = page.title || `Page ${page.page_id}`; if (page.url) { try { const u = new URL(page.url, window.location.href); if (['http:', 'https:'].includes(u.protocol)) { link.href = u.href; link.target = '_blank'; link.rel = 'noopener noreferrer'; } } catch { // Leave link without href if invalid } } left.appendChild(spaceBadge); left.appendChild(link); const meta = document.createElement('div'); meta.className = 'page-card-meta'; meta.textContent = `ID: ${page.page_id} · Accessed: ${formatTimestamp(page.accessed_at)}`; card.appendChild(left); card.appendChild(meta); container.appendChild(card); }); } /** * Renders the authoritative tool history with bounded lazy result expansion. * @param {HTMLElement} container * @param {Array} toolHistory */ export function renderToolHistory(container, toolHistory) { container.replaceChildren(); if (!Array.isArray(toolHistory) || toolHistory.length === 0) { const emptyMsg = document.createElement('p'); emptyMsg.className = 'page-card-meta'; emptyMsg.textContent = 'No remote tool operations recorded.'; container.appendChild(emptyMsg); return; } toolHistory.forEach((entry) => { const card = document.createElement('div'); card.className = 'tool-card'; // Header const header = document.createElement('div'); header.className = 'tool-card-header'; const headerLeft = document.createElement('div'); headerLeft.className = 'tool-card-header-left'; const idSpan = document.createElement('span'); idSpan.className = 'tool-id'; idSpan.textContent = `#${entry.tool_call_id || 'call'}`; const nameSpan = document.createElement('span'); nameSpan.className = 'tool-name'; nameSpan.textContent = entry.tool || 'unknown_tool'; headerLeft.appendChild(idSpan); headerLeft.appendChild(nameSpan); const badges = document.createElement('div'); badges.className = 'tool-badges'; // Status badge const statusBadge = document.createElement('span'); const isSuccess = entry.status === 'success'; statusBadge.className = `badge ${isSuccess ? 'badge-success' : 'badge-error'}`; statusBadge.textContent = isSuccess ? 'Success' : 'Error'; badges.appendChild(statusBadge); // Cache hit badge if (entry.cache_hit) { const cacheBadge = document.createElement('span'); cacheBadge.className = 'badge badge-cache'; cacheBadge.textContent = 'Cache Hit'; badges.appendChild(cacheBadge); } // Truncation badges if (entry.parameters_truncated) { const paramTrunc = document.createElement('span'); paramTrunc.className = 'badge badge-truncated'; paramTrunc.textContent = 'Params Truncated'; badges.appendChild(paramTrunc); } if (entry.result_truncated) { const resTrunc = document.createElement('span'); resTrunc.className = 'badge badge-truncated'; resTrunc.textContent = 'Result Truncated'; badges.appendChild(resTrunc); } header.appendChild(headerLeft); header.appendChild(badges); card.appendChild(header); // Body const body = document.createElement('div'); body.className = 'tool-card-body'; const metaRow = document.createElement('div'); metaRow.className = 'tool-meta-row'; const timeText = `Started: ${formatTimestamp(entry.started_at)} · Completed: ${formatTimestamp(entry.completed_at)}`; metaRow.textContent = timeText; body.appendChild(metaRow); // Expandable toggle button const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.className = 'tool-expand-btn'; toggleBtn.textContent = 'Show details'; toggleBtn.setAttribute('aria-expanded', 'false'); let detailsRendered = false; let detailsContainer = null; toggleBtn.addEventListener('click', () => { const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true'; if (isExpanded) { toggleBtn.setAttribute('aria-expanded', 'false'); toggleBtn.textContent = 'Show details'; if (detailsContainer) { detailsContainer.classList.add('hidden'); } } else { toggleBtn.setAttribute('aria-expanded', 'true'); toggleBtn.textContent = 'Hide details'; if (!detailsRendered) { detailsContainer = document.createElement('div'); detailsContainer.className = 'tool-details'; // Parameters section const paramLabel = document.createElement('div'); paramLabel.className = 'history-subheading'; paramLabel.textContent = 'Parameters'; detailsContainer.appendChild(paramLabel); const paramBox = document.createElement('pre'); paramBox.className = 'tool-result-box'; paramBox.textContent = serializeBounded(entry.parameters || {}, MAX_HISTORY_RESULT_DISPLAY_BYTES); detailsContainer.appendChild(paramBox); // Result or Error section const resultLabel = document.createElement('div'); resultLabel.className = 'history-subheading'; resultLabel.textContent = entry.error ? 'Error' : 'Result'; detailsContainer.appendChild(resultLabel); const resultBox = document.createElement('pre'); resultBox.className = 'tool-result-box'; if (entry.error) { resultBox.textContent = serializeBounded(entry.error, MAX_HISTORY_RESULT_DISPLAY_BYTES); } else { // Lazy bounded serialization without paying full in-memory stringification cost resultBox.textContent = serializeBounded(entry.result, MAX_HISTORY_RESULT_DISPLAY_BYTES); } detailsContainer.appendChild(resultBox); body.appendChild(detailsContainer); detailsRendered = true; } else if (detailsContainer) { detailsContainer.classList.remove('hidden'); } } }); body.appendChild(toggleBtn); card.appendChild(body); container.appendChild(card); }); } /** * Renders the list of exported artifacts. * @param {HTMLElement} container * @param {Array<{ id: string, name: string, size_bytes: number, expires_at: string }>} artifacts * @param {(id: string, name: string) => Promise} onDownload */ export function renderArtifacts(container, artifacts, onDownload) { container.replaceChildren(); if (!Array.isArray(artifacts) || artifacts.length === 0) { return; } artifacts.forEach((art) => { const item = document.createElement('li'); item.className = 'artifact-item'; const info = document.createElement('div'); info.className = 'artifact-info'; const nameSpan = document.createElement('span'); nameSpan.className = 'artifact-name'; nameSpan.textContent = art.name || 'unnamed_artifact'; const metaSpan = document.createElement('span'); metaSpan.className = 'artifact-meta'; metaSpan.textContent = `${formatBytes(art.size_bytes)} · Expires: ${formatTimestamp(art.expires_at)}`; info.appendChild(nameSpan); info.appendChild(metaSpan); const dlBtn = document.createElement('button'); dlBtn.type = 'button'; dlBtn.className = 'download-btn'; dlBtn.textContent = 'Download'; dlBtn.setAttribute('aria-label', `Download ${art.name}`); dlBtn.addEventListener('click', async () => { dlBtn.disabled = true; const originalText = dlBtn.textContent; dlBtn.textContent = 'Downloading...'; try { await onDownload(art.id, art.name); } finally { dlBtn.disabled = false; dlBtn.textContent = originalText; } }); item.appendChild(info); item.appendChild(dlBtn); container.appendChild(item); }); }