| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>PostureCoach — Real-Time Posture Analysis</title> |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose/pose.js" crossorigin="anonymous"></script> |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script> |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"> |
| <style> |
| :root { |
| --bg: #0d0f14; |
| --surface: #141720; |
| --surface2: #1c2030; |
| --border: #252a3a; |
| --good: #22c55e; |
| --good-dim: rgba(34,197,94,0.12); |
| --bad: #f97316; |
| --bad-dim: rgba(249,115,22,0.12); |
| --accent: #6366f1; |
| --accent-dim: rgba(99,102,241,0.15); |
| --text: #e8eaf0; |
| --text-muted: #6b7280; |
| --text-dim: #9ca3af; |
| --radius: 12px; |
| --radius-sm: 8px; |
| } |
| |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| body { |
| font-family: 'Inter', sans-serif; |
| background: var(--bg); |
| color: var(--text); |
| min-height: 100vh; |
| overflow-x: hidden; |
| } |
| |
| |
| header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 18px 28px; |
| border-bottom: 1px solid var(--border); |
| background: var(--surface); |
| position: sticky; |
| top: 0; |
| z-index: 100; |
| } |
| |
| .logo { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| font-weight: 700; |
| font-size: 1.15rem; |
| letter-spacing: -0.02em; |
| } |
| .logo-icon { |
| width: 32px; height: 32px; |
| background: linear-gradient(135deg, var(--accent), #a78bfa); |
| border-radius: 8px; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 16px; |
| } |
| |
| .header-right { |
| display: flex; |
| align-items: center; |
| gap: 14px; |
| } |
| |
| .status-pill { |
| display: flex; |
| align-items: center; |
| gap: 7px; |
| padding: 6px 14px; |
| border-radius: 20px; |
| font-size: 0.78rem; |
| font-weight: 600; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| transition: all 0.3s; |
| } |
| .status-dot { |
| width: 8px; height: 8px; |
| border-radius: 50%; |
| background: var(--text-muted); |
| transition: all 0.3s; |
| } |
| .status-pill.active .status-dot { background: var(--good); box-shadow: 0 0 8px var(--good); animation: pulse 1.5s infinite; } |
| .status-pill.bad-pose { background: var(--bad-dim); border-color: var(--bad); } |
| .status-pill.bad-pose .status-dot { background: var(--bad); box-shadow: 0 0 8px var(--bad); } |
| .status-pill.good-pose { background: var(--good-dim); border-color: var(--good); } |
| .status-pill.good-pose .status-dot { background: var(--good); } |
| |
| @keyframes pulse { |
| 0%,100% { opacity: 1; } 50% { opacity: 0.4; } |
| } |
| |
| |
| .app { |
| display: grid; |
| grid-template-columns: 1fr 360px; |
| gap: 0; |
| min-height: calc(100vh - 65px); |
| } |
| |
| |
| .camera-panel { |
| padding: 24px; |
| display: flex; |
| flex-direction: column; |
| gap: 20px; |
| } |
| |
| .camera-container { |
| position: relative; |
| border-radius: var(--radius); |
| overflow: hidden; |
| background: #000; |
| border: 1px solid var(--border); |
| aspect-ratio: 16/9; |
| max-height: 420px; |
| } |
| |
| #videoElement { |
| width: 100%; height: 100%; |
| object-fit: cover; |
| display: block; |
| transform: scaleX(-1); |
| } |
| |
| #canvasElement { |
| position: absolute; |
| top: 0; left: 0; |
| width: 100%; height: 100%; |
| transform: scaleX(-1); |
| } |
| |
| .camera-overlay { |
| position: absolute; |
| top: 0; left: 0; |
| width: 100%; height: 100%; |
| display: flex; |
| flex-direction: column; |
| justify-content: space-between; |
| pointer-events: none; |
| padding: 16px; |
| } |
| |
| .pose-badge { |
| align-self: flex-end; |
| padding: 8px 16px; |
| border-radius: 20px; |
| font-size: 0.85rem; |
| font-weight: 700; |
| backdrop-filter: blur(12px); |
| border: 2px solid transparent; |
| transition: all 0.3s; |
| letter-spacing: 0.02em; |
| } |
| .pose-badge.good { background: rgba(34,197,94,0.85); color: #fff; border-color: var(--good); } |
| .pose-badge.bad { background: rgba(249,115,22,0.85); color: #fff; border-color: var(--bad); animation: shake 0.4s; } |
| .pose-badge.idle { background: rgba(0,0,0,0.6); color: var(--text-dim); border-color: var(--border); } |
| |
| @keyframes shake { |
| 0%,100%{transform:translateX(0)} 25%{transform:translateX(-4px)} 75%{transform:translateX(4px)} |
| } |
| |
| .angle-display { |
| display: flex; |
| gap: 10px; |
| } |
| .angle-chip { |
| padding: 5px 10px; |
| border-radius: 8px; |
| background: rgba(0,0,0,0.7); |
| backdrop-filter: blur(8px); |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 0.7rem; |
| color: var(--text-dim); |
| border: 1px solid rgba(255,255,255,0.1); |
| } |
| |
| |
| .controls { |
| display: flex; |
| gap: 12px; |
| align-items: center; |
| } |
| |
| .btn { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| padding: 10px 20px; |
| border-radius: var(--radius-sm); |
| font-size: 0.875rem; |
| font-weight: 600; |
| font-family: inherit; |
| cursor: pointer; |
| border: none; |
| transition: all 0.2s; |
| } |
| .btn-primary { |
| background: var(--accent); |
| color: #fff; |
| } |
| .btn-primary:hover { background: #4f52e0; transform: translateY(-1px); } |
| .btn-secondary { |
| background: var(--surface2); |
| color: var(--text); |
| border: 1px solid var(--border); |
| } |
| .btn-secondary:hover { background: var(--border); } |
| .btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; } |
| |
| |
| .stats-row { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 14px; |
| } |
| |
| .stat-card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 16px; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| } |
| .stat-label { |
| font-size: 0.72rem; |
| color: var(--text-muted); |
| font-weight: 500; |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| } |
| .stat-value { |
| font-size: 1.6rem; |
| font-weight: 700; |
| font-family: 'JetBrains Mono', monospace; |
| line-height: 1; |
| } |
| .stat-value.good { color: var(--good); } |
| .stat-value.bad { color: var(--bad); } |
| .stat-value.accent { color: var(--accent); } |
| .stat-sub { |
| font-size: 0.72rem; |
| color: var(--text-muted); |
| margin-top: 2px; |
| } |
| |
| |
| .session-chart { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 20px; |
| } |
| .chart-title { |
| font-size: 0.8rem; |
| font-weight: 600; |
| color: var(--text-dim); |
| margin-bottom: 14px; |
| } |
| .progress-track { |
| height: 28px; |
| background: var(--surface2); |
| border-radius: 6px; |
| overflow: hidden; |
| position: relative; |
| display: flex; |
| } |
| .progress-good { |
| height: 100%; |
| background: linear-gradient(90deg, #22c55e, #4ade80); |
| transition: width 0.6s ease; |
| display: flex; |
| align-items: center; |
| padding-left: 10px; |
| font-size: 0.72rem; |
| font-weight: 700; |
| color: #052e16; |
| min-width: 0; |
| overflow: hidden; |
| white-space: nowrap; |
| } |
| .progress-bad { |
| height: 100%; |
| background: linear-gradient(90deg, #f97316, #fb923c); |
| transition: width 0.6s ease; |
| display: flex; |
| align-items: center; |
| padding-left: 10px; |
| font-size: 0.72rem; |
| font-weight: 700; |
| color: #431407; |
| min-width: 0; |
| overflow: hidden; |
| white-space: nowrap; |
| } |
| |
| .mini-timeline { |
| display: flex; |
| gap: 3px; |
| margin-top: 12px; |
| height: 32px; |
| align-items: flex-end; |
| } |
| .timeline-bar { |
| flex: 1; |
| border-radius: 3px 3px 0 0; |
| min-height: 4px; |
| transition: height 0.3s; |
| } |
| .timeline-bar.good { background: var(--good); opacity: 0.7; } |
| .timeline-bar.bad { background: var(--bad); opacity: 0.7; } |
| .timeline-bar.idle { background: var(--border); } |
| |
| |
| .sidebar { |
| border-left: 1px solid var(--border); |
| background: var(--surface); |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| .sidebar-tabs { |
| display: flex; |
| border-bottom: 1px solid var(--border); |
| } |
| .tab { |
| flex: 1; |
| padding: 14px 10px; |
| text-align: center; |
| font-size: 0.78rem; |
| font-weight: 600; |
| color: var(--text-muted); |
| cursor: pointer; |
| border-bottom: 2px solid transparent; |
| transition: all 0.2s; |
| background: none; |
| border-top: none; |
| border-left: none; |
| border-right: none; |
| font-family: inherit; |
| } |
| .tab.active { |
| color: var(--accent); |
| border-bottom-color: var(--accent); |
| background: var(--accent-dim); |
| } |
| |
| .tab-panel { |
| display: none; |
| flex: 1; |
| overflow-y: auto; |
| padding: 16px; |
| flex-direction: column; |
| gap: 12px; |
| } |
| .tab-panel.active { display: flex; } |
| |
| |
| .alert-settings { |
| background: var(--surface2); |
| border-radius: var(--radius); |
| padding: 14px; |
| border: 1px solid var(--border); |
| } |
| .alert-title { |
| font-size: 0.8rem; |
| font-weight: 600; |
| margin-bottom: 10px; |
| color: var(--text-dim); |
| } |
| .toggle-row { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 8px 0; |
| border-bottom: 1px solid var(--border); |
| font-size: 0.82rem; |
| } |
| .toggle-row:last-child { border-bottom: none; } |
| .toggle { |
| position: relative; |
| width: 36px; height: 20px; |
| } |
| .toggle input { opacity: 0; width: 0; height: 0; } |
| .slider { |
| position: absolute; |
| cursor: pointer; |
| inset: 0; |
| background: var(--border); |
| border-radius: 20px; |
| transition: 0.3s; |
| } |
| .slider:before { |
| content: ''; |
| position: absolute; |
| height: 14px; width: 14px; |
| left: 3px; bottom: 3px; |
| background: white; |
| border-radius: 50%; |
| transition: 0.3s; |
| } |
| input:checked + .slider { background: var(--accent); } |
| input:checked + .slider:before { transform: translateX(16px); } |
| |
| .notif-feed { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| |
| .notif-item { |
| padding: 10px 12px; |
| border-radius: var(--radius-sm); |
| font-size: 0.78rem; |
| display: flex; |
| flex-direction: column; |
| gap: 3px; |
| animation: slideIn 0.3s ease; |
| border-left: 3px solid transparent; |
| } |
| .notif-item.bad { |
| background: var(--bad-dim); |
| border-left-color: var(--bad); |
| } |
| .notif-item.good { |
| background: var(--good-dim); |
| border-left-color: var(--good); |
| } |
| .notif-time { font-size: 0.68rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; } |
| |
| @keyframes slideIn { |
| from { opacity: 0; transform: translateX(20px); } |
| to { opacity: 1; transform: translateX(0); } |
| } |
| |
| |
| .ai-header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| margin-bottom: 4px; |
| } |
| .ai-badge { |
| font-size: 0.65rem; |
| padding: 3px 8px; |
| background: var(--accent-dim); |
| color: var(--accent); |
| border-radius: 10px; |
| font-weight: 600; |
| border: 1px solid rgba(99,102,241,0.3); |
| } |
| |
| .model-select { |
| width: 100%; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| color: var(--text); |
| padding: 9px 12px; |
| border-radius: var(--radius-sm); |
| font-size: 0.8rem; |
| font-family: inherit; |
| cursor: pointer; |
| margin-bottom: 4px; |
| } |
| |
| .hf-token-input { |
| width: 100%; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| color: var(--text); |
| padding: 9px 12px; |
| border-radius: var(--radius-sm); |
| font-size: 0.8rem; |
| font-family: 'JetBrains Mono', monospace; |
| margin-bottom: 4px; |
| outline: none; |
| } |
| .hf-token-input:focus { border-color: var(--accent); } |
| .hf-token-input::placeholder { color: var(--text-muted); font-family: inherit; } |
| |
| .input-label { |
| font-size: 0.72rem; |
| color: var(--text-muted); |
| margin-bottom: 4px; |
| display: block; |
| } |
| |
| .ai-response-box { |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 14px; |
| min-height: 120px; |
| font-size: 0.8rem; |
| line-height: 1.6; |
| color: var(--text-dim); |
| flex: 1; |
| overflow-y: auto; |
| white-space: pre-wrap; |
| } |
| .ai-response-box.loading { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| color: var(--accent); |
| } |
| .ai-disclaimer { |
| font-size: 0.68rem; |
| color: var(--text-muted); |
| margin-top: 6px; |
| padding: 8px 10px; |
| background: var(--surface2); |
| border-radius: 6px; |
| border-left: 2px solid var(--accent); |
| } |
| |
| .spinner { |
| width: 16px; height: 16px; |
| border: 2px solid var(--accent-dim); |
| border-top-color: var(--accent); |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| flex-shrink: 0; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| |
| |
| #toast-container { |
| position: fixed; |
| top: 80px; |
| right: 20px; |
| z-index: 9999; |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| pointer-events: none; |
| } |
| .toast { |
| padding: 12px 18px; |
| border-radius: var(--radius-sm); |
| font-size: 0.82rem; |
| font-weight: 600; |
| max-width: 300px; |
| pointer-events: auto; |
| animation: toastIn 0.3s ease; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.4); |
| } |
| .toast.bad { background: #7c2d12; color: #fed7aa; border: 1px solid var(--bad); } |
| .toast.good { background: #052e16; color: #bbf7d0; border: 1px solid var(--good); } |
| .toast.info { background: #1e1b4b; color: #c7d2fe; border: 1px solid var(--accent); } |
| @keyframes toastIn { |
| from { opacity: 0; transform: translateX(40px); } |
| to { opacity: 1; transform: translateX(0); } |
| } |
| |
| |
| .angle-chart { |
| background: var(--surface2); |
| border-radius: var(--radius-sm); |
| padding: 12px; |
| border: 1px solid var(--border); |
| margin-top: 4px; |
| } |
| .angle-chart canvas { |
| width: 100% !important; |
| height: 70px !important; |
| display: block; |
| } |
| .chart-labels { |
| display: flex; |
| justify-content: space-between; |
| font-size: 0.65rem; |
| color: var(--text-muted); |
| margin-top: 4px; |
| font-family: 'JetBrains Mono', monospace; |
| } |
| |
| |
| .start-overlay { |
| position: absolute; |
| inset: 0; |
| background: rgba(13,15,20,0.92); |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| gap: 12px; |
| z-index: 10; |
| border-radius: var(--radius); |
| backdrop-filter: blur(4px); |
| } |
| .start-overlay h2 { font-size: 1.2rem; font-weight: 700; } |
| .start-overlay p { font-size: 0.82rem; color: var(--text-muted); text-align: center; max-width: 220px; line-height: 1.5; } |
| .camera-icon { font-size: 48px; opacity: 0.6; } |
| |
| .section-title { |
| font-size: 0.72rem; |
| font-weight: 600; |
| color: var(--text-muted); |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| margin-top: 4px; |
| margin-bottom: 2px; |
| } |
| |
| |
| .empty-state { |
| text-align: center; |
| padding: 30px 10px; |
| color: var(--text-muted); |
| font-size: 0.8rem; |
| } |
| |
| |
| ::-webkit-scrollbar { width: 5px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } |
| |
| @media (max-width: 900px) { |
| .app { grid-template-columns: 1fr; } |
| .sidebar { border-left: none; border-top: 1px solid var(--border); min-height: 500px; } |
| .stats-row { grid-template-columns: repeat(2,1fr); } |
| } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <header> |
| <div class="logo"> |
| <div class="logo-icon">🧘</div> |
| PostureCoach |
| </div> |
| <div class="header-right"> |
| <div class="status-pill" id="statusPill"> |
| <div class="status-dot"></div> |
| <span id="statusText">Camera off</span> |
| </div> |
| </div> |
| </header> |
| <h3> |
|
|
| ROLE: You are an expert AI and computer-vision web developer. |
| TASK: Build a complete browser-based mini project called PostureCoach. |
| DESCRIPTION: Create a real-time posture monitoring app using the webcam. |
| DETAIL: Use MediaPipe Pose to detect 33 body landmarks and calculate shoulder, neck, and torso angles. |
| CLASSIFICATION: Detect and display Good Posture and Bad Posture/Slouching with a visual skeleton overlay. |
| BAD POSE: Add Face Too Close detection using Face Detection when only the eyes, nose, mouth, or a large part of the face is visible. |
| DASHBOARD: Show posture status, session time, good/bad frames, angles, alerts, and posture percentage. |
| UI & DEPLOYMENT: Create a clean responsive HTML/CSS/JavaScript app that can run as a static Hugging Face Space. |
| OUTPUT: Give me complete runnable code, explain each major part simply, and include testing steps. |
|
|
|
|
| </h3> |
| |
| <div id="toast-container"></div> |
|
|
| |
| <div class="app"> |
|
|
| |
| <div class="camera-panel"> |
|
|
| |
| <div class="camera-container" id="cameraContainer"> |
| <video id="videoElement" autoplay muted playsinline></video> |
| <canvas id="canvasElement"></canvas> |
|
|
| <div class="camera-overlay"> |
| <div style="display:flex;gap:8px;align-self:flex-end;flex-direction:column;align-items:flex-end"> |
| <div class="pose-badge idle" id="poseBadge">Waiting…</div> |
| <div class="pose-badge idle" id="proximityBadge" style="display:none;font-size:0.75rem">📏 Distance OK</div> |
| </div> |
| <div class="angle-display" id="angleDisplay" style="display:none"> |
| <div class="angle-chip" id="chipShoulder">Shoulders —</div> |
| <div class="angle-chip" id="chipNeck">Neck —</div> |
| <div class="angle-chip" id="chipTilt">Torso —</div> |
| <div class="angle-chip" id="chipDist">Dist —</div> |
| </div> |
| </div> |
|
|
| <div class="start-overlay" id="startOverlay"> |
| <div class="camera-icon">📷</div> |
| <h2>Start your session</h2> |
| <p>Enable your camera to begin real-time posture analysis</p> |
| </div> |
| </div> |
|
|
| |
| <div class="controls"> |
| <button class="btn btn-primary" id="startBtn" onclick="startCamera()">▶ Start Camera</button> |
| <button class="btn btn-secondary" id="stopBtn" onclick="stopCamera()" disabled>■ Stop</button> |
| <button class="btn btn-secondary" onclick="resetSession()">↺ Reset</button> |
| </div> |
|
|
| |
| <div class="stats-row"> |
| <div class="stat-card"> |
| <div class="stat-label">Good Posture</div> |
| <div class="stat-value good" id="goodCount">0</div> |
| <div class="stat-sub" id="goodPct">0% of session</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-label">Bad Posture</div> |
| <div class="stat-value bad" id="badCount">0</div> |
| <div class="stat-sub" id="badPct">0% of session</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-label">Bad Streaks</div> |
| <div class="stat-value accent" id="streakCount">0</div> |
| <div class="stat-sub">Longest: <span id="longestStreak">0</span>s</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-label">Session Time</div> |
| <div class="stat-value accent" id="sessionTime">0:00</div> |
| <div class="stat-sub">Active monitoring</div> |
| </div> |
| </div> |
|
|
| |
| <div class="session-chart"> |
| <div class="chart-title">Session breakdown</div> |
| <div class="progress-track"> |
| <div class="progress-good" id="progressGood" style="width:0%"> |
| <span id="progressGoodLabel"></span> |
| </div> |
| <div class="progress-bad" id="progressBad" style="width:0%"> |
| <span id="progressBadLabel"></span> |
| </div> |
| </div> |
| <div class="mini-timeline" id="miniTimeline"> |
| |
| </div> |
| <div class="chart-labels"> |
| <span>Session start</span> |
| <span>Now</span> |
| </div> |
| </div> |
|
|
| |
| <div class="angle-chart"> |
| <div class="chart-title" style="margin-bottom:8px">Shoulder alignment angle over time</div> |
| <canvas id="sparkCanvas" height="70"></canvas> |
| <div class="chart-labels"><span>Earlier</span><span>Now</span></div> |
| </div> |
|
|
| </div> |
|
|
| |
| <div class="sidebar"> |
| <div class="sidebar-tabs"> |
| <button class="tab active" onclick="switchTab('alerts')">🔔 Alerts</button> |
| <button class="tab" onclick="switchTab('ai')">🤖 AI Coach</button> |
| <button class="tab" onclick="switchTab('log')">📋 Log</button> |
| </div> |
|
|
| |
| <div class="tab-panel active" id="tab-alerts"> |
|
|
| <div class="alert-settings"> |
| <div class="alert-title">Notification settings</div> |
| <div class="toggle-row"> |
| <span>Sound alarm on bad pose</span> |
| <label class="toggle"> |
| <input type="checkbox" id="toggleSound" checked> |
| <span class="slider"></span> |
| </label> |
| </div> |
| <div class="toggle-row"> |
| <span>Browser notifications</span> |
| <label class="toggle"> |
| <input type="checkbox" id="toggleNotif"> |
| <span class="slider"></span> |
| </label> |
| </div> |
| <div class="toggle-row"> |
| <span>Alert every (bad seconds)</span> |
| <select class="model-select" id="alertInterval" style="width:90px;margin:0;padding:5px 8px"> |
| <option value="3">3s</option> |
| <option value="5" selected>5s</option> |
| <option value="10">10s</option> |
| <option value="15">15s</option> |
| </select> |
| </div> |
| </div> |
|
|
| <div class="section-title">Recent alerts</div> |
| <div class="notif-feed" id="notifFeed"> |
| <div class="empty-state">No alerts yet. Start the camera.</div> |
| </div> |
| </div> |
|
|
| |
| <div class="tab-panel" id="tab-ai"> |
|
|
| <div class="ai-header"> |
| <span class="section-title" style="margin:0">AI Recommendation</span> |
| <span class="ai-badge">AI ✦</span> |
| </div> |
|
|
| <label class="input-label">Hugging Face Token (required)</label> |
| <input class="hf-token-input" type="password" id="hfToken" |
| placeholder="hf_xxxxxxxxxxxxxxxxxxxx" |
| value=""> |
|
|
| <label class="input-label">Select AI model</label> |
| <select class="model-select" id="modelSelect"> |
| <optgroup label="Meta Llama"> |
| <option value="meta-llama/Llama-3.2-3B-Instruct">Llama 3.2 3B Instruct</option> |
| <option value="meta-llama/Llama-3.1-8B-Instruct">Llama 3.1 8B Instruct</option> |
| </optgroup> |
| <optgroup label="Mistral"> |
| <option value="mistralai/Mistral-7B-Instruct-v0.3">Mistral 7B Instruct v0.3</option> |
| <option value="mistralai/Mixtral-8x7B-Instruct-v0.1">Mixtral 8x7B Instruct</option> |
| </optgroup> |
| <optgroup label="Microsoft"> |
| <option value="microsoft/Phi-3-mini-4k-instruct">Phi-3 Mini 4K Instruct</option> |
| <option value="microsoft/Phi-3.5-mini-instruct">Phi-3.5 Mini Instruct</option> |
| </optgroup> |
| <optgroup label="Google"> |
| <option value="google/gemma-2-2b-it">Gemma 2 2B IT</option> |
| <option value="google/gemma-2-9b-it">Gemma 2 9B IT</option> |
| </optgroup> |
| <optgroup label="Qwen"> |
| <option value="Qwen/Qwen2.5-7B-Instruct">Qwen2.5 7B Instruct</option> |
| <option value="Qwen/Qwen2.5-3B-Instruct">Qwen2.5 3B Instruct</option> |
| </optgroup> |
| </select> |
|
|
| <button class="btn btn-primary" style="width:100%" onclick="getAIAdvice()"> |
| ✦ Get AI Posture Advice |
| </button> |
|
|
| <div class="ai-response-box" id="aiResponseBox"> |
| Start your camera session, then click "Get AI Posture Advice" to receive personalized recommendations based on your posture data. |
| </div> |
|
|
| <div class="ai-disclaimer"> |
| ⚠️ <strong>AI Recommendation</strong> — This is generated by an AI language model via Hugging Face Inference API and is not a substitute for professional medical advice. |
| </div> |
| </div> |
|
|
| |
| <div class="tab-panel" id="tab-log"> |
| <div class="section-title">Session event log</div> |
| <div class="notif-feed" id="logFeed"> |
| <div class="empty-state">Events will appear here as your session runs.</div> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
|
|
| |
| <script> |
| |
| |
| |
| const state = { |
| running: false, |
| goodFrames: 0, |
| badFrames: 0, |
| totalFrames: 0, |
| currentPose: 'idle', |
| badStreak: 0, |
| longestBadStreak: 0, |
| sessionStart: null, |
| lastAlertTime: 0, |
| badAlertCount: 0, |
| timelineData: [], |
| angleHistory: [], |
| poseHistory: [], |
| camera: null, |
| pose: null, |
| animFrame: null |
| }; |
| |
| let videoEl, canvasEl, ctx; |
| |
| |
| |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| videoEl = document.getElementById('videoElement'); |
| canvasEl = document.getElementById('canvasElement'); |
| ctx = canvasEl.getContext('2d'); |
| renderTimeline(); |
| renderSparkline(); |
| |
| |
| document.getElementById('toggleNotif').addEventListener('change', async function() { |
| if (this.checked && Notification.permission === 'default') { |
| const perm = await Notification.requestPermission(); |
| if (perm !== 'granted') { this.checked = false; showToast('Notification permission denied', 'info'); } |
| } |
| }); |
| }); |
| |
| |
| |
| |
| function switchTab(name) { |
| document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', ['alerts','ai','log'][i] === name)); |
| document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-'+name)); |
| } |
| |
| |
| |
| |
| async function startCamera() { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720, facingMode: 'user' } }); |
| videoEl.srcObject = stream; |
| await videoEl.play(); |
| |
| canvasEl.width = videoEl.videoWidth || 1280; |
| canvasEl.height = videoEl.videoHeight || 720; |
| |
| document.getElementById('startOverlay').style.display = 'none'; |
| document.getElementById('startBtn').disabled = true; |
| document.getElementById('stopBtn').disabled = false; |
| document.getElementById('angleDisplay').style.display = 'flex'; |
| |
| state.running = true; |
| state.sessionStart = Date.now(); |
| |
| const statusPill = document.getElementById('statusPill'); |
| statusPill.classList.add('active'); |
| document.getElementById('statusText').textContent = 'Analyzing…'; |
| |
| initMediaPipe(stream); |
| startSessionTimer(); |
| logEvent('Session started', 'info'); |
| showToast('Camera started — stand in front of the camera', 'info'); |
| } catch(e) { |
| showToast('Camera error: ' + e.message, 'bad'); |
| } |
| } |
| |
| function initMediaPipe(stream) { |
| const pose = new Pose({ |
| locateFile: f => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${f}` |
| }); |
| |
| pose.setOptions({ |
| modelComplexity: 1, |
| smoothLandmarks: true, |
| enableSegmentation: false, |
| minDetectionConfidence: 0.6, |
| minTrackingConfidence: 0.6 |
| }); |
| |
| pose.onResults(onPoseResults); |
| state.pose = pose; |
| |
| const camera = new Camera(videoEl, { |
| onFrame: async () => { |
| if (state.running) await pose.send({ image: videoEl }); |
| }, |
| width: 1280, height: 720 |
| }); |
| camera.start(); |
| state.camera = camera; |
| } |
| |
| function stopCamera() { |
| state.running = false; |
| if (state.camera) state.camera.stop(); |
| if (videoEl.srcObject) videoEl.srcObject.getTracks().forEach(t => t.stop()); |
| |
| document.getElementById('startOverlay').style.display = 'flex'; |
| document.getElementById('startBtn').disabled = false; |
| document.getElementById('stopBtn').disabled = true; |
| document.getElementById('statusPill').classList.remove('active','bad-pose','good-pose'); |
| document.getElementById('statusText').textContent = 'Camera off'; |
| document.getElementById('poseBadge').className = 'pose-badge idle'; |
| document.getElementById('poseBadge').textContent = 'Session ended'; |
| document.getElementById('proximityBadge').style.display = 'none'; |
| logEvent('Session stopped', 'info'); |
| ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); |
| } |
| |
| function resetSession() { |
| Object.assign(state, { |
| goodFrames:0, badFrames:0, totalFrames:0, |
| currentPose:'idle', badStreak:0, longestBadStreak:0, |
| sessionStart: state.running ? Date.now() : null, |
| lastAlertTime:0, badAlertCount:0, |
| timelineData:[], angleHistory:[], poseHistory:[] |
| }); |
| document.getElementById('goodCount').textContent = '0'; |
| document.getElementById('badCount').textContent = '0'; |
| document.getElementById('streakCount').textContent = '0'; |
| document.getElementById('longestStreak').textContent = '0'; |
| document.getElementById('goodPct').textContent = '0% of session'; |
| document.getElementById('badPct').textContent = '0% of session'; |
| document.getElementById('progressGood').style.width = '0%'; |
| document.getElementById('progressBad').style.width = '0%'; |
| document.getElementById('progressGoodLabel').textContent = ''; |
| document.getElementById('progressBadLabel').textContent = ''; |
| document.getElementById('notifFeed').innerHTML = '<div class="empty-state">No alerts yet.</div>'; |
| document.getElementById('logFeed').innerHTML = '<div class="empty-state">Events will appear here.</div>'; |
| renderTimeline(); |
| renderSparkline(); |
| showToast('Session reset', 'info'); |
| } |
| |
| |
| |
| |
| function onPoseResults(results) { |
| ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); |
| |
| if (!results.poseLandmarks) { |
| setPose('idle'); |
| return; |
| } |
| |
| drawConnectors(ctx, results.poseLandmarks, POSE_CONNECTIONS, { color: 'rgba(99,102,241,0.5)', lineWidth: 2 }); |
| drawLandmarks(ctx, results.poseLandmarks, { color: '#6366f1', lineWidth: 1, radius: 4 }); |
| |
| const lm = results.poseLandmarks; |
| |
| |
| const nose = lm[0]; |
| const leftShoulder = lm[11]; |
| const rightShoulder = lm[12]; |
| const leftEar = lm[7]; |
| const rightEar = lm[8]; |
| const leftHip = lm[23]; |
| const rightHip = lm[24]; |
| |
| |
| const dx = rightShoulder.x - leftShoulder.x; |
| const dy = rightShoulder.y - leftShoulder.y; |
| const shoulderAngle = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI); |
| |
| |
| const earMidX = (leftEar.x + rightEar.x) / 2; |
| const earMidY = (leftEar.y + rightEar.y) / 2; |
| const shoulderMidX = (leftShoulder.x + rightShoulder.x) / 2; |
| const shoulderMidY = (leftShoulder.y + rightShoulder.y) / 2; |
| const neckAngle = Math.abs(Math.atan2(earMidX - shoulderMidX, shoulderMidY - earMidY) * 180 / Math.PI); |
| |
| |
| const hipMidX = (leftHip.x + rightHip.x) / 2; |
| const hipMidY = (leftHip.y + rightHip.y) / 2; |
| const torsoAngle = Math.abs(Math.atan2(shoulderMidX - hipMidX, hipMidY - shoulderMidY) * 180 / Math.PI); |
| |
| |
| |
| |
| const leftEyeInner = lm[1]; |
| const rightEyeInner = lm[4]; |
| const eyeSpan = Math.abs(rightEyeInner.x - leftEyeInner.x); |
| |
| const isTooClose = eyeSpan > 0.18; |
| const distCm = Math.round(30 / eyeSpan); |
| document.getElementById('chipDist').textContent = `~${distCm}cm`; |
| |
| const proximityBadge = document.getElementById('proximityBadge'); |
| proximityBadge.style.display = 'block'; |
| if (isTooClose) { |
| proximityBadge.className = 'pose-badge bad'; |
| proximityBadge.textContent = '⚠ Too Close!'; |
| } else { |
| proximityBadge.className = 'pose-badge good'; |
| proximityBadge.textContent = `📏 ~${distCm}cm`; |
| } |
| |
| |
| const isSlouchShoulder = shoulderAngle > 8; |
| const isSlouchNeck = neckAngle > 18; |
| const isSlouchTorso = torsoAngle > 15; |
| |
| const slouchScore = (isSlouchShoulder ? 1 : 0) + (isSlouchNeck ? 1 : 0) + (isSlouchTorso ? 1 : 0); |
| const isGoodPosture = slouchScore <= 1 && !isTooClose; |
| |
| |
| document.getElementById('chipShoulder').textContent = `Shoulder ${shoulderAngle.toFixed(1)}°`; |
| document.getElementById('chipNeck').textContent = `Neck ${neckAngle.toFixed(1)}°`; |
| document.getElementById('chipTilt').textContent = `Torso ${torsoAngle.toFixed(1)}°`; |
| |
| |
| const lineColor = isTooClose ? 'rgba(239,68,68,0.7)' : isGoodPosture ? 'rgba(34,197,94,0.6)' : 'rgba(249,115,22,0.6)'; |
| drawConnectors(ctx, lm, POSE_CONNECTIONS, { color: lineColor, lineWidth: 2 }); |
| drawLandmarks(ctx, lm, { color: isTooClose ? '#ef4444' : isGoodPosture ? '#22c55e' : '#f97316', lineWidth: 1, radius: 5 }); |
| |
| |
| if (isTooClose) { |
| ctx.save(); |
| ctx.strokeStyle = 'rgba(239,68,68,0.8)'; |
| ctx.lineWidth = 6; |
| ctx.strokeRect(3, 3, canvasEl.width - 6, canvasEl.height - 6); |
| ctx.restore(); |
| } |
| |
| setPose(isTooClose ? 'tooclose' : isGoodPosture ? 'good' : 'bad'); |
| recordAngle(shoulderAngle); |
| checkAlarm(); |
| } |
| |
| function setPose(pose) { |
| const prev = state.currentPose; |
| state.currentPose = pose; |
| state.totalFrames++; |
| |
| const badge = document.getElementById('poseBadge'); |
| const pill = document.getElementById('statusPill'); |
| |
| if (pose === 'good') { |
| state.goodFrames++; |
| state.badStreak = 0; |
| badge.className = 'pose-badge good'; |
| badge.textContent = '✓ Good Posture'; |
| pill.className = 'status-pill active good-pose'; |
| document.getElementById('statusText').textContent = 'Good posture'; |
| if (prev !== 'good' && prev !== 'idle') logEvent('Posture corrected ✓', 'good'); |
| } else if (pose === 'tooclose') { |
| state.badFrames++; |
| state.badStreak++; |
| if (state.badStreak > state.longestBadStreak) state.longestBadStreak = state.badStreak; |
| badge.className = 'pose-badge bad'; |
| badge.textContent = '📏 Too Close to Screen!'; |
| pill.className = 'status-pill active bad-pose'; |
| document.getElementById('statusText').textContent = 'Too close — move back'; |
| if (prev !== 'tooclose') logEvent('Too close to screen! Move back ⚠', 'bad'); |
| } else if (pose === 'bad') { |
| state.badFrames++; |
| state.badStreak++; |
| if (state.badStreak > state.longestBadStreak) state.longestBadStreak = state.badStreak; |
| badge.className = 'pose-badge bad'; |
| badge.textContent = '⚠ Slouching'; |
| pill.className = 'status-pill active bad-pose'; |
| document.getElementById('statusText').textContent = 'Slouching detected'; |
| if (prev !== 'bad') logEvent('Slouching detected ⚠', 'bad'); |
| } else { |
| badge.className = 'pose-badge idle'; |
| badge.textContent = 'No person detected'; |
| pill.className = 'status-pill active'; |
| document.getElementById('statusText').textContent = 'Searching…'; |
| } |
| |
| state.poseHistory.push(pose); |
| updateStats(); |
| |
| |
| if (state.totalFrames % 10 === 0) { |
| state.timelineData.push(pose); |
| renderTimeline(); |
| } |
| } |
| |
| function recordAngle(angle) { |
| state.angleHistory.push(angle); |
| if (state.angleHistory.length > 80) state.angleHistory.shift(); |
| renderSparkline(); |
| } |
| |
| |
| |
| |
| function updateStats() { |
| const total = state.goodFrames + state.badFrames; |
| const goodPct = total > 0 ? Math.round(state.goodFrames / total * 100) : 0; |
| const badPct = 100 - goodPct; |
| |
| document.getElementById('goodCount').textContent = state.goodFrames; |
| document.getElementById('badCount').textContent = state.badFrames; |
| document.getElementById('streakCount').textContent = state.badAlertCount; |
| document.getElementById('longestStreak').textContent = Math.round(state.longestBadStreak / 30); |
| document.getElementById('goodPct').textContent = `${goodPct}% of session`; |
| document.getElementById('badPct').textContent = `${badPct}% of session`; |
| |
| const gw = goodPct + '%'; |
| const bw = badPct + '%'; |
| document.getElementById('progressGood').style.width = gw; |
| document.getElementById('progressBad').style.width = bw; |
| document.getElementById('progressGoodLabel').textContent = goodPct > 15 ? `Good ${goodPct}%` : ''; |
| document.getElementById('progressBadLabel').textContent = badPct > 15 ? `Bad ${badPct}%` : ''; |
| } |
| |
| |
| |
| |
| function startSessionTimer() { |
| const tick = () => { |
| if (!state.running || !state.sessionStart) return; |
| const s = Math.floor((Date.now() - state.sessionStart) / 1000); |
| const m = Math.floor(s / 60); |
| document.getElementById('sessionTime').textContent = `${m}:${String(s % 60).padStart(2,'0')}`; |
| setTimeout(tick, 1000); |
| }; |
| tick(); |
| } |
| |
| |
| |
| |
| function checkAlarm() { |
| if (state.currentPose !== 'bad' && state.currentPose !== 'tooclose') return; |
| const interval = parseInt(document.getElementById('alertInterval').value) * 30; |
| if (state.badStreak > 0 && state.badStreak % interval === 0) { |
| state.badAlertCount++; |
| triggerAlert(); |
| } |
| } |
| |
| function triggerAlert() { |
| const msg = getBadPostureMessage(); |
| addNotification(msg, 'bad'); |
| if (document.getElementById('toggleSound').checked) playBeep(); |
| if (document.getElementById('toggleNotif').checked && Notification.permission === 'granted') { |
| new Notification('PostureCoach Alert ⚠', { body: msg, icon: '🧘' }); |
| } |
| showToast(msg, 'bad'); |
| document.getElementById('streakCount').textContent = state.badAlertCount; |
| } |
| |
| function getBadPostureMessage() { |
| if (state.currentPose === 'tooclose') { |
| const msgs = [ |
| 'Move back from the screen — you\'re too close!', |
| 'Screen distance too short — scoot your chair back', |
| 'Your face is very close to the display — move away', |
| 'Protect your eyes — increase screen distance', |
| ]; |
| return msgs[state.badAlertCount % msgs.length]; |
| } |
| const msgs = [ |
| 'Sit up straight — you\'re slouching!', |
| 'Shoulders back, chin up!', |
| 'Check your posture — you\'ve been slouching', |
| 'Straighten your spine!', |
| 'Take a moment to adjust your posture', |
| 'Roll your shoulders back and sit tall', |
| ]; |
| return msgs[state.badAlertCount % msgs.length]; |
| } |
| |
| function playBeep() { |
| try { |
| const ac = new AudioContext(); |
| const osc = ac.createOscillator(); |
| const gain = ac.createGain(); |
| osc.connect(gain); |
| gain.connect(ac.destination); |
| osc.type = 'sine'; |
| osc.frequency.setValueAtTime(880, ac.currentTime); |
| osc.frequency.exponentialRampToValueAtTime(440, ac.currentTime + 0.3); |
| gain.gain.setValueAtTime(0.4, ac.currentTime); |
| gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.4); |
| osc.start(); |
| osc.stop(ac.currentTime + 0.4); |
| } catch(e) {} |
| } |
| |
| function addNotification(msg, type) { |
| const feed = document.getElementById('notifFeed'); |
| const empty = feed.querySelector('.empty-state'); |
| if (empty) empty.remove(); |
| |
| const time = new Date().toLocaleTimeString(); |
| const div = document.createElement('div'); |
| div.className = `notif-item ${type}`; |
| div.innerHTML = `<span>${type === 'bad' ? '⚠' : '✓'} ${msg}</span><span class="notif-time">${time}</span>`; |
| feed.prepend(div); |
| |
| |
| const items = feed.querySelectorAll('.notif-item'); |
| if (items.length > 20) items[items.length - 1].remove(); |
| } |
| |
| function logEvent(msg, type) { |
| const feed = document.getElementById('logFeed'); |
| const empty = feed.querySelector('.empty-state'); |
| if (empty) empty.remove(); |
| |
| const time = new Date().toLocaleTimeString(); |
| const div = document.createElement('div'); |
| div.className = `notif-item ${type}`; |
| div.innerHTML = `<span>${msg}</span><span class="notif-time">${time}</span>`; |
| feed.prepend(div); |
| |
| const items = feed.querySelectorAll('.notif-item'); |
| if (items.length > 50) items[items.length - 1].remove(); |
| } |
| |
| |
| |
| |
| function showToast(msg, type) { |
| const c = document.getElementById('toast-container'); |
| const t = document.createElement('div'); |
| t.className = `toast ${type}`; |
| const icons = { bad: '⚠', good: '✓', info: 'ℹ' }; |
| t.innerHTML = `<span>${icons[type]||'ℹ'}</span><span>${msg}</span>`; |
| c.prepend(t); |
| setTimeout(() => { t.style.opacity='0'; t.style.transform='translateX(40px)'; t.style.transition='all 0.3s'; setTimeout(()=>t.remove(),300); }, 4000); |
| } |
| |
| |
| |
| |
| function renderTimeline() { |
| const container = document.getElementById('miniTimeline'); |
| container.innerHTML = ''; |
| const data = state.timelineData.slice(-60); |
| const maxBars = 60; |
| const bars = Math.max(data.length, 20); |
| |
| for (let i = 0; i < bars; i++) { |
| const d = data[i] || 'idle'; |
| const bar = document.createElement('div'); |
| bar.className = `timeline-bar ${d === 'tooclose' ? 'bad' : d}`; |
| const h = d === 'good' ? 28 : (d === 'bad' || d === 'tooclose') ? 20 : 6; |
| if (d === 'tooclose') bar.style.background = '#ef4444'; |
| bar.style.height = h + 'px'; |
| container.appendChild(bar); |
| } |
| } |
| |
| |
| |
| |
| function renderSparkline() { |
| const canvas = document.getElementById('sparkCanvas'); |
| const w = canvas.parentElement.clientWidth - 24; |
| const h = 70; |
| canvas.width = w; |
| canvas.height = h; |
| const c = canvas.getContext('2d'); |
| c.clearRect(0, 0, w, h); |
| |
| const data = state.angleHistory; |
| if (data.length < 2) { |
| c.fillStyle = 'rgba(99,102,241,0.15)'; |
| c.fillRect(0, 0, w, h); |
| c.fillStyle = '#6b7280'; |
| c.font = '11px Inter'; |
| c.textAlign = 'center'; |
| c.fillText('Start camera to see angle data', w/2, h/2+4); |
| return; |
| } |
| |
| const maxVal = Math.max(...data, 20); |
| const minVal = 0; |
| const range = maxVal - minVal; |
| |
| |
| const thY = h - ((8 - minVal) / range) * h; |
| c.setLineDash([4, 4]); |
| c.strokeStyle = 'rgba(249,115,22,0.4)'; |
| c.lineWidth = 1; |
| c.beginPath(); |
| c.moveTo(0, thY); |
| c.lineTo(w, thY); |
| c.stroke(); |
| c.setLineDash([]); |
| |
| |
| const grad = c.createLinearGradient(0, 0, 0, h); |
| grad.addColorStop(0, 'rgba(99,102,241,0.5)'); |
| grad.addColorStop(1, 'rgba(99,102,241,0.0)'); |
| c.beginPath(); |
| data.forEach((v, i) => { |
| const x = (i / (data.length - 1)) * w; |
| const y = h - ((v - minVal) / range) * h; |
| i === 0 ? c.moveTo(x, y) : c.lineTo(x, y); |
| }); |
| c.lineTo(w, h); |
| c.lineTo(0, h); |
| c.closePath(); |
| c.fillStyle = grad; |
| c.fill(); |
| |
| |
| c.beginPath(); |
| data.forEach((v, i) => { |
| const x = (i / (data.length - 1)) * w; |
| const y = h - ((v - minVal) / range) * h; |
| i === 0 ? c.moveTo(x, y) : c.lineTo(x, y); |
| }); |
| c.strokeStyle = '#6366f1'; |
| c.lineWidth = 2; |
| c.stroke(); |
| } |
| |
| |
| |
| |
| async function getAIAdvice() { |
| const token = document.getElementById('hfToken').value.trim(); |
| if (!token) { |
| showToast('Please enter your Hugging Face token', 'info'); |
| switchTab('ai'); |
| return; |
| } |
| |
| const model = document.getElementById('modelSelect').value; |
| const box = document.getElementById('aiResponseBox'); |
| |
| const total = state.goodFrames + state.badFrames; |
| const goodPct = total > 0 ? Math.round(state.goodFrames / total * 100) : 0; |
| const badPct = 100 - goodPct; |
| const avgAngle = state.angleHistory.length |
| ? (state.angleHistory.reduce((a,b)=>a+b,0) / state.angleHistory.length).toFixed(1) |
| : 'N/A'; |
| const sessionSec = state.sessionStart ? Math.floor((Date.now()-state.sessionStart)/1000) : 0; |
| |
| const tooCloseAlerts = state.poseHistory.filter(p => p === 'tooclose').length; |
| |
| const prompt = `You are an expert physiotherapist and posture coach. A user has completed a posture monitoring session with the following data: |
| |
| Session duration: ${Math.floor(sessionSec/60)}m ${sessionSec%60}s |
| Good posture: ${state.goodFrames} frames (${goodPct}%) |
| Bad posture / slouching: ${state.badFrames} frames (${badPct}%) |
| Too-close-to-screen detections: ${tooCloseAlerts} frames |
| Bad posture alerts triggered: ${state.badAlertCount} |
| Longest bad posture streak: ~${Math.round(state.longestBadStreak/30)}s |
| Average shoulder alignment angle: ${avgAngle}° |
| Current posture status: ${state.currentPose} |
| |
| Based on this data, provide: |
| 1. A brief assessment of the user's posture habits (2-3 sentences) |
| 2. 3 specific, actionable exercises or adjustments to improve their posture |
| 3. A workstation tip relevant to their data |
| 4. An encouraging closing statement |
| |
| Be concise, warm, and practical. Format with clear numbered sections. Do not use markdown headers.`; |
| |
| box.className = 'ai-response-box loading'; |
| box.innerHTML = '<div class="spinner"></div> Asking ' + model.split('/')[1] + '…'; |
| |
| try { |
| |
| const res = await fetch(`https://router.huggingface.co/v1/chat/completions`, { |
| method: 'POST', |
| headers: { |
| 'Authorization': `Bearer ${token}`, |
| 'Content-Type': 'application/json' |
| }, |
| body: JSON.stringify({ |
| model: model, |
| messages: [{ role: 'user', content: prompt }], |
| max_tokens: 500, |
| temperature: 0.7, |
| stream: false |
| }) |
| }); |
| |
| if (!res.ok) { |
| const err = await res.json().catch(() => ({})); |
| throw new Error(err.error?.message || `HTTP ${res.status}`); |
| } |
| |
| const data = await res.json(); |
| const text = data.choices?.[0]?.message?.content || 'No response received.'; |
| |
| box.className = 'ai-response-box'; |
| box.textContent = text; |
| |
| logEvent(`AI advice fetched from ${model.split('/')[1]}`, 'good'); |
| switchTab('ai'); |
| |
| } catch(e) { |
| box.className = 'ai-response-box'; |
| box.textContent = `Error: ${e.message}\n\nTips:\n• Make sure your HF token is valid and has read access\n• Some models require a Pro HF subscription\n• Try a smaller model like Phi-3 Mini or Gemma 2 2B`; |
| showToast('AI request failed: ' + e.message, 'bad'); |
| } |
| } |
| </script> |
| </body> |
| </html> |
|
|