Spaces:
Running
Running
| <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) ; 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 ; transition: none ; } } | |
| </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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/\"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| 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> | |