v1.5 — день по умолчанию, resetState(), все комбинации кнопок

This commit is contained in:
2026-06-02 06:17:30 +03:00
parent 902b341f35
commit 0033f01683
+101 -60
View File
@@ -66,9 +66,10 @@
</div> </div>
<script> <script>
const VERSION = 'v1.4'; const VERSION = 'v1.5';
document.getElementById('ver').textContent = VERSION; document.getElementById('ver').textContent = VERSION;
// DOM-элементы
const btnRecord = document.getElementById('btnRecord'); const btnRecord = document.getElementById('btnRecord');
const btnPlay = document.getElementById('btnPlay'); const btnPlay = document.getElementById('btnPlay');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
@@ -80,17 +81,50 @@ const txtInput = document.getElementById('txtInput');
const btnSend = document.getElementById('btnSend'); const btnSend = document.getElementById('btnSend');
const btnTheme = document.getElementById('btnTheme'); const btnTheme = document.getElementById('btnTheme');
let mediaRecorder, chunks = [], userBlob = null; // Состояние
let mediaRecorder = null, chunks = [], userBlob = null;
let isRecording = false, micAnalyser = null, micAnimId = null; let isRecording = false, micAnalyser = null, micAnimId = null;
let audioContext = null, chatHistory = []; let audioContext = null, chatHistory = [], stream = null;
let pendingText = null, pendingSendBtn = null; let pendingText = null, pendingSendBtn = null;
let abortTranscribe = null, abortLLM = null, transcribeTimer = null, llmTimer = null;
// Светлая тема по умолчанию // Только день! Никакой ночи по умолчанию
if (localStorage.getItem('say_dark') === '1') { document.body.classList.add('dark'); btnTheme.textContent = '☀️'; } localStorage.setItem('say_dark', '0');
document.body.classList.remove('dark');
btnTheme.textContent = '🌙';
let history = JSON.parse(localStorage.getItem('say_history') || '[]'); let history = JSON.parse(localStorage.getItem('say_history') || '[]');
function setStatus(msg) { statusEl.innerHTML = msg; } function setStatus(msg) { statusEl.innerHTML = msg; }
// ===== Сброс состояния =====
function resetState(keepHistory) {
// Останавливаем запись если идёт
if (mediaRecorder && mediaRecorder.state === 'recording') {
try { mediaRecorder.stop(); } catch(e) {}
}
// Закрываем стрим микрофона
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
// Отменяем анимацию
cancelAnimationFrame(micAnimId);
if (audioContext) try { audioContext.close(); } catch(e) {}
// Отменяем запросы
if (abortTranscribe) try { abortTranscribe.abort(); } catch(e) {}
if (abortLLM) try { abortLLM.abort(); } catch(e) {}
// Таймеры
clearInterval(transcribeTimer); clearInterval(llmTimer);
// Состояния
audioContext = null; micAnalyser = null; micAnimId = null;
isRecording = false; userBlob = null;
pendingText = null;
if (pendingSendBtn) { pendingSendBtn.remove(); pendingSendBtn = null; }
// Кнопки
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false;
btnPlay.disabled = true;
if (!keepHistory) { chatHistory = []; chat.innerHTML = ''; }
}
// ===== История =====
function renderHistory() { function renderHistory() {
histList.innerHTML = history.slice(-20).map((h, i) => histList.innerHTML = history.slice(-20).map((h, i) =>
'<div class="hitem" data-idx="' + i + '" title="' + h.time + '">' + '<div class="hitem" data-idx="' + i + '" title="' + h.time + '">' +
@@ -127,8 +161,15 @@ function fmt(s) {
renderHistory(); renderHistory();
// ===== Отправка ===== // ===== Сброс (работает в любом состоянии) =====
document.getElementById('btnReset').onclick = () => {
resetState(false);
setStatus('🧹 Сброшено');
};
// ===== Отправка текста =====
async function doSend() { async function doSend() {
if (btnRecord.disabled) return; // если идёт процесс — игнорируем
let text = ''; let text = '';
if (txtInput.value.trim()) text += txtInput.value.trim() + ' '; if (txtInput.value.trim()) text += txtInput.value.trim() + ' ';
if (pendingText) text += pendingText; if (pendingText) text += pendingText;
@@ -141,28 +182,26 @@ async function doSend() {
} }
btnSend.onclick = doSend; btnSend.onclick = doSend;
txtInput.onkeydown = e => { if (e.key === 'Enter') doSend(); }; txtInput.onkeydown = e => { if (e.key === 'Enter' && !btnRecord.disabled) doSend(); };
// ===== Запись ===== // ===== Запись =====
btnRecord.onclick = async () => { btnRecord.onclick = async () => {
if (isRecording) { stopRecording(); return; } if (isRecording) {
try { // Остановить запись
isRecording = true; btnRecord.textContent = '⏹ Стоп'; isRecording = false;
setStatus('🎤 Запись...'); btnRecord.disabled = true;
await startRecording(); btnRecord.textContent = '⏳ Обработка...';
} catch (err) { setStatus('⏳ Обработка...');
let msg = '⛔ '; mediaRecorder.stop();
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён.'; return;
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден.';
else msg += 'Ошибка: ' + (err.message || err.name);
setStatus(msg);
btnRecord.textContent = '🎙 Записать'; isRecording = false;
} }
}; // Начать запись
try {
async function startRecording() { resetState(true); // сброс без очистки чата
isRecording = true; btnRecord.textContent = '⏹ Стоп'; btnRecord.disabled = false;
setStatus('🎤 Запись...');
chunks = []; chunks = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContext = new AudioContext(); audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream); const source = audioContext.createMediaStreamSource(stream);
micAnalyser = audioContext.createAnalyser(); micAnalyser.fftSize = 256; micAnalyser = audioContext.createAnalyser(); micAnalyser.fftSize = 256;
@@ -170,21 +209,25 @@ async function startRecording() {
mediaRecorder = new MediaRecorder(stream); mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => chunks.push(e.data); mediaRecorder.ondataavailable = e => chunks.push(e.data);
mediaRecorder.onstop = async () => { mediaRecorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
cancelAnimationFrame(micAnimId); cancelAnimationFrame(micAnimId);
if (audioContext) audioContext.close(); if (audioContext) try { audioContext.close(); } catch(e) {}
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
audioContext = null; micAnalyser = null; micAnimId = null;
if (chunks.length === 0) { resetState(true); setStatus('❗ Запись пуста'); return; }
userBlob = new Blob(chunks, { type: 'audio/webm' }); userBlob = new Blob(chunks, { type: 'audio/webm' });
btnPlay.disabled = false; btnPlay.disabled = false;
await transcribe(); await transcribe();
}; };
mediaRecorder.start(); mediaRecorder.start();
} catch (err) {
resetState(true);
let msg = '⛔ ';
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён.';
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден.';
else msg += 'Ошибка: ' + (err.message || err.name);
setStatus(msg);
} }
};
function stopRecording() {
btnRecord.disabled = true;
setStatus('⏳ Обработка...');
mediaRecorder.stop();
}
function drawMeter() { function drawMeter() {
if (!micAnalyser) return; if (!micAnalyser) return;
@@ -208,28 +251,28 @@ btnPlay.onclick = () => {
a.play(); setStatus('🔊 Ваша запись...'); a.play(); setStatus('🔊 Ваша запись...');
}; };
// ===== Транскрибация → подтверждение → LLM ===== // ===== Транскрибация =====
async function transcribe() { async function transcribe() {
let tStart = Date.now(); const tStart = Date.now();
setStatus('🎤 Транскрибация... 0с'); setStatus('🎤 Транскрибация... 0с');
const tTimer = setInterval(() => setStatus('🎤 Транскрибация... ' + ((Date.now() - tStart) / 1000).toFixed(0) + 'с'), 200); transcribeTimer = setInterval(() => setStatus('🎤 Транскрибация... ' + ((Date.now() - tStart) / 1000).toFixed(0) + 'с'), 200);
abortTranscribe = new AbortController();
try { try {
const fd = new FormData(); fd.append('audio', userBlob); const fd = new FormData(); fd.append('audio', userBlob);
const ac = new AbortController(); setTimeout(() => ac.abort(), 30000); setTimeout(() => abortTranscribe.abort(), 30000);
const tRes = await fetch('/api/whisper', { method: 'POST', body: fd, signal: ac.signal }); const tRes = await fetch('/api/whisper', { method: 'POST', body: fd, signal: abortTranscribe.signal });
clearInterval(tTimer); clearInterval(transcribeTimer);
const tData = await tRes.json(); const tData = await tRes.json();
const text = tData.text || ''; const text = tData.text || '';
const tElapsed = ((Date.now() - tStart) / 1000).toFixed(1); const tElapsed = ((Date.now() - tStart) / 1000).toFixed(1);
if (!text.trim()) { if (!text.trim()) {
setStatus('⛔ Транскрибация: ' + tElapsed + 'с — ничего не распознано'); setStatus('⛔ Ничего не распознано (' + tElapsed + 'с)');
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false; btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false;
userBlob = null; btnPlay.disabled = true; userBlob = null; btnPlay.disabled = true;
return; return;
} }
setStatus('✅ Транскрибация: ' + tElapsed + 'с'); setStatus('✅ Транскрибация: ' + tElapsed + 'с');
// Показываем распознанный текст + кнопку подтверждения
const msgDiv = document.createElement('div'); const msgDiv = document.createElement('div');
msgDiv.className = 'msg user'; msgDiv.className = 'msg user';
msgDiv.innerHTML = '🗣 ' + fmt(text); msgDiv.innerHTML = '🗣 ' + fmt(text);
@@ -239,37 +282,38 @@ async function transcribe() {
btn.style.marginTop = '8px'; btn.style.fontSize = '.8rem'; btn.style.marginTop = '8px'; btn.style.fontSize = '.8rem';
btn.onclick = () => { btn.onclick = () => {
pendingText = text; pendingText = text;
pendingSendBtn = btn;
btn.textContent = '✓ Отмечено';
btn.style.background = '#16a34a'; btn.style.borderColor = '#16a34a';
doSend(); doSend();
}; };
msgDiv.appendChild(btn); msgDiv.appendChild(btn);
chat.appendChild(msgDiv); chat.appendChild(msgDiv);
chat.scrollTop = chat.scrollHeight; chat.scrollTop = chat.scrollHeight;
} catch (err) { } catch (err) {
clearInterval(tTimer); if (err.name === 'AbortError') setStatus('⛔ Таймаут транскрибации');
setStatus('⛔ Ошибка: ' + err.message); else setStatus('⛔ Ошибка: ' + err.message);
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false; clearInterval(transcribeTimer);
userBlob = null; btnPlay.disabled = true;
} }
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false;
} }
// ===== LLM =====
async function askLLM(text) { async function askLLM(text) {
let lStart = Date.now(); const lStart = Date.now();
statusEl.innerHTML += '<br>🤔 Думаю... 0с'; btnRecord.disabled = true; btnSend.disabled = true;
const lTimer = setInterval(() => { statusEl.innerHTML = statusEl.innerHTML.replace(/⏳.*/, '') + '<br>🤔 Думаю... 0с';
llmTimer = setInterval(() => {
const sec = ((Date.now() - lStart) / 1000).toFixed(0); const sec = ((Date.now() - lStart) / 1000).toFixed(0);
statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '🤔 Думаю... ' + sec + 'с'); statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '🤔 Думаю... ' + sec + 'с');
}, 200); }, 200);
abortLLM = new AbortController();
try { try {
setTimeout(() => abortLLM.abort(), 120000);
const cRes = await fetch('/api/chat', { const cRes = await fetch('/api/chat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-oss-120b', message: text, history: chatHistory }), body: JSON.stringify({ model: 'gpt-oss-120b', message: text, history: chatHistory }),
signal: (() => { const a = new AbortController(); setTimeout(() => a.abort(), 120000); return a.signal; })(), signal: abortLLM.signal,
}); });
clearInterval(lTimer); clearInterval(llmTimer);
const cData = await cRes.json(); const cData = await cRes.json();
const resp = cData.response || cData.error || 'Ошибка'; const resp = cData.response || cData.error || 'Ошибка';
const lElapsed = ((Date.now() - lStart) / 1000).toFixed(1); const lElapsed = ((Date.now() - lStart) / 1000).toFixed(1);
@@ -286,23 +330,20 @@ async function askLLM(text) {
localStorage.setItem('say_history', JSON.stringify(history)); localStorage.setItem('say_history', JSON.stringify(history));
renderHistory(); renderHistory();
} catch (err) { } catch (err) {
clearInterval(lTimer); clearInterval(llmTimer);
statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '⛔ ' + err.message); if (err.name === 'AbortError') statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '⛔ Таймаут');
else statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '⛔ ' + err.message);
} }
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false; btnRecord.disabled = false; btnSend.disabled = false;
userBlob = null; btnPlay.disabled = true; userBlob = null; btnPlay.disabled = true;
} }
// Тема // ===== Тема =====
btnTheme.onclick = () => { btnTheme.onclick = () => {
const dark = !document.body.classList.toggle('dark'); const dark = document.body.classList.toggle('dark');
btnTheme.textContent = dark ? '☀️' : '🌙'; btnTheme.textContent = dark ? '☀️' : '🌙';
localStorage.setItem('say_dark', dark ? '1' : '0'); localStorage.setItem('say_dark', dark ? '1' : '0');
}; };
document.getElementById('btnReset').onclick = () => {
chatHistory = []; chat.innerHTML = ''; setStatus('');
};
</script> </script>
</body> </body>
</html> </html>