CoolFace
Apppublic

DavidL72Code/UMB_Sustainable_Chatbot

sourceHugging Faceupdated 5d agoView on Hugging Face
0likes
dashboard.js672 linesDownload Raw Back to static
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