Artur Mukhamadiev a9908a533f frontend: web UI track handoff (contract revision 1)
Static frontend with vendored marked/DOMPurify, bounded Markdown
pipeline, same-origin mock server with scenario selection, unit,
contract and CDP end-to-end tests under frontend/**.
2026-09-14 21:57:54 +03:00

420 lines
13 KiB
JavaScript

/**
* 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<object>} 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<void>} 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);
});
}