DavidL72Code/UMB_Sustainable_Chatbot
0
1const API_BASE_STORAGE_KEY = "ssl_api_base";2 3function resolvedApiBase() {4 const params = new URLSearchParams(window.location.search);5 const override = params.get("api_base");6 const stored = (() => {7 try {8 return window.localStorage.getItem(API_BASE_STORAGE_KEY) || "";9 } catch {10 return "";11 }12 })();13 const requested = (override || window.API_BASE || stored || "").replace(/\/+$/, "");14 // Same-origin by default: vercel.json rewrites /api to the Space, so the15 // session cookie is first-party. A cross-site cookie from hf.space is16 // dropped by Safari and by Chrome's third-party cookie blocking, which made17 // every dashboard call 401 no matter how CORS was configured.18 const defaultApiBase = "";19 return requested === window.location.origin ? requested : defaultApiBase;20}21 22function dashboardDetailHref(eventId) {23 const hrefParams = new URLSearchParams();24 hrefParams.set("id", eventId || "");25 const apiBase = resolvedApiBase();26 if (apiBase) {27 hrefParams.set("api_base", apiBase);28 }29 return `/dashboard-detail.html?${hrefParams.toString()}`;30}31 32function dashboardHomeHref() {33 const hrefParams = new URLSearchParams();34 const apiBase = resolvedApiBase();35 if (apiBase) {36 hrefParams.set("api_base", apiBase);37 }38 const query = hrefParams.toString();39 return query ? `/dashboard.html?${query}` : "/dashboard.html";40}41 42function dashboardApiUrl(path) {43 const base = resolvedApiBase();44 if (!base) return path;45 return `${base}${path}`;46}47 48async function logoutAdmin() {49 await fetch(dashboardApiUrl("/api/admin/logout"), {50 method: "POST",51 credentials: "include",52 });53 const login = new URL("/admin-login.html", window.location.origin);54 const base = resolvedApiBase();55 if (base !== window.location.origin) login.searchParams.set("api_base", base);56 window.location.replace(login.toString());57}58 59function setText(id, value) {60 const node = document.getElementById(id);61 if (node) node.textContent = value;62}63 64function sanitizeDashboardUrl(value) {65 if (typeof value !== "string") return null;66 const trimmed = value.trim();67 if (!trimmed || trimmed === "URL not provided") return null;68 try {69 const parsed = new URL(trimmed, window.location.origin);70 return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;71 } catch {72 return null;73 }74}75 76function createStatusPill(status) {77 const pill = document.createElement("span");78 pill.className = `status-pill status-${status || "answered"}`;79 pill.textContent = status || "answered";80 return pill;81}82 83function createScorePill(score, isLowConfidence) {84 if (score === null || score === undefined) {85 const span = document.createElement("span");86 span.className = "muted-value";87 span.textContent = "N/A";88 return span;89 }90 91 const pill = document.createElement("span");92 pill.className = `score-pill${isLowConfidence ? " score-low" : ""}`;93 pill.textContent = String(score);94 return pill;95}96 97function formatNumber(value) {98 const number = Number(value || 0);99 return Number.isFinite(number) ? number.toLocaleString() : "0";100}101 102function formatSeconds(milliseconds) {103 const seconds = Number(milliseconds || 0) / 1000;104 if (seconds > 0 && seconds < 0.01) return "<0.01 s";105 return `${seconds.toFixed(2)} s`;106}107 108function formatCost(value) {109 if (value === null || value === undefined) return "unpriced";110 return `$${Number(value).toFixed(6)}`;111}112 113function createMetricCell(primary, secondary) {114 const cell = document.createElement("td");115 const main = document.createElement("span");116 main.className = "count-pair";117 main.textContent = primary;118 cell.appendChild(main);119 if (secondary) {120 const note = document.createElement("span");121 note.className = "muted-value";122 note.textContent = secondary;123 cell.appendChild(note);124 }125 return cell;126}127 128function renderMetricsTable(containerId, emptyId, headers, rows) {129 const container = document.getElementById(containerId);130 const empty = document.getElementById(emptyId);131 if (!container) return;132 container.innerHTML = "";133 if (!rows.length) {134 if (empty) empty.hidden = false;135 return;136 }137 if (empty) empty.hidden = true;138 const table = document.createElement("table");139 table.className = "metrics-table";140 const thead = document.createElement("thead");141 const headRow = document.createElement("tr");142 headers.forEach((header) => {143 const th = document.createElement("th");144 th.textContent = header;145 headRow.appendChild(th);146 });147 thead.appendChild(headRow);148 const tbody = document.createElement("tbody");149 rows.forEach((cells) => {150 const tr = document.createElement("tr");151 cells.forEach((value) => {152 const td = document.createElement("td");153 if (value instanceof Node) td.appendChild(value);154 else td.textContent = value;155 tr.appendChild(td);156 });157 tbody.appendChild(tr);158 });159 table.append(thead, tbody);160 container.appendChild(table);161}162 163function createKindPill(kind) {164 const pill = document.createElement("span");165 pill.className = `kind-pill kind-${kind || "step"}`;166 pill.textContent = kind || "step";167 return pill;168}169 170function renderEmptyRow(message) {171 const row = document.createElement("tr");172 const cell = document.createElement("td");173 cell.colSpan = 9;174 cell.className = "empty-state";175 cell.textContent = message;176 row.appendChild(cell);177 return row;178}179 180function renderRankedList(listId, emptyId, items) {181 const list = document.getElementById(listId);182 const empty = document.getElementById(emptyId);183 if (!list || !empty) return;184 185 list.innerHTML = "";186 if (!items || items.length === 0) {187 empty.hidden = false;188 return;189 }190 191 empty.hidden = true;192 items.forEach(([label, count]) => {193 const item = document.createElement("li");194 const left = document.createElement("span");195 left.textContent = label;196 const right = document.createElement("strong");197 right.textContent = String(count);198 item.append(left, right);199 list.appendChild(item);200 });201}202 203function renderProblemEvents(events) {204 const list = document.getElementById("problemEventsList");205 const empty = document.getElementById("problemEventsEmpty");206 if (!list || !empty) return;207 208 list.innerHTML = "";209 if (!events || events.length === 0) {210 empty.hidden = false;211 return;212 }213 214 empty.hidden = true;215 events.forEach((event) => {216 const link = document.createElement("a");217 link.href = dashboardDetailHref(event.id || "");218 link.appendChild(createStatusPill(event.status || "answered"));219 link.append(` ${event.display_label || event.id || "Interaction"}`);220 list.appendChild(link);221 });222}223 224const SESSION_STATS_KEY = "ssl_session_turns";225 226function readSessionTurns() {227 try {228 const turns = JSON.parse(sessionStorage.getItem(SESSION_STATS_KEY) || "[]");229 return Array.isArray(turns) ? turns : [];230 } catch {231 return [];232 }233}234 235async function readSavedHistory() {236 // Only ever the caller's own saved chats. Anonymous visitors get nothing237 // back, which is correct: nothing of theirs was stored.238 try {239 const response = await fetch(dashboardApiUrl("/api/my/dashboard"), { credentials: "include" });240 if (!response.ok) return { signed_in: false, conversations: [] };241 return await response.json();242 } catch {243 return { signed_in: false, conversations: [] };244 }245}246 247async function buildPersonalDashboard() {248 // This dashboard is per-person, not the staff view. The current session's249 // turns come from the browser and are never sent anywhere; a signed-in250 // visitor also sees their saved chats. The aggregate staff dashboard stays251 // behind admin auth on /api/dashboard.252 const turns = readSessionTurns();253 const saved = await readSavedHistory();254 255 const answered = turns.length;256 const sum = (pick) => turns.reduce((total, turn) => total + (Number(pick(turn)) || 0), 0);257 const totalTokens = sum((t) => t.total_tokens);258 const totalCost = sum((t) => t.cost_usd);259 const totalLatency = sum((t) => t.latency_ms);260 261 const categoryCounts = new Map();262 turns.forEach((turn) => (turn.categories || []).forEach((category) => {263 categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1);264 }));265 266 const sourceCounts = new Map();267 const countSource = (source) => {268 const label = source.title || source.source_path || "Unknown source";269 sourceCounts.set(label, (sourceCounts.get(label) || 0) + 1);270 };271 turns.forEach((turn) => (turn.sources || []).forEach(countSource));272 (saved.conversations || []).forEach((c) => (c.sources || []).forEach(countSource));273 274 const savedQuestions = (saved.conversations || []).reduce(275 (total, c) => total + (Number(c.question_count) || 0), 0276 );277 278 const history = turns.slice().reverse().map((turn, index) => ({279 id: `session-${turns.length - index}`,280 display_label: turn.question || "Question",281 question: turn.question || "",282 status: turn.status || "answered",283 is_low_confidence: Boolean(turn.low_confidence),284 latency_ms: turn.latency_ms,285 total_tokens: turn.total_tokens,286 cost_usd: turn.cost_usd,287 session_only: true,288 }));289 290 (saved.conversations || []).forEach((c) => {291 (c.questions || []).forEach((question, index) => {292 history.push({293 id: `${c.id}-${index}`,294 display_label: question,295 question,296 status: "answered",297 saved: true,298 });299 });300 });301 302 return {303 signed_in: Boolean(saved.signed_in),304 stats: {305 total: answered + savedQuestions,306 clarifications: turns.filter((t) => t.status === "clarification").length,307 low_confidence: turns.filter((t) => t.low_confidence).length,308 blocked: turns.filter((t) => t.status === "blocked").length,309 errors: turns.filter((t) => t.status === "error").length,310 avg_latency_ms: answered ? totalLatency / answered : 0,311 total_tokens: totalTokens,312 avg_tokens: answered ? Math.round(totalTokens / answered) : 0,313 total_cost_usd: totalCost,314 avg_cost_usd: answered ? totalCost / answered : 0,315 },316 // renderRankedList destructures each item as [label, count], so send317 // entry pairs — objects render as blanks and throw on the spread.318 source_usage: [...sourceCounts.entries()].sort((a, b) => b[1] - a[1]),319 category_usage: [...categoryCounts.entries()].sort((a, b) => b[1] - a[1]),320 problem_events: turns321 .filter((t) => t.low_confidence || t.status === "error" || t.status === "clarification")322 .map((t) => ({ id: t.question || "question", notes: t.low_confidence ? "low confidence" : t.status })),323 chat_history: history,324 };325}326 327async function loadDashboardPage() {328 const body = document.getElementById("historyTableBody");329 if (!body) return;330 331 try {332 const dashboard = await buildPersonalDashboard();333 334 setText("metricTotal", String(dashboard.stats?.total ?? 0));335 setText("metricClarifications", String(dashboard.stats?.clarifications ?? 0));336 setText("metricLowConfidence", String(dashboard.stats?.low_confidence ?? 0));337 setText("metricBlocked", String(dashboard.stats?.blocked ?? 0));338 setText("metricErrors", String(dashboard.stats?.errors ?? 0));339 setText("metricAvgLatency", formatSeconds(dashboard.stats?.avg_latency_ms));340 setText("metricTotalTokens", formatNumber(dashboard.stats?.total_tokens));341 setText("metricAvgTokens", `${formatNumber(dashboard.stats?.avg_tokens)} avg / answer`);342 setText("metricTotalCost", `$${Number(dashboard.stats?.total_cost_usd || 0).toFixed(4)}`);343 setText("metricAvgCost", `${formatCost(dashboard.stats?.avg_cost_usd ?? 0)} avg / answer`);344 345 body.innerHTML = "";346 const history = dashboard.chat_history || [];347 if (history.length === 0) {348 body.appendChild(renderEmptyRow("No chat logs yet. Ask the chatbot a question and refresh this page."));349 } else {350 history.forEach((event) => {351 const row = document.createElement("tr");352 353 const statusCell = document.createElement("td");354 statusCell.appendChild(createStatusPill(event.status));355 356 const mappingCell = document.createElement("td");357 const link = document.createElement("a");358 link.className = "history-link";359 link.href = dashboardDetailHref(event.id || "");360 const strong = document.createElement("strong");361 strong.textContent = event.display_label || event.id || "Interaction";362 const preview = document.createElement("span");363 preview.textContent = event.preview_text || "";364 const timestamp = document.createElement("small");365 timestamp.textContent = event.timestamp || event.id || "";366 link.append(strong, preview);367 mappingCell.append(link, timestamp);368 369 const confidenceCell = document.createElement("td");370 confidenceCell.appendChild(createScorePill(event.confidence_score, event.is_low_confidence));371 372 const sourceCell = document.createElement("td");373 const shown = document.createElement("span");374 shown.className = "count-pair";375 shown.textContent = `${event.source_count || 0} shown`;376 const retrieved = document.createElement("span");377 retrieved.className = "muted-value";378 retrieved.textContent = `${event.retrieved_count || 0} retrieved`;379 sourceCell.append(shown, retrieved);380 381 const pathCell = document.createElement("td");382 const pathLabel = document.createElement("span");383 pathLabel.className = "path-label";384 pathLabel.textContent = event.path_label || event.response_mode || "direct";385 pathLabel.title = event.path_label || "";386 pathCell.appendChild(pathLabel);387 388 const retrievalCell = event.top_score === null || event.top_score === undefined389 ? createMetricCell("N/A")390 : createMetricCell(`top ${Number(event.top_score).toFixed(3)}`, `gap ${Number(event.score_gap || 0).toFixed(3)}`);391 392 const tokenCell = event.total_tokens393 ? createMetricCell(formatNumber(event.total_tokens), `${event.token_usage?.call_count || 0} call(s)`)394 : createMetricCell("N/A");395 396 const costCell = document.createElement("td");397 costCell.textContent = formatCost(event.cost_usd);398 399 const latencyCell = document.createElement("td");400 latencyCell.textContent = formatSeconds(event.latency_ms);401 if (event.latency_breakdown) {402 const note = document.createElement("span");403 note.className = "muted-value";404 note.textContent = `retrieval ${formatSeconds(event.latency_breakdown.retrieval_ms)} / llm ${formatSeconds(event.latency_breakdown.llm_ms)}`;405 latencyCell.appendChild(note);406 }407 408 row.append(statusCell, mappingCell, confidenceCell, pathCell, sourceCell, retrievalCell, tokenCell, costCell, latencyCell);409 body.appendChild(row);410 });411 }412 413 // Say plainly whose data this is, and whether it will survive the tab.414 const scopeNote = document.getElementById("scopeNote");415 if (scopeNote) {416 scopeNote.textContent = dashboard.signed_in417 ? "Your activity — this session plus your saved chats"418 : "Your activity this session only — sign in to keep it";419 }420 421 renderRankedList("sourceUsageList", "sourceUsageEmpty", dashboard.source_usage || []);422 renderRankedList("categoryUsageList", "categoryUsageEmpty", dashboard.category_usage || []);423 renderProblemEvents(dashboard.problem_events || []);424 } catch (error) {425 body.innerHTML = "";426 body.appendChild(renderEmptyRow(error.message || "Unable to load dashboard."));427 const emptyIds = ["sourceUsageEmpty", "categoryUsageEmpty", "problemEventsEmpty"];428 emptyIds.forEach((id) => {429 const node = document.getElementById(id);430 if (node) {431 node.hidden = false;432 node.textContent = "Unable to load dashboard data from the backend.";433 }434 });435 }436}437 438function renderDetailStat(label, value) {439 const wrapper = document.createElement("div");440 const dt = document.createElement("dt");441 dt.textContent = label;442 const dd = document.createElement("dd");443 if (value instanceof Node) {444 dd.appendChild(value);445 } else {446 dd.textContent = value;447 }448 wrapper.append(dt, dd);449 return wrapper;450}451 452function renderConfidenceSection(event) {453 const section = document.getElementById("confidenceSection");454 if (!section) return;455 456 section.innerHTML = "";457 const confidence = event.trace?.confidence;458 if (!confidence) {459 const empty = document.createElement("p");460 empty.className = "empty-state";461 empty.textContent = "No confidence data was recorded for this interaction.";462 section.appendChild(empty);463 return;464 }465 466 const stats = document.createElement("dl");467 stats.className = "detail-stats single-column";468 stats.append(469 renderDetailStat("Score", event.confidence_score ?? "N/A"),470 renderDetailStat("Low Confidence", event.is_low_confidence ? "yes" : "no")471 );472 section.appendChild(stats);473 474 if (event.confidence_reasons?.length) {475 const list = document.createElement("ul");476 list.className = "reason-list";477 event.confidence_reasons.forEach((reason) => {478 const item = document.createElement("li");479 item.textContent = reason;480 list.appendChild(item);481 });482 section.appendChild(list);483 }484}485 486function renderSources(sources) {487 const list = document.getElementById("detailSources");488 const empty = document.getElementById("detailSourcesEmpty");489 if (!list || !empty) return;490 491 list.innerHTML = "";492 if (!sources || sources.length === 0) {493 empty.hidden = false;494 return;495 }496 497 empty.hidden = true;498 sources.forEach((source) => {499 const item = document.createElement("li");500 const title = document.createElement("strong");501 title.textContent = source.title || "Untitled source";502 item.appendChild(title);503 const safeUrl = sanitizeDashboardUrl(source.url);504 if (safeUrl) {505 const link = document.createElement("a");506 link.href = safeUrl;507 link.target = "_blank";508 link.rel = "noreferrer";509 link.textContent = safeUrl;510 item.appendChild(link);511 }512 list.appendChild(item);513 });514}515 516async function loadDashboardDetailPage() {517 const detailEmpty = document.getElementById("detailEmpty");518 if (!detailEmpty) return;519 520 const params = new URLSearchParams(window.location.search);521 const eventId = params.get("id");522 if (!eventId) {523 const text = document.getElementById("detailEmptyText");524 if (text) text.textContent = "No interaction id was provided.";525 return;526 }527 528 try {529 const response = await fetch(dashboardApiUrl(`/api/dashboard/interaction/${encodeURIComponent(eventId)}`), { credentials: "include" });530 if (response.status === 401) {531 const login = new URL("/admin-login.html", window.location.origin);532 const base = resolvedApiBase();533 if (base !== window.location.origin) login.searchParams.set("api_base", base);534 window.location.replace(login.toString());535 return;536 }537 if (response.status === 404) {538 const text = document.getElementById("detailEmptyText");539 if (text) text.textContent = "That log entry could not be found in the local JSONL file.";540 return;541 }542 if (!response.ok) throw new Error(`Interaction request failed (${response.status})`);543 const event = await response.json();544 545 detailEmpty.hidden = true;546 const detailLayout = document.getElementById("detailLayout");547 if (detailLayout) detailLayout.hidden = false;548 549 setText("detailLabel", event.display_label || event.id || "Interaction");550 setText("detailTimestamp", event.timestamp || "");551 setText("detailSourceSummary", `${event.source_count || 0} returned sources`);552 setText("detailPreviewText", event.preview_text || "");553 554 const stats = document.getElementById("detailStats");555 if (stats) {556 stats.innerHTML = "";557 stats.append(558 renderDetailStat("Status", createStatusPill(event.status || "answered")),559 renderDetailStat("Latency", formatSeconds(event.latency_ms)),560 renderDetailStat("Mode", event.response_mode || "unknown"),561 renderDetailStat("Clarification", event.needs_clarification ? "yes" : "no"),562 renderDetailStat("Blocked", event.blocked ? "yes" : "no"),563 renderDetailStat("Confidence", event.confidence_score ?? "N/A"),564 renderDetailStat("Sources", String(event.source_count || 0))565 );566 }567 568 renderConfidenceSection(event);569 renderSources(event.sources || []);570 setText("retrievalSummary", JSON.stringify(event.retrieval_summary || {}, null, 2));571 572 setText("detailPathLabel", event.path_label || event.response_mode || "direct");573 renderMetricsTable(574 "pathTable",575 "pathEmpty",576 ["#", "Step", "Kind", "Model", "Latency", "Tokens"],577 (event.path || []).map((step, index) => [578 String(index + 1),579 step.step || "",580 createKindPill(step.kind),581 step.model || "—",582 formatSeconds(step.latency_ms),583 step.total_tokens ? formatNumber(step.total_tokens) : "—",584 ])585 );586 587 const routeStats = document.getElementById("routeStats");588 if (routeStats) {589 routeStats.innerHTML = "";590 const route = event.route_summary || {};591 routeStats.append(592 renderDetailStat("Response mode", route.response_mode || event.response_mode || "unknown"),593 renderDetailStat("Routing mode", route.routing_mode || "unknown"),594 renderDetailStat("Question type", route.question_type || "unknown")595 );596 }597 598 const latencyStats = document.getElementById("latencyStats");599 if (latencyStats) {600 const breakdown = event.latency_breakdown || {};601 latencyStats.innerHTML = "";602 latencyStats.append(603 renderDetailStat("Retrieval", formatSeconds(breakdown.retrieval_ms)),604 renderDetailStat("LLM calls", formatSeconds(breakdown.llm_ms)),605 renderDetailStat("Other", formatSeconds(breakdown.other_ms))606 );607 }608 setText("detailLatencyTotal", `${formatSeconds(event.latency_ms)} total`);609 610 const usage = event.token_usage || {};611 setText("detailCostTotal", formatCost(usage.cost_usd));612 const tokenStats = document.getElementById("tokenStats");613 if (tokenStats) {614 tokenStats.innerHTML = "";615 tokenStats.append(616 renderDetailStat("Input", formatNumber(usage.input_tokens)),617 renderDetailStat("Output", formatNumber(usage.output_tokens)),618 renderDetailStat("Thinking", formatNumber(usage.thinking_tokens)),619 renderDetailStat("Cached", formatNumber(usage.cached_tokens)),620 renderDetailStat("Total", formatNumber(usage.total_tokens)),621 renderDetailStat("LLM calls", String(usage.call_count || 0))622 );623 }624 renderMetricsTable(625 "tokenTable",626 "tokenEmpty",627 ["Stage", "Model", "In", "Out", "Think", "Cost"],628 (event.llm_calls || []).map((call) => [629 call.step || "",630 call.model || "",631 formatNumber(call.input_tokens),632 formatNumber(call.output_tokens),633 formatNumber(call.thinking_tokens),634 formatCost(call.cost_usd),635 ])636 );637 const unpricedNote = document.getElementById("tokenUnpricedNote");638 if (unpricedNote) unpricedNote.hidden = usage.fully_priced !== false;639 640 renderMetricsTable(641 "retrievalScoreTable",642 "retrievalScoreEmpty",643 ["Rank", "Score", "Title", "Section", "Chunk", "Source"],644 (event.retrieval_scores || []).map((rowData) => [645 String(rowData.rank ?? ""),646 rowData.forced ? "pinned" : Number(rowData.score || 0).toFixed(4),647 rowData.title || "—",648 rowData.section_name || "—",649 rowData.chunk_index === null || rowData.chunk_index === undefined ? "—" : String(rowData.chunk_index),650 rowData.source_path || "—",651 ])652 );653 } catch (error) {654 const text = document.getElementById("detailEmptyText");655 if (text) text.textContent = error.message || "Unable to load interaction details.";656 }657}658 659if (document.getElementById("historyTableBody")) {660 document.getElementById("logoutButton")?.addEventListener("click", logoutAdmin);661 loadDashboardPage();662}663 664if (document.getElementById("detailEmpty")) {665 document.getElementById("logoutButton")?.addEventListener("click", logoutAdmin);666 const dashboardBackLink = document.querySelector('a[href="/dashboard.html"]');667 if (dashboardBackLink) {668 dashboardBackLink.href = dashboardHomeHref();669 }670 loadDashboardDetailPage();671}672 