Files
say/public/index.html
T

200 lines
7.4 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Say — Голосовой ассистент</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<div id="main">
<h1>🎙️ Say <span id="ver">v1.0</span></h1>
<div id="visualizer">
<canvas id="meter" width="300" height="60"></canvas>
</div>
<div id="controls">
<button id="btnRecord">🎙 Записать</button>
<button id="btnPlay" disabled>▶ Прослушать</button>
<button id="btnReset">🗑 Сброс</button>
</div>
<div id="status"></div>
<div id="chat"></div>
</div>
<div id="sidebar">
<div id="history-title">История</div>
<div id="history-list"></div>
</div>
</div>
<script>
const VERSION = 'v1.0';
document.getElementById('ver').textContent = VERSION;
const btnRecord = document.getElementById('btnRecord');
const btnPlay = document.getElementById('btnPlay');
const statusEl = document.getElementById('status');
const chat = document.getElementById('chat');
const histList = document.getElementById('history-list');
const meter = document.getElementById('meter');
const meterCtx = meter.getContext('2d');
let mediaRecorder, chunks = [], userBlob = null;
let isRecording = false, micAnalyser = null, micAnimId = null;
let audioContext = null, sessionSid = null;
let thinkStart = 0, thinkTimer = null;
// История — массив {q, a, time}
let history = JSON.parse(localStorage.getItem('say_history') || '[]');
function setStatus(msg) { statusEl.innerHTML = msg; }
function renderHistory() {
histList.innerHTML = history.slice(-20).map((h, i) =>
'<div class="hitem" data-idx="' + i + '" title="' + h.time + '">' +
'<span class="hq">' + esc(h.q).substring(0, 60) + '</span>' +
'</div>'
).join('');
document.querySelectorAll('.hitem').forEach(el => {
el.onclick = () => {
const h = history[+el.dataset.idx];
chat.innerHTML += '<div class="user">📋 ' + esc(h.q) + '</div>';
chat.innerHTML += '<div class="bot">🤖 ' + esc(h.a) + '</div>';
chat.scrollTop = chat.scrollHeight;
};
});
}
function esc(s) { return String(s).replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
renderHistory();
btnRecord.onclick = async () => {
if (isRecording) { stopRecording(); return; }
try {
isRecording = true;
btnRecord.textContent = '⏹ Стоп';
setStatus('🎤 Запись...');
await startRecording();
} catch (err) {
let msg = '⛔ ';
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён.';
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден.';
else msg += 'Ошибка: ' + (err.message || err.name);
setStatus(msg);
btnRecord.textContent = '🎙 Записать';
isRecording = false;
}
};
async function startRecording() {
chunks = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
micAnalyser = audioContext.createAnalyser();
micAnalyser.fftSize = 256;
source.connect(micAnalyser);
drawMeter();
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => chunks.push(e.data);
mediaRecorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
cancelAnimationFrame(micAnimId);
if (audioContext) audioContext.close();
userBlob = new Blob(chunks, { type: 'audio/webm' });
btnPlay.disabled = false;
await transcribeAndChat();
};
mediaRecorder.start();
}
function stopRecording() {
btnRecord.disabled = true;
setStatus('⏳ Обработка...');
mediaRecorder.stop();
}
function drawMeter() {
if (!micAnalyser) return;
const data = new Uint8Array(micAnalyser.frequencyBinCount);
micAnalyser.getByteTimeDomainData(data);
const w = meter.width, h = meter.height;
meterCtx.fillStyle = '#1a1a2e';
meterCtx.fillRect(0, 0, w, h);
meterCtx.strokeStyle = '#6c5ce7';
meterCtx.lineWidth = 2;
meterCtx.beginPath();
for (let i = 0; i < data.length; i++) {
meterCtx.lineTo((i / data.length) * w, (data[i] / 255) * h);
}
meterCtx.stroke();
micAnimId = requestAnimationFrame(drawMeter);
}
btnPlay.onclick = () => {
if (!userBlob) return;
const a = new Audio(URL.createObjectURL(userBlob));
a.onended = () => URL.revokeObjectURL(a.src);
a.play(); setStatus('🔊 Ваша запись...');
};
async function transcribeAndChat() {
try {
setStatus('📝 Распознавание...');
const fd = new FormData(); fd.append('audio', userBlob);
const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 30000);
const tRes = await fetch('/api/whisper', { method: 'POST', body: fd, signal: ac.signal });
clearTimeout(to);
const tData = await tRes.json();
const text = tData.text || '(не распознано)';
chat.innerHTML += '<div class="user">🗣 ' + esc(text) + '</div>';
chat.scrollTop = chat.scrollHeight;
startThinkTimer();
const cRes = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-oss-120b', message: text, sid: sessionSid }),
signal: (() => { const a = new AbortController(); setTimeout(() => a.abort(), 120000); return a.signal; })(),
});
stopThinkTimer();
const cData = await cRes.json();
if (cData.sid) sessionSid = cData.sid;
const resp = cData.response || cData.error || 'Ошибка';
chat.innerHTML += '<div class="bot">🤖 ' + esc(resp) + '</div>';
chat.scrollTop = chat.scrollHeight;
setStatus('✅ Готово — ' + ((Date.now() - thinkStart) / 1000).toFixed(1) + 'с');
history.push({ q: text, a: resp, time: new Date().toLocaleTimeString() });
if (history.length > 20) history.shift();
localStorage.setItem('say_history', JSON.stringify(history));
renderHistory();
} catch (err) {
stopThinkTimer();
setStatus('⛔ ' + err.message);
}
btnRecord.textContent = '🎙 Записать';
btnRecord.disabled = false; isRecording = false;
userBlob = null; btnPlay.disabled = true;
}
function startThinkTimer() {
thinkStart = Date.now();
thinkTimer = setInterval(() => {
setStatus('🤖 Думает... ' + ((Date.now() - thinkStart) / 1000).toFixed(0) + 'с');
}, 200);
}
function stopThinkTimer() { if (thinkTimer) { clearInterval(thinkTimer); thinkTimer = null; } }
document.getElementById('btnReset').onclick = async () => {
await fetch('/api/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sid: sessionSid }) });
sessionSid = null; chat.innerHTML = ''; setStatus('🧹 Очищено');
};
</script>
</body>
</html>