Files
say/public/index.html
T
2026-06-01 23:23:50 +03:00

252 lines
9.6 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Say — Голосовой ассистент | Nubes</title>
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<div id="main">
<header class="topbar">
<a href="/" class="topbar-logo"><img src="/nubes-logo.svg" alt="Nubes" width="100"></a>
<span class="topbar-sep">|</span>
<span class="topbar-title">Say <span id="ver">v1.0</span></span>
<span class="topbar-right">Голосовой ассистент</span>
</header>
<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>
<button id="btnTheme" title="День/Ночь">🌙</button>
</div>
<div id="status"></div>
<div class="card">
<div id="chat"></div>
</div>
</div>
<div id="sidebar">
<div id="history-title">История</div>
<div id="history-list"></div>
</div>
</div>
<script>
const VERSION = 'v1.1';
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="msg user">📋 ' + fmt(h.q) + '</div>';
chat.innerHTML += '<div class="msg bot">🤖 ' + fmt(h.a) + '</div>';
chat.scrollTop = chat.scrollHeight;
};
});
}
function esc(s) { return String(s).replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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="msg 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="msg 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('🧹 Очищено');
};
// Тема
const btnTheme = document.getElementById('btnTheme');
let dark = localStorage.getItem('say_dark') !== '0';
function applyTheme() {
document.body.className = dark ? 'dark' : 'light';
btnTheme.textContent = dark ? '🌙' : '☀️';
}
applyTheme();
btnTheme.onclick = () => { dark = !dark; localStorage.setItem('say_dark', dark ? '1' : '0'); applyTheme(); };
</script>
</body>
</html>