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

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