v1.5 — день по умолчанию, resetState(), все комбинации кнопок
This commit is contained in:
+101
-60
@@ -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>
|
||||||
Reference in New Issue
Block a user