carlosduplar
Restructure repo: src/, frontend/, modal/ dirs; update app_file paths; update .gitignore
3ab287a Download frontend/custom_index.html from build-small-hackathon/patient-virtuel-dentiste: direct link, hf CLI and curl.
- Browser
- Download file 28.4 kB
-
https://huggingface.co/spaces/build-small-hackathon/patient-virtuel-dentiste/resolve/main/frontend/custom_index.html
- Command line
-
hf download hf://spaces/build-small-hackathon/patient-virtuel-dentiste/frontend/custom_index.html
-
curl -L -o custom_index.html https://huggingface.co/spaces/build-small-hackathon/patient-virtuel-dentiste/resolve/main/frontend/custom_index.html
28.4 kB
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Patient Virtuel · Français en médecine dentaire</title> | |
| <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=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { margin: 0; box-sizing: border-box; } | |
| :root { | |
| --bg: #19120c; | |
| --accent: #ff5719; | |
| --accent-dim: rgba(255, 87, 25, 0.15); | |
| --glass: rgba(255, 87, 25, 0.06); | |
| --glass-border: rgba(255, 200, 150, 0.1); | |
| --text: #efe0d4; | |
| --text-dim: #e6beb2; | |
| --surface-low: #221a13; | |
| --surface-high: #312821; | |
| --outline: #ac897e; | |
| --outline-variant: #5c4037; | |
| --serif: "EB Garamond", Georgia, serif; | |
| --sans: "Inter", ui-sans-serif, system-ui, sans-serif; | |
| } | |
| html, body { | |
| height: 100%; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: var(--sans); | |
| -webkit-font-smoothing: antialiased; | |
| overflow-x: hidden; | |
| } | |
| body { | |
| background-image: | |
| radial-gradient(60rem 50rem at 0% 100%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%), | |
| radial-gradient(50rem 40rem at 100% 0%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 60%); | |
| background-attachment: fixed; | |
| } | |
| /* Glass */ | |
| .glass { | |
| background: var(--glass); | |
| backdrop-filter: blur(40px); | |
| -webkit-backdrop-filter: blur(40px); | |
| border: 1px solid var(--glass-border); | |
| box-shadow: 0 20px 50px rgba(0,0,0,0.3); | |
| border-radius: 16px; | |
| } | |
| /* Layout */ | |
| .app { | |
| position: relative; | |
| z-index: 1; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| max-width: 1280px; | |
| margin: 0 auto; | |
| padding: 28px 32px; | |
| } | |
| /* Header */ | |
| .header { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| margin-bottom: 36px; | |
| flex-shrink: 0; | |
| align-self: center; | |
| } | |
| .header-icon { | |
| width: 44px; height: 44px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 4px 20px rgba(255, 87, 25, 0.3); | |
| flex-shrink: 0; | |
| } | |
| .header-icon svg { width: 24px; height: 24px; color: #fff; } | |
| .header-title { | |
| font-family: var(--serif); | |
| font-size: 32px; | |
| font-weight: 400; | |
| letter-spacing: 0.01em; | |
| } | |
| .header-sub { | |
| font-size: 14px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.2em; | |
| color: var(--accent); | |
| font-weight: 600; | |
| } | |
| /* Two-column grid */ | |
| .main-grid { | |
| display: grid; | |
| grid-template-columns: 5fr 7fr; | |
| gap: 32px; | |
| flex: 1; | |
| min-height: 0; | |
| } | |
| /* Left column */ | |
| .left-col { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 28px; | |
| } | |
| /* Description */ | |
| .desc h2 { | |
| font-family: var(--serif); | |
| font-size: 56px; | |
| font-weight: 400; | |
| line-height: 1.15; | |
| letter-spacing: -0.01em; | |
| } | |
| .desc h2 em { | |
| font-style: italic; | |
| color: var(--accent); | |
| } | |
| .desc p { | |
| color: var(--text-dim); | |
| font-size: 14px; | |
| line-height: 1.6; | |
| margin-top: 16px; | |
| } | |
| /* How-to */ | |
| .howto { | |
| padding: 24px; | |
| background: rgba(255,255,255,0.03); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| border-radius: 12px; | |
| backdrop-filter: blur(10px); | |
| } | |
| .howto .howto-title { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.15em; | |
| color: var(--accent); | |
| font-weight: 700; | |
| } | |
| .howto ol { | |
| list-style: none; | |
| margin: 16px 0 0; | |
| padding: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .howto li { | |
| display: flex; | |
| gap: 12px; | |
| font-size: 13px; | |
| color: var(--text-dim); | |
| line-height: 1.6; | |
| } | |
| .howto li .num { | |
| flex-shrink: 0; | |
| width: 20px; height: 20px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| border: 1px solid rgba(255,255,255,0.15); | |
| font-size: 10px; | |
| font-weight: 600; | |
| color: var(--accent); | |
| margin-top: 2px; | |
| } | |
| .howto li strong { color: var(--text); } | |
| /* Mic area */ | |
| .mic-area { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 20px; | |
| padding: 12px 0; | |
| } | |
| .mic-btn { | |
| width: 136px; height: 136px; | |
| border-radius: 50%; | |
| border: 2px solid var(--glass-border); | |
| background: var(--glass); | |
| backdrop-filter: blur(30px); | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.3s ease; | |
| position: relative; | |
| } | |
| .mic-btn:hover { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 40px rgba(255,87,25,0.2); | |
| } | |
| .mic-btn:disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| } | |
| .mic-btn.recording { | |
| border-color: var(--accent); | |
| background: var(--accent-dim); | |
| animation: mic-pulse 1.8s ease-in-out infinite; | |
| } | |
| @keyframes mic-pulse { | |
| 0%, 100% { | |
| box-shadow: | |
| 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent), | |
| 0 0 60px 10px color-mix(in srgb, var(--accent) 35%, transparent); | |
| } | |
| 50% { | |
| box-shadow: | |
| 0 0 0 24px color-mix(in srgb, var(--accent) 0%, transparent), | |
| 0 0 90px 24px color-mix(in srgb, var(--accent) 45%, transparent); | |
| } | |
| } | |
| .mic-btn svg { width: 42px; height: 42px; color: var(--text); transition: color 0.3s; } | |
| .mic-btn.recording svg { color: var(--accent); } | |
| .mic-label { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.18em; | |
| color: var(--text-dim); | |
| padding: 6px 16px; | |
| border-radius: 20px; | |
| background: var(--surface-low); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid var(--glass-border); | |
| font-weight: 600; | |
| } | |
| .mic-label.active { | |
| color: var(--accent); | |
| border-color: var(--accent); | |
| background: var(--accent-dim); | |
| } | |
| /* Controls */ | |
| .controls { | |
| display: flex; | |
| gap: 12px; | |
| } | |
| .btn { | |
| flex: 1; | |
| padding: 11px 20px; | |
| border-radius: 10px; | |
| border: 1px solid var(--glass-border); | |
| background: var(--glass); | |
| color: var(--text); | |
| font-size: 13px; | |
| font-weight: 600; | |
| font-family: var(--sans); | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .btn:hover { border-color: var(--accent); background: var(--accent-dim); } | |
| .btn-stop { | |
| background: rgba(139, 0, 0, 0.35); | |
| border-color: rgba(139, 0, 0, 0.5); | |
| color: #ffb4ab; | |
| } | |
| .btn-stop:hover { background: rgba(139, 0, 0, 0.6); } | |
| .btn:disabled { opacity: 0.35; cursor: not-allowed; } | |
| /* Status bar */ | |
| #status { | |
| text-align: center; | |
| font-size: 13px; | |
| color: var(--accent); | |
| font-weight: 500; | |
| min-height: 20px; | |
| transition: opacity 0.2s; | |
| } | |
| /* Right column */ | |
| .right-col { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| min-height: 0; | |
| } | |
| /* Chat */ | |
| .chat-wrap { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| border: 1px solid var(--glass-border); | |
| border-radius: 16px; | |
| background: var(--glass); | |
| backdrop-filter: blur(40px); | |
| overflow: hidden; | |
| min-height: 0; | |
| } | |
| .chat-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 14px 18px; | |
| border-bottom: 1px solid var(--glass-border); | |
| } | |
| .chat-header-label { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.18em; | |
| color: var(--accent); | |
| font-weight: 600; | |
| } | |
| .chat-header-live { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.12em; | |
| color: var(--text-dim); | |
| } | |
| #chat { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| padding: 16px 18px; | |
| overflow-y: auto; | |
| min-height: 120px; | |
| max-height: 50vh; | |
| } | |
| #chat::-webkit-scrollbar { width: 6px; } | |
| #chat::-webkit-scrollbar-thumb { background: var(--outline-variant); border-radius: 3px; } | |
| #chat::-webkit-scrollbar-track { background: transparent; } | |
| .msg { | |
| padding: 12px 16px; | |
| border-radius: 12px; | |
| font-size: 14px; | |
| line-height: 1.6; | |
| max-width: 88%; | |
| animation: fadeIn 0.3s ease; | |
| } | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } | |
| .msg-user { | |
| align-self: flex-end; | |
| background: var(--accent-dim); | |
| border: 1px solid var(--glass-border); | |
| color: var(--text); | |
| border-top-right-radius: 4px; | |
| } | |
| .msg-patient { | |
| align-self: flex-start; | |
| background: var(--surface-high); | |
| border: 1px solid rgba(255,255,255,0.06); | |
| color: var(--text); | |
| border-top-left-radius: 4px; | |
| } | |
| .msg-role { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| color: var(--text-dim); | |
| margin-bottom: 4px; | |
| font-weight: 600; | |
| } | |
| .msg-patient .msg-role { color: var(--accent); } | |
| .no-audio { cursor: help; font-size: 1.1em; } | |
| /* Feedback */ | |
| .feedback { | |
| border: 1px solid var(--glass-border); | |
| border-radius: 16px; | |
| background: var(--glass); | |
| backdrop-filter: blur(40px); | |
| overflow: hidden; | |
| } | |
| .feedback-header { | |
| padding: 14px 18px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| border-bottom: 1px solid var(--glass-border); | |
| } | |
| .feedback-header h3 { | |
| font-family: var(--serif); | |
| font-size: 18px; | |
| font-weight: 400; | |
| } | |
| .feedback-badge { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| padding: 2px 10px; | |
| border-radius: 20px; | |
| border: 1px solid var(--glass-border); | |
| color: var(--text-dim); | |
| font-weight: 600; | |
| } | |
| .feedback-header .arrow { | |
| transition: transform 0.3s; | |
| color: var(--text-dim); | |
| font-size: 14px; | |
| } | |
| .feedback.open .feedback-header .arrow { transform: rotate(180deg); } | |
| .feedback-body { | |
| padding: 18px; | |
| transition: max-height 0.3s ease, opacity 0.25s ease; | |
| max-height: 500px; | |
| opacity: 1; | |
| overflow-y: auto; | |
| } | |
| .feedback:not(.open) .feedback-body { | |
| max-height: 0; | |
| padding: 0 18px; | |
| opacity: 0; | |
| } | |
| .feedback-intro { | |
| font-size: 13px; | |
| color: var(--text-dim); | |
| line-height: 1.6; | |
| margin-bottom: 14px; | |
| white-space: pre-wrap; | |
| } | |
| .feedback-empty { | |
| font-size: 13px; | |
| color: var(--text-dim); | |
| font-style: italic; | |
| } | |
| .feedback-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 12px; | |
| } | |
| .feedback-table th { | |
| text-align: left; | |
| padding: 8px 10px; | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| color: var(--accent); | |
| border-bottom: 1px solid var(--glass-border); | |
| font-weight: 600; | |
| } | |
| .feedback-table td { | |
| padding: 8px 10px; | |
| border-bottom: 1px solid var(--glass-border); | |
| color: var(--text); | |
| line-height: 1.5; | |
| font-size: 12px; | |
| } | |
| /* Feedback sections */ | |
| .feedback-section { margin-bottom: 14px; } | |
| .feedback-section-title { | |
| font-family: var(--serif); | |
| font-size: 14px; | |
| font-weight: 600; | |
| margin-bottom: 6px; | |
| color: var(--accent); | |
| } | |
| .feedback-list { | |
| list-style: none; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| .feedback-list li { | |
| font-size: 12px; | |
| line-height: 1.6; | |
| padding: 2px 0; | |
| color: var(--text); | |
| } | |
| .feedback-list li::before { | |
| content: "•"; | |
| color: var(--accent); | |
| margin-right: 6px; | |
| } | |
| /* Progress bars for Bilan */ | |
| .bilan-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 8px; | |
| } | |
| .bilan-label { | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| color: var(--text-dim); | |
| width: 140px; | |
| flex-shrink: 0; | |
| } | |
| .bilan-bar { | |
| flex: 1; | |
| height: 8px; | |
| background: rgba(255,255,255,0.08); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .bilan-fill { | |
| height: 100%; | |
| background: var(--accent); | |
| border-radius: 4px; | |
| transition: width 0.5s ease; | |
| } | |
| .bilan-score { | |
| font-size: 11px; | |
| color: var(--text-dim); | |
| width: 30px; | |
| text-align: right; | |
| } | |
| /* Footer */ | |
| .footer { | |
| text-align: center; | |
| font-size: 10px; | |
| color: var(--text-dim); | |
| padding-top: 20px; | |
| margin-top: 28px; | |
| border-top: 1px solid rgba(255,255,255,0.04); | |
| flex-shrink: 0; | |
| } | |
| /* Hidden audio element */ | |
| #audio-player { display: none; } | |
| /* Loading dots */ | |
| .dots::after { | |
| content: ''; | |
| animation: dots 1.5s steps(4, end) infinite; | |
| } | |
| @keyframes dots { | |
| 0% { content: ''; } | |
| 25% { content: '.'; } | |
| 50% { content: '..'; } | |
| 75% { content: '...'; } | |
| } | |
| /* Responsive */ | |
| @media (max-width: 860px) { | |
| .main-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .app { padding: 20px 16px; } | |
| .desc h2 { font-size: 38px; } | |
| #chat { max-height: 40vh; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <!-- Header --> | |
| <div class="header"> | |
| <div class="header-icon"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M4.8 2.3A.3.3 0 1 0 5 2H4a2 2 0 0 0-2 2v5a6 6 0 0 0 6 6v0a6 6 0 0 0 6-6V4a2 2 0 0 0-2-2h-1a.2.2 0 1 0 .3.3"/> | |
| <path d="M8 15v1a6 6 0 0 0 6 6v0a6 6 0 0 0 6-6v-4"/> | |
| <circle cx="20" cy="10" r="2"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <div class="header-title">Patient Virtuel · Français en médecine dentaire</div> | |
| <div class="header-sub">Entraînement vocal en français dental</div> | |
| </div> | |
| </div> | |
| <!-- Two-column grid --> | |
| <div class="main-grid"> | |
| <!-- Left column --> | |
| <div class="left-col"> | |
| <!-- Description --> | |
| <div class="desc"> | |
| <h2>Pratiquez votre <em>Français Dentaire</em></h2> | |
| <p> | |
| Un patient virtuel qui vous répond à voix haute. Entraînez-vous aux conversations | |
| de soins dentaires en français, puis recevez un bilan structuré de votre vocabulaire | |
| et de votre grammaire. | |
| </p> | |
| </div> | |
| <!-- How to use --> | |
| <div class="howto"> | |
| <div class="howto-title">Comment utiliser</div> | |
| <ol> | |
| <li><span class="num">1</span><span>Appuyez sur le bouton micro pour commencer à enregistrer.</span></li> | |
| <li><span class="num">2</span><span>Parlez en français — saluez le patient, posez des questions, expliquez les soins.</span></li> | |
| <li><span class="num">3</span><span>Appuyez à nouveau pour arrêter — le patient vous répond à voix haute.</span></li> | |
| <li><span class="num">4</span><span>Dites <strong>« Fin de la séance »</strong> ou appuyez sur <strong>Terminer</strong> pour obtenir votre bilan.</span></li> | |
| </ol> | |
| </div> | |
| <!-- Mic --> | |
| <div class="mic-area"> | |
| <button class="mic-btn" id="mic-btn" onclick="toggleRecording()"> | |
| <svg id="mic-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/> | |
| <path d="M19 10v2a7 7 0 0 1-14 0v-2"/> | |
| <line x1="12" x2="12" y1="19" y2="22"/> | |
| </svg> | |
| <svg id="stop-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none"> | |
| <rect x="6" y="6" width="12" height="12" rx="2"/> | |
| </svg> | |
| </button> | |
| <div class="mic-label" id="mic-label">Appuyez pour parler</div> | |
| </div> | |
| <!-- Controls --> | |
| <div class="controls"> | |
| <button class="btn btn-stop" id="btn-end" onclick="endSession()">Terminer la séance</button> | |
| <button class="btn" id="btn-reset" onclick="resetSession()">Nouvelle séance</button> | |
| </div> | |
| <!-- Status --> | |
| <div id="status"></div> | |
| </div> | |
| <!-- Right column --> | |
| <div class="right-col"> | |
| <!-- Chat --> | |
| <div class="chat-wrap"> | |
| <div class="chat-header"> | |
| <span class="chat-header-label">Transcription</span> | |
| <span class="chat-header-live">En direct</span> | |
| </div> | |
| <div id="chat"></div> | |
| </div> | |
| <!-- Feedback --> | |
| <div class="feedback open" id="feedback"> | |
| <div class="feedback-header" onclick="toggleFeedback()"> | |
| <div style="display:flex;align-items:center;gap:10px"> | |
| <h3>Bilan de la séance</h3> | |
| <span class="feedback-badge" id="feedback-count">0 notes</span> | |
| </div> | |
| <span class="arrow">▾</span> | |
| </div> | |
| <div class="feedback-body"> | |
| <div class="feedback-intro" id="feedback-intro"></div> | |
| <div class="feedback-empty" id="feedback-empty">Le bilan se construira au fil de la conversation.</div> | |
| <div id="feedback-points-forts" class="feedback-section" style="display:none"> | |
| <div class="feedback-section-title">Points forts</div> | |
| <ul class="feedback-list" id="feedback-points-list"></ul> | |
| </div> | |
| <table class="feedback-table" id="feedback-table" style="display:none"> | |
| <thead><tr><th>Citation</th><th>Correction</th><th>Pourquoi</th></tr></thead> | |
| <tbody id="feedback-tbody"></tbody> | |
| </table> | |
| <div id="feedback-vocab" class="feedback-section" style="display:none"> | |
| <div class="feedback-section-title">Vocabulaire dentaire utile</div> | |
| <ul class="feedback-list" id="feedback-vocab-list"></ul> | |
| </div> | |
| <div id="feedback-priorite" class="feedback-section" style="display:none"> | |
| <div class="feedback-section-title">Priorité pour la prochaine séance</div> | |
| <ul class="feedback-list" id="feedback-priorite-list"></ul> | |
| </div> | |
| <div id="feedback-bilan" class="feedback-section" style="display:none"> | |
| <div class="feedback-section-title">Bilan</div> | |
| <div id="feedback-bilan-rows"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Footer --> | |
| <div class="footer"> | |
| <a href="/field-notes" style="color:#ff5719;text-decoration:none;">📝 Field Notes</a><br> | |
| Modèle: Gemma 4 26B-A4B (Modal·L4) · STT: faster-whisper (Modal·T4) · TTS: piper-tts | |
| </div> | |
| </div> | |
| <audio id="audio-player" autoplay></audio> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.2.1/dist/browser.js"; | |
| let client = null; | |
| let sessionState = null; | |
| let mediaRecorder = null; | |
| let audioChunks = []; | |
| let isRecording = false; | |
| let isProcessing = false; | |
| const micBtn = document.getElementById("mic-btn"); | |
| const micIcon = document.getElementById("mic-icon"); | |
| const stopIcon = document.getElementById("stop-icon"); | |
| const micLabel = document.getElementById("mic-label"); | |
| const statusEl = document.getElementById("status"); | |
| const chatEl = document.getElementById("chat"); | |
| const feedbackEl = document.getElementById("feedback"); | |
| const feedbackIntro = document.getElementById("feedback-intro"); | |
| const feedbackTbody = document.getElementById("feedback-tbody"); | |
| const feedbackTable = document.getElementById("feedback-table"); | |
| const feedbackEmpty = document.getElementById("feedback-empty"); | |
| const feedbackCount = document.getElementById("feedback-count"); | |
| const feedbackPointsForts = document.getElementById("feedback-points-forts"); | |
| const feedbackPointsList = document.getElementById("feedback-points-list"); | |
| const feedbackVocab = document.getElementById("feedback-vocab"); | |
| const feedbackVocabList = document.getElementById("feedback-vocab-list"); | |
| const feedbackPriorite = document.getElementById("feedback-priorite"); | |
| const feedbackPrioriteList = document.getElementById("feedback-priorite-list"); | |
| const feedbackBilan = document.getElementById("feedback-bilan"); | |
| const feedbackBilanRows = document.getElementById("feedback-bilan-rows"); | |
| const audioPlayer = document.getElementById("audio-player"); | |
| const btnEnd = document.getElementById("btn-end"); | |
| const btnReset = document.getElementById("btn-reset"); | |
| // Connect to Gradio backend | |
| async function initClient() { | |
| try { | |
| client = await Client.connect(window.location.origin); | |
| const result = await client.predict("/reset_session", {}); | |
| sessionState = result.data[0].state; | |
| console.log("Connected to backend"); | |
| } catch (err) { | |
| console.error("Failed to connect:", err); | |
| setStatus("⛔ Impossible de se connecter au serveur."); | |
| } | |
| } | |
| // Recording | |
| async function toggleRecording() { | |
| if (isProcessing) return; | |
| if (isRecording) { | |
| stopRecording(); | |
| } else { | |
| startRecording(); | |
| } | |
| } | |
| async function startRecording() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| audioChunks = []; | |
| mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" }); | |
| mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); }; | |
| mediaRecorder.onstop = () => { | |
| stream.getTracks().forEach(t => t.stop()); | |
| const blob = new Blob(audioChunks, { type: "audio/webm" }); | |
| processAudio(blob); | |
| }; | |
| mediaRecorder.start(); | |
| isRecording = true; | |
| micBtn.classList.add("recording"); | |
| micIcon.style.display = "none"; | |
| stopIcon.style.display = "block"; | |
| micLabel.textContent = "Enregistrement…"; | |
| micLabel.classList.add("active"); | |
| setStatus(""); | |
| } catch (err) { | |
| setStatus("⛔ Microphone non disponible."); | |
| } | |
| } | |
| function stopRecording() { | |
| if (mediaRecorder && mediaRecorder.state === "recording") { | |
| mediaRecorder.stop(); | |
| } | |
| isRecording = false; | |
| micBtn.classList.remove("recording"); | |
| micIcon.style.display = "block"; | |
| stopIcon.style.display = "none"; | |
| micLabel.textContent = "Appuyez pour parler"; | |
| micLabel.classList.remove("active"); | |
| } | |
| // Process audio turn | |
| async function processAudio(blob) { | |
| if (!client) return; | |
| isProcessing = true; | |
| micBtn.disabled = true; | |
| btnEnd.disabled = true; | |
| setStatus("🎙 Transcription…"); | |
| try { | |
| const audioFile = new File([blob], "recording.webm", { type: blob.type || "audio/webm" }); | |
| setStatus("🧠 Réflexion…"); | |
| const result = await client.predict("/process_turn", { | |
| audio: audioFile, | |
| state: sessionState, | |
| }); | |
| const d = result.data[0]; | |
| sessionState = d.state; | |
| // Render chat | |
| renderChat(d.chat); | |
| // Play audio | |
| if (d.audio_url) { | |
| setStatus("🔊 Synthèse vocale…"); | |
| await playAudio(d.audio_url); | |
| } else { | |
| const bubbles = chatEl.querySelectorAll(".msg-patient"); | |
| const last = bubbles[bubbles.length - 1]; | |
| if (last) last.innerHTML += ' <span class="no-audio">🔇</span>'; | |
| } | |
| // Feedback | |
| if (d.feedback_open) { | |
| showFeedback(d.feedback_intro, d.feedback_table, d.feedback_points_forts, d.feedback_vocabulaire, d.feedback_priorite, d.feedback_bilan); | |
| } | |
| setStatus(d.status || ""); | |
| } catch (err) { | |
| console.error("Process error:", err); | |
| setStatus("⛔ Erreur. Réessayez."); | |
| } finally { | |
| isProcessing = false; | |
| micBtn.disabled = false; | |
| btnEnd.disabled = false; | |
| } | |
| } | |
| // End session | |
| async function endSession() { | |
| if (!client || isProcessing) return; | |
| isProcessing = true; | |
| micBtn.disabled = true; | |
| btnEnd.disabled = true; | |
| setStatus("📝 Génération du récapitulatif…"); | |
| try { | |
| const result = await client.predict("/end_session", { state: sessionState }); | |
| const d = result.data[0]; | |
| sessionState = d.state; | |
| renderChat(d.chat); | |
| if (d.audio_url) { | |
| setStatus("🔊 Synthèse vocale…"); | |
| await playAudio(d.audio_url); | |
| } | |
| if (d.feedback_open) { | |
| showFeedback(d.feedback_intro, d.feedback_table, d.feedback_points_forts, d.feedback_vocabulaire, d.feedback_priorite, d.feedback_bilan); | |
| } | |
| setStatus(""); | |
| } catch (err) { | |
| console.error("End session error:", err); | |
| setStatus("⛔ Erreur lors de la génération du bilan."); | |
| } finally { | |
| isProcessing = false; | |
| micBtn.disabled = false; | |
| btnEnd.disabled = false; | |
| } | |
| } | |
| // Reset | |
| async function resetSession() { | |
| if (!client || isProcessing) return; | |
| try { | |
| const result = await client.predict("/reset_session", {}); | |
| sessionState = result.data[0].state; | |
| chatEl.innerHTML = ""; | |
| feedbackEl.classList.add("open"); | |
| feedbackIntro.textContent = ""; | |
| feedbackTbody.innerHTML = ""; | |
| feedbackTable.style.display = "none"; | |
| feedbackEmpty.style.display = ""; | |
| feedbackPointsForts.style.display = "none"; | |
| feedbackPointsList.innerHTML = ""; | |
| feedbackVocab.style.display = "none"; | |
| feedbackVocabList.innerHTML = ""; | |
| feedbackPriorite.style.display = "none"; | |
| feedbackPrioriteList.innerHTML = ""; | |
| feedbackBilan.style.display = "none"; | |
| feedbackBilanRows.innerHTML = ""; | |
| feedbackCount.textContent = "0 notes"; | |
| setStatus(""); | |
| audioPlayer.pause(); | |
| audioPlayer.src = ""; | |
| } catch (err) { | |
| console.error("Reset error:", err); | |
| } | |
| } | |
| // Render chat | |
| function renderChat(messages) { | |
| chatEl.innerHTML = ""; | |
| for (const msg of messages) { | |
| if (msg.role === "system") continue; | |
| const div = document.createElement("div"); | |
| div.className = `msg ${msg.role === "user" ? "msg-user" : "msg-patient"}`; | |
| const roleLabel = msg.role === "user" ? "Vous" : "Patient"; | |
| div.innerHTML = `<div class="msg-role">${roleLabel}</div>${escapeHtml(msg.content)}`; | |
| chatEl.appendChild(div); | |
| } | |
| chatEl.scrollTop = chatEl.scrollHeight; | |
| } | |
| // Feedback | |
| function showFeedback(intro, table, pointsForts, vocabulaire, priorite, bilan) { | |
| feedbackIntro.textContent = intro; | |
| feedbackTbody.innerHTML = ""; | |
| // Points forts | |
| if (pointsForts && pointsForts.length) { | |
| feedbackPointsForts.style.display = ""; | |
| feedbackPointsList.innerHTML = ""; | |
| for (const item of pointsForts) { | |
| const li = document.createElement("li"); | |
| li.textContent = item; | |
| feedbackPointsList.appendChild(li); | |
| } | |
| } else { | |
| feedbackPointsForts.style.display = "none"; | |
| } | |
| // Error table | |
| if (table && table.length) { | |
| feedbackTable.style.display = ""; | |
| feedbackEmpty.style.display = "none"; | |
| feedbackCount.textContent = table.length + (table.length > 1 ? " notes" : " note"); | |
| for (const row of table) { | |
| const tr = document.createElement("tr"); | |
| const cells = Array.isArray(row) ? row : [row["citation"], row["correction"], row["pourquoi"]]; | |
| for (const cell of cells) { | |
| const td = document.createElement("td"); | |
| td.textContent = cell || ""; | |
| tr.appendChild(td); | |
| } | |
| feedbackTbody.appendChild(tr); | |
| } | |
| } else { | |
| feedbackTable.style.display = "none"; | |
| } | |
| // Vocabulaire dentaire utile | |
| if (vocabulaire && vocabulaire.length) { | |
| feedbackVocab.style.display = ""; | |
| feedbackVocabList.innerHTML = ""; | |
| for (const item of vocabulaire) { | |
| const li = document.createElement("li"); | |
| li.textContent = item; | |
| feedbackVocabList.appendChild(li); | |
| } | |
| } else { | |
| feedbackVocab.style.display = "none"; | |
| } | |
| // Priorité | |
| if (priorite && priorite.length) { | |
| feedbackPriorite.style.display = ""; | |
| feedbackPrioriteList.innerHTML = ""; | |
| for (const item of priorite) { | |
| const li = document.createElement("li"); | |
| li.textContent = item; | |
| feedbackPrioriteList.appendChild(li); | |
| } | |
| } else { | |
| feedbackPriorite.style.display = "none"; | |
| } | |
| // Bilan scores with progress bars | |
| if (bilan && Object.keys(bilan).length) { | |
| feedbackBilan.style.display = ""; | |
| feedbackBilanRows.innerHTML = ""; | |
| const labels = { | |
| grammaire: "Grammaire", | |
| fluidite: "Fluidité", | |
| vocabulaire_dentaire: "Vocabulaire dentaire", | |
| communication_clinique: "Communication clinique", | |
| }; | |
| for (const [key, label] of Object.entries(labels)) { | |
| const val = bilan[key]; | |
| if (val == null) continue; | |
| const pct = Math.max(10, (val / 5) * 100); | |
| const row = document.createElement("div"); | |
| row.className = "bilan-row"; | |
| row.innerHTML = | |
| `<span class="bilan-label">${label}</span>` + | |
| `<div class="bilan-bar"><div class="bilan-fill" style="width:${pct}%"></div></div>` + | |
| `<span class="bilan-score">${val}/5</span>`; | |
| feedbackBilanRows.appendChild(row); | |
| } | |
| } else { | |
| feedbackBilan.style.display = "none"; | |
| } | |
| // Show table count if any visible | |
| if (!table || !table.length) { | |
| feedbackEmpty.style.display = "none"; | |
| feedbackCount.textContent = ""; | |
| } | |
| feedbackEl.classList.add("open"); | |
| } | |
| function toggleFeedback() { | |
| feedbackEl.classList.toggle("open"); | |
| } | |
| // Audio playback | |
| function playAudio(url) { | |
| return new Promise((resolve) => { | |
| audioPlayer.onended = resolve; | |
| audioPlayer.onerror = resolve; | |
| audioPlayer.src = url; | |
| audioPlayer.play().catch(resolve); | |
| }); | |
| } | |
| // Status | |
| function setStatus(text) { | |
| statusEl.textContent = text; | |
| if (text && text.startsWith("🧠")) { | |
| statusEl.classList.add("dots"); | |
| } else { | |
| statusEl.classList.remove("dots"); | |
| } | |
| } | |
| function escapeHtml(text) { | |
| const div = document.createElement("div"); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Init | |
| initClient(); | |
| // Expose to global scope for onclick handlers | |
| window.toggleRecording = toggleRecording; | |
| window.endSession = endSession; | |
| window.resetSession = resetSession; | |
| window.toggleFeedback = toggleFeedback; | |
| </script> | |
| </body> | |
| </html> | |