Spaces:
Paused
Paused
| <html lang="uz"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Help.me - Tez Tibbiy Yordam</title> | |
| <link rel="icon" href="/static/favicon.ico" type="image/x-icon"> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> | |
| <style> | |
| :root { | |
| --bg-dark: #0f172a; | |
| --bg-medium: #1e293b; | |
| --bg-light: #334155; | |
| --text-primary: #f1f5f9; | |
| --text-secondary: #94a3b8; | |
| --accent-red: #ef4444; | |
| --accent-green: #10b981; | |
| --accent-blue: #3b82f6; | |
| --accent-purple: #8b5cf6; | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: var(--text-primary); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| padding: 1rem; | |
| overflow: hidden; | |
| } | |
| .app-container { | |
| width: 100%; | |
| max-width: 420px; | |
| height: 90vh; | |
| max-height: 850px; | |
| background: var(--bg-dark); | |
| border-radius: 24px; | |
| box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.5); | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| position: relative; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| animation: slideInFromBottom 0.5s ease-out; | |
| } | |
| @keyframes slideInFromBottom { | |
| from { | |
| transform: translateY(50px); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateY(0); | |
| opacity: 1; | |
| } | |
| } | |
| .app-header { | |
| padding: 16px 20px; | |
| text-align: center; | |
| flex-shrink: 0; | |
| position: relative; | |
| background: var(--bg-medium); | |
| border-bottom: 1px solid var(--bg-light); | |
| } | |
| .app-header h1 { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| margin: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| color: var(--accent-blue); | |
| } | |
| /* YANGI: Admin paneli tugmasi uchun yaxshilangan stillar */ | |
| .admin-panel-btn { | |
| position: absolute; | |
| top: 12px; | |
| right: 12px; | |
| background: var(--accent-blue); | |
| color: white; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 6px 10px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-decoration: none; | |
| transition: all 0.2s ease; | |
| animation: pulse-admin 2s infinite; | |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); | |
| } | |
| .admin-panel-btn:hover { | |
| background: var(--accent-blue); | |
| color: white; | |
| transform: scale(1.05); | |
| } | |
| /* YANGI: Admin tugmasi uchun pulsatsiya animatsiyasi */ | |
| @keyframes pulse-admin { | |
| 0% { | |
| box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7); | |
| } | |
| 70% { | |
| box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); | |
| } | |
| 100% { | |
| box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); | |
| } | |
| } | |
| .connection-status { | |
| padding: 8px 16px; | |
| text-align: center; | |
| font-size: 0.8rem; | |
| flex-shrink: 0; | |
| transition: all 0.3s ease; | |
| background: var(--bg-medium); | |
| } | |
| .connection-status.connected { | |
| color: var(--accent-green); | |
| } | |
| .connection-status.disconnected { | |
| color: var(--accent-red); | |
| } | |
| .chat-container { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px; | |
| background: var(--bg-dark); | |
| } | |
| .message { | |
| margin-bottom: 16px; | |
| display: flex; | |
| gap: 10px; | |
| animation: messageSlide 0.3s ease-out; | |
| } | |
| @keyframes messageSlide { | |
| from { | |
| opacity: 0; | |
| transform: translateY(20px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .message.user { | |
| flex-direction: row-reverse; | |
| } | |
| .message-avatar { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.2rem; | |
| flex-shrink: 0; | |
| } | |
| .message.ai .message-avatar { | |
| background: linear-gradient(135deg, var(--accent-blue), #2563eb); | |
| } | |
| .message.user .message-avatar { | |
| background: linear-gradient(135deg, var(--accent-green), #059669); | |
| } | |
| .message-content { | |
| max-width: 75%; | |
| padding: 12px 16px; | |
| border-radius: 16px; | |
| line-height: 1.5; | |
| font-size: 0.95rem; | |
| } | |
| .message.ai .message-content { | |
| background: var(--bg-medium); | |
| color: var(--text-primary); | |
| border-bottom-left-radius: 4px; | |
| } | |
| .message.user .message-content { | |
| background: var(--accent-blue); | |
| color: white; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .typing-indicator { | |
| display: none; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 12px 16px; | |
| background: var(--bg-medium); | |
| border-radius: 16px; | |
| width: fit-content; | |
| margin-bottom: 16px; | |
| } | |
| .typing-indicator.show { | |
| display: flex; | |
| } | |
| .typing-dots { | |
| display: flex; | |
| gap: 4px; | |
| } | |
| .typing-dots span { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--text-secondary); | |
| animation: typingBounce 1.4s infinite ease-in-out both; | |
| } | |
| .typing-dots span:nth-child(1) { | |
| animation-delay: -0.32s; | |
| } | |
| .typing-dots span:nth-child(2) { | |
| animation-delay: -0.16s; | |
| } | |
| @keyframes typingBounce { | |
| 0%, | |
| 80%, | |
| 100% { | |
| transform: scale(0); | |
| } | |
| 40% { | |
| transform: scale(1.0); | |
| } | |
| } | |
| .input-container { | |
| background: var(--bg-dark); | |
| padding: 20px; | |
| border-top: 1px solid var(--bg-light); | |
| flex-shrink: 0; | |
| } | |
| .recording-mode { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .record-button-wrapper { | |
| position: relative; | |
| width: 90px; | |
| height: 90px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-top: 10px; | |
| } | |
| .record-button { | |
| width: 80px; | |
| height: 80px; | |
| border-radius: 50%; | |
| border: none; | |
| background: var(--accent-red); | |
| color: white; | |
| font-size: 2rem; | |
| cursor: pointer; | |
| box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.5); | |
| transition: all 0.3s ease; | |
| z-index: 5; | |
| animation: pulse-red 2s infinite; | |
| } | |
| @keyframes pulse-red { | |
| 0% { | |
| box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); | |
| } | |
| 70% { | |
| box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); | |
| } | |
| 100% { | |
| box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); | |
| } | |
| } | |
| .record-button.recording { | |
| background: var(--accent-green); | |
| animation: none; | |
| box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.5); | |
| } | |
| .visualizer-ring { | |
| position: absolute; | |
| width: 90px; | |
| height: 90px; | |
| border: 2px solid var(--accent-green); | |
| border-radius: 50%; | |
| opacity: 0; | |
| transform: scale(1); | |
| } | |
| .recording .visualizer-ring { | |
| animation: wave 2s infinite ease-out; | |
| } | |
| .visualizer-ring:nth-child(2) { | |
| animation-delay: 0.5s; | |
| } | |
| .visualizer-ring:nth-child(3) { | |
| animation-delay: 1s; | |
| } | |
| .visualizer-ring:nth-child(4) { | |
| animation-delay: 1.5s; | |
| } | |
| @keyframes wave { | |
| 0% { | |
| transform: scale(1); | |
| opacity: 0.7; | |
| } | |
| 100% { | |
| transform: scale(2.5); | |
| opacity: 0; | |
| } | |
| } | |
| .recording-status { | |
| text-align: center; | |
| color: var(--text-secondary); | |
| font-size: 0.9rem; | |
| height: 25px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .lang-switcher { | |
| display: flex; | |
| justify-content: center; | |
| gap: 8px; | |
| margin-top: 8px; | |
| margin-bottom: 8px; | |
| } | |
| .lang-btn { | |
| background: var(--bg-light); | |
| border: none; | |
| color: var(--text-secondary); | |
| padding: 4px 12px; | |
| border-radius: 16px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .lang-btn:hover { | |
| background: var(--bg-light); | |
| color: var(--text-primary); | |
| } | |
| .lang-btn.active { | |
| background: var(--accent-blue); | |
| color: white; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app-container"> | |
| <!-- App Header --> | |
| <div class="app-header"> | |
| <a href="/dispatcher" target="_blank" class="admin-panel-btn" title="Dispetcher Panel"> | |
| <i class="bi bi-shield-lock-fill"></i> Admin Panel | |
| </a> | |
| <h1 data-translate-key="appTitle"> | |
| Tez Tibbiy Yordam | |
| </h1> | |
| </div> | |
| <!-- Connection Status --> | |
| <div id="connection-status" class="connection-status disconnected"> | |
| <i class="bi bi-circle-fill" style="font-size: 0.6rem; margin-right: 6px;"></i> | |
| <span data-translate-key="connecting">Serverga ulanmoqda...</span> | |
| </div> | |
| <!-- Chat Messages --> | |
| <div id="chat-container" class="chat-container"> | |
| <!-- Salomlashuv xabari uchun joy --> | |
| </div> | |
| <!-- Input Container --> | |
| <div class="input-container"> | |
| <div id="recording-mode" class="recording-mode"> | |
| <p class="text-center" data-translate-key="speakLangs" | |
| style="font-size: 0.85rem; color: var(--text-secondary); letter-spacing: 0.5px;"> | |
| (GAPIRING: UZB / ENG / RUS) | |
| </p> | |
| <div class="lang-switcher"> | |
| <button class="lang-btn active" onclick="setLanguage('uz')">UZ</button> | |
| <button class="lang-btn" onclick="setLanguage('ru')">RU</button> | |
| <button class="lang-btn" onclick="setLanguage('en')">EN</button> | |
| </div> | |
| <div id="record-button-wrapper" class="record-button-wrapper"> | |
| <div class="visualizer-ring"></div> | |
| <div class="visualizer-ring"></div> | |
| <div class="visualizer-ring"></div> | |
| <div class="visualizer-ring"></div> | |
| <button id="record-button" class="record-button"> | |
| <i class="bi bi-mic-fill"></i> | |
| </button> | |
| </div> | |
| <div class="recording-status"> | |
| <span id="recording-text" data-translate-key="recordInstruction"> | |
| Yordam so'rash uchun tugmani bosing | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ==================== GLOBAL VARIABLES ==================== | |
| let ws = null; | |
| let mediaRecorder = null; | |
| let audioChunks = []; | |
| let isRecording = false; | |
| let reconnectAttempts = 0; | |
| const MAX_RECONNECT_ATTEMPTS = 5; | |
| const translations = { | |
| uz: { | |
| appTitle: 'Tez Tibbiy Yordam', | |
| speakLangs: '(GAPIRING: UZB / ENG / RUS)', | |
| connecting: 'Serverga ulanmoqda...', | |
| connected: 'Ulandi', | |
| disconnected: 'Uzildi', | |
| connectionFailed: 'Ulanib bo\'lmadi. Sahifani yangilang.', | |
| recordInstruction: 'Yordam so\'rash uchun tugmani bosing', | |
| recording: 'Yozilmoqda... To\'xtatish uchun bosing', | |
| micError: 'Mikrofonni ishlatib bo\'lmadi. Ruxsat bering.', | |
| wsNotConnected: 'Server bilan aloqa yo\'q. Iltimos, kuting...', | |
| welcomeMessage: 'Assalomu alaykum! Men sizning AI yordamchingizman. Sizga qanday yordam bera olaman?' | |
| }, | |
| ru: { | |
| appTitle: 'Скорая Помощь', | |
| speakLangs: '(ГОВОРИТЕ: UZB / ENG / RUS)', | |
| connecting: 'Соединение с сервером...', | |
| connected: 'Подключено', | |
| disconnected: 'Отключено', | |
| connectionFailed: 'Не удалось подключиться. Обновите страницу.', | |
| recordInstruction: 'Нажмите кнопку, чтобы позвать на помощь', | |
| recording: 'Идет запись... Нажмите, чтобы остановить', | |
| micError: 'Не удалось получить доступ к микрофону. Разрешите доступ.', | |
| wsNotConnected: 'Нет соединения с сервером. Подождите...', | |
| welcomeMessage: 'Здравствуйте! Я ваш AI-помощник. Чем я могу вам помочь?' | |
| }, | |
| en: { | |
| appTitle: 'Emergency Service', | |
| speakLangs: '(SPEAK IN: UZB / ENG / RUS)', | |
| connecting: 'Connecting to server...', | |
| connected: 'Connected', | |
| disconnected: 'Disconnected', | |
| connectionFailed: 'Failed to connect. Please refresh the page.', | |
| recordInstruction: 'Press the button to call for help', | |
| recording: 'Recording... Press to stop', | |
| micError: 'Could not access the microphone. Please grant permission.', | |
| wsNotConnected: 'Not connected to the server. Please wait...', | |
| welcomeMessage: 'Hello! I am your AI assistant. How can I help you?' | |
| } | |
| }; | |
| // ==================== DOM ELEMENTS ==================== | |
| const chatContainer = document.getElementById('chat-container'); | |
| const connectionStatusDiv = document.getElementById('connection-status'); | |
| const recordButton = document.getElementById('record-button'); | |
| const recordButtonWrapper = document.getElementById('record-button-wrapper'); | |
| const recordingText = document.getElementById('recording-text'); | |
| // ==================== LANGUAGE FUNCTIONALITY ==================== | |
| function setLanguage(lang) { | |
| if (!translations[lang]) return; | |
| document.documentElement.lang = lang; | |
| document.querySelectorAll('[data-translate-key]').forEach(el => { | |
| const key = el.dataset.translateKey; | |
| if (translations[lang][key]) { | |
| el.innerHTML = translations[lang][key]; | |
| } | |
| }); | |
| document.querySelectorAll('.lang-btn').forEach(btn => { | |
| btn.classList.remove('active'); | |
| if (btn.innerText.toLowerCase() === lang) { | |
| btn.classList.add('active'); | |
| } | |
| }); | |
| localStorage.setItem('preferredLanguage', lang); | |
| updateConnectionStatus(ws && ws.readyState === WebSocket.OPEN); | |
| } | |
| // ==================== WEBSOCKET CONNECTION ==================== | |
| function connectWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws/chat`; | |
| console.log('🔌 WebSocket ulanish:', wsUrl); | |
| ws = new WebSocket(wsUrl); | |
| ws.onopen = () => { | |
| console.log('✅ WebSocket ulandi'); | |
| reconnectAttempts = 0; | |
| updateConnectionStatus(true); | |
| }; | |
| ws.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| console.log('📨 Xabar olindi:', data); | |
| handleWebSocketMessage(data); | |
| } catch (e) { | |
| console.error('❌ Xabar parse xatoligi:', e); | |
| } | |
| }; | |
| ws.onerror = (error) => { | |
| console.error('❌ WebSocket xatolik:', error); | |
| updateConnectionStatus(false); | |
| }; | |
| ws.onclose = () => { | |
| console.log('📴 WebSocket uzildi'); | |
| updateConnectionStatus(false); | |
| if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { | |
| reconnectAttempts++; | |
| setTimeout(connectWebSocket, 3000); | |
| } else { | |
| const lang = localStorage.getItem('preferredLanguage') || 'uz'; | |
| connectionStatusDiv.querySelector('span').textContent = translations[lang].connectionFailed; | |
| } | |
| }; | |
| } | |
| function updateConnectionStatus(connected) { | |
| const lang = localStorage.getItem('preferredLanguage') || 'uz'; | |
| const statusText = connected ? translations[lang].connected : translations[lang].disconnected; | |
| connectionStatusDiv.className = `connection-status ${connected ? 'connected' : 'disconnected'}`; | |
| connectionStatusDiv.innerHTML = `<i class="bi bi-circle-fill" style="font-size: 0.6rem; margin-right: 6px;"></i> ${statusText}`; | |
| } | |
| // ==================== MESSAGE HANDLING ==================== | |
| function handleWebSocketMessage(data) { | |
| hideTypingIndicator(); | |
| switch (data.type) { | |
| case 'ai_response': addMessage('ai', data.text); break; | |
| case 'audio_response': playAudioResponse(data.audio_url); break; | |
| case 'transcription_result': | |
| addMessage('user', data.text); | |
| showTypingIndicator(); | |
| break; | |
| case 'error': addMessage('ai', `❌ Xatolik: ${data.message}`); break; | |
| default: console.log('📦 Noma\'lum xabar turi:', data.type); | |
| } | |
| } | |
| function playAudioResponse(audioUrl) { | |
| try { | |
| console.log('🔊 Audio ijro etilmoqda:', audioUrl); | |
| const audio = new Audio(audioUrl); | |
| audio.play().catch(error => console.error('❌ Audio play xatoligi:', error)); | |
| } catch (error) { | |
| console.error('❌ playAudioResponse xatoligi:', error); | |
| } | |
| } | |
| function addMessage(sender, text, isWelcome = false) { | |
| const typingIndicator = document.getElementById('typing-indicator'); | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = `message ${sender}`; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'message-avatar'; | |
| avatar.innerHTML = sender === 'ai' ? '<i class="bi bi-robot"></i>' : '<i class="bi bi-person-fill"></i>'; | |
| const content = document.createElement('div'); | |
| content.className = 'message-content'; | |
| content.textContent = text; | |
| messageDiv.appendChild(avatar); | |
| messageDiv.appendChild(content); | |
| if (isWelcome && chatContainer.children.length > 0) { | |
| chatContainer.insertBefore(messageDiv, chatContainer.firstChild); | |
| } else if (typingIndicator) { | |
| chatContainer.insertBefore(messageDiv, typingIndicator); | |
| } else { | |
| chatContainer.appendChild(messageDiv); | |
| } | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| function showTypingIndicator() { | |
| const indicator = document.getElementById('typing-indicator'); | |
| if (indicator) { | |
| indicator.classList.add('show'); | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| } | |
| function hideTypingIndicator() { | |
| const indicator = document.getElementById('typing-indicator'); | |
| if (indicator) { | |
| indicator.classList.remove('show'); | |
| } | |
| } | |
| // ==================== VOICE RECORDING ==================== | |
| async function startRecording() { | |
| const lang = localStorage.getItem('preferredLanguage') || 'uz'; | |
| try { | |
| if (!ws || ws.readyState !== WebSocket.OPEN) { | |
| alert(translations[lang].wsNotConnected); | |
| return; | |
| } | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); | |
| audioChunks = []; | |
| mediaRecorder.ondataavailable = event => event.data.size > 0 && audioChunks.push(event.data); | |
| mediaRecorder.onstop = () => { | |
| sendAudioToServer(new Blob(audioChunks, { type: 'audio/webm' })); | |
| stream.getTracks().forEach(track => track.stop()); | |
| }; | |
| mediaRecorder.start(); | |
| isRecording = true; | |
| recordButton.classList.add('recording'); | |
| recordButtonWrapper.classList.add('recording'); | |
| recordButton.innerHTML = '<i class="bi bi-stop-fill"></i>'; | |
| recordingText.textContent = translations[lang].recording; | |
| } catch (error) { | |
| console.error('❌ Mikrofon xatoligi:', error); | |
| alert(translations[lang].micError); | |
| } | |
| } | |
| function stopRecording() { | |
| if (mediaRecorder && isRecording) { | |
| mediaRecorder.stop(); | |
| isRecording = false; | |
| const lang = localStorage.getItem('preferredLanguage') || 'uz'; | |
| recordButton.classList.remove('recording'); | |
| recordButtonWrapper.classList.remove('recording'); | |
| recordButton.innerHTML = '<i class="bi bi-mic-fill"></i>'; | |
| recordingText.textContent = translations[lang].recordInstruction; | |
| showTypingIndicator(); | |
| } | |
| } | |
| async function sendAudioToServer(audioBlob) { | |
| try { | |
| const arrayBuffer = await audioBlob.arrayBuffer(); | |
| ws.send(new Uint8Array(arrayBuffer)); | |
| ws.send('__END__'); | |
| } catch (error) { | |
| console.error('❌ Audio yuborishda xatolik:', error); | |
| hideTypingIndicator(); | |
| } | |
| } | |
| // ==================== EVENT LISTENERS ==================== | |
| recordButton.addEventListener('click', () => isRecording ? stopRecording() : startRecording()); | |
| // ==================== INITIALIZATION ==================== | |
| function initialize() { | |
| const savedLang = localStorage.getItem('preferredLanguage') || 'uz'; | |
| setLanguage(savedLang); | |
| connectWebSocket(); | |
| const lang = localStorage.getItem('preferredLanguage') || 'uz'; | |
| const welcomeMessageHTML = ` | |
| <div class="message ai"> | |
| <div class="message-avatar"><i class="bi bi-robot"></i></div> | |
| <div class="message-content">${translations[lang].welcomeMessage}</div> | |
| </div>`; | |
| const typingIndicatorHTML = ` | |
| <div id="typing-indicator" class="message ai typing-indicator"> | |
| <div class="message-avatar"><i class="bi bi-robot"></i></div> | |
| <div class="message-content"> | |
| <div class="typing-dots"> | |
| <span></span><span></span><span></span> | |
| </div> | |
| </div> | |
| </div>`; | |
| chatContainer.innerHTML = welcomeMessageHTML + typingIndicatorHTML; | |
| } | |
| document.addEventListener('DOMContentLoaded', initialize); | |
| </script> | |
| </body> | |
| </html> |