mukul-chauhan-methdai's picture
Allow unlocking personality editing from Settings (v0.3.4)
8db7d9a
Raw
History Blame Contribute Delete
95.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MethdAI Receptionist</title>
<!-- Plus Jakarta Sans β€” premium feel, modern character, pairs well with
the methdai.com brand language (clean geometric forms + warm humanist
proportions). Inter remains the fallback in case of slow CDN. -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ============================================================
MethdAI Receptionist β€” Premium-minimal design system.
Light-first, violet-biased neutrals, single indigo accent;
semantic green/amber/red reserved for STATE only. Dark theme
via body.theme-dark (JS toggles it; localStorage 'mr-theme').
Token names are kept from the previous system so the inline
styles in the markup keep resolving.
============================================================ */
:root {
--bg: #f5f5f9;
--panel: #ffffff;
--panel-2: #f2f2f8;
--border: #ebebf1;
--border-strong: #dcdce6;
--accent: #5b57e0;
--accent-strong: #4b46cf;
--accent-soft: rgba(91, 87, 224, 0.10);
--accent-gradient: linear-gradient(145deg, #6f6be6 0%, #5b57e0 100%);
--green: #16a34a; --green-soft: rgba(22, 163, 74, 0.11);
--orange: #c2760a; --orange-soft: rgba(194, 118, 10, 0.12);
--red: #dc2626; --red-soft: rgba(220, 38, 38, 0.10);
--text: #16161f;
--muted: #6c6c7c;
--faint: #9a9aab;
--shadow-sm: 0 1px 2px rgba(20, 20, 45, 0.05);
--shadow-md: 0 1px 3px rgba(20,20,45,.05), 0 14px 30px -16px rgba(20, 20, 45, 0.16);
--shadow-lg: 0 20px 48px -18px rgba(20, 20, 45, 0.26);
--radius-md: 12px;
--radius-lg: 18px;
--font: 'Plus Jakarta Sans', 'Inter', system-ui, -apple-system, "Segoe UI", sans-serif;
}
body.theme-dark {
--bg: #0d0d13;
--panel: #16161e;
--panel-2: #1e1e28;
--border: #262631;
--border-strong: #34343f;
--accent: #8b87ff;
--accent-strong: #a5a2ff;
--accent-soft: rgba(139, 135, 255, 0.14);
--accent-gradient: linear-gradient(145deg, #8b87ff 0%, #6f6bf0 100%);
--green: #31c463; --green-soft: rgba(49, 196, 99, 0.15);
--orange: #e0a02a; --orange-soft: rgba(224, 160, 42, 0.15);
--red: #f2585b; --red-soft: rgba(242, 88, 91, 0.15);
--text: #e9e9f1;
--muted: #9a9aab;
--faint: #6c6c7c;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 1px 2px rgba(0,0,0,.35), 0 18px 40px -20px rgba(0, 0, 0, 0.7);
--shadow-lg: 0 24px 56px -20px rgba(0, 0, 0, 0.8);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text); font-family: var(--font);
min-height: 100vh; line-height: 1.5; -webkit-font-smoothing: antialiased;
}
.num, .stat-value, .appt-time, .guest-ts, .outbox-ts,
.visitor-log-table td { font-variant-numeric: tabular-nums; }
/* ---- Header ---- */
header {
position: sticky; top: 0; z-index: 30;
display: flex; align-items: center; gap: 14px; flex-wrap: wrap; row-gap: 10px;
padding: 13px clamp(16px, 4vw, 36px);
background: color-mix(in srgb, var(--panel) 82%, transparent);
backdrop-filter: saturate(1.4) blur(14px);
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 1.02rem; font-weight: 700; letter-spacing: -0.01em; }
header .badge {
background: var(--accent-soft); color: var(--accent-strong);
font-size: 0.64rem; font-weight: 700; padding: 3px 9px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.08em;
}
.brand-logo { height: 32px; width: auto; border-radius: 9px; display: inline-block; object-fit: contain; }
.brand-logo-fallback { font-size: 1.35rem; }
.face-status {
display: flex; align-items: center; gap: 8px;
font-size: 0.8rem; font-weight: 600; color: var(--muted);
padding: 6px 12px; border-radius: 999px;
background: var(--panel-2); border: 1px solid var(--border);
}
.face-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); transition: background .3s; }
.face-status .dot.known { background: var(--green); }
.face-status .dot.unknown { background: var(--orange); }
/* ---- Buttons ---- */
.btn {
display: inline-flex; align-items: center; gap: 7px;
background: var(--panel); color: var(--text);
border: 1px solid var(--border); border-radius: 10px;
padding: 7px 13px; font-family: inherit; font-size: 0.78rem; font-weight: 600;
cursor: pointer; box-shadow: var(--shadow-sm);
transition: transform .12s, box-shadow .12s, background .12s, border-color .12s;
text-decoration: none;
}
.btn:hover { background: var(--panel-2); border-color: var(--border-strong); transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn.accent { background: var(--accent); color: #fff; border-color: transparent;
box-shadow: 0 6px 16px -6px var(--accent); }
.btn.accent:hover { background: var(--accent-strong); }
.btn.danger { color: var(--red); border-color: var(--red-soft); }
.btn.danger:hover { background: var(--red-soft); border-color: var(--red-soft); }
.btn.icon-only { padding: 8px 10px; font-size: 1rem; line-height: 1; }
.btn:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible, summary:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
/* Volume control */
#volume-control {
display: flex; align-items: center; gap: 8px; padding: 5px 12px;
border: 1px solid var(--border) !important; border-radius: 999px;
background: var(--panel-2); font-size: 0.74rem; color: var(--muted);
}
#volume-slider { accent-color: var(--accent); }
/* ---- Grid + sections ---- */
.grid {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 18px;
padding: clamp(16px, 3vw, 26px) clamp(16px, 4vw, 36px) 44px;
max-width: 1360px; margin: 0 auto;
}
.section-heading {
grid-column: 1 / -1; display: flex; align-items: center; gap: 14px;
font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--faint); padding: 22px 2px 2px; border: none;
}
.section-heading > span { color: var(--faint); }
.section-heading::after { content: ""; flex: 1; height: 1px; background: var(--border); }
.section-heading .count {
font-size: 0.68rem; color: var(--accent); background: var(--accent-soft);
padding: 2px 9px; border-radius: 999px; letter-spacing: 0.04em;
}
/* ---- Panels ---- */
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow-md);
}
.panel-header {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 14px 18px; border-bottom: 1px solid var(--border);
font-size: 0.8rem; font-weight: 700; letter-spacing: 0.01em;
text-transform: none; color: var(--text);
}
.panel-header .icon { display: none; } /* de-emoji: clean typographic headers */
.panel-body { padding: 18px; }
.empty, .empty-state, td.empty { color: var(--muted); font-size: 0.82rem; text-align: center; padding: 24px 0; }
/* ---- Stats strip ---- */
.stats-strip { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.stat-card {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 18px 20px;
display: flex; flex-direction: column; gap: 3px; box-shadow: var(--shadow-md);
}
.stat-label { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.11em; color: var(--faint); font-weight: 700; margin-bottom: 4px; }
.stat-value { font-size: 2.2rem; font-weight: 800; line-height: 1; letter-spacing: -0.03em; }
.stat-value.accent { color: var(--accent); }
.stat-value.green { color: var(--green); }
.stat-value.dim { color: var(--text); font-size: 1.35rem; font-weight: 700; margin-top: 6px; }
.stat-sub { font-size: 0.74rem; color: var(--muted); margin-top: 5px; }
/* ---- Video / camera ---- */
.video-panel { grid-column: 1 / -1; }
.video-layout { display: flex; gap: 16px; align-items: stretch; }
.video-wrap {
position: relative; background: #0c0d14; border-radius: var(--radius-md);
overflow: hidden; flex: 1; min-width: 0; min-height: 300px;
display: flex; align-items: center; justify-content: center;
}
.video-wrap img { width: 100%; max-height: 420px; object-fit: contain; display: block; }
.video-overlay {
position: absolute; bottom: 12px; left: 12px;
background: rgba(0,0,0,0.55); backdrop-filter: blur(4px);
border-radius: 8px; padding: 5px 11px; font-size: 0.78rem; font-weight: 700; color: #fff;
}
.video-overlay.known { color: #6ee7a8; }
.video-overlay.unknown { color: #f6c56b; }
#stream-status { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; }
.best-face-panel {
width: 172px; flex-shrink: 0; background: var(--panel-2);
border: 1px solid var(--border); border-radius: var(--radius-md);
display: flex; flex-direction: column; align-items: center; overflow: hidden;
}
.best-face-title, .event-title {
font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--faint); padding: 12px 8px 8px; width: 100%; text-align: center;
}
.best-face-title { border-bottom: 1px solid var(--border); }
.best-face-img-wrap {
width: 120px; height: 120px; margin: 14px auto 8px; border-radius: 18px; overflow: hidden;
background: var(--panel); display: flex; align-items: center; justify-content: center;
box-shadow: inset 0 0 0 1px var(--border);
}
.best-face-img-wrap img { width: 100%; height: 100%; object-fit: cover; filter: contrast(1.05); }
.best-face-placeholder { color: var(--faint); font-size: 2rem; }
.best-face-name { font-size: 0.92rem; font-weight: 700; text-align: center; padding: 0 8px 2px; min-height: 1.2em; }
.best-face-name.known { color: var(--green); }
.best-face-name.unknown { color: var(--orange); }
.best-face-meta { font-size: 0.68rem; color: var(--muted); text-align: center; padding: 0 8px 12px; }
.best-face-events-panel { width: 100%; border-top: 1px solid var(--border); margin-top: 4px; padding: 10px 4px; }
details.best-face-events-panel > summary { cursor: pointer; list-style: none; user-select: none; }
details.best-face-events-panel > summary::-webkit-details-marker { display: none; }
details.best-face-events-panel > summary::after { content: ' β–Έ'; color: var(--faint); font-size: 0.7rem; }
details.best-face-events-panel[open] > summary::after { content: ' β–Ύ'; }
.event-compact-list { list-style: none; display: flex; flex-direction: column; gap: 5px; padding: 4px 12px 8px; }
.event-compact-item { padding: 5px 0; border-top: 1px solid var(--border); }
.event-compact-item:first-child { border-top: none; }
.event-compact-key { font-size: 0.64rem; color: var(--faint); text-transform: uppercase; letter-spacing: 0.06em; }
.event-compact-val { font-size: 0.72rem; font-weight: 600; margin-top: 2px; line-height: 1.3; }
.event-compact-val.known { color: var(--green); }
.event-compact-val.unknown { color: var(--orange); }
@media (max-width: 640px) { .best-face-panel { display: none; } }
/* ---- Session ---- */
.session-panel { grid-column: 1 / -1; }
.session-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.session-cell { background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px 14px; }
.session-cell-label { font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--faint); font-weight: 700; margin-bottom: 5px; }
.session-cell-value { font-size: 0.95rem; font-weight: 600; }
.session-cell-value.dim { color: var(--muted); font-weight: 500; }
.session-state-pill {
display: inline-flex; align-items: center; gap: 7px; padding: 5px 14px; border-radius: 999px;
font-size: 0.85rem; font-weight: 700; text-transform: capitalize; letter-spacing: 0.01em;
transition: background .3s, color .3s, box-shadow .4s;
}
.session-state-pill.pulse { animation: state-pulse 1.4s ease-out; }
@keyframes state-pulse {
0% { box-shadow: 0 0 0 0 var(--accent-soft); }
40% { box-shadow: 0 0 0 6px var(--accent-soft); }
100% { box-shadow: 0 0 0 0 transparent; }
}
.session-state-pill.idle { background: var(--panel-2); color: var(--muted); }
.session-state-pill.visitor_detected, .session-state-pill.greeting,
.session-state-pill.ask_name, .session-state-pill.checking_appointment,
.session-state-pill.notifying_employee { background: var(--accent-soft); color: var(--accent-strong); }
.session-state-pill.recognized, .session-state-pill.appointment_matched,
.session-state-pill.notified, .session-state-pill.complete { background: var(--green-soft); color: var(--green); }
.session-state-pill.no_appointment, .session-state-pill.email_failed,
.session-state-pill.unknown_employee, .session-state-pill.error,
.session-state-pill.multiple_people { background: var(--orange-soft); color: var(--orange); }
.session-trail { grid-column: 1 / -1; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 8px 0 0; min-height: 18px; }
.session-trail-label { font-size: 0.62rem; color: var(--faint); text-transform: uppercase; letter-spacing: 0.09em; font-weight: 700; margin-right: 4px; }
.trail-step { font-size: 0.68rem; color: var(--muted); padding: 3px 9px; border-radius: 7px; background: var(--panel-2); border: 1px solid var(--border); }
.trail-step.current { color: var(--accent-strong); border-color: transparent; background: var(--accent-soft); font-weight: 700; }
.trail-arrow { color: var(--faint); font-size: 0.7rem; }
.session-hint { grid-column: 1 / -1; background: var(--accent-soft); border-radius: var(--radius-md); padding: 12px 15px; font-size: 0.85rem; line-height: 1.55; }
.session-hint-label { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--accent-strong); font-weight: 700; margin-bottom: 5px; }
/* ---- Calendar ---- */
.appt-list, .guest-list, .outbox-list { list-style: none; display: flex; flex-direction: column; }
.guest-list, .outbox-list { max-height: 320px; overflow-y: auto; }
.appt-item, .guest-item {
display: flex; align-items: center; gap: 12px; padding: 11px 2px; background: transparent; border: none;
}
.appt-item + .appt-item, .guest-item + .guest-item { border-top: 1px solid var(--border); }
.appt-time { font-size: 0.82rem; font-weight: 700; color: var(--accent); min-width: 66px; white-space: nowrap; }
.appt-name, .guest-name { font-weight: 600; font-size: 0.9rem; }
.appt-note, .guest-ts { font-size: 0.75rem; color: var(--muted); margin-top: 1px; }
.guest-main { flex: 1; min-width: 0; }
.guest-avatar {
width: 40px; height: 40px; border-radius: 12px; flex-shrink: 0; overflow: hidden;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.85rem; color: var(--accent); background: var(--accent-soft);
}
.guest-avatar img { width: 100%; height: 100%; object-fit: cover; filter: contrast(1.05); }
.delete-guest-btn {
margin-left: auto; flex-shrink: 0; border: 1px solid var(--border);
background: var(--panel); color: var(--muted); border-radius: 8px;
padding: 5px 9px; font-size: 0.72rem; cursor: pointer; transition: all .12s;
}
.delete-guest-btn:hover { color: var(--red); border-color: var(--red-soft); background: var(--red-soft); }
/* ---- Outbox ---- */
.outbox-list { gap: 9px; }
.outbox-item { padding: 12px 13px; border-radius: var(--radius-md); background: var(--panel-2); border: 1px solid var(--border); }
.outbox-meta { display: flex; gap: 10px; align-items: baseline; margin-bottom: 3px; }
.outbox-ts { font-size: 0.72rem; color: var(--accent); font-weight: 600; white-space: nowrap; }
.outbox-to { font-size: 0.74rem; color: var(--muted); }
.outbox-subject { font-size: 0.84rem; font-weight: 600; }
.outbox-body { font-size: 0.76rem; color: var(--muted); margin-top: 2px; }
@keyframes new-item-flash {
0% { background: var(--green-soft); box-shadow: inset 0 0 0 1px var(--green); }
80% { background: var(--green-soft); }
100% { background: var(--panel-2); box-shadow: none; }
}
.outbox-item.fresh, .visitor-log-table tr.fresh td { animation: new-item-flash 2.4s ease-out; }
/* ---- Visitor log table ---- */
.visitor-log-panel, .employees-panel { grid-column: 1 / -1; }
.visitor-log-wrap { max-height: 300px; overflow-y: auto; }
.visitor-log-table, .employees-table { width: 100%; border-collapse: collapse; font-size: 0.84rem; }
.visitor-log-table thead th, .employees-table thead th {
position: sticky; top: 0; background: var(--panel); text-align: left; padding: 11px 14px;
font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--faint);
font-weight: 700; border-bottom: 1px solid var(--border);
}
.visitor-log-table tbody td, .employees-table tbody td { padding: 12px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.visitor-log-table tbody tr:hover, .employees-table tbody tr:hover { background: var(--panel-2); }
.visitor-log-pill { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 700; }
.visitor-log-pill.notified, .visitor-log-pill.complete { background: var(--green-soft); color: var(--green); }
.visitor-log-pill.no_appointment, .visitor-log-pill.email_failed,
.visitor-log-pill.unknown_employee, .visitor-log-pill.error { background: var(--orange-soft); color: var(--orange); }
.alias-chip { display: inline-block; font-size: 0.7rem; padding: 2px 9px; background: var(--accent-soft); color: var(--accent-strong); border-radius: 999px; margin: 0 4px 2px 0; }
.row-actions { display: flex; gap: 6px; justify-content: flex-end; }
/* ---- Modal ---- */
.modal-backdrop { position: fixed; inset: 0; background: rgba(15, 15, 25, 0.5); backdrop-filter: blur(3px); display: none; align-items: center; justify-content: center; z-index: 60; }
.modal-backdrop.show { display: flex; }
.modal { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-lg); width: 92%; max-width: 480px; padding: 22px 24px; box-shadow: var(--shadow-lg); }
.modal h2 { font-size: 1.05rem; margin-bottom: 14px; font-weight: 700; }
.modal label { display: block; font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--faint); font-weight: 700; margin: 13px 0 5px; }
.modal input[type="text"], .modal input[type="email"], .modal textarea {
width: 100%; padding: 9px 11px; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; color: var(--text); font: inherit; font-size: 0.9rem;
}
.modal input:focus, .modal textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.modal .hint { font-size: 0.72rem; color: var(--muted); margin-top: 5px; }
.modal .actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.modal .err { background: var(--red-soft); border: 1px solid var(--red-soft); color: var(--red); padding: 9px 11px; border-radius: 10px; font-size: 0.78rem; margin-top: 11px; display: none; }
.modal .err.show { display: block; }
/* ---- Diagnostics ---- */
.diagnostics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
.diag-tile { background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px 14px; }
.diag-tile-label { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--faint); font-weight: 700; margin-bottom: 6px; }
.diag-tile-status { display: flex; align-items: center; gap: 8px; font-size: 0.9rem; font-weight: 700; }
.diag-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--faint); flex-shrink: 0; }
.diag-dot.ok { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); }
.diag-dot.fail { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
.diag-dot.unknown { background: var(--orange); box-shadow: 0 0 0 3px var(--orange-soft); }
.diag-tile-meta { font-size: 0.72rem; color: var(--muted); margin-top: 5px; }
/* ---- Settings ---- */
.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.settings-field { background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px 14px; }
.settings-key { font-size: 0.72rem; font-weight: 700; color: var(--accent); letter-spacing: 0.03em; }
.settings-meta { display: flex; gap: 6px; align-items: center; margin-top: 3px; }
.settings-pill { font-size: 0.6rem; padding: 2px 7px; border-radius: 5px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; }
.settings-pill.secret { background: var(--orange-soft); color: var(--orange); }
.settings-pill.restart { background: var(--red-soft); color: var(--red); }
.settings-pill.set { background: var(--green-soft); color: var(--green); }
.settings-pill.unset { background: var(--panel); color: var(--muted); }
.settings-input, input, select, textarea {
width: 100%; padding: 8px 10px; margin-top: 7px; background: var(--bg);
border: 1px solid var(--border); border-radius: 10px; color: var(--text);
font-size: 0.82rem; font-family: ui-monospace, "Cascadia Code", monospace;
}
.settings-input:focus, input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
/* header controls opt out of the generic input treatment above */
#volume-slider { width: 78px; margin-top: 0; padding: 0; background: transparent; border: none; box-shadow: none; }
.settings-bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
.settings-note { font-size: 0.74rem; color: var(--muted); }
code { background: var(--panel-2); color: var(--accent-strong); padding: 1px 6px; border-radius: 5px; font-size: 0.82em; }
details.settings-advanced { grid-column: 1 / -1; margin-top: 6px; border: 1px dashed var(--border); border-radius: var(--radius-md); padding: 10px 14px; }
details.settings-advanced > summary { cursor: pointer; font-size: 0.72rem; color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; font-weight: 700; list-style: none; user-select: none; }
details.settings-advanced > summary::before { content: 'β–Έ '; display: inline-block; transition: transform .15s; }
details.settings-advanced[open] > summary::before { content: 'β–Ύ '; }
details.settings-advanced > .settings-grid { margin-top: 12px; }
.settings-field.flash-target { animation: flashTarget 1.5s ease-out; }
@keyframes flashTarget {
0% { box-shadow: 0 0 0 3px transparent; }
30% { box-shadow: 0 0 0 3px var(--accent); }
100% { box-shadow: 0 0 0 3px transparent; }
}
.settings-info-btn {
display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px;
margin-left: 6px; border-radius: 50%; cursor: help; background: var(--accent-soft);
color: var(--accent); font-size: 0.7rem; font-weight: 700; border: 1px solid transparent;
user-select: none; position: relative;
}
.settings-info-btn:hover { background: var(--accent); color: #fff; }
.settings-info-tooltip {
position: absolute; left: 50%; bottom: calc(100% + 8px); transform: translateX(-50%);
width: 280px; padding: 11px 13px; background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow-lg); font-size: 0.78rem; color: var(--text);
line-height: 1.5; text-align: left; text-transform: none; letter-spacing: 0; font-weight: 400;
opacity: 0; pointer-events: none; transition: opacity .15s; z-index: 50;
}
.settings-info-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top-color: var(--border); }
.settings-info-btn:hover .settings-info-tooltip, .settings-info-btn:focus .settings-info-tooltip { opacity: 1; pointer-events: auto; }
.settings-info-tooltip strong { color: var(--accent); }
.settings-info-tooltip code { background: var(--panel-2); }
/* ---- Admin fold ---- */
.admin-fold { grid-column: 1 / -1; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow-md); }
.admin-fold > summary { cursor: pointer; list-style: none; padding: 14px 18px; display: flex; align-items: center; gap: 10px; font-size: 0.8rem; font-weight: 700; color: var(--text); transition: background .15s; }
.admin-fold > summary::-webkit-details-marker { display: none; }
.admin-fold > summary::before { content: "β–Έ"; font-size: 0.8rem; color: var(--accent); transition: transform .15s; }
.admin-fold[open] > summary::before { transform: rotate(90deg); }
.admin-fold > summary:hover { background: var(--panel-2); }
.admin-fold .panel { border: none; border-radius: 0; box-shadow: none; }
.admin-fold .panel + .panel { border-top: 1px solid var(--border); }
/* ---- Welcome banner ---- */
.welcome-banner {
grid-column: 1 / -1;
background: linear-gradient(135deg, var(--accent-soft), color-mix(in srgb, var(--accent-soft) 40%, transparent));
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius: var(--radius-lg); padding: 18px 22px; margin-bottom: 4px; box-shadow: var(--shadow-md);
}
.welcome-banner-header { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1rem; color: var(--text); margin-bottom: 12px; }
.welcome-banner-icon { font-size: 1.3rem; }
.welcome-banner-body { display: flex; flex-direction: column; gap: 8px; }
.welcome-banner-item { display: flex; gap: 12px; align-items: flex-start; padding: 11px 13px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-md); cursor: pointer; transition: transform .12s, box-shadow .12s; }
.welcome-banner-item:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: var(--accent); }
.welcome-banner-item-icon { font-size: 1.15rem; line-height: 1.3; }
.welcome-banner-item-text { flex: 1; }
.welcome-banner-item-text > div:first-child { font-weight: 700; color: var(--text); font-size: 0.92rem; }
.welcome-banner-item-help { margin-top: 3px; color: var(--muted); font-size: 0.78rem; line-height: 1.45; }
.welcome-banner-item-help a { color: var(--accent); text-decoration: underline; }
.welcome-banner-footer { margin-top: 12px; color: var(--muted); font-size: 0.75rem; }
/* ---- Toast ---- */
.toast {
position: fixed; bottom: 22px; right: 22px; z-index: 100; padding: 13px 17px; border-radius: 12px;
font-size: 0.85rem; font-weight: 600; background: var(--panel); color: var(--text);
border: 1px solid var(--border); box-shadow: var(--shadow-lg);
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.ok { border-color: var(--green); }
.toast.err { border-color: var(--red); }
.tiny-credit { text-align: center; font-size: 0.7rem; color: var(--faint); padding: 10px 12px 16px; }
.tiny-credit a { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
header .badge-secondary { font-size: 0.7rem; color: var(--accent); background: var(--accent-soft); border: 1px solid transparent; padding: 2px 9px; border-radius: 6px; }
/* ---- Responsive ---- */
@media (max-width: 1100px) {
.stats-strip { grid-template-columns: repeat(2, 1fr); }
.session-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 800px) {
.grid { grid-template-columns: 1fr; }
.video-panel, .visitor-log-panel, .session-panel, .stats-strip, .employees-panel { grid-column: 1; }
.stats-strip, .session-grid { grid-template-columns: 1fr; }
.video-layout { flex-direction: column; }
.best-face-panel { width: 100%; }
}
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
</style>
</head>
<body>
<header>
<img src="/static/logo.png" alt="MethdAI"
class="brand-logo"
onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';" />
<span class="brand-logo-fallback" style="font-size:1.4rem; display:none;">πŸ€–</span>
<h1>MethdAI Receptionist</h1>
<!-- Advanced / Developer: opens the persona + prompt + tools studio.
Kept off the default flow (operators land on this dashboard, not the
studio); this is the deliberate escape hatch for editing the robot's
personality when you actually need to. -->
<a class="btn" id="btn-advanced" href="/static/personality.html"
style="margin-left:auto; text-decoration:none;"
title="Developer: edit the robot's persona, prompt & enabled tools">
πŸ§‘β€πŸ’» Advanced
</a>
<button class="btn icon-only" id="btn-theme-toggle"
style="margin-left:8px"
title="Toggle light / dark theme">
<span id="theme-toggle-icon">πŸŒ™</span>
</button>
<!-- "Reset demo" button hidden 2026-05-21 β€” dev scaffolding doesn't
belong in a public release. Uncomment for v2 if a "danger zone"
affordance is wanted; pair with the addEventListener line at the
bottom of the file (also commented). -->
<!--
<button class="btn" id="btn-demo-reset"
title="Wipe guests, visitor log, outbox, and active session. Keeps employees, calendar, settings.">
🧹 Reset demo
</button>
-->
<!-- Volume slider β€” wraps the daemon-level pactl so operators can
tune speaker volume without leaving the dashboard. -->
<div id="volume-control" style="margin-left:12px; display:flex; align-items:center; gap:8px;
padding:3px 10px; border:1px solid var(--border); border-radius:6px;
font-size:0.75rem; color:var(--muted);"
title="Speaker volume (pactl). Click πŸ”‡ to mute.">
<button id="volume-mute" style="background:none; border:none; cursor:pointer;
color:var(--text); font-size:0.95rem; padding:0; line-height:1;">πŸ”Š</button>
<input type="range" id="volume-slider" min="0" max="100" value="80"
style="width:72px; accent-color:var(--accent);" />
<span id="volume-label" style="min-width:32px; text-align:right;">β€”</span>
</div>
<div class="face-status">
<div class="dot" id="face-dot"></div>
<span id="face-label">Scanning...</span>
<span id="face-confidence" style="color: var(--accent)"></span>
</div>
</header>
<div class="grid">
<!-- First-run welcome banner β€” populated by updateWelcomeBanner() in JS.
Visible only while one or more essential settings is unset; goes
away on its own once everything is configured. -->
<div id="welcome-banner" class="welcome-banner" style="display:none;">
<div class="welcome-banner-header">
<span class="welcome-banner-icon">πŸ‘‹</span>
<span class="welcome-banner-title">Welcome β€” finish setup to start using the receptionist</span>
</div>
<div class="welcome-banner-body" id="welcome-banner-body"></div>
<div class="welcome-banner-footer">
Each item links to the right field below. After saving, click
<strong>Restart</strong> in your terminal or relaunch the app.
</div>
</div>
<div class="section-heading"><span>Live</span></div>
<!-- Stats strip β€” at a glance -->
<div class="stats-strip">
<div class="stat-card">
<span class="stat-label">Visits Today</span>
<span class="stat-value accent" id="stat-visits-today">β€”</span>
<span class="stat-sub" id="stat-visits-sub">Since midnight</span>
</div>
<div class="stat-card">
<span class="stat-label">Emails Delivered</span>
<span class="stat-value green" id="stat-emails-today">β€”</span>
<span class="stat-sub">Hosts notified today</span>
</div>
<div class="stat-card">
<span class="stat-label">Last Visit</span>
<span class="stat-value dim" id="stat-last-visit">β€”</span>
<span class="stat-sub" id="stat-last-visit-time">β€”</span>
</div>
<!-- Known Guests KPI. Replaced the old "Current State" card here β€” that
value was a duplicate of the State pill in the Active Session panel
below. The authoritative live state (with the journey trail + LLM
cue) lives there; this slot now surfaces a non-duplicative metric. -->
<div class="stat-card">
<span class="stat-label">Known Guests</span>
<span class="stat-value" id="stat-known-guests">β€”</span>
<span class="stat-sub">Registered faces</span>
</div>
</div>
<!-- Video Feed β€” spans all 3 columns -->
<div class="panel video-panel">
<div class="panel-header">
<span><span class="icon">πŸŽ₯</span>Live Camera Feed</span>
<span id="stream-status" style="color:var(--green)">● LIVE</span>
</div>
<div class="panel-body" style="padding:12px">
<div class="video-layout">
<!-- MJPEG live stream -->
<div class="video-wrap">
<img id="video-feed" src="/video_feed" alt="Camera feed" onerror="this.style.display='none'" />
<div class="video-overlay" id="face-overlay">Scanning...</div>
</div>
<!-- Best face from 5-second window -->
<div class="best-face-panel">
<div class="best-face-title">Best Face (5s)</div>
<div class="best-face-img-wrap" id="best-face-img-wrap">
<span class="best-face-placeholder">πŸ‘€</span>
</div>
<div class="best-face-name unknown" id="best-face-name">β€”</div>
<div class="best-face-meta" id="best-face-meta">No detection</div>
<details class="best-face-events-panel">
<summary class="event-title">🧠 Last Face Event β†’ Model</summary>
<ul class="event-compact-list">
<li class="event-compact-item">
<div class="event-compact-key">Status</div>
<div class="event-compact-val" id="event-status">No face event sent yet</div>
</li>
<li class="event-compact-item">
<div class="event-compact-key">Current</div>
<div class="event-compact-val" id="event-current">β€”</div>
</li>
<li class="event-compact-item">
<div class="event-compact-key">Previous</div>
<div class="event-compact-val" id="event-previous">β€”</div>
</li>
</ul>
</details>
</div>
</div>
</div>
</div>
<!-- Active Session β€” state machine surface -->
<div class="panel session-panel">
<div class="panel-header">
<span><span class="icon">⚑</span>Active Session</span>
<button class="btn danger" id="btn-reset-session" title="Force the current visitor session back to IDLE">Reset Session</button>
</div>
<div class="panel-body">
<div class="session-grid">
<div class="session-cell">
<div class="session-cell-label">State</div>
<div class="session-cell-value">
<span class="session-state-pill idle" id="session-state">idle</span>
</div>
</div>
<div class="session-cell">
<div class="session-cell-label">Visitor</div>
<div class="session-cell-value dim" id="session-visitor">β€”</div>
</div>
<div class="session-cell">
<div class="session-cell-label">Host</div>
<div class="session-cell-value dim" id="session-employee">β€”</div>
</div>
<div class="session-cell">
<div class="session-cell-label">Appointment</div>
<div class="session-cell-value dim" id="session-appointment">β€”</div>
</div>
<div class="session-trail" id="session-trail-wrap">
<span class="session-trail-label">Journey</span>
<span id="session-trail" style="display:contents"></span>
</div>
<div class="session-hint" id="session-hint-wrap">
<div class="session-hint-label">Latest cue sent to LLM</div>
<div id="session-hint-text">Waiting for a state change…</div>
</div>
</div>
</div>
</div>
<div class="section-heading"><span>Today</span></div>
<!-- Today's Schedule -->
<div class="panel">
<div class="panel-header">
<span><span class="icon">πŸ“…</span>Today's Schedule</span>
</div>
<div class="panel-body">
<ul class="appt-list" id="calendar-list">
<li class="empty">Loading...</li>
</ul>
</div>
</div>
<!-- Known Guests -->
<div class="panel">
<div class="panel-header">
<span><span class="icon">πŸ‘₯</span>Known Guests</span>
<span style="display:flex; gap:8px; align-items:center">
<span id="guest-count" style="color:var(--muted)"></span>
<button class="btn accent" id="btn-manual-register"
title="Register the visitor currently in front of the camera by typing their name (use when the bot can't transcribe it)">
+ Register Manually
</button>
</span>
</div>
<div class="panel-body">
<ul class="guest-list" id="guest-list">
<li class="empty">No visitors registered yet β€” new faces appear here after they check in.</li>
</ul>
</div>
</div>
<!-- Mailbox Out -->
<div class="panel">
<div class="panel-header">
<span><span class="icon">πŸ“§</span>Mailbox Out</span>
<span style="display:flex; gap:10px; align-items:center">
<span id="outbox-count" style="color:var(--muted)"></span>
<button class="btn danger" id="btn-outbox-clear"
title="Clear the sent-mail log (does not recall emails already delivered)">πŸ—‘ Clear</button>
</span>
</div>
<div class="panel-body">
<ul class="outbox-list" id="outbox-list">
<li class="empty">No host notifications yet β€” emails to hosts will appear here.</li>
</ul>
</div>
</div>
<!-- History β€” Visitor Log moved here from the bottom on 2026-05-21 so
operators see today's activity right under the appointment list,
not after scrolling past Settings + Diagnostics. -->
<div class="section-heading"><span>History</span></div>
<!-- Visitor Log -->
<div class="panel visitor-log-panel">
<div class="panel-header">
<span><span class="icon">πŸ“‹</span>Visitor Log</span>
<span style="display:flex; gap:12px; align-items:center">
<span id="visitor-log-count" style="color:var(--muted)"></span>
<a class="btn accent" href="/api/visitor_log.csv" download>⬇ Export CSV</a>
<button class="btn danger" id="btn-visitor-log-clear"
title="Permanently delete every row from the visitor log. Does not affect employees, settings, or known guests.">
πŸ—‘ Clear log
</button>
</span>
</div>
<div class="panel-body" style="padding:0">
<div class="visitor-log-wrap">
<table class="visitor-log-table">
<thead>
<tr>
<th>Arrived</th>
<th>Visitor</th>
<th>Host</th>
<th>Appointment</th>
<th>Email</th>
<th>Outcome</th>
</tr>
</thead>
<tbody id="visitor-log-tbody">
<tr><td colspan="6" class="empty">No visits logged yet</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Employees β€” directory CRUD (operators need this on the surface) -->
<div class="panel employees-panel">
<div class="panel-header">
<span><span class="icon">πŸ§‘β€πŸ’Ό</span>Employees</span>
<span style="display:flex; gap:12px; align-items:center">
<span id="employees-count" style="color:var(--muted)"></span>
<button class="btn accent" id="btn-employee-add">+ Add</button>
</span>
</div>
<div class="panel-body" style="padding:0">
<div style="max-height:280px; overflow-y:auto">
<table class="employees-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Title</th>
<th>Aliases</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody id="employees-tbody">
<tr><td colspan="5" class="empty">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Settings β€” env-var editor + nested Diagnostics. Moved to the
bottom of the page 2026-05-21 because once a system is configured
these are rarely touched. Diagnostics is now a <details> expander
at the end of this panel, alongside the existing Advanced settings
expander, so all "system internals" live in one place. -->
<div class="panel employees-panel">
<div class="panel-header">
<span><span class="icon">βš™οΈ</span>Settings</span>
<span id="settings-env-path" style="color:var(--muted); font-size:0.72rem;"></span>
</div>
<div class="panel-body">
<div class="settings-grid" id="settings-grid">
<div class="settings-field"><div class="settings-key">Loading…</div></div>
</div>
<div class="settings-bar">
<span class="settings-note">
Secrets are masked. Edits write to <code>.env</code>.
<span class="settings-pill restart">RESTART</span> = restart required.
</span>
<button class="btn accent" id="btn-settings-save">Save changes</button>
</div>
<!-- Diagnostics β€” nested inside Settings, collapsed by default.
Same visual treatment as the Advanced settings expander. -->
<details class="settings-advanced" style="margin-top:14px;">
<summary>Diagnostics (health checks Β· speaker test)</summary>
<div style="margin-top:12px; display:flex; gap:8px; justify-content:flex-end;">
<button class="btn" id="btn-speaker-test">πŸ”Š Speaker Test</button>
<button class="btn accent" id="btn-diag-refresh">↻ Run all checks</button>
</div>
<div class="diagnostics-grid" id="diagnostics-grid" style="margin-top:12px;">
<div class="diag-tile">
<div class="diag-tile-label">Loading…</div>
</div>
</div>
</details>
</div>
</div>
</div>
<!-- Employee Add/Edit modal -->
<div class="modal-backdrop" id="employee-modal-bg">
<div class="modal">
<h2 id="employee-modal-title">Add Employee</h2>
<input type="hidden" id="employee-id">
<label for="employee-name">Name</label>
<input type="text" id="employee-name" placeholder="e.g. Priya Sharma" autocomplete="off">
<label for="employee-email">Email</label>
<input type="email" id="employee-email" placeholder="priya@company.com" autocomplete="off">
<label for="employee-title">Title (optional)</label>
<input type="text" id="employee-title" placeholder="Head of Product" autocomplete="off">
<label for="employee-aliases">Aliases (comma-separated, optional)</label>
<input type="text" id="employee-aliases" placeholder="Priya, Priya S" autocomplete="off">
<div class="hint">
Aliases are short forms the bot might hear. "Priya" or "AJ" let visitors say a nickname
and still match the right person.
</div>
<div class="err" id="employee-modal-err"></div>
<div class="actions">
<button class="btn" id="employee-modal-cancel">Cancel</button>
<button class="btn accent" id="employee-modal-save">Save</button>
</div>
</div>
</div>
<!-- Toast notifications -->
<div class="toast" id="toast"></div>
<footer class="tiny-credit">
Powered by <strong>MethdAI</strong> Β· Built on <a href="https://huggingface.co/docs/reachy_mini" target="_blank" rel="noreferrer">Reachy Mini</a>
</footer>
<script>
const BASE = '';
function escHtml(v) {
return String(v || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function deleteGuest(name) {
if (!name) return;
if (!window.confirm(`Delete guest "${name}"? This cannot be undone.`)) return;
try {
const r = await fetch(`${BASE}/api/guests?name=${encodeURIComponent(name)}`, {
method: 'DELETE',
});
if (!r.ok) {
let msg = '';
try {
const d = await r.json();
msg = d.error || '';
} catch (e) {
msg = '';
}
throw new Error(msg || `Delete failed (${r.status})`);
}
await refreshGuests();
} catch (e) {
window.alert(`Could not delete guest: ${e.message || e}`);
}
}
// ---- Face status ----
async function refreshFaceStatus() {
try {
const r = await fetch(`${BASE}/api/face_status`);
const d = await r.json();
const dot = document.getElementById('face-dot');
const label = document.getElementById('face-label');
const conf = document.getElementById('face-confidence');
const overlay = document.getElementById('face-overlay');
if (d.is_known) {
dot.className = 'dot known';
label.textContent = d.name;
// `confidence` is an LBPH *distance* (lower = better match), not a
// 0–1 probability β€” multiplying by 100 produced nonsense like
// "5037%" in the header. Show the raw LBPH value, consistent with
// the Best-Face panel and the session cue below.
conf.textContent = `LBPH ${(Number(d.confidence) || 0).toFixed(1)}`;
overlay.textContent = `βœ“ Known: ${d.name}`;
overlay.className = 'video-overlay known';
} else {
dot.className = 'dot unknown';
label.textContent = d.name || 'Unknown';
conf.textContent = '';
overlay.textContent = d.name === 'No face' ? 'No face detected' : 'Unknown visitor';
overlay.className = 'video-overlay unknown';
}
} catch(e) {}
}
// ---- Best face thumbnail (5-second window) ----
let _bestFaceTs = 0;
async function refreshBestFace() {
try {
const r = await fetch(`${BASE}/api/best_face_jpeg?t=${Date.now()}`);
const wrap = document.getElementById('best-face-img-wrap');
const nameEl = document.getElementById('best-face-name');
const metaEl = document.getElementById('best-face-meta');
// 204 No Content = no face in the 5-second window; 4xx/5xx = real failure.
if (!r.ok || r.status === 204) {
wrap.innerHTML = '<span class="best-face-placeholder">πŸ‘€</span>';
nameEl.textContent = 'β€”';
nameEl.className = 'best-face-name unknown';
metaEl.textContent = 'No detection';
return;
}
const name = r.headers.get('X-Face-Name') || 'Unknown';
const lbph = parseFloat(r.headers.get('X-Face-Confidence') || '0');
const area = parseInt(r.headers.get('X-Face-Area') || '0', 10);
const isKnown = name !== 'Unknown' && name !== 'No face';
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const prev = wrap.querySelector('img');
if (prev && prev._blobUrl) URL.revokeObjectURL(prev._blobUrl);
wrap.innerHTML = `<img alt="${name}" />`;
const img = wrap.querySelector('img');
img._blobUrl = url;
img.src = url;
nameEl.textContent = name;
nameEl.className = `best-face-name ${isKnown ? 'known' : 'unknown'}`;
metaEl.textContent = isKnown
? `LBPH: ${lbph.toFixed(1)} Area: ${area}pxΒ²`
: `Area: ${area}pxΒ²`;
} catch(e) {}
}
// ---- Calendar ----
async function refreshCalendar() {
try {
const r = await fetch(`${BASE}/api/calendar`);
const appts = await r.json();
const ul = document.getElementById('calendar-list');
if (!appts.length) { ul.innerHTML = "<li class=\"empty\">No appointments on today's calendar.</li>"; return; }
ul.innerHTML = appts.map(a => `
<li class="appt-item">
<span class="appt-time">${a.time}</span>
<div>
<div class="appt-name">${a.name}</div>
<div class="appt-note">${a.note || ''}</div>
${a.visiting ? `<div class="appt-note">β†’ ${a.visiting}</div>` : ''}
</div>
</li>`).join('');
} catch(e) {}
}
// ---- Guests ----
async function refreshGuests() {
try {
const r = await fetch(`${BASE}/api/guests`);
const guests = await r.json();
const ul = document.getElementById('guest-list');
document.getElementById('guest-count').textContent = guests.length ? `${guests.length}` : '';
const kg = document.getElementById('stat-known-guests');
if (kg) kg.textContent = `${guests.length}`;
if (!guests.length) { ul.innerHTML = '<li class="empty">No visitors registered yet β€” new faces appear here after they check in.</li>'; return; }
ul.innerHTML = guests.map(g => `
<li class="guest-item">
<div class="guest-avatar">
${g.thumbnail_url
? `<img src="${g.thumbnail_url}?t=${Date.now()}" alt="${escHtml(g.name)}" />`
: escHtml((g.name || '?')[0].toUpperCase())
}
</div>
<div class="guest-main">
<div class="guest-name">${escHtml(g.name)}</div>
<div class="guest-ts">${escHtml(g.timestamp)}</div>
</div>
<button class="delete-guest-btn" type="button" data-guest-name="${encodeURIComponent(g.name)}">Delete</button>
</li>`).join('');
} catch(e) {}
}
document.getElementById('guest-list').addEventListener('click', (ev) => {
const btn = ev.target.closest('.delete-guest-btn');
if (!btn) return;
const encoded = btn.getAttribute('data-guest-name') || '';
const name = decodeURIComponent(encoded);
deleteGuest(name);
});
// ---- Outbox ----
let _lastOutboxCount = 0;
async function refreshOutbox() {
try {
const r = await fetch(`${BASE}/api/outbox`);
const items = await r.json();
const ul = document.getElementById('outbox-list');
document.getElementById('outbox-count').textContent = items.length ? `${items.length}` : '';
if (!items.length) {
ul.innerHTML = '<li class="empty">No host notifications yet β€” emails to hosts will appear here.</li>';
_lastOutboxCount = 0;
return;
}
const isNew = items.length > _lastOutboxCount;
_lastOutboxCount = items.length;
ul.innerHTML = [...items].reverse().map((n, idx) => `
<li class="outbox-item${isNew && idx === 0 ? ' fresh' : ''}">
<div class="outbox-meta">
<span class="outbox-ts">${n.timestamp}</span>
<span class="outbox-to">To: ${n.to}</span>
</div>
<div class="outbox-subject">${n.subject}</div>
<div class="outbox-body">${n.body}</div>
</li>`).join('');
} catch(e) {}
}
// ---- Last face event sent to model ----
async function refreshFaceEventSent() {
try {
const r = await fetch(`${BASE}/api/face_event_last_sent`);
const d = await r.json();
const statusEl = document.getElementById('event-status');
const currentEl = document.getElementById('event-current');
const previousEl = document.getElementById('event-previous');
if (!d.sent) {
statusEl.textContent = 'No face event sent yet';
statusEl.className = 'event-compact-val';
currentEl.textContent = 'β€”';
currentEl.className = 'event-compact-val';
previousEl.textContent = 'β€”';
previousEl.className = 'event-compact-val';
return;
}
const state = d.state || 'unknown';
const stateClass = state === 'known' ? 'known' : 'unknown';
const stateLabel = state === 'known' ? 'Known' : (state === 'no_face' ? 'No Face' : 'Unknown');
const currentName = d.name || 'β€”';
const prevLabel = d.previous_state === 'known'
? `Known (${d.previous_name || 'Unknown'})`
: (d.previous_state === 'no_face' ? 'No Face' : 'Unknown');
statusEl.textContent = `Sent (${stateLabel})`;
statusEl.className = `event-compact-val ${stateClass}`;
currentEl.textContent = state === 'known'
? `${stateLabel} (${currentName}) Β· det=${(Number(d.detection_confidence) || 0).toFixed(2)} Β· lbph=${(Number(d.lbph_confidence) || 0).toFixed(1)}`
: `${stateLabel} Β· det=${(Number(d.detection_confidence) || 0).toFixed(2)} Β· lbph=${(Number(d.lbph_confidence) || 0).toFixed(1)}`;
currentEl.className = `event-compact-val ${stateClass}`;
previousEl.textContent = prevLabel;
previousEl.className = 'event-compact-val';
} catch(e) {}
}
// ---- Stats strip ----
async function refreshStats() {
try {
const r = await fetch(`${BASE}/api/stats`);
const d = await r.json();
const v = document.getElementById('stat-visits-today');
v.textContent = (d.visits_today ?? 0).toString();
const e = document.getElementById('stat-emails-today');
e.textContent = (d.emails_delivered_today ?? 0).toString();
const lvEl = document.getElementById('stat-last-visit');
const lvTimeEl = document.getElementById('stat-last-visit-time');
if (d.last_visit) {
lvEl.textContent = d.last_visit.visitor_name || d.last_visit.recognized_face_name || 'β€”';
lvTimeEl.textContent = fmtVisitTime(d.last_visit.started_at);
} else {
lvEl.textContent = 'β€”';
lvTimeEl.textContent = 'No visits yet';
}
// Current state / visitor intentionally NOT rendered here anymore β€”
// they duplicated the Active Session panel. See the Known Guests card.
} catch(e) {}
}
// ---- Active session panel ----
let _lastSessionState = null;
// Bounded trail β€” track up to 8 distinct states visited in this session.
// Cleared whenever we transition back to IDLE.
let _sessionTrail = [];
function renderSessionTrail(current) {
const wrap = document.getElementById('session-trail');
if (!_sessionTrail.length) {
wrap.innerHTML = '<span class="trail-step">idle</span>';
return;
}
wrap.innerHTML = _sessionTrail.map((s, i) => {
const isLast = i === _sessionTrail.length - 1;
const cls = (isLast && s === current) ? 'trail-step current' : 'trail-step';
const arrow = isLast ? '' : '<span class="trail-arrow"> β†’ </span>';
return `<span class="${cls}">${s.replaceAll('_', ' ')}</span>${arrow}`;
}).join('');
}
async function refreshSession() {
try {
const [sessR, evR] = await Promise.all([
fetch(`${BASE}/api/session`),
fetch(`${BASE}/api/session_event_last_sent`),
]);
const sess = await sessR.json();
const ev = await evR.json();
const stateEl = document.getElementById('session-state');
const visEl = document.getElementById('session-visitor');
const empEl = document.getElementById('session-employee');
const apptEl = document.getElementById('session-appointment');
if (!sess.available) {
stateEl.textContent = 'unavailable';
stateEl.className = 'session-state-pill idle';
visEl.textContent = empEl.textContent = apptEl.textContent = 'β€”';
return;
}
const state = sess.current_state || 'idle';
stateEl.textContent = state.replaceAll('_', ' ');
stateEl.className = `session-state-pill ${state}`;
// Flash pulse on transition
if (_lastSessionState !== null && _lastSessionState !== state) {
stateEl.classList.remove('pulse');
// Force reflow so the animation restarts when re-added
void stateEl.offsetWidth;
stateEl.classList.add('pulse');
// Trail bookkeeping
if (state === 'idle') {
_sessionTrail = [];
} else if (_sessionTrail[_sessionTrail.length - 1] !== state) {
_sessionTrail.push(state);
if (_sessionTrail.length > 8) _sessionTrail.shift();
}
} else if (_lastSessionState === null) {
// First load β€” prime the trail with the current state if it's non-idle
if (state !== 'idle') _sessionTrail = [state];
}
_lastSessionState = state;
renderSessionTrail(state);
const visitor = sess.visitor_name || sess.recognized_face_name;
visEl.textContent = visitor || 'β€”';
visEl.className = `session-cell-value ${visitor ? '' : 'dim'}`;
const host = sess.employee_name;
empEl.textContent = host || 'β€”';
empEl.className = `session-cell-value ${host ? '' : 'dim'}`;
const appt = sess.matched_appointment;
if (appt && appt.time) {
apptEl.textContent = `${appt.time}${appt.note ? ' Β· ' + appt.note : ''}`;
apptEl.className = 'session-cell-value';
} else {
apptEl.textContent = 'β€”';
apptEl.className = 'session-cell-value dim';
}
const hintText = document.getElementById('session-hint-text');
if (ev.sent && ev.hint) {
hintText.textContent = ev.hint;
} else if (ev.sent) {
hintText.textContent = '(no hint for this state β€” bot waits for the visitor to speak)';
} else {
hintText.textContent = 'Waiting for a state change…';
}
} catch(e) {}
}
async function resetSession() {
if (!window.confirm('Force-reset the current visitor session? This drops any in-progress conversation back to IDLE.')) return;
try {
const r = await fetch(`${BASE}/api/session/reset`, { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
await Promise.all([refreshSession(), refreshStats()]);
} catch(e) {
window.alert(`Reset failed: ${e.message || e}`);
}
}
document.getElementById('btn-reset-session').addEventListener('click', resetSession);
// ---- Visitor log ----
function fmtVisitTime(iso) {
if (!iso) return 'β€”';
// Show HH:MM if it's today, otherwise MM/DD HH:MM
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
if (sameDay) return `${hh}:${mm}`;
const mo = String(d.getMonth() + 1).padStart(2, '0');
const dy = String(d.getDate()).padStart(2, '0');
return `${mo}/${dy} ${hh}:${mm}`;
}
let _lastVisitorLogTopId = null;
async function refreshVisitorLog() {
try {
const r = await fetch(`${BASE}/api/visitor_log?limit=50`);
const d = await r.json();
const tbody = document.getElementById('visitor-log-tbody');
const countEl = document.getElementById('visitor-log-count');
if (!d.available) {
countEl.textContent = '';
tbody.innerHTML = '<tr><td colspan="6" class="empty">Visitor log not available</td></tr>';
return;
}
countEl.textContent = `Today: ${d.today_count}`;
const visits = d.visits || [];
if (!visits.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty">No visits logged yet</td></tr>';
_lastVisitorLogTopId = null;
return;
}
const topId = visits[0] && visits[0].id;
const hasNew = (_lastVisitorLogTopId !== null) && (topId !== _lastVisitorLogTopId);
_lastVisitorLogTopId = topId;
tbody.innerHTML = visits.map((v, idx) => {
const visitor = escHtml(v.visitor_name || v.recognized_face_name || 'β€”');
const host = escHtml(v.employee_name || 'β€”');
const apptTime = v.matched_appointment_time ? escHtml(v.matched_appointment_time) : 'β€”';
const apptNote = v.matched_appointment_note ? `<div style="color:var(--muted);font-size:0.7rem">${escHtml(v.matched_appointment_note)}</div>` : '';
const emailCell = v.email_sent_to
? `<span style="color:var(--green)">${escHtml(v.email_sent_to)}</span>`
: '<span style="color:var(--muted)">β€”</span>';
const stateClass = escHtml(v.final_state || '');
const stateLabel = escHtml((v.final_state || '').replaceAll('_', ' '));
const freshAttr = (hasNew && idx === 0) ? ' class="fresh"' : '';
return `
<tr${freshAttr}>
<td>${fmtVisitTime(v.started_at)}</td>
<td>${visitor}</td>
<td>${host}</td>
<td>${apptTime}${apptNote}</td>
<td>${emailCell}</td>
<td><span class="visitor-log-pill ${stateClass}">${stateLabel}</span></td>
</tr>`;
}).join('');
} catch(e) {}
}
// ---- Visitor log wipe (scoped to this panel only) -------------------
async function wipeVisitorLog() {
if (!window.confirm(
"Permanently delete every row from the visitor log?\n\n"
+ "This does not affect employees, settings, known guests, or the current session."
)) return;
try {
const r = await fetch(`${BASE}/api/visitor_log`, { method: 'DELETE' });
const d = await r.json();
if (!r.ok || !d.ok) throw new Error(d.error || `HTTP ${r.status}`);
showToast(`Cleared ${d.removed} entries`, 'ok');
await refreshVisitorLog();
} catch (e) {
showToast(`Clear failed: ${e.message || e}`, 'err');
}
}
document.getElementById('btn-visitor-log-clear')?.addEventListener('click', wipeVisitorLog);
async function clearOutbox() {
if (!window.confirm(
"Clear the sent-mail log?\n\n"
+ "This only clears the dashboard panel β€” it does not recall emails already delivered to hosts."
)) return;
try {
const r = await fetch(`${BASE}/api/outbox`, { method: 'DELETE' });
const d = await r.json();
if (!r.ok || !d.ok) throw new Error(d.error || `HTTP ${r.status}`);
showToast(`Cleared ${d.removed} email(s)`, 'ok');
await refreshOutbox();
} catch (e) {
showToast(`Clear failed: ${e.message || e}`, 'err');
}
}
document.getElementById('btn-outbox-clear')?.addEventListener('click', clearOutbox);
// ---- Model name ----
// Header badge removed 2026-05-21 β€” the model is internal config, not
// operator-facing. The endpoint still loads (silently) in case future
// callers want the value, but no DOM write happens.
async function loadModelName() { /* no-op β€” model badge removed from header */ }
// ---- Toast helper ----
let _toastTimer = null;
function showToast(msg, kind /* 'ok' | 'err' */) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = `toast show ${kind || ''}`;
if (_toastTimer) clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => {
el.classList.remove('show');
}, 3000);
}
// ---- Employees ----
async function refreshEmployees() {
try {
const r = await fetch(`${BASE}/api/employees`);
const d = await r.json();
const employees = d.employees || [];
document.getElementById('employees-count').textContent =
employees.length ? `${employees.length} employee${employees.length === 1 ? '' : 's'}` : '';
const tbody = document.getElementById('employees-tbody');
if (!employees.length) {
tbody.innerHTML = '<tr><td colspan="5" class="empty">No employees yet β€” click + Add Employee to start</td></tr>';
return;
}
tbody.innerHTML = employees.map(e => {
const aliases = (e.aliases || []).map(a => `<span class="alias-chip">${escHtml(a)}</span>`).join('');
return `
<tr>
<td><strong>${escHtml(e.name)}</strong></td>
<td style="font-family:ui-monospace,monospace; font-size:0.78rem">${escHtml(e.email)}</td>
<td style="color:var(--muted)">${escHtml(e.title || 'β€”')}</td>
<td>${aliases || '<span style="color:var(--muted)">β€”</span>'}</td>
<td>
<div class="row-actions">
<button class="btn" data-employee-edit="${e.id}">Edit</button>
<button class="btn danger" data-employee-delete="${e.id}" data-employee-name="${encodeURIComponent(e.name)}">Delete</button>
</div>
</td>
</tr>`;
}).join('');
} catch(e) { /* silent */ }
}
function openEmployeeModal(employee /* null = add */) {
document.getElementById('employee-modal-title').textContent = employee ? 'Edit Employee' : 'Add Employee';
document.getElementById('employee-id').value = employee ? employee.id : '';
document.getElementById('employee-name').value = employee ? (employee.name || '') : '';
document.getElementById('employee-email').value = employee ? (employee.email || '') : '';
document.getElementById('employee-title').value = employee ? (employee.title || '') : '';
document.getElementById('employee-aliases').value = employee ? ((employee.aliases || []).join(', ')) : '';
document.getElementById('employee-modal-err').classList.remove('show');
document.getElementById('employee-modal-bg').classList.add('show');
setTimeout(() => document.getElementById('employee-name').focus(), 50);
}
function closeEmployeeModal() {
document.getElementById('employee-modal-bg').classList.remove('show');
}
async function saveEmployeeModal() {
const id = document.getElementById('employee-id').value;
const name = document.getElementById('employee-name').value.trim();
const email = document.getElementById('employee-email').value.trim();
const title = document.getElementById('employee-title').value.trim();
const aliases = document.getElementById('employee-aliases').value
.split(',').map(s => s.trim()).filter(Boolean);
const errEl = document.getElementById('employee-modal-err');
errEl.classList.remove('show');
if (!name) { errEl.textContent = 'Name is required.'; errEl.classList.add('show'); return; }
if (!email || !/^.+@.+\..+/.test(email)) { errEl.textContent = 'Valid email is required.'; errEl.classList.add('show'); return; }
const body = { name, email, title: title || null, aliases };
try {
const url = id ? `${BASE}/api/employees/${id}` : `${BASE}/api/employees`;
const method = id ? 'PATCH' : 'POST';
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok || !d.ok) {
errEl.textContent = d.error || `HTTP ${r.status}`;
errEl.classList.add('show');
return;
}
closeEmployeeModal();
showToast(id ? `Updated ${name}` : `Added ${name}`, 'ok');
await refreshEmployees();
} catch (e) {
errEl.textContent = String(e.message || e);
errEl.classList.add('show');
}
}
async function deleteEmployee(id, name) {
if (!window.confirm(`Delete employee "${name}"?`)) return;
try {
const r = await fetch(`${BASE}/api/employees/${id}`, { method: 'DELETE' });
const d = await r.json();
if (!r.ok || !d.ok) throw new Error(d.error || `HTTP ${r.status}`);
showToast(`Deleted ${name}`, 'ok');
await refreshEmployees();
} catch (e) {
showToast(`Delete failed: ${e.message || e}`, 'err');
}
}
document.getElementById('btn-employee-add').addEventListener('click', () => openEmployeeModal(null));
document.getElementById('employee-modal-cancel').addEventListener('click', closeEmployeeModal);
document.getElementById('employee-modal-bg').addEventListener('click', (ev) => {
if (ev.target.id === 'employee-modal-bg') closeEmployeeModal();
});
document.getElementById('employee-modal-save').addEventListener('click', saveEmployeeModal);
document.getElementById('employees-tbody').addEventListener('click', async (ev) => {
const editBtn = ev.target.closest('[data-employee-edit]');
if (editBtn) {
const id = editBtn.getAttribute('data-employee-edit');
try {
const r = await fetch(`${BASE}/api/employees`);
const d = await r.json();
const emp = (d.employees || []).find(e => String(e.id) === String(id));
if (emp) openEmployeeModal(emp);
} catch (e) { showToast(`Load failed: ${e.message || e}`, 'err'); }
return;
}
const delBtn = ev.target.closest('[data-employee-delete]');
if (delBtn) {
const id = delBtn.getAttribute('data-employee-delete');
const name = decodeURIComponent(delBtn.getAttribute('data-employee-name') || '');
deleteEmployee(id, name);
}
});
// ---- Diagnostics ----
function diagTile(label, status, meta) {
const cls = status === true ? 'ok' : status === false ? 'fail' : 'unknown';
const txt = status === true ? 'OK' : status === false ? 'FAIL' : 'β€”';
return `
<div class="diag-tile">
<div class="diag-tile-label">${escHtml(label)}</div>
<div class="diag-tile-status">
<span class="diag-dot ${cls}"></span><span>${txt}</span>
</div>
${meta ? `<div class="diag-tile-meta">${meta}</div>` : ''}
</div>`;
}
async function refreshDiagnostics() {
const grid = document.getElementById('diagnostics-grid');
try {
const r = await fetch(`${BASE}/api/diagnostics/health`);
const d = await r.json();
const c = d.checks || {};
const tiles = [];
const cfg = c.config || {};
// Voice β€” backend-aware (Gemini by default, OpenAI if switched)
const voice = c.voice || {};
const voiceLabel = cfg.voice_backend === 'openai' ? 'Voice Β· OpenAI' : 'Voice Β· Gemini';
tiles.push(diagTile(voiceLabel,
voice.ok === undefined ? null : voice.ok,
voice.latency_ms != null
? `Latency: ${voice.latency_ms} ms${cfg.voice_key_set ? '' : ' Β· key NOT set'}`
: (voice.error || '')));
// Email β€” backend-aware (Gmail / Resend / outbox-only)
const email = c.email || {};
const emailLabel = cfg.email_backend === 'gmail' ? 'Email Β· Gmail'
: cfg.email_backend === 'resend' ? 'Email Β· Resend'
: 'Email Β· outbox only';
const emailMeta = email.note
? email.note
: (email.latency_ms != null
? `Latency: ${email.latency_ms} ms${cfg.email_ready ? '' : ' Β· not configured'}`
: (email.error || ''));
tiles.push(diagTile(emailLabel, email.ok === undefined ? null : email.ok, emailMeta));
const daemon = c.daemon || {};
tiles.push(diagTile('Reachy Daemon',
daemon.ok === undefined ? null : daemon.ok,
daemon.latency_ms != null ? `State: ${daemon.state || '?'} Β· ${daemon.latency_ms} ms` : (daemon.error || '')));
const wifi = c.wifi || {};
const wifiMeta = wifi.link_quality_pct != null
? `${wifi.link_quality_pct}% Β· ${wifi.signal_dbm || '?'} dBm Β· ${wifi.essid || ''}`
: (wifi.error || 'β€”');
tiles.push(diagTile('Wi-Fi', wifi.ok, wifiMeta));
const audio = c.audio || {};
tiles.push(diagTile('Audio output',
audio.ok,
audio.sinks ? `${audio.sinks.length} sink(s)` : (audio.error || 'β€”')));
tiles.push(diagTile('Calendar (iCal)',
cfg.ical_url_set, cfg.ical_url_set ? 'URL set' : 'No URL β€” walk-in flow only (lookup_employee)'));
tiles.push(diagTile('Sender',
!!cfg.email_from, cfg.email_from || 'Not set'));
tiles.push(diagTile('Model',
!!cfg.model, cfg.model || ''));
grid.innerHTML = tiles.join('');
} catch (e) {
grid.innerHTML = `<div class="diag-tile"><div class="diag-tile-label">Diagnostics failed</div><div class="diag-tile-meta">${escHtml(String(e.message || e))}</div></div>`;
}
}
async function runSpeakerTest() {
try {
const r = await fetch(`${BASE}/api/diagnostics/speaker_test`, { method: 'POST' });
const d = await r.json();
showToast(d.ok ? 'Beep sent (listen)' : `Speaker test failed: ${d.error || ''}`, d.ok ? 'ok' : 'err');
} catch (e) {
showToast(`Speaker test failed: ${e.message || e}`, 'err');
}
}
document.getElementById('btn-speaker-test').addEventListener('click', runSpeakerTest);
document.getElementById('btn-diag-refresh').addEventListener('click', refreshDiagnostics);
// ---- Settings ----
let _settingsCache = [];
async function refreshSettings() {
try {
const r = await fetch(`${BASE}/api/settings`);
const d = await r.json();
_settingsCache = d.settings || [];
document.getElementById('settings-env-path').textContent = d.env_path ? `.env: ${d.env_path}` : '';
const grid = document.getElementById('settings-grid');
// ── Enum dropdowns ───────────────────────────────────────────
// Keys here render as <select> instead of free-text. Add an option
// with value='' on top whenever the operator should be able to
// revert to "system default".
const _ENUM_SETTINGS = {
'VOICE_BACKEND': [
{ value: 'gemini', label: 'gemini β€” Google Live (recommended)' },
{ value: 'openai', label: 'openai β€” GPT Realtime' },
],
'GEMINI_LIVE_VOICE': [
{ value: '', label: '(default β€” Puck)' },
{ value: 'Puck', label: 'Puck β€” friendly, mid-male (default)' },
{ value: 'Charon', label: 'Charon β€” deep male, formal' },
{ value: 'Kore', label: 'Kore β€” warm female' },
{ value: 'Fenrir', label: 'Fenrir β€” gravelly male' },
{ value: 'Aoede', label: 'Aoede β€” bright female' },
{ value: 'Leda', label: 'Leda β€” soft female' },
{ value: 'Orus', label: 'Orus β€” neutral male' },
{ value: 'Zephyr', label: 'Zephyr β€” airy female' },
],
'STT_DISABLE_BIAS': [
{ value: '', label: '(default β€” false, bias enabled)' },
{ value: 'false', label: 'false β€” use STT bias prompt' },
{ value: 'true', label: 'true β€” disable bias prompt' },
],
'RECEPTION_PROFILE_UNLOCK': [
{ value: '', label: '(default β€” locked)' },
{ value: 'false', label: 'false β€” locked (receptionist protected)' },
{ value: 'true', label: 'true β€” unlock personality editing' },
],
'STT_LANGUAGE': [
{ value: '', label: '(default β€” auto-detect, recommended)' },
{ value: 'en', label: 'en β€” English (forces English phonemes)' },
{ value: 'en-IN', label: 'en-IN β€” Indian English' },
{ value: 'hi', label: 'hi β€” Hindi' },
{ value: 'es', label: 'es β€” Spanish' },
{ value: 'fr', label: 'fr β€” French' },
{ value: 'de', label: 'de β€” German' },
{ value: 'ja', label: 'ja β€” Japanese' },
{ value: 'zh', label: 'zh β€” Chinese' },
],
'FACE_TTL_DAYS': [
{ value: '', label: '(default β€” 90 days)' },
{ value: '7', label: '7 days β€” short-term' },
{ value: '30', label: '30 days β€” monthly cleanup' },
{ value: '90', label: '90 days β€” recommended' },
{ value: '180', label: '180 days β€” long-term' },
{ value: '365', label: '365 days β€” one year' },
{ value: '0', label: '0 β€” never expire' },
],
'VISITOR_LOG_RETENTION_DAYS': [
{ value: '', label: '(default β€” 365 days)' },
{ value: '30', label: '30 days' },
{ value: '90', label: '90 days' },
{ value: '180', label: '180 days' },
{ value: '365', label: '365 days β€” one year (recommended)' },
{ value: '730', label: '730 days β€” two years' },
{ value: '0', label: '0 β€” keep forever' },
],
};
// ── Backend-scoped visibility ────────────────────────────────
// Hide OpenAI-only fields when running Gemini, and vice versa.
// Empty list = always visible regardless of backend.
const _BACKEND_SCOPE = {
'OPENAI_API_KEY': ['openai'],
'STT_DISABLE_BIAS': ['openai'],
'STT_LANGUAGE': ['openai'],
'GEMINI_API_KEY': ['gemini'],
'GEMINI_LIVE_VOICE': ['gemini'],
};
const currentBackend = (() => {
const row = _settingsCache.find(s => s.key === 'VOICE_BACKEND');
return ((row && row.value) || 'gemini').toLowerCase();
})();
// ── Advanced settings (collapsed by default) ─────────────────
const _ADVANCED_KEYS = new Set([
'FACE_TTL_DAYS', 'VISITOR_LOG_RETENTION_DAYS', 'FACE_LBPH_THRESHOLD',
'RECEPTION_PROFILE_UNLOCK',
]);
// ── Info tooltips per key ────────────────────────────────────
// Shown via an β“˜ hover button next to the key name. HTML allowed.
const _INFO_TIPS = {
'RECEPTION_PROFILE_UNLOCK':
'<strong>Unlock the robot\'s personality for editing.</strong><br>'
+ 'The app ships <strong>locked</strong> to the receptionist profile so the '
+ 'reception flow can\'t be broken at runtime.<br><br>'
+ 'Set to <code>true</code> to unlock the <strong>Advanced β†’ Personality studio</strong> '
+ '(edit the prompt, tools, and voice; Save / Apply / Use-on-startup all work).<br><br>'
+ '⚠️ Restart required. Leave <code>locked</code> for a production lobby robot; '
+ 'unlock only on a unit you\'re free to experiment with.',
'FACE_TTL_DAYS':
'<strong>How long a registered face is kept in the guest DB.</strong><br>'
+ 'After this many days of no visit, the face is auto-deleted on next startup, '
+ 'forcing re-registration on their next visit.<br><br>'
+ 'Examples: <code>30</code> for visitors you only want remembered for a month, '
+ '<code>365</code> for regulars, <code>0</code> = never expire.<br><br>'
+ '⚠️ Set too low and returning regulars get re-prompted for their name every visit.',
'VISITOR_LOG_RETENTION_DAYS':
'<strong>How long visit records stay in the visitor log DB.</strong><br>'
+ 'Older entries are purged on startup. Used for audit/analytics export.<br><br>'
+ 'Examples: <code>365</code> for a one-year audit window (recommended), '
+ '<code>0</code> to keep forever.<br><br>'
+ '⚠️ GDPR / privacy regimes may require a maximum retention β€” check your local rules.',
'FACE_LBPH_THRESHOLD':
'<strong>How confident face recognition must be before claiming a match.</strong><br>'
+ 'LBPH distance β€” <em>lower</em> = more strict.<br><br>'
+ 'Defaults to <code>80</code>. Try <code>60-70</code> if the bot recognizes strangers '
+ 'as known guests, or <code>90-110</code> if it fails to recognize people it should know.<br><br>'
+ '⚠️ Tuning this changes face-matching behavior dramatically. Test with several faces '
+ 'after each change.',
};
// ── Render helper ────────────────────────────────────────────
function renderField(s) {
const pills = [];
pills.push(`<span class="settings-pill ${s.is_set ? 'set' : 'unset'}">${s.is_set ? 'SET' : 'UNSET'}</span>`);
if (s.is_secret) pills.push('<span class="settings-pill secret">SECRET</span>');
if (s.requires_restart) pills.push('<span class="settings-pill restart">RESTART</span>');
const tip = _INFO_TIPS[s.key];
const infoBtn = tip
? `<span class="settings-info-btn" tabindex="0">β“˜<span class="settings-info-tooltip">${tip}</span></span>`
: '';
const enumChoices = _ENUM_SETTINGS[s.key];
if (enumChoices) {
const currentVal = (s.is_set && !s.is_secret) ? (s.value || '') : '';
const optionsHtml = enumChoices.map(opt => {
const sel = (opt.value === currentVal) ? ' selected' : '';
return `<option value="${escHtml(opt.value)}"${sel}>${escHtml(opt.label)}</option>`;
}).join('');
return `
<div class="settings-field">
<div class="settings-key">${escHtml(s.key)}${infoBtn}</div>
<div class="settings-meta">${pills.join('')}</div>
<select class="settings-input" data-settings-key="${escHtml(s.key)}">
${optionsHtml}
</select>
</div>`;
}
const placeholder = s.is_secret && s.is_set
? `${s.value} β€” enter a new value to overwrite`
: 'enter value';
const type = s.is_secret ? 'password' : 'text';
return `
<div class="settings-field">
<div class="settings-key">${escHtml(s.key)}${infoBtn}</div>
<div class="settings-meta">${pills.join('')}</div>
<input type="${type}" class="settings-input" data-settings-key="${escHtml(s.key)}"
placeholder="${escHtml(placeholder)}" autocomplete="off" />
</div>`;
}
// ── Split into primary vs advanced, applying backend filter ──
const primary = [];
const advanced = [];
for (const s of _settingsCache) {
const scope = _BACKEND_SCOPE[s.key];
if (scope && !scope.includes(currentBackend)) continue; // hidden
if (_ADVANCED_KEYS.has(s.key)) advanced.push(s);
else primary.push(s);
}
const primaryHtml = primary.map(renderField).join('');
const advancedHtml = advanced.length
? `<details class="settings-advanced">
<summary>Advanced (face DB tuning, log retention)</summary>
<div class="settings-grid">${advanced.map(renderField).join('')}</div>
</details>`
: '';
grid.innerHTML = primaryHtml + advancedHtml;
// After rendering settings, recompute the first-run welcome banner.
updateWelcomeBanner(currentBackend);
} catch(e) { /* silent */ }
}
// ── First-run welcome banner ─────────────────────────────────────
// Reads the rendered settings cache, identifies essential keys that
// are still unset, and shows a clickable checklist. Hides itself
// automatically once everything essential is set.
function updateWelcomeBanner(backend) {
const banner = document.getElementById('welcome-banner');
const body = document.getElementById('welcome-banner-body');
if (!banner || !body) return;
const byKey = Object.fromEntries(_settingsCache.map(s => [s.key, s]));
const isSet = (key) => !!(byKey[key] && byKey[key].is_set);
const missing = [];
// Voice β€” must have a key for whichever backend is selected.
if (backend === 'gemini' && !isSet('GEMINI_API_KEY')) {
missing.push({
target: 'GEMINI_API_KEY',
icon: '🎀',
text: 'Add your Gemini API key β€” without it the bot can\'t talk.',
help: 'Get a free key in 30s: <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener">aistudio.google.com/app/apikey</a>. Free tier is enough for testing.',
});
}
if (backend === 'openai' && !isSet('OPENAI_API_KEY')) {
missing.push({
target: 'OPENAI_API_KEY',
icon: '🎀',
text: 'Add your OpenAI API key β€” without it the bot can\'t talk.',
help: 'Get one at <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener">platform.openai.com/api-keys</a>. You\'ll need to add a payment method.',
});
}
// Email β€” required to actually notify hosts. Dry-run mode works
// without it (outbox only), but production needs it.
if (!isSet('RESEND_API_KEY')) {
missing.push({
target: 'RESEND_API_KEY',
icon: 'πŸ“§',
text: 'Add a Resend API key to deliver real emails.',
help: 'Free tier (3000 emails/month) at <a href="https://resend.com" target="_blank" rel="noopener">resend.com</a>. Without it, the bot still works but emails only go to the dashboard\'s Mailbox Out panel.',
});
}
// Calendar β€” optional but most operators want it.
if (!isSet('RECEPTION_ICS_URL')) {
missing.push({
target: 'RECEPTION_ICS_URL',
icon: 'πŸ“…',
text: 'Paste your Google Calendar iCal URL (optional).',
help: 'In Google Calendar β†’ Settings β†’ Integrate calendar β†’ <em>Secret address in iCal format</em>. Without it, walk-ins still work via the employee directory below.',
});
}
if (missing.length === 0) { banner.style.display = 'none'; return; }
body.innerHTML = missing.map(m => `
<div class="welcome-banner-item" data-target="${escHtml(m.target)}">
<span class="welcome-banner-item-icon">${m.icon}</span>
<div class="welcome-banner-item-text">
<div>${escHtml(m.text)}</div>
<div class="welcome-banner-item-help">${m.help}</div>
</div>
</div>
`).join('');
banner.style.display = 'block';
// Click β†’ scroll to the matching settings field + brief pulse.
body.querySelectorAll('.welcome-banner-item').forEach(item => {
item.addEventListener('click', () => {
const key = item.getAttribute('data-target');
const input = document.querySelector(`#settings-grid [data-settings-key="${CSS.escape(key)}"]`);
if (!input) return;
const field = input.closest('.settings-field');
// Make sure the Advanced expander is open if the target is in it.
const details = field && field.closest('details.settings-advanced');
if (details) details.open = true;
field?.scrollIntoView({ behavior: 'smooth', block: 'center' });
field?.classList.remove('flash-target');
// re-flow to restart the animation cleanly
void field?.offsetWidth;
field?.classList.add('flash-target');
input.focus();
});
});
}
async function saveSettings() {
const inputs = document.querySelectorAll('#settings-grid [data-settings-key]');
const payload = {};
inputs.forEach(el => {
const key = el.getAttribute('data-settings-key');
const val = (el.value || '').trim();
if (val) payload[key] = val;
});
if (!Object.keys(payload).length) {
showToast('Nothing to save β€” type a value in any field first.', 'err');
return;
}
try {
const r = await fetch(`${BASE}/api/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const d = await r.json();
if (!r.ok || !d.ok) throw new Error(d.error || `HTTP ${r.status}`);
const restartNote = d.restart_required ? ' Β· RESTART REQUIRED' : '';
showToast(`Saved: ${d.updated.join(', ')}${restartNote}`, 'ok');
await refreshSettings();
} catch (e) {
showToast(`Save failed: ${e.message || e}`, 'err');
}
}
document.getElementById('btn-settings-save').addEventListener('click', saveSettings);
// ---- Demo Reset ----
async function demoReset() {
const ok = window.confirm(
"Wipe everything for a clean demo?\n\n" +
" β€’ Removes all known guests (face DB)\n" +
" β€’ Clears the visitor log\n" +
" β€’ Clears the email outbox\n" +
" β€’ Resets the active session\n\n" +
"KEEPS: employees, calendar config, settings."
);
if (!ok) return;
try {
const r = await fetch(`${BASE}/api/demo/reset`, { method: 'POST' });
const d = await r.json();
if (!r.ok || !d.ok) {
const errs = (d.errors || []).join('; ') || `HTTP ${r.status}`;
showToast(`Reset partial: ${errs}`, 'err');
} else {
showToast(
`Reset OK Β· guests: ${d.guests_removed}, visits: ${d.visits_removed}, outbox: ${d.outbox_removed}`,
'ok',
);
}
// Refresh everything affected
await Promise.all([
refreshGuests(), refreshVisitorLog(), refreshOutbox(),
refreshSession(), refreshStats(),
]);
} catch (e) {
showToast(`Reset failed: ${e.message || e}`, 'err');
}
}
// Reset-demo binding hidden 2026-05-21. Uncomment along with the
// <button id="btn-demo-reset"> block in the header (search the file
// for "Reset demo button hidden") to re-enable in v2.
// document.getElementById('btn-demo-reset').addEventListener('click', demoReset);
// ---- Theme toggle (light ↔ dark) -------------------------------------
// Reads localStorage on load, applies the saved theme, and updates the
// sun/moon icon. Defaults to light (matches methdai.com brand) when no
// preference has been saved yet.
(function initTheme() {
const saved = localStorage.getItem('mr-theme') || 'light';
if (saved === 'dark') document.body.classList.add('theme-dark');
const icon = document.getElementById('theme-toggle-icon');
if (icon) icon.textContent = saved === 'dark' ? 'β˜€οΈ' : 'πŸŒ™';
})();
document.getElementById('btn-theme-toggle')?.addEventListener('click', () => {
const isDark = document.body.classList.toggle('theme-dark');
localStorage.setItem('mr-theme', isDark ? 'dark' : 'light');
const icon = document.getElementById('theme-toggle-icon');
if (icon) icon.textContent = isDark ? 'β˜€οΈ' : 'πŸŒ™';
});
// ---- Manual register (fallback when voice transcription keeps failing) ----
async function manualRegister() {
const name = (window.prompt(
"Type the visitor's name to register them with the face currently in view:"
) || "").trim();
if (!name) return;
try {
const r = await fetch(`${BASE}/api/guests/manual_register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const d = await r.json();
if (!r.ok || !d.ok) {
showToast(`Register failed: ${d.error || `HTTP ${r.status}`}`, 'err');
return;
}
showToast(`Registered ${name} (total: ${d.total_guests})`, 'ok');
await Promise.all([refreshGuests(), refreshSession(), refreshStats()]);
} catch (e) {
showToast(`Register failed: ${e.message || e}`, 'err');
}
}
document.getElementById('btn-manual-register').addEventListener('click', manualRegister);
// ---- Volume control ----
const _volSlider = document.getElementById('volume-slider');
const _volLabel = document.getElementById('volume-label');
const _volMute = document.getElementById('volume-mute');
let _volSaveTimer = null;
let _volMuted = false;
function _updateVolumeUi(state) {
if (state && typeof state.percent === 'number') {
_volSlider.value = String(state.percent);
_volLabel.textContent = `${state.percent}%`;
} else {
_volLabel.textContent = 'β€”';
}
_volMuted = !!(state && state.muted);
_volMute.textContent = _volMuted ? 'πŸ”‡' : 'πŸ”Š';
}
async function refreshVolume() {
try {
const r = await fetch(`${BASE}/api/audio/volume`);
const d = await r.json();
if (d.ok) _updateVolumeUi(d);
} catch(e) { /* silent */ }
}
async function applyVolume(percent) {
try {
const r = await fetch(`${BASE}/api/audio/volume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ percent }),
});
const d = await r.json();
if (!r.ok || !d.ok) {
showToast(`Volume change failed: ${d.error || `HTTP ${r.status}`}`, 'err');
} else {
_updateVolumeUi(d);
}
} catch(e) {
showToast(`Volume change failed: ${e.message || e}`, 'err');
}
}
// Throttle: while dragging, update the label live but only POST 200ms after
// the user stops moving. Avoids spamming pactl on every pixel.
_volSlider.addEventListener('input', (ev) => {
const v = parseInt(ev.target.value, 10);
_volLabel.textContent = `${v}%`;
if (_volSaveTimer) clearTimeout(_volSaveTimer);
_volSaveTimer = setTimeout(() => applyVolume(v), 200);
});
_volMute.addEventListener('click', () => {
// Toggle by setting to 0 or restoring to current slider value
if (_volMuted || parseInt(_volSlider.value, 10) === 0) {
const restored = Math.max(20, parseInt(_volSlider.value, 10) || 60);
applyVolume(restored);
} else {
applyVolume(0);
}
});
// Initial load
loadModelName();
refreshCalendar();
refreshGuests();
refreshOutbox();
refreshFaceStatus();
refreshBestFace();
refreshFaceEventSent();
refreshVisitorLog();
refreshStats();
refreshSession();
refreshEmployees();
refreshDiagnostics();
refreshSettings();
refreshVolume();
// Polling intervals
setInterval(refreshFaceStatus, 500);
setInterval(refreshBestFace, 500);
setInterval(refreshCalendar, 30000);
setInterval(refreshGuests, 3000);
setInterval(refreshOutbox, 2000);
setInterval(refreshFaceEventSent, 1000);
setInterval(refreshVisitorLog, 3000);
setInterval(refreshStats, 1500);
setInterval(refreshSession, 1000);
setInterval(refreshEmployees, 10000); // slower poll β€” changes via UI
setInterval(refreshDiagnostics, 15000); // slower β€” comprehensive check
// settings: don't poll (would clobber typing); user refreshes manually via reload
</script>
</body>
</html>