test / frontend /app.js
Anish
Deploy ParcelPilot AI with Git LFS
2567e7e
Raw
History Blame Contribute Delete
32.3 kB
/* ════════════════════════════════════════════════════════════════
ParcelPilot Intelligence β€” Application Controller
════════════════════════════════════════════════════════════════ */
'use strict';
/* ─── State ─── */
let pendingAction = null;
let evalScenarios = [];
let currentUser = {};
let matrixLoaded = false;
/* ─── Boot ─── */
document.addEventListener('DOMContentLoaded', () => {
initContextSwitcher();
initTabs();
initComposer();
initSubtabs();
prefetchScenarios();
initExportLog();
// Pre-fetch proactive insights so badge shows immediately
loadProactiveInsights();
});
/* ════════════════════════════════════════
CONTEXT SWITCHER
════════════════════════════════════════ */
function initContextSwitcher() {
const modeEl = document.getElementById('context-mode-select');
const accountEl = document.getElementById('account-select');
const roleEl = document.getElementById('role-select');
const update = () => {
currentUser = buildContext();
updateContextIndicator();
// Re-fetch proactive when context changes (if on that tab)
if (document.getElementById('tab-proactive').classList.contains('active')) {
loadProactiveInsights();
}
};
modeEl.addEventListener('change', update);
accountEl.addEventListener('change', update);
roleEl.addEventListener('change', update);
update();
}
function buildContext() {
const mode = document.getElementById('context-mode-select').value;
const accountId = document.getElementById('account-select').value;
const role = document.getElementById('role-select').value;
const is_internal = mode === 'internal';
const accountNames = {
'ACCT-001': 'Northstar Logistics',
'ACCT-002': 'LumenWorks',
'ACCT-003': 'Beacon Retail',
'ACCT-004': 'Axis Labs',
};
return {
account_id: accountId,
account_name: accountNames[accountId] || accountId,
is_internal,
role: is_internal ? role : 'customer',
user_id: is_internal ? 'USR-STAFF-99' : `USR-${accountId}`,
};
}
function updateContextIndicator() {
const el = document.getElementById('active-context-indicator');
if (!el) return;
const ctx = currentUser;
if (ctx.is_internal) {
el.textContent = `Internal Β· ${ctx.role.replace(/_/g, ' ')}`;
el.style.color = '#22d3ee';
} else {
el.textContent = `${ctx.account_name} Β· Customer`;
el.style.color = '#34d399';
}
}
/* ════════════════════════════════════════
TAB ROUTING
════════════════════════════════════════ */
function initTabs() {
document.querySelectorAll('.navtab[data-tab]').forEach(btn => {
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
});
}
function activateTab(tabId) {
document.querySelectorAll('.navtab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.stage-pane').forEach(p => p.classList.remove('active'));
const btn = document.querySelector(`.navtab[data-tab="${tabId}"]`);
const pane = document.getElementById(tabId);
if (btn) btn.classList.add('active');
if (pane) pane.classList.add('active');
if (tabId === 'tab-proactive') loadProactiveInsights();
if (tabId === 'tab-matrix') loadContractMatrix();
if (tabId === 'tab-data') loadExplorer('subtab-documents');
}
/* ════════════════════════════════════════
EVALUATOR PRESETS
════════════════════════════════════════ */
async function prefetchScenarios() {
try {
const res = await fetch('/api/evaluator/scenarios');
evalScenarios = await res.json();
} catch (e) {
console.warn('Could not load scenarios:', e);
}
}
function onPresetSelectChange(sel) {
const id = sel.value;
if (!id) return;
sel.value = '';
runScenario(id);
}
function runScenario(id) {
const s = evalScenarios.find(x => x.id === id);
if (!s) return;
document.getElementById('context-mode-select').value = s.is_internal ? 'internal' : 'customer';
document.getElementById('account-select').value = s.account_id;
document.getElementById('role-select').value = s.role;
document.getElementById('context-mode-select').dispatchEvent(new Event('change'));
activateTab('tab-chat');
document.getElementById('chat-input').value = s.prompt;
sendMessage();
}
/* ════════════════════════════════════════
CHAT COMPOSER
════════════════════════════════════════ */
function initComposer() {
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
sendBtn.addEventListener('click', sendMessage);
input.addEventListener('keydown', e => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
} else if (e.key === 'Enter' && modKey) {
e.preventDefault();
sendMessage();
}
});
input.addEventListener('input', () => autoResizeTextarea(input));
}
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
async function sendMessage() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
input.style.height = 'auto';
appendMsg('user', text);
const loadingId = appendTyping();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: text, ...currentUser }),
});
const data = await res.json();
removeMsg(loadingId);
if (!res.ok) {
appendMsg('assistant', `Error ${res.status}: ${data.detail || 'Request failed.'}`);
return;
}
// Update latency/confidence display
if (data.metrics) {
const ms = data.metrics.total_duration_ms;
const pct = Math.round(data.metrics.confidence_score * 100);
document.getElementById('metrics-summary-text').textContent =
`${ms}ms Β· ${pct}% confidence`;
}
appendMsg('assistant', data.answer, data.citations, data.widget_data);
updateEvidencePanel(data.citations, data.conflict_matrix, data.trace_steps);
if (data.pending_action) {
pendingAction = data.pending_action;
openActionModal(data.pending_action);
}
} catch (err) {
removeMsg(loadingId);
appendMsg('assistant', 'Network error β€” could not reach the ParcelPilot backend. Is the server running?');
console.error(err);
}
}
/* ════════════════════════════════════════
MESSAGE RENDERING
════════════════════════════════════════ */
function appendMsg(role, text, citations = [], widgetData = null) {
const area = document.getElementById('chat-messages');
const row = document.createElement('div');
row.className = `msg-row msg-${role}`;
const avatar = document.createElement('div');
avatar.className = 'msg-avatar';
avatar.setAttribute('aria-hidden', 'true');
avatar.innerHTML = role === 'user'
? `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="3" stroke="rgba(148,163,184,0.8)" stroke-width="1.2"/><path d="M2 14c0-3.3 2.7-5 6-5s6 1.7 6 5" stroke="rgba(148,163,184,0.8)" stroke-width="1.2"/></svg>`
: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" fill="rgba(56,189,248,0.2)" stroke="rgba(56,189,248,0.55)" stroke-width="1"/><circle cx="8" cy="8" r="2.5" fill="#38bdf8"/></svg>`;
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
if (role === 'assistant') {
const nameEl = document.createElement('p');
nameEl.className = 'msg-name';
nameEl.textContent = 'ParcelPilot Intelligence';
bubble.appendChild(nameEl);
}
const textNode = document.createElement('div');
textNode.innerHTML = renderMarkdown(text);
bubble.appendChild(textNode);
// Widget card
if (widgetData && widgetData.type !== 'action_pending') {
const wc = renderWidgetCard(widgetData);
if (wc) bubble.appendChild(wc);
}
// Citations
if (citations && citations.length > 0) {
const citeBlock = document.createElement('div');
citeBlock.className = 'chat-citations';
const label = document.createElement('div');
label.className = 'chat-citations-label';
label.textContent = 'Source Citations';
citeBlock.appendChild(label);
citations.forEach(c => {
const cr = document.createElement('div');
cr.className = 'chat-citation-row';
cr.innerHTML = `<span class="citation-dot"></span><span>${escHtml(c.source)} <span class="text-muted">(${escHtml(c.authority_level)})</span></span>`;
citeBlock.appendChild(cr);
});
bubble.appendChild(citeBlock);
}
row.appendChild(avatar);
row.appendChild(bubble);
area.appendChild(row);
area.scrollTop = area.scrollHeight;
}
function renderWidgetCard(data) {
if (!data) return null;
const card = document.createElement('div');
card.className = 'widget-card';
if (data.type === 'order_cancellation_widget') {
card.innerHTML = `
<div class="widget-header">
<span class="widget-header-left">Order Cancellation Analysis Β· ${escHtml(data.order_id)}</span>
<span class="pill pill-gray">${escHtml(data.account_name)}</span>
</div>
<div class="widget-grid">
<div class="widget-cell">
<span class="widget-cell-label">Order Status</span>
<span class="widget-cell-value">${escHtml(data.order_status)}</span>
</div>
<div class="widget-cell">
<span class="widget-cell-label">Time Since Booking</span>
<span class="widget-cell-value">${data.elapsed_minutes} min</span>
</div>
<div class="widget-cell">
<span class="widget-cell-label">SOP v4 Default Fee</span>
<span class="widget-cell-value strikethrough">INR ${data.standard_fee_inr}</span>
</div>
<div class="widget-cell">
<span class="widget-cell-label">Final Fee</span>
<span class="widget-cell-value ${data.fee_waived ? 'positive' : 'negative'}">INR ${data.final_fee_inr}${data.fee_waived ? ' β€” Waived' : ''}</span>
</div>
</div>
<div class="widget-footer">${escHtml(data.governing_document)}</div>
`;
} else if (data.type === 'service_credit_widget') {
const delayVal = data.delay_hours !== null && data.delay_hours !== undefined
? `${data.delay_hours}h` : 'N/A';
card.innerHTML = `
<div class="widget-header">
<span class="widget-header-left">Service Credit Evaluation Β· ${escHtml(data.order_id)}</span>
<span class="pill pill-gray">${escHtml(data.account_name)}</span>
</div>
<div class="widget-grid">
<div class="widget-cell">
<span class="widget-cell-label">Pickup Delay</span>
<span class="widget-cell-value">${delayVal}</span>
</div>
<div class="widget-cell">
<span class="widget-cell-label">Required Threshold</span>
<span class="widget-cell-value">&gt; ${data.required_threshold_hours}h</span>
</div>
<div class="widget-cell">
<span class="widget-cell-label">Eligible</span>
<span class="widget-cell-value ${data.eligible ? 'positive' : 'negative'}">${data.eligible ? 'Yes' : 'No β€” Below Threshold'}</span>
</div>
<div class="widget-cell">
<span class="widget-cell-label">Credit Amount</span>
<span class="widget-cell-value ${data.eligible ? 'positive' : ''}">INR ${data.credit_amount_inr}</span>
</div>
</div>
<div class="widget-footer">${escHtml(data.governing_document)}</div>
`;
}
return card.innerHTML ? card : null;
}
function appendTyping() {
const id = `typing-${Date.now()}`;
const area = document.getElementById('chat-messages');
const row = document.createElement('div');
row.id = id;
row.className = 'msg-row msg-assistant msg-typing';
row.innerHTML = `
<div class="msg-avatar" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" fill="rgba(56,189,248,0.2)" stroke="rgba(56,189,248,0.55)" stroke-width="1"/><circle cx="8" cy="8" r="2.5" fill="#38bdf8"/></svg>
</div>
<div class="msg-bubble">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
`;
area.appendChild(row);
area.scrollTop = area.scrollHeight;
return id;
}
function removeMsg(id) {
const el = document.getElementById(id);
if (el) el.remove();
}
/* ════════════════════════════════════════
EVIDENCE PANEL
════════════════════════════════════════ */
function updateEvidencePanel(citations, conflictMatrix, traceSteps) {
// Show anchor card filled state
const anchorCard = document.getElementById('anchor-card');
const anchorEmpty = document.getElementById('anchor-empty');
const anchorFilled = document.getElementById('anchor-filled');
if (citations && citations.length > 0) {
const top = citations[0];
if (anchorEmpty) anchorEmpty.style.display = 'none';
if (anchorFilled) anchorFilled.style.display = '';
if (anchorCard) anchorCard.classList.remove('anchor-card--empty');
const badgeEl = document.getElementById('anchor-badge');
const filenameEl = document.getElementById('anchor-filename');
const quoteEl = document.getElementById('anchor-quote');
const metaEl = document.getElementById('anchor-meta');
if (badgeEl) badgeEl.textContent = top.authority_level || 'Level 4 Β· Signed Contract';
if (filenameEl) filenameEl.textContent = shortDocName(top.source);
if (quoteEl) quoteEl.textContent = top.relevance || 'Primary governing clause evaluated.';
if (metaEl) metaEl.textContent = `Source: ${top.source}`;
}
// Precedence matrix
const matrixEl = document.getElementById('conflict-matrix-list');
if (matrixEl && conflictMatrix && conflictMatrix.length > 0) {
matrixEl.innerHTML = '';
conflictMatrix.forEach(m => {
const statusClass = resolveStatusClass(m.status);
const statusLabel = formatStatus(m.status);
const row = document.createElement('div');
row.className = `matrix-row ${statusClass}`;
row.innerHTML = `
<div class="matrix-level-bar"></div>
<div class="matrix-row-content">
<div class="matrix-row-header">
<span class="matrix-source-name">${escHtml(shortDocName(m.source_name))}</span>
<span class="matrix-authority">${escHtml(m.authority_level)}</span>
</div>
<div class="matrix-rule">${escHtml(m.rule_stated)}</div>
<div class="matrix-status-chip">${escHtml(statusLabel)}</div>
</div>
`;
matrixEl.appendChild(row);
});
}
// Execution trace
const traceBody = document.getElementById('dag-body');
const traceCount = document.getElementById('trace-count');
if (traceBody && traceSteps && traceSteps.length > 0) {
traceCount.textContent = `${traceSteps.length} steps`;
traceBody.innerHTML = '';
traceSteps.forEach(s => {
const step = document.createElement('div');
step.className = 'trace-step';
step.innerHTML = `
<div class="trace-step-header">
<span class="trace-step-name">Step ${s.step_id}: ${escHtml(s.name || '')}</span>
<span class="trace-step-ms">${s.duration_ms ?? 'β€”'}ms</span>
</div>
<div class="trace-step-detail">${escHtml(s.details || s.status || '')}</div>
`;
traceBody.appendChild(step);
});
}
}
function resolveStatusClass(status = '') {
const s = status.toUpperCase();
if (s.includes('OVERRIDING_WINNER') || s.includes('WINNER')) return 'status-winner';
if (s.includes('APPLIED') || s.includes('CONTRACT')) return 'status-applied';
if (s.includes('OVERRIDDEN') || s.includes('REPLACED') || s.includes('DEFAULT')) return 'status-overridden';
if (s.includes('ERROR') || s.includes('DISREGARD')) return 'status-error';
if (s.includes('EXCLUDED') || s.includes('NOT_APPLICABLE')) return 'status-excluded';
return 'status-applied';
}
function formatStatus(status = '') {
return status
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase());
}
/* ════════════════════════════════════════
ACTION MODAL
════════════════════════════════════════ */
function openActionModal(action) {
document.getElementById('modal-action-title').textContent = action.action_title || 'System Action';
document.getElementById('modal-action-details').textContent = JSON.stringify(action.parameters || {}, null, 2);
document.getElementById('action-modal').classList.remove('hidden');
}
function closeActionModal() {
document.getElementById('action-modal').classList.add('hidden');
}
async function confirmAction(confirmed) {
closeActionModal();
if (!pendingAction) return;
const loadingId = appendTyping();
try {
const res = await fetch('/api/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action_id: pendingAction.action_id,
confirmed,
...currentUser,
}),
});
const data = await res.json();
removeMsg(loadingId);
appendMsg('assistant', confirmed
? `**Confirmed.** ${data.message || 'Action executed successfully.'}`
: 'Action declined β€” no changes were applied to the system.'
);
} catch (err) {
removeMsg(loadingId);
appendMsg('assistant', 'Failed to record confirmation response.');
}
pendingAction = null;
}
/* ════════════════════════════════════════
PROACTIVE OPS RADAR
════════════════════════════════════════ */
async function loadProactiveInsights() {
const ctx = currentUser;
const badge = document.getElementById('proactive-alert-count');
if (badge) badge.classList.add('loading');
const url = `/api/proactive/insights?account_id=${ctx.account_id}&is_internal=${ctx.is_internal}&role=${ctx.role}`;
try {
const res = await fetch(url);
const data = await res.json();
if (badge) {
badge.classList.remove('loading');
badge.textContent = data.total_alerts || '0';
}
if (data.access_restricted) {
['list-sla-breaches','list-security','list-clusters','list-carrier'].forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = `<div class="radar-empty">${escHtml(data.message || 'Access restricted.')}</div>`;
});
['count-sla-breaches','count-security','count-clusters','count-carrier'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = '0';
});
return;
}
// SLA Breaches
setStat('count-sla-breaches', data.sla_breaches.length);
renderList('list-sla-breaches', data.sla_breaches.map(b => ({
id: b.ticket_id,
pillClass:'pill-rose',
badgeText: b.overdue_by_minutes > 0 ? `${b.overdue_by_minutes}m overdue` : 'Approaching',
secondary: b.severity,
subject: b.subject,
source: b.rule_source,
action: { label: 'Escalate β†’', scenarioId: 'scenario-4' }
})));
// Security
setStat('count-security', data.security_alerts.length);
renderList('list-security', data.security_alerts.map(s => ({
id: s.ticket_id,
pillClass:'pill-amber',
badgeText:'Critical',
secondary: null,
subject: s.description,
source: s.recommended_action,
})));
// Clusters
setStat('count-clusters', data.ticket_clusters.length);
renderList('list-clusters', data.ticket_clusters.map(c => ({
id: c.known_issue_id,
pillClass:'pill-cyan',
badgeText:`${c.affected_tickets.length} tickets`,
secondary: null,
subject: c.issue_title,
source: `Workaround: ${c.workaround}`,
})));
// Carrier
setStat('count-carrier', data.carrier_delays.length);
renderList('list-carrier', data.carrier_delays.map(car => ({
id: car.order_id,
pillClass:'pill-violet',
badgeText:`${car.delay_hours}h delay`,
secondary: null,
subject: `${car.carrier} β€” ${car.issue_summary}`,
source: car.recommended_action,
})));
} catch (err) {
console.error('Proactive insights error:', err);
if (badge) badge.classList.remove('loading');
}
}
function setStat(id, count) {
const el = document.getElementById(id);
if (el) el.textContent = String(count);
}
function renderList(listId, items) {
const el = document.getElementById(listId);
if (!el) return;
if (!items || items.length === 0) {
el.innerHTML = '<div class="radar-empty">No active issues detected.</div>';
return;
}
el.innerHTML = items.map(item => `
<div class="alert-item">
<div class="alert-item-header">
<span class="alert-item-id pill ${escHtml(item.pillClass)}">${escHtml(item.id)}</span>
${item.secondary ? `<span class="pill ${escHtml(item.pillClass)}">${escHtml(item.secondary)}</span>` : ''}
<span class="pill ${escHtml(item.pillClass)}" style="margin-left:auto">${escHtml(item.badgeText)}</span>
</div>
<div class="alert-item-subject">${escHtml(item.subject)}</div>
<div class="alert-item-source">${escHtml(item.source)}</div>
${item.action
? `<button class="alert-action-btn" onclick="runScenario('${escHtml(item.action.scenarioId)}')">${escHtml(item.action.label)}</button>`
: ''}
</div>
`).join('');
}
/* ════════════════════════════════════════
CONTRACT MATRIX
════════════════════════════════════════ */
async function loadContractMatrix() {
if (matrixLoaded) return; // Only fetch once
const grid = document.getElementById('contract-matrix-grid');
grid.innerHTML = '<div class="radar-empty" style="padding:20px">Loading contracts…</div>';
try {
const res = await fetch('/api/data/compare-contracts');
const data = await res.json();
grid.innerHTML = data.map(item => {
const planPill = item.plan === 'Enterprise'
? `<span class="pill pill-blue">${item.plan}</span>`
: item.plan === 'Growth'
? `<span class="pill pill-emerald">${item.plan}</span>`
: `<span class="pill pill-gray">${item.plan}</span>`;
const hasContract = item.governing_contract !== 'None (Standard Policy Applies)'
&& item.governing_contract !== 'None (Standard Enterprise Policy Applies)';
return `
<div class="contract-card">
<div class="contract-card-header">
<span class="contract-card-name">
${escHtml(item.account_name)}
<span style="color:var(--c-txt-3);font-size:11px;font-weight:400">(${escHtml(item.account_id)})</span>
</span>
<div style="display:flex;gap:6px;align-items:center">
${planPill}
${hasContract ? `<span class="pill pill-emerald" style="font-size:9px">Custom Contract</span>` : ''}
</div>
</div>
<div class="contract-card-body">
<div class="contract-row">
<div class="contract-row-label">Agreement</div>
<div class="contract-row-value muted">${escHtml(item.governing_contract)}</div>
</div>
<div class="contract-row">
<div class="contract-row-label">P1 SLA Target</div>
<div class="contract-row-value"><strong>${escHtml(item.p1_sla)}</strong></div>
</div>
<div class="contract-row">
<div class="contract-row-label">Cancellation Rule</div>
<div class="contract-row-value highlight">${escHtml(item.cancellation_rule)}</div>
</div>
<div class="contract-row">
<div class="contract-row-label">Service Credit Rule</div>
<div class="contract-row-value">${escHtml(item.service_credit_rule)}</div>
</div>
</div>
<div class="contract-note">${escHtml(item.precedence_notes)}</div>
</div>
`;
}).join('');
matrixLoaded = true;
} catch (err) {
grid.innerHTML = `<div class="radar-empty">Failed to load contract matrix.</div>`;
}
}
/* ════════════════════════════════════════
DATA EXPLORER
════════════════════════════════════════ */
function initSubtabs() {
document.querySelectorAll('.subtab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.subtab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadExplorer(btn.dataset.subtab);
});
});
}
async function loadExplorer(subtabId) {
const container = document.getElementById('explorer-content');
container.innerHTML = '<div class="radar-empty" style="padding:24px;">Loading…</div>';
const endpoints = {
'subtab-documents': '/api/data/documents',
'subtab-accounts': '/api/data/accounts',
'subtab-orders': '/api/data/orders',
'subtab-tickets': '/api/data/tickets',
};
const endpoint = endpoints[subtabId];
if (!endpoint) return;
const ctx = currentUser;
const url = `${endpoint}?account_id=${ctx.account_id}&is_internal=${ctx.is_internal}&role=${ctx.role}`;
try {
const res = await fetch(url);
const data = await res.json();
if (!data || data.length === 0) {
container.innerHTML = '<div class="radar-empty" style="padding:24px;">No records accessible under current context permissions.</div>';
return;
}
const keys = Object.keys(data[0]);
const table = document.createElement('table');
table.className = 'data-table';
table.innerHTML = `
<thead>
<tr>${keys.map(k => `<th>${escHtml(k.replace(/_/g,' '))}</th>`).join('')}</tr>
</thead>
<tbody>
${data.map(row =>
`<tr>${keys.map(k => `<td>${escHtml(String(row[k] ?? 'β€”'))}</td>`).join('')}</tr>`
).join('')}
</tbody>
`;
container.innerHTML = '';
container.appendChild(table);
} catch (err) {
container.innerHTML = '<div class="radar-empty" style="padding:24px;">Failed to load data.</div>';
}
}
/* ════════════════════════════════════════
MARKDOWN & UTILITIES
════════════════════════════════════════ */
function renderMarkdown(text) {
if (!text) return '';
// Escape first, then apply markdown
let t = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
t = t
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, `<code style="font-family:var(--font-mono);font-size:11.5px;background:rgba(255,255,255,0.07);padding:1px 5px;border-radius:3px;color:#22d3ee;">$1</code>`)
.replace(/^### (.+)$/gm, '<h4 style="font-size:14px;font-weight:700;margin:12px 0 6px;letter-spacing:-0.01em;color:#f1f5f9">$1</h4>')
.replace(/^#### (.+)$/gm, '<h5 style="font-size:12.5px;font-weight:700;margin:9px 0 4px;color:#cbd5e1">$1</h5>')
.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid rgba(255,255,255,0.08);margin:10px 0">')
.replace(/^- (.+)$/gm, '<li style="margin:3px 0;padding-left:4px;list-style:none;display:flex;gap:8px"><span style="color:#475569;flex-shrink:0">β€”</span><span>$1</span></li>')
.replace(/^\d+\. (.+)$/gm, '<li style="margin:3px 0;list-style:decimal;margin-left:18px">$1</li>')
.replace(/\n\n/g, '</p><p style="margin-top:7px">');
return `<p>${t}</p>`;
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function shortDocName(name) {
if (!name) return 'β€”';
return name
.replace(/^\d+_/, '')
.replace(/\.pdf$/i, '')
.replace(/_/g, ' ')
.trim();
}
/* ════════════════════════════════════════
EXPORT LOG
════════════════════════════════════════ */
function initExportLog() {
const btn = document.getElementById('export-log-btn');
if (btn) btn.addEventListener('click', exportAuditLog);
}
function exportAuditLog() {
const dateStr = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `ParcelPilot_Audit_Log_${currentUser.account_id}_${dateStr}.txt`;
let content = `=================================================================\n`;
content += `PARCELPILOT AI OPERATIONS - AUDIT LOG\n`;
content += `Generated: ${new Date().toISOString()}\n`;
content += `Context: ${currentUser.account_name} (${currentUser.account_id}) | Role: ${currentUser.role}\n`;
content += `=================================================================\n\n`;
const messages = document.querySelectorAll('.msg-row');
if (messages.length === 0) {
content += `No conversation history available in current session.\n`;
} else {
messages.forEach(msg => {
const isUser = msg.classList.contains('msg-user');
const author = isUser ? currentUser.account_name : 'ParcelPilot AI';
// Get main text
let text = '';
if (isUser) {
text = msg.querySelector('.msg-bubble').textContent.trim();
} else {
// Strip out citations text and widget text for cleaner log
const bubble = msg.cloneNode(true);
const widget = bubble.querySelector('.widget-card');
const citations = bubble.querySelector('.chat-citations');
if (widget) widget.remove();
if (citations) citations.remove();
text = bubble.querySelector('.msg-bubble').textContent.replace('ParcelPilot Intelligence', '').trim();
}
content += `[${author}]\n${text}\n\n`;
});
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}