feat: версия, таймер ответа, история 20 вопросов, боковая панель
This commit is contained in:
+86
-53
@@ -8,39 +8,70 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1>🎙️ Say</h1>
|
<div id="app">
|
||||||
|
<div id="main">
|
||||||
|
<h1>🎙️ Say <span id="ver">v1.0</span></h1>
|
||||||
|
|
||||||
<div id="models">
|
<div id="visualizer">
|
||||||
<button class="model-btn active">GPT-OSS-120B</button>
|
<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>
|
</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>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const VERSION = 'v1.0';
|
||||||
|
document.getElementById('ver').textContent = VERSION;
|
||||||
|
|
||||||
const btnRecord = document.getElementById('btnRecord');
|
const btnRecord = document.getElementById('btnRecord');
|
||||||
const btnPlay = document.getElementById('btnPlay');
|
const btnPlay = document.getElementById('btnPlay');
|
||||||
const status = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const chat = document.getElementById('chat');
|
const chat = document.getElementById('chat');
|
||||||
|
const histList = document.getElementById('history-list');
|
||||||
const meter = document.getElementById('meter');
|
const meter = document.getElementById('meter');
|
||||||
const meterCtx = meter.getContext('2d');
|
const meterCtx = meter.getContext('2d');
|
||||||
|
|
||||||
let mediaRecorder, chunks = [], userBlob = null;
|
let mediaRecorder, chunks = [], userBlob = null;
|
||||||
let isRecording = false, micAnalyser = null, micAnimId = null;
|
let isRecording = false, micAnalyser = null, micAnimId = null;
|
||||||
let audioContext = null, sessionSid = null;
|
let audioContext = null, sessionSid = null;
|
||||||
|
let thinkStart = 0, thinkTimer = null;
|
||||||
|
|
||||||
function setStatus(msg) { status.textContent = msg; }
|
// История — массив {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">📋 ' + esc(h.q) + '</div>';
|
||||||
|
chat.innerHTML += '<div class="bot">🤖 ' + esc(h.a) + '</div>';
|
||||||
|
chat.scrollTop = chat.scrollHeight;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function esc(s) { return String(s).replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
renderHistory();
|
||||||
|
|
||||||
btnRecord.onclick = async () => {
|
btnRecord.onclick = async () => {
|
||||||
if (isRecording) { stopRecording(); return; }
|
if (isRecording) { stopRecording(); return; }
|
||||||
@@ -51,7 +82,7 @@
|
|||||||
await startRecording();
|
await startRecording();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let msg = '⛔ ';
|
let msg = '⛔ ';
|
||||||
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён. Разрешите в настройках браузера.';
|
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён.';
|
||||||
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден.';
|
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден.';
|
||||||
else msg += 'Ошибка: ' + (err.message || err.name);
|
else msg += 'Ошибка: ' + (err.message || err.name);
|
||||||
setStatus(msg);
|
setStatus(msg);
|
||||||
@@ -69,7 +100,6 @@
|
|||||||
micAnalyser.fftSize = 256;
|
micAnalyser.fftSize = 256;
|
||||||
source.connect(micAnalyser);
|
source.connect(micAnalyser);
|
||||||
drawMeter();
|
drawMeter();
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -100,9 +130,7 @@
|
|||||||
meterCtx.lineWidth = 2;
|
meterCtx.lineWidth = 2;
|
||||||
meterCtx.beginPath();
|
meterCtx.beginPath();
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
const x = (i / data.length) * w;
|
meterCtx.lineTo((i / data.length) * w, (data[i] / 255) * h);
|
||||||
const y = (data[i] / 255) * h;
|
|
||||||
i === 0 ? meterCtx.moveTo(x, y) : meterCtx.lineTo(x, y);
|
|
||||||
}
|
}
|
||||||
meterCtx.stroke();
|
meterCtx.stroke();
|
||||||
micAnimId = requestAnimationFrame(drawMeter);
|
micAnimId = requestAnimationFrame(drawMeter);
|
||||||
@@ -110,57 +138,62 @@
|
|||||||
|
|
||||||
btnPlay.onclick = () => {
|
btnPlay.onclick = () => {
|
||||||
if (!userBlob) return;
|
if (!userBlob) return;
|
||||||
const url = URL.createObjectURL(userBlob);
|
const a = new Audio(URL.createObjectURL(userBlob));
|
||||||
const a = new Audio(url);
|
a.onended = () => URL.revokeObjectURL(a.src);
|
||||||
a.onended = () => URL.revokeObjectURL(url);
|
a.play(); setStatus('🔊 Ваша запись...');
|
||||||
a.play();
|
|
||||||
setStatus('🔊 Ваша запись...');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function transcribeAndChat() {
|
async function transcribeAndChat() {
|
||||||
try {
|
try {
|
||||||
setStatus('📝 Распознавание...');
|
setStatus('📝 Распознавание...');
|
||||||
const fd = new FormData();
|
const fd = new FormData(); fd.append('audio', userBlob);
|
||||||
fd.append('audio', userBlob);
|
const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 30000);
|
||||||
const controller = new AbortController();
|
const tRes = await fetch('/api/whisper', { method: 'POST', body: fd, signal: ac.signal });
|
||||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
clearTimeout(to);
|
||||||
const tRes = await fetch('/api/whisper', { method: 'POST', body: fd, signal: controller.signal });
|
|
||||||
clearTimeout(timeout);
|
|
||||||
const tData = await tRes.json();
|
const tData = await tRes.json();
|
||||||
const text = tData.text || '(не распознано)';
|
const text = tData.text || '(не распознано)';
|
||||||
chat.innerHTML += '<div class="user">🗣 Вы: ' + text + '</div>';
|
chat.innerHTML += '<div class="user">🗣 ' + esc(text) + '</div>';
|
||||||
|
chat.scrollTop = chat.scrollHeight;
|
||||||
|
|
||||||
setStatus('🤖 Думает...');
|
startThinkTimer();
|
||||||
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, sid: sessionSid }),
|
body: JSON.stringify({ model: 'gpt-oss-120b', message: text, sid: sessionSid }),
|
||||||
signal: (() => { const ac = new AbortController(); setTimeout(() => ac.abort(), 120000); return ac.signal; })(),
|
signal: (() => { const a = new AbortController(); setTimeout(() => a.abort(), 120000); return a.signal; })(),
|
||||||
});
|
});
|
||||||
|
stopThinkTimer();
|
||||||
const cData = await cRes.json();
|
const cData = await cRes.json();
|
||||||
if (cData.sid) sessionSid = cData.sid;
|
if (cData.sid) sessionSid = cData.sid;
|
||||||
chat.innerHTML += '<div class="bot">🤖 GPT-OSS: ' + (cData.response || cData.error || 'Ошибка') + '</div>';
|
const resp = cData.response || cData.error || 'Ошибка';
|
||||||
|
chat.innerHTML += '<div class="bot">🤖 ' + esc(resp) + '</div>';
|
||||||
chat.scrollTop = chat.scrollHeight;
|
chat.scrollTop = chat.scrollHeight;
|
||||||
setStatus('✅ Готово');
|
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) {
|
} catch (err) {
|
||||||
setStatus('⛔ Ошибка: ' + err.message);
|
stopThinkTimer();
|
||||||
|
setStatus('⛔ ' + err.message);
|
||||||
}
|
}
|
||||||
btnRecord.textContent = '🎙 Записать';
|
btnRecord.textContent = '🎙 Записать';
|
||||||
btnRecord.disabled = false;
|
btnRecord.disabled = false; isRecording = false;
|
||||||
isRecording = false;
|
userBlob = null; btnPlay.disabled = true;
|
||||||
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 = async () => {
|
document.getElementById('btnReset').onclick = async () => {
|
||||||
await fetch('/api/reset', {
|
await fetch('/api/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sid: sessionSid }) });
|
||||||
method: 'POST',
|
sessionSid = null; chat.innerHTML = ''; setStatus('🧹 Очищено');
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ sid: sessionSid }),
|
|
||||||
});
|
|
||||||
sessionSid = null;
|
|
||||||
chat.innerHTML = '';
|
|
||||||
setStatus('🧹 История очищена');
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+23
-14
@@ -1,22 +1,31 @@
|
|||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 640px; margin: 0 auto; padding: 20px; background: #0d0d1a; color: #e0e0e0; }
|
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 0; background: #0d0d1a; color: #e0e0e0; }
|
||||||
h1 { text-align: center; color: #7c6ff7; margin-bottom: 8px; }
|
|
||||||
|
|
||||||
#models { display: flex; gap: 8px; justify-content: center; margin: 12px 0; }
|
#app { display: flex; height: 100vh; }
|
||||||
.model-btn { padding: 6px 14px; border: 1px solid #7c6ff7; background: transparent; color: #7c6ff7; border-radius: 6px; font-size: 13px; cursor: default; }
|
#main { flex: 1; padding: 20px; max-width: 680px; margin: 0 auto; overflow-y: auto; }
|
||||||
.model-btn.active { background: #7c6ff7; color: #fff; }
|
|
||||||
|
|
||||||
#visualizer { text-align: center; margin: 10px 0; }
|
#ver { font-size: 14px; color: #666; font-weight: normal; }
|
||||||
#meter { border-radius: 6px; background: #1a1a2e; }
|
|
||||||
|
|
||||||
#controls { display: flex; gap: 10px; justify-content: center; margin: 12px 0; }
|
h1 { color: #7c6ff7; margin: 0 0 8px; font-size: 20px; }
|
||||||
#btnRecord { padding: 10px 28px; background: #e84393; color: #fff; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; }
|
|
||||||
|
#visualizer { margin: 10px 0; }
|
||||||
|
#meter { border-radius: 6px; background: #1a1a2e; display: block; margin: 0 auto; }
|
||||||
|
|
||||||
|
#controls { display: flex; gap: 8px; justify-content: center; margin: 10px 0; flex-wrap: wrap; }
|
||||||
|
#btnRecord { padding: 10px 24px; background: #e84393; color: #fff; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; }
|
||||||
#btnRecord:disabled { opacity: 0.5; cursor: not-allowed; }
|
#btnRecord:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
#btnPlay { padding: 10px 20px; background: #00b894; color: #fff; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; }
|
#btnPlay { padding: 10px 16px; background: #00b894; color: #fff; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||||
#btnPlay:disabled { opacity: 0.4; cursor: not-allowed; }
|
#btnPlay:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
#btnReset { padding: 10px 16px; background: #636e72; color: #fff; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||||
|
|
||||||
#status { text-align: center; color: #888; font-size: 14px; min-height: 20px; margin: 8px 0; }
|
#status { text-align: center; color: #888; font-size: 14px; min-height: 20px; margin: 6px 0; }
|
||||||
|
|
||||||
#chat { border: 1px solid #2a2a3e; border-radius: 8px; padding: 14px; min-height: 240px; max-height: 380px; overflow-y: auto; margin: 12px 0; background: #13132a; }
|
#chat { border: 1px solid #2a2a3e; border-radius: 8px; padding: 12px; min-height: 200px; max-height: 340px; overflow-y: auto; background: #13132a; }
|
||||||
.user { color: #00cec9; margin: 8px 0; word-break: break-word; }
|
.user { color: #00cec9; margin: 6px 0; word-break: break-word; font-size: 14px; }
|
||||||
.bot { color: #a29bfe; margin: 8px 0; word-break: break-word; }
|
.bot { color: #a29bfe; margin: 6px 0 12px; word-break: break-word; font-size: 14px; }
|
||||||
|
|
||||||
|
#sidebar { width: 280px; background: #111122; border-left: 1px solid #2a2a3e; padding: 14px; overflow-y: auto; }
|
||||||
|
#history-title { color: #7c6ff7; font-weight: bold; margin-bottom: 10px; font-size: 15px; }
|
||||||
|
.hitem { padding: 6px 8px; border-radius: 4px; cursor: pointer; margin-bottom: 4px; font-size: 13px; color: #bbb; }
|
||||||
|
.hitem:hover { background: #1a1a3e; }
|
||||||
|
.hq { display: block; }
|
||||||
Reference in New Issue
Block a user