v1.3 — лого встроено в HTML
This commit is contained in:
+218
-195
@@ -10,7 +10,13 @@
|
||||
<body>
|
||||
|
||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.85rem;color:var(--muted);">
|
||||
<img src="/nubes-logo.svg" alt="Nubes" width="130" height="28" style="display:block;">
|
||||
<svg viewBox="0 0 311 69" width="130" height="28" style="display:block;">
|
||||
<g transform="translate(-330 -228)">
|
||||
<path d="M348.061 246.203 332.42 246.203 330 256.661 336.75 256.661 336.794 295.573 336.794 296.585 336.795 296.585 347.25 296.585 347.25 263.611C347.25 259.78 350.368 256.663 354.2 256.663L376.489 256.793C380.322 256.793 383.44 259.912 383.44 263.743L383.44 296.717 393.896 296.717 393.896 263.743C393.896 254.144 386.087 246.338 376.495 246.338L354.196 246.204 348.061 246.221 348.061 246.203Z" fill="#2563eb" fill-rule="evenodd"/>
|
||||
<path d="M582.095 296.65 626.768 296.65C634.793 296.65 641 290.03 641 282.009 641 273.985 634.473 267.458 626.449 267.458L601.724 267.458C599.446 267.458 597.595 265.607 597.595 263.328L597.595 260.825C597.595 258.548 599.446 256.697 601.724 256.697L628.041 256.697 630.452 246.276 601.724 246.276C593.7 246.276 587.174 252.802 587.174 260.825L587.174 263.328C587.174 271.352 593.7 277.879 601.724 277.879L626.449 277.879C628.726 277.879 630.579 279.732 630.579 282.009 630.579 284.283 628.726 286.4 626.449 286.4L584.126 286.4 582.095 296.65Z" fill="#2563eb" fill-rule="evenodd"/>
|
||||
<path d="M564.549 246.347 537.193 246.347C529.027 246.347 522.385 252.989 522.385 261.156L522.385 279.521C522.385 288.916 530.028 296.557 539.422 296.557L576.846 296.557 579.196 286.401 539.422 286.401C535.775 286.401 532.805 283.172 532.805 279.521L532.748 278.028 567.684 278.028 579.354 277.959 579.354 268.958 579.354 261.156C579.354 252.989 572.712 246.347 564.549 246.347ZM568.935 267.54 532.805 267.54 532.805 261.156C532.805 258.737 534.774 256.767 537.193 256.767L564.549 256.767C566.966 256.767 568.935 258.737 568.935 261.156L568.935 267.54Z" fill="#2563eb" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span style="color:var(--border);">|</span>
|
||||
<b style="color:var(--text);">Say</b>
|
||||
<span id="ver" style="margin-left:4px;"></span>
|
||||
@@ -39,7 +45,13 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Диалог</div>
|
||||
<div class="card-body" id="chat" style="min-height:200px;max-height:380px;overflow-y:auto;"></div>
|
||||
<div class="card-body">
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:10px;">
|
||||
<input id="txtInput" type="text" placeholder="Введите текст или используйте микрофон..." style="flex:1;padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;outline:none;background:var(--card);color:var(--text);">
|
||||
<button id="btnSend" class="btn" style="font-size:13px;">➤</button>
|
||||
</div>
|
||||
<div id="chat" style="min-height:200px;max-height:340px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,206 +63,217 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const VERSION = 'v1.1';
|
||||
document.getElementById('ver').textContent = VERSION;
|
||||
<script>
|
||||
const VERSION = 'v1.3';
|
||||
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');
|
||||
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');
|
||||
const txtInput = document.getElementById('txtInput');
|
||||
const btnSend = document.getElementById('btnSend');
|
||||
|
||||
let mediaRecorder, chunks = [], userBlob = null;
|
||||
let isRecording = false, micAnalyser = null, micAnimId = null;
|
||||
let audioContext = null, chatHistory = [];
|
||||
let thinkStart = 0, thinkTimer = null;
|
||||
let mediaRecorder, chunks = [], userBlob = null;
|
||||
let isRecording = false, micAnalyser = null, micAnimId = null;
|
||||
let audioContext = null, chatHistory = [];
|
||||
|
||||
// История — массив {q, a, time}
|
||||
let history = JSON.parse(localStorage.getItem('say_history') || '[]');
|
||||
// Светлая тема — по умолчанию
|
||||
if (localStorage.getItem('say_dark') === '1') document.body.classList.add('dark');
|
||||
|
||||
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,'<').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>';
|
||||
}
|
||||
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,'<').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>');
|
||||
s = s.replace(/\n/g, '<br>');
|
||||
s = s.replace(/<ul>\s*<\/ul>/g, '');
|
||||
return '<p>' + s + '</p>';
|
||||
}
|
||||
|
||||
renderHistory();
|
||||
|
||||
// ===== Отправка текста =====
|
||||
async function sendText(text) {
|
||||
if (!text.trim()) return;
|
||||
txtInput.value = '';
|
||||
chat.innerHTML += '<div class="msg user">⌨ ' + fmt(text) + '</div>';
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
await askLLM(text);
|
||||
}
|
||||
|
||||
btnSend.onclick = () => sendText(txtInput.value);
|
||||
txtInput.onkeydown = e => { if (e.key === 'Enter') sendText(txtInput.value); };
|
||||
|
||||
// ===== Запись =====
|
||||
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('🔊 Ваша запись...');
|
||||
};
|
||||
|
||||
// ===== Транскрибация → LLM =====
|
||||
async function transcribeAndChat() {
|
||||
let tStart = Date.now();
|
||||
setStatus('🎤 Транскрибация... 0с');
|
||||
const tTimer = setInterval(() => setStatus('🎤 Транскрибация... ' + ((Date.now() - tStart) / 1000).toFixed(0) + 'с'), 200);
|
||||
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);
|
||||
const tData = await tRes.json();
|
||||
const text = tData.text || '(не распознано)';
|
||||
const tElapsed = ((Date.now() - tStart) / 1000).toFixed(1);
|
||||
setStatus('✅ Транскрибация: ' + tElapsed + 'с');
|
||||
chat.innerHTML += '<div class="msg user">🗣 ' + fmt(text) + '</div>';
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
await askLLM(text);
|
||||
} catch (err) {
|
||||
clearInterval(tTimer);
|
||||
setStatus('⛔ Ошибка: ' + err.message);
|
||||
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false;
|
||||
userBlob = null; btnPlay.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function askLLM(text) {
|
||||
let lStart = Date.now();
|
||||
statusEl.innerHTML += '<br>🤔 Думаю... 0с';
|
||||
const lTimer = setInterval(() => {
|
||||
const sec = ((Date.now() - lStart) / 1000).toFixed(0);
|
||||
statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '🤔 Думаю... ' + sec + 'с');
|
||||
}, 200);
|
||||
try {
|
||||
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; })(),
|
||||
});
|
||||
clearInterval(lTimer);
|
||||
const cData = await cRes.json();
|
||||
const resp = cData.response || cData.error || 'Ошибка';
|
||||
const lElapsed = ((Date.now() - lStart) / 1000).toFixed(1);
|
||||
statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '✅ Ответ: ' + lElapsed + 'с');
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
clearInterval(lTimer);
|
||||
statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '⛔ ' + err.message);
|
||||
}
|
||||
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false;
|
||||
userBlob = null; btnPlay.disabled = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
// Тема
|
||||
document.getElementById('btnTheme').onclick = () => {
|
||||
const dark = !document.body.classList.toggle('dark');
|
||||
document.getElementById('btnTheme').textContent = dark ? '☀️' : '🌙';
|
||||
localStorage.setItem('say_dark', dark ? '1' : '0');
|
||||
};
|
||||
document.getElementById('btnTheme').textContent = document.body.classList.contains('dark') ? '☀️' : '🌙';
|
||||
|
||||
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>
|
||||
document.getElementById('btnReset').onclick = () => {
|
||||
chatHistory = []; chat.innerHTML = ''; setStatus('');
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user