PINE-AI-Amdocs / frontend /call_mobile.html
maitrang04's picture
Update frontend/call_mobile.html
4ecdccf verified
<!DOCTYPE html>
<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>