233 lines
8.7 KiB
HTML
233 lines
8.7 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Say — Голосовой ассистент</title>
|
||
<link rel="stylesheet" href="/style.css">
|
||
</head>
|
||
<body>
|
||
|
||
<div id="app">
|
||
<div id="main">
|
||
<h1>🎙️ Say <span id="ver">v1.0</span></h1>
|
||
|
||
<div id="models">
|
||
<span class="model-tag">🤖 GPT-OSS-120B</span>
|
||
<span class="model-tag">🎤 Whisper-v3-Turbo</span>
|
||
</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>
|
||
</div>
|
||
|
||
<div id="sidebar">
|
||
<div id="history-title">История</div>
|
||
<div id="history-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const VERSION = 'v1.0';
|
||
document.getElementById('ver').textContent = VERSION;
|
||
|
||
const btnRecord = document.getElementById('btnRecord');
|
||
const btnPlay = document.getElementById('btnPlay');
|
||
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, chatHistory = [];
|
||
let thinkStart = 0, thinkTimer = null;
|
||
|
||
// История — массив {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">📋 ' + fmt(h.q) + '</div>';
|
||
chat.innerHTML += '<div class="bot">🤖 ' + fmt(h.a) + '</div>';
|
||
chat.scrollTop = chat.scrollHeight;
|
||
};
|
||
});
|
||
}
|
||
function esc(s) { return String(s).replace(/</g,'<').replace(/>/g,'>'); }
|
||
function fmt(s) {
|
||
s = esc(s);
|
||
// Заменяем **жирный**
|
||
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
||
// Заменяем `код`
|
||
s = s.replace(/`(.+?)`/g, '<code>$1</code>');
|
||
// Заменяем *курсив*
|
||
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
|
||
// Горизонтальная линия ---
|
||
s = s.replace(/^---+/gm, '<hr>');
|
||
// Заголовки ###
|
||
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||
// Маркированный список
|
||
s = s.replace(/^[•\-*] (.+)$/gm, '<li>$1</li>');
|
||
s = s.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
|
||
// Нумерованный список
|
||
s = s.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
||
// Абзацы (двойной перенос строки)
|
||
s = s.replace(/\n\n+/g, '</p><p>');
|
||
// Одинарный перенос → <br>
|
||
s = s.replace(/\n/g, '<br>');
|
||
// Убираем пустые <ul>
|
||
s = s.replace(/<ul>\s*<\/ul>/g, '');
|
||
return '<p>' + s + '</p>';
|
||
}
|
||
|
||
renderHistory();
|
||
|
||
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;
|
||
}
|
||
};
|
||
|
||
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 transcribeAndChat();
|
||
};
|
||
mediaRecorder.start();
|
||
}
|
||
|
||
function stopRecording() {
|
||
btnRecord.disabled = true;
|
||
setStatus('⏳ Обработка...');
|
||
mediaRecorder.stop();
|
||
}
|
||
|
||
function drawMeter() {
|
||
if (!micAnalyser) return;
|
||
const data = new Uint8Array(micAnalyser.frequencyBinCount);
|
||
micAnalyser.getByteTimeDomainData(data);
|
||
const w = meter.width, h = meter.height;
|
||
meterCtx.fillStyle = '#1a1a2e';
|
||
meterCtx.fillRect(0, 0, w, h);
|
||
meterCtx.strokeStyle = '#6c5ce7';
|
||
meterCtx.lineWidth = 2;
|
||
meterCtx.beginPath();
|
||
for (let i = 0; i < data.length; i++) {
|
||
meterCtx.lineTo((i / data.length) * w, (data[i] / 255) * h);
|
||
}
|
||
meterCtx.stroke();
|
||
micAnimId = requestAnimationFrame(drawMeter);
|
||
}
|
||
|
||
btnPlay.onclick = () => {
|
||
if (!userBlob) return;
|
||
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 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">🗣 ' + fmt(text) + '</div>';
|
||
chat.scrollTop = chat.scrollHeight;
|
||
|
||
startThinkTimer();
|
||
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; })(),
|
||
});
|
||
stopThinkTimer();
|
||
const cData = await cRes.json();
|
||
const resp = cData.response || cData.error || 'Ошибка';
|
||
chatHistory.push({ role: 'user', content: text });
|
||
chatHistory.push({ role: 'assistant', content: resp });
|
||
if (chatHistory.length > 40) chatHistory = chatHistory.slice(-40);
|
||
chat.innerHTML += '<div class="bot">🤖 ' + fmt(resp) + '</div>';
|
||
chat.scrollTop = chat.scrollHeight;
|
||
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) {
|
||
stopThinkTimer();
|
||
setStatus('⛔ ' + err.message);
|
||
}
|
||
btnRecord.textContent = '🎙 Записать';
|
||
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 = () => {
|
||
chatHistory = []; chat.innerHTML = ''; setStatus('🧹 Очищено');
|
||
};
|
||
</script>
|
||
</body>
|
||
</html> |