diff --git a/public/components/assistant.html b/public/components/assistant.html index 7a2742f..e6b4f59 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -45,6 +45,8 @@
@@ -71,6 +73,7 @@ + diff --git a/public/css/assistant.css b/public/css/assistant.css index 8fcd03e..704019d 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -182,3 +182,23 @@ .assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; } .assistant-side { gap:10px; } } + +/* Patient take home modal + actions */ +.assistant-takehome-modal { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center; z-index: 120; } +.assistant-takehome-modal .modal-content { width: min(640px, 92vw); max-height: 82vh; display: flex; flex-direction: column; } +.assistant-takehome-result { white-space: pre-wrap; overflow-y: auto; font-size: .95rem; line-height: 1.55; } +.assistant-takehome-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; border-top: 1px solid var(--border, #e5e7eb); padding-top: .75rem; } +.assistant-takehome-email { display: flex; gap: .5rem; margin-left: auto; } +.assistant-takehome-email input { width: 220px; } +.assistant-takehome-loading { padding: 1.25rem 0; color: var(--muted, #6b7280); } +.assistant-translate-langs { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .4rem; } +.assistant-translate-none { color: var(--muted, #6b7280); font-size: .85rem; } + +/* Voice: composer mic + conversation overlay */ +.assistant-mic.recording { background: var(--danger, #dc2626); color: #fff; } +.assistant-voice-overlay { position: fixed; inset: 0; background: rgba(15,23,42,.55); display: flex; align-items: center; justify-content: center; z-index: 115; } +.assistant-voice-card { background: var(--surface, #fff); border-radius: 14px; padding: 1.75rem 2rem; text-align: center; box-shadow: 0 18px 50px rgba(0,0,0,.28); display: flex; flex-direction: column; gap: 1rem; align-items: center; min-width: 280px; } +.assistant-voice-status { font-size: 1rem; color: var(--muted, #6b7280); min-height: 1.4rem; } +.assistant-voice-mic { width: 72px; height: 72px; border-radius: 50%; border: none; background: var(--accent, #0f766e); color: #fff; font-size: 1.6rem; cursor: pointer; } +.assistant-voice-mic.recording { background: var(--danger, #dc2626); animation: assistant-pulse 1.2s infinite; } +@keyframes assistant-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.45); } 50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); } } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index d3a24c3..24e762e 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -50,6 +50,39 @@ import { var imageStore = createAssistantImageStore(); var TRANSLATE_LANGUAGES = [['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['it', 'Italiano'], ['pt', 'Português'], ['zh', '中文'], ['ar', 'العربية'], ['ru', 'Русский'], ['hi', 'हिन्दी'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['tr', 'Türkçe'], ['uk', 'Українська'], ['fa', 'فارسی'], ['sw', 'Kiswahili']]; + var translateLanguagesAvailable = null; // { libretranslate: [...], deepl: [...] } + var translateLanguagesLoading = false; + + function refreshTranslateLanguages(pop) { + if (translateLanguagesLoading) return; + translateLanguagesLoading = true; + fetch('/clinical-assistant/translate/languages', { headers: getAuthHeaders(), credentials: 'same-origin' }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data && data.success && data.languages) translateLanguagesAvailable = data.languages; + }) + .catch(function() { /* keep the full static list on failure */ }) + .finally(function() { + translateLanguagesLoading = false; + var open = document.querySelector('[data-assistant-translate-pop]'); + if (open) renderTranslateLanguages(open, open.assistantMessageRow); + }); + } + + function renderTranslateLanguages(pop, row) { + var list = pop.querySelector('.assistant-translate-langs'); + if (!list) return; + var available = translateLanguagesAvailable; + var pairs = TRANSLATE_LANGUAGES.filter(function(pair) { + if (!available) return true; + var codes = available[translateProvider] || available.libretranslate || []; + return codes.indexOf(pair[0]) !== -1; + }); + list.innerHTML = pairs.map(function(pair) { + return ''; + }).join(''); + if (!pairs.length) list.innerHTML = 'No languages available for this provider.'; + } var SOURCE_EXCERPT_PREVIEW = 10000; document.addEventListener('tabChanged', function (e) { @@ -100,6 +133,12 @@ import { if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat); if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel); if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf); + var takehomeBtn = document.getElementById('btn-assistant-takehome'); + if (takehomeBtn) takehomeBtn.addEventListener('click', openPatientTakehome); + var micBtn = document.getElementById('btn-assistant-mic'); + if (micBtn) micBtn.addEventListener('click', toggleAssistantMic); + var voiceBtn = document.getElementById('btn-assistant-voice'); + if (voiceBtn) voiceBtn.addEventListener('click', openConversationMode); if (imageBtn) imageBtn.addEventListener('click', generateImage); if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage); if (attachInput) attachInput.addEventListener('change', onAttachFiles); @@ -890,6 +929,280 @@ import { } // ── Per-message translate with provider choice (local-first) ─────── + + // ── Voice: mic dictation in the composer + conversation (call) mode ── + var assistantMic = { recording: false, recorder: null, recognition: null, finalText: '', sessionFinals: '' }; + + function toggleAssistantMic() { + if (assistantMic.recording) stopAssistantMic(); + else startAssistantMic(); + } + + function startAssistantMic() { + var input = document.getElementById('assistant-input'); + assistantMic.finalText = String(input && input.value || ''); + assistantMic.sessionFinals = ''; + assistantMic.recorder = new AudioRecorder(); + assistantMic.recording = true; + assistantMic.recorder.start().then(function() { + var btn = document.getElementById('btn-assistant-mic'); + if (btn) { btn.classList.add('recording'); var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-stop'; } + assistantMic.recognition = createSpeechRecognition(); + if (assistantMic.recognition) { + assistantMic.recognition.onresult = function(e) { + var interim = ''; + for (var i = e.resultIndex; i < e.results.length; i++) { + if (e.results[i].isFinal) assistantMic.sessionFinals += e.results[i][0].transcript + ' '; + else interim = e.results[i][0].transcript; + } + var input2 = document.getElementById('assistant-input'); + if (input2) { input2.value = (assistantMic.finalText + assistantMic.sessionFinals).trim() + (interim ? ' ' + interim : ''); updateConversationBudget(); } + }; + assistantMic.recognition.onend = function() { if (assistantMic.recording) { try { assistantMic.recognition.start(); } catch (e) {} } }; + try { assistantMic.recognition.start(); } catch (e) {} + } + }).catch(function() { + assistantMic.recording = false; + if (typeof showToast === 'function') showToast('Microphone denied', 'error'); + }); + } + + function stopAssistantMic() { + assistantMic.recording = false; + var btn = document.getElementById('btn-assistant-mic'); + if (btn) { btn.classList.remove('recording'); var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-microphone'; } + if (assistantMic.recognition) { try { assistantMic.recognition.stop(); } catch (e) {} assistantMic.recognition = null; } + var recorder = assistantMic.recorder; + assistantMic.recorder = null; + if (!recorder) return; + recorder.stop().then(function(blob) { + if (typeof transcribeAudio === 'function' && blob && blob.size > 100) { + transcribeAudio(blob).then(function(data) { + if (data && data.success && data.text) { + var input = document.getElementById('assistant-input'); + if (input) { input.value = data.text; updateConversationBudget(); } + } + }).catch(function() {}); + } + }).catch(function() {}); + } + + var conversationMode = { active: false, listening: false, recorder: null, recognition: null, sessionFinals: '' }; + + function openConversationMode() { + if (conversationMode.active) return; + var view = document.getElementById('assistant-chat-view'); + if (!view) return; + conversationMode.active = true; + var overlay = document.createElement('div'); + overlay.className = 'assistant-voice-overlay'; + overlay.id = 'assistant-voice-overlay'; + overlay.innerHTML = '' + String(text).replace(/&/g,'&').replace(//g,'>') + '
' + + 'This summary was created for a caregiver. Keep following your care team’s instructions.