feat: версия, таймер ответа, история 20 вопросов, боковая панель

This commit is contained in:
2026-06-01 22:53:07 +03:00
parent 62da6ce35c
commit 57954b3e65
2 changed files with 109 additions and 67 deletions
+86 -53
View File
@@ -8,39 +8,70 @@
</head>
<body>
<h1>🎙️ Say</h1>
<div id="app">
<div id="main">
<h1>🎙️ Say <span id="ver">v1.0</span></h1>
<div id="models">
<button class="model-btn active">GPT-OSS-120B</button>
<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>
<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>
<script>
const VERSION = 'v1.0';
document.getElementById('ver').textContent = VERSION;
const btnRecord = document.getElementById('btnRecord');
const btnPlay = document.getElementById('btnPlay');
const status = document.getElementById('status');
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;
function setStatus(msg) { status.textContent = msg; }
// История — массив {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; }
@@ -51,7 +82,7 @@
await startRecording();
} catch (err) {
let msg = '⛔ ';
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён. Разрешите в настройках браузера.';
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён.';
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден.';
else msg += 'Ошибка: ' + (err.message || err.name);
setStatus(msg);
@@ -69,7 +100,6 @@
micAnalyser.fftSize = 256;
source.connect(micAnalyser);
drawMeter();
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => chunks.push(e.data);
mediaRecorder.onstop = async () => {
@@ -100,9 +130,7 @@
meterCtx.lineWidth = 2;
meterCtx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = (i / data.length) * w;
const y = (data[i] / 255) * h;
i === 0 ? meterCtx.moveTo(x, y) : meterCtx.lineTo(x, y);
meterCtx.lineTo((i / data.length) * w, (data[i] / 255) * h);
}
meterCtx.stroke();
micAnimId = requestAnimationFrame(drawMeter);
@@ -110,57 +138,62 @@
btnPlay.onclick = () => {
if (!userBlob) return;
const url = URL.createObjectURL(userBlob);
const a = new Audio(url);
a.onended = () => URL.revokeObjectURL(url);
a.play();
setStatus('🔊 Ваша запись...');
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 controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
const tRes = await fetch('/api/whisper', { method: 'POST', body: fd, signal: controller.signal });
clearTimeout(timeout);
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">🗣 Вы: ' + text + '</div>';
chat.innerHTML += '<div class="user">🗣 ' + esc(text) + '</div>';
chat.scrollTop = chat.scrollHeight;
setStatus('🤖 Думает...');
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 ac = new AbortController(); setTimeout(() => ac.abort(), 120000); return ac.signal; })(),
signal: (() => { const a = new AbortController(); setTimeout(() => a.abort(), 120000); return a.signal; })(),
});
stopThinkTimer();
const cData = await cRes.json();
if (cData.sid) sessionSid = cData.sid;
chat.innerHTML += '<div class="bot">🤖 GPT-OSS: ' + (cData.response || cData.error || 'Ошибка') + '</div>';
const resp = cData.response || cData.error || 'Ошибка';
chat.innerHTML += '<div class="bot">🤖 ' + esc(resp) + '</div>';
chat.scrollTop = chat.scrollHeight;
setStatus('✅ Готово');
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) {
setStatus('⛔ Ошибка: ' + err.message);
stopThinkTimer();
setStatus('⛔ ' + err.message);
}
btnRecord.textContent = '🎙 Записать';
btnRecord.disabled = false;
isRecording = false;
userBlob = null;
btnPlay.disabled = true;
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('🧹 История очищена');
await fetch('/api/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sid: sessionSid }) });
sessionSid = null; chat.innerHTML = ''; setStatus('🧹 Очищено');
};
</script>
</body>