v1.5 — день по умолчанию, resetState(), все комбинации кнопок
This commit is contained in:
+107
-66
@@ -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,51 +182,53 @@ 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; }
|
||||
if (isRecording) {
|
||||
// Остановить запись
|
||||
isRecording = false;
|
||||
btnRecord.disabled = true;
|
||||
btnRecord.textContent = '⏳ Обработка...';
|
||||
setStatus('⏳ Обработка...');
|
||||
mediaRecorder.stop();
|
||||
return;
|
||||
}
|
||||
// Начать запись
|
||||
try {
|
||||
isRecording = true; btnRecord.textContent = '⏹ Стоп';
|
||||
resetState(true); // сброс без очистки чата
|
||||
isRecording = true; btnRecord.textContent = '⏹ Стоп'; btnRecord.disabled = false;
|
||||
setStatus('🎤 Запись...');
|
||||
await startRecording();
|
||||
chunks = [];
|
||||
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 () => {
|
||||
cancelAnimationFrame(micAnimId);
|
||||
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();
|
||||
} 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);
|
||||
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 transcribe();
|
||||
};
|
||||
mediaRecorder.start();
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
btnRecord.disabled = true;
|
||||
setStatus('⏳ Обработка...');
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
|
||||
function drawMeter() {
|
||||
if (!micAnalyser) return;
|
||||
const data = new Uint8Array(micAnalyser.frequencyBinCount);
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user