Spaces:
Sleeping
Sleeping
| <html lang="vi"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>PINE AI - Mobile Interface</title> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| /* --- CORE VARIABLES --- */ | |
| :root { | |
| --vnpt-blue: #00a1e4; | |
| --vnpt-dark: #0072bc; | |
| --bg-body: #f4f7fa; | |
| --chat-bot: #ffffff; | |
| --chat-user: #e3f2fd; | |
| } | |
| /* Sử dụng dvh để fix lỗi thanh địa chỉ trên mobile */ | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| margin: 0; | |
| display: flex; | |
| height: 100vh; | |
| height: 100dvh; /* Dynamic Viewport Height */ | |
| background: var(--bg-body); | |
| color: #333; | |
| overflow: hidden; | |
| flex-direction: row; | |
| } | |
| /* --- SIDEBAR (DESKTOP) --- */ | |
| .sidebar { | |
| width: 320px; | |
| background: linear-gradient(160deg, var(--vnpt-dark) 0%, var(--vnpt-blue) 100%); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 30px 20px; | |
| color: white; | |
| box-shadow: 4px 0 20px rgba(0,0,0,0.1); | |
| z-index: 10; | |
| transition: all 0.3s ease; | |
| } | |
| .mascot-container { width: 100px; height: 100px; background: rgba(255,255,255,0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 10px; border: 3px solid rgba(255,255,255,0.3); transition: 0.3s; position: relative; flex-shrink: 0; } | |
| .mascot-container.active { border-color: #fff; box-shadow: 0 0 30px rgba(255,255,255,0.6); transform: scale(1.05); } | |
| .mascot-container.playing { animation: audioPulse 1s infinite; } | |
| .mascot-container img { width: 70%; } | |
| .brand-title { font-size: 20px; font-weight: 700; margin: 5px 0 0 0; letter-spacing: 1px; } | |
| .brand-sub { font-size: 11px; opacity: 0.8; margin-bottom: 15px; font-weight: 300; } | |
| .visualizer { display: flex; gap: 3px; height: 30px; margin-top: auto; align-items: center; } | |
| .bar { width: 4px; height: 5px; background: rgba(255,255,255,0.6); border-radius: 5px; transition: 0.1s; } | |
| /* --- UX DASHBOARD --- */ | |
| .ux-dashboard { | |
| width: 100%; | |
| background: rgba(255, 255, 255, 0.15); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 16px; | |
| padding: 15px; | |
| box-sizing: border-box; | |
| margin-bottom: 20px; | |
| box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); | |
| } | |
| .metric-item { margin-bottom: 12px; } | |
| .metric-label { font-size: 9px; color: rgba(255, 255, 255, 0.9); margin-bottom: 2px; font-weight: 600; text-transform: uppercase; } | |
| .metric-value { font-size: 20px; font-weight: 800; color: #fff; display: flex; justify-content: space-between; align-items: baseline; } | |
| .metric-unit { font-size: 10px; font-weight: 500; color: rgba(255, 255, 255, 0.7); } | |
| #ux-intent { | |
| background: rgba(0, 161, 228, 0.2); border: 1px solid rgba(255, 255, 255, 0.3); | |
| color: #ffffff; font-size: 14px; font-weight: 700; text-align: center; | |
| padding: 6px 10px; border-radius: 20px; margin-top: 5px; text-transform: capitalize; | |
| min-height: 20px; display: flex; align-items: center; justify-content: center; | |
| } | |
| .sentiment-track { width: 100%; height: 6px; background: rgba(0, 0, 0, 0.25); border-radius: 10px; overflow: hidden; margin-top: 5px; } | |
| .sentiment-fill { height: 100%; width: 50%; background: #f1c40f; transition: width 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); } | |
| .latency-group { display: flex; gap: 5px; margin-top: 5px; } | |
| .latency-box { flex: 1; background: rgba(0,0,0,0.2); padding: 5px; border-radius: 6px; display: flex; flex-direction: column; align-items: center; } | |
| .latency-title { font-size: 8px; color: rgba(255,255,255,0.7); } | |
| .latency-num { font-size: 12px; font-weight: 700; } | |
| .stt-color { color: #4fc3f7; } .ai-color { color: #f1c40f; } | |
| /* --- MAIN CONTENT --- */ | |
| .main-content { flex: 1; display: flex; flex-direction: column; background: white; border-radius: 30px 0 0 30px; margin-left: -20px; z-index: 20; overflow: hidden; box-shadow: -5px 0 20px rgba(0,0,0,0.05); } | |
| .top-bar { padding: 15px 25px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; background: white; flex-wrap: wrap; gap: 10px; } | |
| .call-input { display: flex; gap: 10px; align-items: center; width: 100%; justify-content: space-between; } | |
| .input-group { display: flex; align-items: center; gap: 5px; background: #f0f2f5; padding: 5px 10px; border-radius: 10px; } | |
| .input-group label { font-size: 11px; font-weight: 600; color: #555; white-space: nowrap; } | |
| /* Font size 16px to prevent iOS zoom */ | |
| .input-group input { border: none; background: transparent; width: 35px; font-weight: bold; color: var(--vnpt-dark); outline: none; font-size: 16px; text-align: center; } | |
| /* CSS CHO NÚT BẬT LOA (MỚI) */ | |
| #btnAudioFix { | |
| background: #fff3e0; color: #d35400; border: 1px solid #ffe0b2; | |
| padding: 8px 12px; border-radius: 10px; font-weight: 600; cursor: pointer; transition: 0.3s; | |
| display: flex; align-items: center; gap: 5px; font-size: 12px; white-space: nowrap; | |
| } | |
| #btnAudioFix.active { background: #e8f5e9; color: #2e7d32; border-color: #c8e6c9; } | |
| #btnCall { background: #27ae60; color: white; border: none; padding: 10px 15px; border-radius: 10px; font-weight: 600; cursor: pointer; transition: 0.3s; display: flex; align-items: center; gap: 5px; font-size: 13px; white-space: nowrap; } | |
| .chat-area { flex: 1; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; scroll-behavior: smooth; background: #fff; } | |
| .msg { max-width: 85%; padding: 10px 14px; border-radius: 15px; font-size: 14px; line-height: 1.4; animation: fadeIn 0.3s ease; } | |
| .msg.bot { background: var(--chat-bot); border: 1px solid #eee; align-self: flex-start; border-bottom-left-radius: 2px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); } | |
| .msg.user { background: var(--chat-user); color: #0d47a1; align-self: flex-end; border-bottom-right-radius: 2px; } | |
| .msg.info { align-self: center; font-size: 11px; color: #999; background: #f5f5f5; padding: 4px 10px; border-radius: 20px; } | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } | |
| /* Control Panel with Safe Area for iPhone */ | |
| .control-panel { | |
| padding: 15px; | |
| padding-bottom: calc(15px + env(safe-area-inset-bottom)); /* Safe area */ | |
| display: flex; flex-direction: column; align-items: center; | |
| border-top: 1px solid #f0f0f0; background: white; | |
| } | |
| #recordBtn { width: 65px; height: 65px; border-radius: 50%; border: none; background: var(--vnpt-blue); color: white; font-size: 22px; cursor: pointer; box-shadow: 0 4px 10px rgba(0,161,228,0.3); transition: 0.2s; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; } | |
| #recordBtn.recording { background: #ff4757; animation: pulse 1.5s infinite; } | |
| @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255, 71, 87, 0.4); } 70% { box-shadow: 0 0 0 15px rgba(255, 71, 87, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 71, 87, 0); } } | |
| @keyframes audioPulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } } | |
| .status-text { margin-top: 10px; font-weight: 600; color: #888; font-size: 12px; } | |
| /* TIMER STYLE */ | |
| .call-timer { font-family: 'Courier New', monospace; font-size: 14px; font-weight: 700; color: var(--vnpt-dark); background: #e3f2fd; padding: 6px 10px; border-radius: 6px; display: flex; align-items: center; gap: 5px; } | |
| /* RATING */ | |
| .rating-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.6); z-index: 1000; display: none; align-items: center; justify-content: center; backdrop-filter: blur(4px); } | |
| .rating-box { background: white; width: 90%; max-width: 350px; padding: 25px; border-radius: 20px; text-align: center; } | |
| .star-group { display: flex; justify-content: center; gap: 8px; margin: 15px 0; } | |
| .star-group i { font-size: 28px; color: #ddd; } .star-group i.active { color: #f1c40f; } | |
| #ratingNote { width: 100%; padding: 10px; border: 1px solid #eee; border-radius: 10px; margin-bottom: 15px; resize: none; height: 60px; box-sizing: border-box; } | |
| .rating-actions button { padding: 10px 15px; border-radius: 8px; border: none; font-weight: 600; font-size: 13px; } | |
| .btn-skip { background: #f0f2f5; } .btn-submit { background: var(--vnpt-blue); color: white; } | |
| /* --- MOBILE SPECIFIC CSS --- */ | |
| @media (max-width: 768px) { | |
| body { flex-direction: column; } | |
| .sidebar { | |
| width: 100%; | |
| height: auto; | |
| flex-direction: row; | |
| padding: 10px 15px; | |
| border-radius: 0 0 20px 20px; | |
| justify-content: space-between; | |
| flex-wrap: wrap; | |
| box-sizing: border-box; | |
| } | |
| .mascot-container { width: 50px; height: 50px; margin: 0; } | |
| .brand-info { display: flex; flex-direction: column; justify-content: center; margin-left: 10px; flex: 1; } | |
| .brand-title { font-size: 16px; margin: 0; } | |
| .brand-sub { margin: 0; font-size: 10px; } | |
| .visualizer { display: none; } | |
| /* Mobile Dashboard: Horizontal Scroll without Scrollbar */ | |
| .ux-dashboard { | |
| margin-top: 10px; | |
| width: 100%; | |
| margin-bottom: 0; | |
| padding: 10px; | |
| display: flex; | |
| flex-direction: row; | |
| gap: 10px; | |
| overflow-x: auto; | |
| background: rgba(0,0,0,0.2); | |
| border: none; | |
| /* Hide Scrollbar */ | |
| scrollbar-width: none; | |
| -ms-overflow-style: none; | |
| } | |
| .ux-dashboard::-webkit-scrollbar { display: none; } | |
| .metric-item { | |
| min-width: 100px; | |
| margin-bottom: 0; | |
| background: rgba(255,255,255,0.1); | |
| padding: 5px 8px; | |
| border-radius: 8px; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| flex-shrink: 0; /* Prevent shrinking */ | |
| } | |
| .metric-value { font-size: 16px; } | |
| .latency-group { display: none; } | |
| .sentiment-track { height: 4px; } | |
| .main-content { | |
| margin-left: 0; | |
| border-radius: 20px 20px 0 0; | |
| margin-top: -15px; | |
| box-shadow: 0 -5px 20px rgba(0,0,0,0.1); | |
| } | |
| .top-bar { padding: 10px 15px; border-bottom: none; } | |
| .input-group label { display: none; } | |
| .input-group::before { content: "\f007"; font-family: "Font Awesome 5 Free"; font-weight: 900; color: #555; font-size: 12px; margin-right: 5px; } | |
| #btnCall { flex: 1; justify-content: center; } | |
| .call-timer { margin-right: 0; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="sidebar"> | |
| <div style="display: flex; align-items: center;"> | |
| <div class="mascot-container" id="mascotBox"> | |
| <img src="https://github.com/chydua/PINE/blob/main/Screenshot_2025-12-18_at_19.19.29-removebg-preview.png?raw=true" alt="PINE"> | |
| </div> | |
| <div class="brand-info" style="margin-left: 10px;"> | |
| <div class="brand-title">PINE AI</div> | |
| <div class="brand-sub">Smart Contact Center</div> | |
| </div> | |
| </div> | |
| <div class="ux-dashboard"> | |
| <div class="metric-item"> | |
| <div class="metric-label">ĐỘ TRỄ</div> | |
| <div class="metric-value"> | |
| <span id="ux-latency-total">0.00</span><span class="metric-unit">s</span> | |
| </div> | |
| <div class="latency-group"> | |
| <div class="latency-box"><span class="latency-title">STT</span><span id="ux-latency-stt" class="latency-num stt-color">0.00</span></div> | |
| <div class="latency-box"><span class="latency-title">AI</span><span id="ux-latency-ai" class="latency-num ai-color">0.00</span></div> | |
| </div> | |
| </div> | |
| <div class="metric-item"> | |
| <div class="metric-label">CẢM XÚC</div> | |
| <div class="metric-value" id="ux-sentiment-text" style="font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">---</div> | |
| <div class="sentiment-track"> | |
| <div class="sentiment-fill" id="ux-sentiment-bar" style="width: 50%;"></div> | |
| </div> | |
| </div> | |
| <div class="metric-item"> | |
| <div class="metric-label">Ý ĐỊNH</div> | |
| <div id="ux-intent" style="font-size: 11px;">---</div> | |
| </div> | |
| </div> | |
| <div class="visualizer" id="visualizer"> | |
| <div class="bar"></div><div class="bar"></div><div class="bar"></div> | |
| <div class="bar"></div><div class="bar"></div><div class="bar"></div> | |
| </div> | |
| <div style="font-size: 10px; opacity: 0.6; margin-top: 10px;" class="brand-sub">© 2025 VNPT GROUP</div> | |
| </div> | |
| <div class="main-content"> | |
| <div class="top-bar"> | |
| <div class="call-input"> | |
| <div class="call-timer"> | |
| <i class="far fa-clock"></i> | |
| <span id="callTimerDisplay">00:00</span> | |
| </div> | |
| <div class="input-group"> | |
| <label>ID:</label> | |
| <input type="number" id="customerId" value="1" min="1" max="10"> | |
| </div> | |
| <button id="btnAudioFix" onclick="forceAudioUnlock()"> | |
| <i class="fas fa-volume-mute"></i> BẬT LOA | |
| </button> | |
| <button id="btnCall" type="button" onclick="startCall()"> | |
| <i class="fas fa-phone-alt"></i> GỌI | |
| </button> | |
| </div> | |
| </div> | |
| <div class="chat-area" id="logArea"> | |
| <div class="msg info">Chọn ID và nhấn "GỌI".<br>Nếu không nghe tiếng, hãy nhấn "BẬT LOA" trước.</div> | |
| </div> | |
| <div class="control-panel" id="chatArea"> | |
| <button id="recordBtn" type="button" | |
| ontouchstart="handleTouchStart(event)" | |
| ontouchend="handleTouchEnd(event)" | |
| onmousedown="startRecording()" | |
| onmouseup="stopRecording()" | |
| onmouseleave="stopRecording()" | |
| disabled> | |
| <i class="fas fa-microphone"></i> | |
| </button> | |
| <div class="status-text" id="status">Sẵn sàng kết nối</div> | |
| </div> | |
| </div> | |
| <div id="ratingOverlay" class="rating-overlay"> | |
| <div class="rating-box"> | |
| <h3>Đánh giá</h3> | |
| <p>Trải nghiệm của bạn thế nào?</p> | |
| <div class="star-group"> | |
| <i class="fas fa-star" onclick="selectStar(1)"></i> | |
| <i class="fas fa-star" onclick="selectStar(2)"></i> | |
| <i class="fas fa-star" onclick="selectStar(3)"></i> | |
| <i class="fas fa-star" onclick="selectStar(4)"></i> | |
| <i class="fas fa-star" onclick="selectStar(5)"></i> | |
| </div> | |
| <textarea id="ratingNote" placeholder="Góp ý..."></textarea> | |
| <div class="rating-actions"> | |
| <button class="btn-skip" type="button" onclick="closeRating()">Đóng</button> | |
| <button class="btn-submit" type="button" onclick="submitRating()">Gửi</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const API_URL = ""; // Thay IP nếu cần | |
| let mediaRecorder; | |
| let audioChunks = []; | |
| let audioQueue = []; | |
| let isPlaying = false; | |
| let isSessionEnded = false; | |
| let currentAudioObject = null; | |
| let audioContext, analyser, dataArray; | |
| let currentBotBubble = null; | |
| let abortController = null; | |
| let heartBeatNode = null; // Máy tạo nhịp tim âm thanh | |
| let audioContextInitialized = false; | |
| // --- TIMER VARS --- | |
| let timerInterval = null; | |
| let t0_record = 0; | |
| let t1_stt_end = 0; | |
| let currentPhase = 'idle'; | |
| let isLogicReady = false; | |
| // --- CALL DURATION TIMER VARS --- | |
| let callDurationInterval = null; | |
| let callSeconds = 0; | |
| // --- HÀM QUAN TRỌNG: KHỞI TẠO ÂM THANH (Fix lỗi iOS) --- | |
| async function ensureAudioContext() { | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)({ | |
| latencyHint: 'interactive', | |
| sampleRate: 44100 | |
| }); | |
| } | |
| if (audioContext.state === 'suspended') { | |
| await audioContext.resume(); | |
| } | |
| audioContextInitialized = true; | |
| return audioContext; | |
| } | |
| async function unlockAudio() { | |
| try { | |
| await ensureAudioContext(); | |
| // Tạo Heartbeat oscillator để giữ AudioContext không bị iOS đóng | |
| if (!heartBeatNode) { | |
| const oscillator = audioContext.createOscillator(); | |
| const gainNode = audioContext.createGain(); | |
| oscillator.type = 'sine'; | |
| oscillator.frequency.setValueAtTime(0.1, audioContext.currentTime); // Tần số siêu thấp | |
| gainNode.gain.setValueAtTime(0.001, audioContext.currentTime); // Âm lượng siêu nhỏ | |
| oscillator.connect(gainNode); | |
| gainNode.connect(audioContext.destination); | |
| oscillator.start(); | |
| heartBeatNode = { oscillator, gainNode }; | |
| console.log("🔊 Heartbeat Audio Started"); | |
| } | |
| } catch (error) { | |
| console.error("Error in unlockAudio:", error); | |
| } | |
| } | |
| async function forceAudioUnlock() { | |
| const btn = document.getElementById('btnAudioFix'); | |
| btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ...'; | |
| try { | |
| if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| await audioContext.resume(); | |
| // Phát một âm thanh rỗng ngay lập tức | |
| const buffer = audioContext.createBuffer(1, 1, 22050); | |
| const source = audioContext.createBufferSource(); | |
| source.buffer = buffer; | |
| source.connect(audioContext.destination); | |
| source.start(0); | |
| await unlockAudio(); | |
| btn.innerHTML = '<i class="fas fa-volume-up"></i> LOA OK'; | |
| btn.classList.add('active'); | |
| log("Đã kích hoạt loa thành công!", "info"); | |
| } catch(e) { | |
| console.error(e); | |
| btn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> LỖI'; | |
| } | |
| } | |
| function resetAudio() { | |
| if (heartBeatNode) { | |
| try { heartBeatNode.oscillator.stop(); } catch(e) {} | |
| heartBeatNode = null; | |
| } | |
| // Không close context hoàn toàn để tái sử dụng, chỉ reset trạng thái UI | |
| document.getElementById('btnAudioFix').classList.remove('active'); | |
| document.getElementById('btnAudioFix').innerHTML = '<i class="fas fa-volume-mute"></i> BẬT LOA'; | |
| } | |
| // --- MOBILE TOUCH HANDLING --- | |
| function handleTouchStart(e) { | |
| e.preventDefault(); | |
| unlockAudio(); // Kích hoạt audio ngay khi chạm | |
| startRecording(); | |
| } | |
| function handleTouchEnd(e) { | |
| e.preventDefault(); | |
| stopRecording(); | |
| } | |
| function startCallTimer() { | |
| stopCallTimer(); | |
| callSeconds = 0; | |
| updateCallTimerDisplay(); | |
| callDurationInterval = setInterval(() => { | |
| callSeconds++; | |
| updateCallTimerDisplay(); | |
| }, 10000); | |
| } | |
| function stopCallTimer() { | |
| if (callDurationInterval) { | |
| clearInterval(callDurationInterval); | |
| callDurationInterval = null; | |
| } | |
| } | |
| function resetCallTimer() { | |
| stopCallTimer(); | |
| callSeconds = 0; | |
| updateCallTimerDisplay(); | |
| } | |
| function updateCallTimerDisplay() { | |
| const m = Math.floor(callSeconds / 60).toString().padStart(2, '0'); | |
| const s = (callSeconds % 60).toString().padStart(2, '0'); | |
| document.getElementById('callTimerDisplay').innerText = `${m}:${s}`; | |
| } | |
| // --- VISUALIZER --- | |
| function initVisualizer(stream) { | |
| if(!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| const source = audioContext.createMediaStreamSource(stream); | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 64; | |
| source.connect(analyser); | |
| dataArray = new Uint8Array(analyser.frequencyBinCount); | |
| drawVisualizer(); | |
| } | |
| function drawVisualizer() { | |
| if (!analyser) return; | |
| requestAnimationFrame(drawVisualizer); | |
| analyser.getByteFrequencyData(dataArray); | |
| document.querySelectorAll('.bar').forEach((bar, i) => { | |
| const h = Math.max(5, dataArray[i] / 255 * 30); | |
| bar.style.height = h + 'px'; | |
| }); | |
| } | |
| function log(msg, type='info') { | |
| const logArea = document.getElementById('logArea'); | |
| if (type === 'bot' && currentBotBubble) { | |
| currentBotBubble.innerHTML += msg; | |
| } else { | |
| const div = document.createElement('div'); | |
| div.className = `msg ${type}`; | |
| div.innerHTML = msg; | |
| logArea.appendChild(div); | |
| if (type === 'bot') currentBotBubble = div; | |
| else currentBotBubble = null; | |
| } | |
| logArea.scrollTo({ top: logArea.scrollHeight, behavior: 'smooth' }); | |
| } | |
| // --- RATING --- | |
| let currentRating = 0; | |
| function showRating() { document.getElementById('ratingOverlay').style.display = 'flex'; currentRating = 0; updateStars(0); } | |
| function closeRating() { document.getElementById('ratingOverlay').style.display = 'none'; resetCallUI(); } | |
| function selectStar(n) { currentRating = n; updateStars(n); } | |
| function updateStars(n) { document.querySelectorAll('.star-group i').forEach((star, index) => { if (index < n) star.classList.add('active'); else star.classList.remove('active'); }); } | |
| async function submitRating() { | |
| if (currentRating === 0) { alert("Vui lòng chọn số sao!"); return; } | |
| const cid = document.getElementById('customerId').value; | |
| const note = document.getElementById('ratingNote').value; | |
| document.querySelector('.btn-submit').innerText = "Đang gửi..."; | |
| try { | |
| await fetch(`${API_URL}/submit-feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: cid, stars: currentRating, note: note }) }); | |
| alert("Cảm ơn đánh giá của bạn!"); | |
| } catch (e) { alert("Đã lưu đánh giá (Offline Mode)!"); } | |
| document.querySelector('.btn-submit').innerText = "Gửi"; | |
| closeRating(); | |
| } | |
| // --- CALL CONTROL --- | |
| function resetCallUI() { | |
| document.getElementById('btnCall').disabled = false; | |
| document.getElementById('btnCall').innerHTML = '<i class="fas fa-phone-alt"></i> GỌI'; | |
| document.getElementById('btnCall').style.background = '#27ae60'; | |
| document.getElementById('btnCall').onclick = startCall; | |
| document.getElementById('recordBtn').disabled = true; | |
| document.getElementById('status').innerText = "Sẵn sàng kết nối"; | |
| if (mediaRecorder && mediaRecorder.state === "recording") mediaRecorder.stop(); | |
| currentBotBubble = null; | |
| stopTimer(); | |
| resetCallTimer(); | |
| } | |
| function endCall() { | |
| stopBotResponse(); | |
| stopCallTimer(); | |
| isSessionEnded = true; | |
| document.getElementById('mascotBox').classList.remove('active', 'playing'); | |
| resetAudio(); // Reset heartbeat khi kết thúc | |
| showRating(); | |
| } | |
| async function startCall() { | |
| await unlockAudio(); // Kích hoạt audio trước khi gọi | |
| const cid = document.getElementById('customerId').value; | |
| const btnCall = document.getElementById('btnCall'); | |
| btnCall.disabled = true; btnCall.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; | |
| document.getElementById('logArea').innerHTML = ''; | |
| document.getElementById('status').innerText = "Đang khởi tạo..."; | |
| isSessionEnded = false; audioQueue = []; isPlaying = false; currentBotBubble = null; | |
| stopBotResponse(); | |
| startCallTimer(); | |
| try { | |
| abortController = new AbortController(); | |
| const formData = new FormData(); | |
| formData.append('customer_id', cid); | |
| const res = await fetch(`${API_URL}/start-call`, { | |
| method: 'POST', | |
| body: formData, | |
| signal: abortController.signal | |
| }); | |
| btnCall.disabled = false; btnCall.innerHTML = '<i class="fas fa-phone-slash"></i>'; | |
| btnCall.style.background = '#e74c3c'; btnCall.onclick = endCall; | |
| document.getElementById('recordBtn').disabled = false; | |
| document.getElementById('status').innerText = "Nhấn giữ Mic để nói"; | |
| await readStream(res); | |
| } catch (e) { | |
| if (e.name === 'AbortError') return; | |
| console.error(e); log("Lỗi kết nối Server!", 'info'); resetCallUI(); | |
| } | |
| } | |
| function stopBotResponse() { | |
| if (currentAudioObject) { currentAudioObject.pause(); currentAudioObject = null; } | |
| audioQueue = []; isPlaying = false; | |
| if (abortController) { abortController.abort(); abortController = null; } | |
| document.getElementById('mascotBox').classList.remove('active', 'playing'); | |
| stopTimer(); | |
| } | |
| // --- STREAM & AUDIO LOGIC --- | |
| async function readStream(response) { | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| isLogicReady = false; | |
| try { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split("\n"); | |
| buffer = lines.pop(); | |
| for (const line of lines) { | |
| if (!line.trim()) continue; | |
| try { | |
| const data = JSON.parse(line); | |
| if (data.user_text) { | |
| currentBotBubble = null; | |
| log(data.user_text, 'user'); | |
| switchToAiPhase(); | |
| } | |
| if (data.type === "metrics_update") { | |
| isLogicReady = true; | |
| updateUXDashboard(data.data); | |
| } | |
| if (data.bot_text) { | |
| log(data.bot_text, 'bot'); | |
| if (isLogicReady) { stopTimer(); } | |
| } | |
| if (data.audio_base64) { | |
| audioQueue.push(data.audio_base64); | |
| if (!isPlaying) processAudioQueue(); | |
| } | |
| if (data.end_session) { | |
| isSessionEnded = true; | |
| stopTimer(); | |
| if (audioQueue.length === 0 && !isPlaying) showRating(); | |
| } | |
| } catch (e) { console.error("JSON Error:", e); } | |
| } | |
| } | |
| } catch (err) { | |
| if (err.name === 'AbortError') console.log("Stream aborted by user."); | |
| else console.error("Stream Error:", err); | |
| } | |
| } | |
| async function processAudioQueue() { | |
| if (isPlaying || audioQueue.length === 0) return; | |
| try { | |
| await ensureAudioContext(); | |
| // Tạm dừng heartbeat khi AI nói để tránh xung đột (tùy chọn) | |
| if (heartBeatNode && heartBeatNode.gainNode) { | |
| heartBeatNode.gainNode.gain.setValueAtTime(0, audioContext.currentTime); | |
| } | |
| isPlaying = true; | |
| document.getElementById('mascotBox').classList.add('active', 'playing'); | |
| document.getElementById('status').innerText = "PINE đang trả lời..."; | |
| const base64Data = audioQueue.shift(); | |
| // Giải mã Base64 sang ArrayBuffer | |
| const binaryString = window.atob(base64Data); | |
| const len = binaryString.length; | |
| const bytes = new Uint8Array(len); | |
| for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i); | |
| // Sử dụng Web Audio API để phát thay vì Audio Element (Tốt hơn cho Mobile) | |
| const audioBuffer = await audioContext.decodeAudioData(bytes.buffer); | |
| const source = audioContext.createBufferSource(); | |
| source.buffer = audioBuffer; | |
| const gainNode = audioContext.createGain(); | |
| gainNode.gain.value = 1.0; | |
| source.connect(gainNode); | |
| gainNode.connect(audioContext.destination); | |
| currentAudioObject = { pause: () => { try{source.stop()}catch(e){} } }; // Dummy object để hàm stopBotResponse gọi | |
| source.onended = () => { | |
| isPlaying = false; | |
| // Khôi phục heartbeat | |
| if (heartBeatNode && heartBeatNode.gainNode) { | |
| heartBeatNode.gainNode.gain.setValueAtTime(0.001, audioContext.currentTime); | |
| } | |
| if (audioQueue.length === 0) { | |
| document.getElementById('mascotBox').classList.remove('active', 'playing'); | |
| if (isSessionEnded) { | |
| document.getElementById('status').innerText = "Kết thúc phiên."; | |
| setTimeout(showRating, 500); | |
| } else { | |
| document.getElementById('status').innerText = "Đến lượt bạn nói..."; | |
| } | |
| } | |
| processAudioQueue(); | |
| }; | |
| source.start(0); | |
| } catch (error) { | |
| console.error("Error playing audio:", error); | |
| isPlaying = false; | |
| processAudioQueue(); | |
| } | |
| } | |
| function updateUXDashboard(metrics) { | |
| if (metrics.latency_total) document.getElementById("ux-latency-total").innerText = metrics.latency_total; | |
| if (metrics.latency_stt) document.getElementById("ux-latency-stt").innerText = metrics.latency_stt; | |
| if (metrics.latency_ai) document.getElementById("ux-latency-ai").innerText = metrics.latency_ai; | |
| const score = metrics.sentiment; | |
| const bar = document.getElementById("ux-sentiment-bar"); | |
| const text = document.getElementById("ux-sentiment-text"); | |
| const percent = ((score + 1) / 2) * 100; | |
| bar.style.width = `${percent}%`; | |
| if (score > 0.3) { text.innerText = "Tích cực 😄"; text.style.color = "#2ecc71"; bar.style.background = "#2ecc71"; } | |
| else if (score < -0.3) { text.innerText = "Tiêu cực 😡"; text.style.color = "#ff6b6b"; bar.style.background = "#ff6b6b"; } | |
| else { text.innerText = "Trung tính 😐"; text.style.color = "#f1c40f"; bar.style.background = "#f1c40f"; } | |
| // UPDATE INTENT | |
| const intentBox = document.getElementById("ux-intent"); | |
| intentBox.innerText = metrics.intent; | |
| } | |
| // --- RECORDING & TIMER LOGIC --- | |
| function startRecording() { | |
| if (!mediaRecorder) return; | |
| stopBotResponse(); | |
| document.getElementById("ux-latency-total").innerText = "0.00"; | |
| document.getElementById("ux-latency-stt").innerText = "0.00"; | |
| document.getElementById("ux-latency-ai").innerText = "0.00"; | |
| currentBotBubble = null; | |
| audioChunks = []; | |
| // Kích hoạt lại AudioContext nếu nó bị suspended | |
| unlockAudio().then(() => { | |
| mediaRecorder.start(); | |
| document.getElementById('recordBtn').classList.add('recording'); | |
| document.getElementById('status').innerText = "Đang nghe bạn nói..."; | |
| }); | |
| } | |
| function stopRecording() { | |
| if (mediaRecorder && mediaRecorder.state === "recording") { | |
| mediaRecorder.stop(); | |
| document.getElementById('recordBtn').classList.remove('recording'); | |
| document.getElementById('status').innerHTML = '<i class="fas fa-brain fa-pulse"></i> Đang suy nghĩ...'; | |
| t0_record = performance.now(); | |
| currentPhase = 'stt'; | |
| t1_stt_end = 0; | |
| isLogicReady = false; | |
| if(timerInterval) clearInterval(timerInterval); | |
| timerInterval = setInterval(updateTimerUI, 30000); | |
| } | |
| } | |
| function switchToAiPhase() { | |
| if (currentPhase === 'stt') { | |
| t1_stt_end = performance.now(); | |
| currentPhase = 'ai'; | |
| } | |
| } | |
| function stopTimer() { | |
| if (timerInterval) { | |
| clearInterval(timerInterval); | |
| timerInterval = null; | |
| currentPhase = 'idle'; | |
| updateTimerUI(); | |
| } | |
| } | |
| function updateTimerUI() { | |
| const now = performance.now(); | |
| let stt_val = 0; | |
| let ai_val = 0; | |
| if (currentPhase === 'stt') { | |
| stt_val = (now - t0_record) / 1000; | |
| ai_val = 0; | |
| } else if (currentPhase === 'ai') { | |
| stt_val = (t1_stt_end - t0_record) / 1000; | |
| ai_val = (now - t1_stt_end) / 1000; | |
| } else if (currentPhase === 'idle' && t1_stt_end > 0) { | |
| return; | |
| } | |
| document.getElementById("ux-latency-stt").innerText = stt_val.toFixed(2); | |
| document.getElementById("ux-latency-ai").innerText = ai_val.toFixed(2); | |
| document.getElementById("ux-latency-total").innerText = (stt_val + ai_val).toFixed(2); | |
| } | |
| // --- MICROPHONE SETUP --- | |
| navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { | |
| initVisualizer(stream); | |
| mediaRecorder = new MediaRecorder(stream); | |
| mediaRecorder.ondataavailable = event => audioChunks.push(event.data); | |
| mediaRecorder.onstop = sendAudio; | |
| }).catch(err => { | |
| log("Lỗi: Không thể truy cập Micro", 'info'); | |
| document.getElementById('status').classList.add('error'); | |
| }); | |
| async function sendAudio() { | |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); | |
| if (audioBlob.size < 1000) { document.getElementById('status').innerText = "Nói quá ngắn, thử lại."; return; } | |
| const cid = document.getElementById('customerId').value; | |
| const formData = new FormData(); | |
| formData.append('file', audioBlob, 'voice.webm'); | |
| formData.append('customer_id', cid); | |
| try { | |
| abortController = new AbortController(); | |
| const res = await fetch(`${API_URL}/chat-voice`, { method: 'POST', body: formData, signal: abortController.signal }); | |
| await readStream(res); | |
| } catch (e) { | |
| if (e.name !== 'AbortError') { | |
| stopTimer(); | |
| log("Lỗi xử lý AI", 'info'); | |
| document.getElementById('status').innerText = "Lỗi kết nối!"; | |
| } | |
| } | |
| } | |
| // Kích hoạt Audio Context ngay lần chạm đầu tiên | |
| document.body.addEventListener('touchstart', function() { | |
| unlockAudio(); | |
| }, { once: true }); | |
| document.body.addEventListener('click', function() { | |
| unlockAudio(); | |
| }, { once: true }); | |
| </script> | |
| </body> | |
| </html> |