Commit 3f4ec0c9 by michaelpastushkov

fix

parent 8264d894
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>KotGPT</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<style>
/* Layout: header + main (scrolls) + composer; fixed footer */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
margin: 0;
}
header {
position: sticky;
top: 0;
z-index: 1020;
}
main {
flex: 1 1 auto;
overflow-y: auto;
position: relative;
/* leave room so fixed footer doesn’t overlap last messages */
padding-bottom: 3.5rem;
/* ≈ footer height */
}
.composer {
position: sticky;
bottom: 0;
z-index: 1020;
background: #fff;
border-top: 1px solid #dee2e6;
}
footer {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 1010;
font-size: .75rem;
color: #6c757d;
text-align: center;
padding: .5rem;
border-top: 1px solid #dee2e6;
background: #fdfdfd;
}
.cat-spinner {
width: 36px;
height: 36px;
object-fit: contain;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35));
}
.thinking-cat {
width: 64px;
height: 64px;
object-fit: contain;
display: block;
margin: .25rem auto;
}
.message {
max-width: 900px;
margin: 0 auto 1rem auto;
padding: 1rem 1.25rem;
border-radius: .75rem;
line-height: 1.6;
font-size: 1rem;
word-break: break-word;
}
.user-message {
background-color: #ffffff;
border: 1px solid #dee2e6;
white-space: pre-wrap;
}
.assistant-message {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
}
.assistant-message h1,
.assistant-message h2,
.assistant-message h3 {
margin-top: 1rem;
margin-bottom: .6rem;
}
.assistant-message p {
margin: .5rem 0;
}
.assistant-message code {
background: #fff;
border: 1px solid #e9ecef;
border-radius: .25rem;
padding: .05rem .35rem;
}
.assistant-message pre code {
display: block;
padding: .75rem;
}
.assistant-message ul,
.assistant-message ol {
padding-left: 1.25rem;
}
.assistant-message table {
width: 100%;
margin: 1rem 0;
}
/* First-run logo layout */
#mainScroll {
display: flex;
flex-direction: column;
position: relative;
}
#welcomeLogo {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
#welcomeLogo img {
max-width: 520px;
width: 100%;
height: auto;
filter: drop-shadow(0 6px 16px rgba(0, 0, 0, .15));
opacity: .98;
}
/* Sticky in-content scroll indicator (same pattern as previous file) */
#scrollDownBtn {
position: sticky;
margin-left: auto;
bottom: 1rem;
align-self: flex-end;
right: 1rem;
transform: translate(-1rem, 0);
z-index: 1030;
display: none;
border: none;
border-radius: 999px;
width: 36px;
height: 36px;
background: rgba(13, 110, 253, .95);
color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, .18);
cursor: pointer;
transition: opacity .18s ease-in-out;
opacity: .92;
}
#scrollDownBtn:hover {
opacity: 1;
}
#scrollDownBtn svg {
width: 22px;
height: 22px;
pointer-events: none;
}
#scrollDownBtn.show {
display: inline-flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<!-- Header -->
<header class="py-3 bg-dark text-white shadow-sm">
<div class="container d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<img id="catIcon" class="cat-spinner" src="/static/images/cat-still.png" alt="Cat logo" />
<h1 class="h4 mb-0">KotGPT</h1>
</div>
</div>
</header>
<!-- Conversation (scrollable) -->
<main class="container my-3" id="mainScroll">
<div id="welcomeLogo">
<img id="welcomeImg" src="/static/images/ask-a-cat-en.png" alt="Ask a Cat">
</div>
<div id="thread"></div>
<!-- Sticky scroll indicator -->
<button id="scrollDownBtn" type="button" aria-label="Scroll down">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M12 16a1 1 0 0 1-.7-.29l-6-6a1 1 0 1 1 1.4-1.42L12 13.59l5.3-5.3a1 1 0 0 1 1.4 1.42l-6 6A1 1 0 0 1 12 16z" />
</svg>
</button>
</main>
<!-- Composer -->
<div class="composer py-3">
<div class="container">
<form id="promptForm" class="d-flex flex-column gap-2">
<textarea id="inputText" class="form-control" rows="3" placeholder=""></textarea>
<div class="d-flex align-items-center gap-2">
<button id="goBtn" class="btn btn-primary" type="submit"></button>
<button id="stopBtn" class="btn btn-outline-danger" type="button" style="display:none"></button>
</div>
</form>
</div>
</div>
<!-- Disclaimer Footer (fixed, non-scrolling) -->
<footer id="disclaimer"></footer>
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
<script>
// --- i18n setup (kept) ---
const DEFAULT_LOCALE = 'en';
const SUPPORTED = ['en', 'ru'];
let I18N = {};
let CURRENT_LOCALE = detectLocale();
let isRunning = false;
function getLangFromQuery() {
const m = location.search.match(/[?&]lang=(en|ru)\b/i);
return m ? m[1].toLowerCase() : null;
}
function detectLocale() {
const q = getLangFromQuery();
if (q && SUPPORTED.includes(q)) return q;
const nav = (navigator.language || '').toLowerCase();
if (nav.startsWith('ru')) return 'ru';
return DEFAULT_LOCALE;
}
async function loadLocale(locale) {
try {
const res = await fetch(`/locales/${locale}.json`, { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
I18N = await res.json();
CURRENT_LOCALE = locale;
document.documentElement.lang = locale;
applyI18n();
} catch (e) {
if (locale !== DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE);
}
}
function t(key, fallback = '') {
const parts = key.split('.');
let cur = I18N;
for (const p of parts) {
if (cur && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
else return fallback || key;
}
return (typeof cur === 'string') ? cur : (fallback || key);
}
function setWelcomeImage() {
const welcomeImg = document.getElementById('welcomeImg');
if (!welcomeImg) return;
const srcByLocale = {
en: '/static/images/ask-a-cat-en.png',
ru: '/static/images/ask-a-cat-ru.png'
};
welcomeImg.src = srcByLocale[CURRENT_LOCALE] || srcByLocale.en;
}
function applyI18n() {
document.title = t('app.title', 'KotGPT');
const catIcon = document.getElementById('catIcon');
if (catIcon) catIcon.alt = t('header.logoAlt', 'Cat logo');
const welcomeImg = document.getElementById('welcomeImg');
if (welcomeImg) welcomeImg.alt = t('main.welcomeAlt', 'Ask a Cat');
const inputText = document.getElementById('inputText');
if (inputText) {
inputText.placeholder = isRunning
? t('status.thinking', 'Thinking…')
: t('composer.placeholder', 'What can I help you with?');
}
const goBtn = document.getElementById('goBtn');
if (goBtn) goBtn.textContent = t('buttons.go', 'Go');
const stopBtn = document.getElementById('stopBtn');
if (stopBtn) stopBtn.textContent = t('buttons.stop', 'Stop');
const disclaimer = document.getElementById('disclaimer');
if (disclaimer) disclaimer.textContent = t('footer.disclaimer', 'Beware: Cat can make mistakes.');
const scrollDownBtn = document.getElementById('scrollDownBtn');
if (scrollDownBtn) {
scrollDownBtn.setAttribute('aria-label', t('buttons.scrollDown', 'Scroll down'));
scrollDownBtn.title = t('buttons.scrollDown', 'Scroll down');
}
setWelcomeImage();
}
// --- UI elements ---
const ENDPOINT = '/api/stream';
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif';
const thread = document.getElementById('thread');
const promptForm = document.getElementById('promptForm');
const inputText = document.getElementById('inputText');
const goBtn = document.getElementById('goBtn');
const stopBtn = document.getElementById('stopBtn');
const welcomeLogo = document.getElementById('welcomeLogo');
const mainScroll = document.getElementById('mainScroll');
const scrollDownBtn = document.getElementById('scrollDownBtn');
const BOTTOM_EPS = 50;
let firstRequestDone = false;
let controller = null;
// --- Content rendering helpers ---
function renderMarkdown(targetEl, markdownText) {
const html = marked.parse(markdownText, { breaks: true, gfm: true, headerIds: false });
targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
}
function showThinkingCat(targetEl) {
targetEl.innerHTML = '';
const img = document.createElement('img');
img.className = 'thinking-cat';
img.src = CAT_GIF + '?t=' + Date.now();
img.alt = t('status.thinking', 'Thinking…');
targetEl.appendChild(img);
}
function clearThinkingCatIfPresent(targetEl) {
const img = targetEl.querySelector('img.thinking-cat');
if (img) targetEl.innerHTML = '';
}
// --- Scroll indicator logic (mirrors the first script) ---
function needsScroll() {
return mainScroll.scrollHeight - mainScroll.clientHeight > 8;
}
function atBottom() {
return mainScroll.scrollTop + mainScroll.clientHeight >= mainScroll.scrollHeight - BOTTOM_EPS;
}
function isNearBottom() { return atBottom(); } // alias for clarity
function updateScrollIndicator() {
if (needsScroll() && !atBottom()) {
scrollDownBtn.classList.add('show');
} else {
scrollDownBtn.classList.remove('show');
}
}
// scroll one viewport down on click
scrollDownBtn.addEventListener('click', () => {
mainScroll.scrollBy({ top: mainScroll.clientHeight, behavior: 'smooth' });
setTimeout(updateScrollIndicator, 320);
});
mainScroll.addEventListener('scroll', updateScrollIndicator);
window.addEventListener('resize', updateScrollIndicator);
// --- Composer lock / buttons ---
function setComposerLocked(locked) {
isRunning = locked;
inputText.placeholder = locked
? t('status.thinking', 'Thinking…')
: t('composer.placeholder', 'What can I help you with?');
inputText.disabled = locked;
goBtn.style.display = locked ? 'none' : 'inline-block';
stopBtn.style.display = locked ? 'inline-block' : 'none';
if (locked) inputText.blur(); else inputText.focus();
}
// --- App flow ---
async function start() {
const prompt = inputText.value.trim();
if (!prompt) return;
if (!firstRequestDone) { welcomeLogo?.remove(); firstRequestDone = true; }
const shouldAutoScroll = isNearBottom();
const userMsg = document.createElement('div');
userMsg.className = 'message user-message';
userMsg.textContent = prompt;
thread.appendChild(userMsg);
const assistantMsg = document.createElement('div');
assistantMsg.className = 'message assistant-message';
showThinkingCat(assistantMsg);
thread.appendChild(assistantMsg);
if (shouldAutoScroll) assistantMsg.scrollIntoView({ behavior: 'smooth', block: 'start' });
inputText.value = '';
controller = new AbortController();
setComposerLocked(true);
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: prompt }),
signal: controller.signal
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let rawMarkdown = '';
let receivedAny = false;
let rafPending = false;
const flush = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (!receivedAny) { clearThinkingCatIfPresent(assistantMsg); receivedAny = true; }
renderMarkdown(assistantMsg, rawMarkdown);
if (shouldAutoScroll && isNearBottom()) {
mainScroll.scrollTop = mainScroll.scrollHeight;
}
updateScrollIndicator();
});
};
for (; ;) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const ch of chunk) rawMarkdown += ch; // char-by-char
flush();
}
flush();
} catch (err) {
if (err.name !== 'AbortError') {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, `\n\n**${t('status.errorLabel', 'Error')}:** ${err.message || err}`);
} else {
if (assistantMsg.querySelector('img.thinking-cat')) {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, t('status.stopped', '_Stopped._'));
}
}
} finally {
setComposerLocked(false);
controller = null;
updateScrollIndicator();
}
}
async function stop() {
if (controller) controller.abort();
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) { }
}
// Events
document.getElementById('promptForm').addEventListener('submit', e => {
e.preventDefault();
if (goBtn.style.display !== 'none') start();
});
document.getElementById('inputText').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault();
start();
}
});
document.getElementById('stopBtn').addEventListener('click', stop);
// Init
window.addEventListener('DOMContentLoaded', async () => {
try { await fetch('/api/clear', { method: 'GET' }); } catch (_) { }
await loadLocale(CURRENT_LOCALE);
document.getElementById('inputText').focus();
updateScrollIndicator();
});
</script>
</body>
</html>
\ No newline at end of file
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>KotGPT</title> <title>KotGPT</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"/> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<style> <style>
body { /* Layout: header + main (scrolls) + composer; fixed footer */
min-height: 100vh; body {
display: flex; min-height: 100vh;
flex-direction: column; display: flex;
background-color: #f8f9fa; flex-direction: column;
margin: 0; background-color: #f8f9fa;
} margin: 0;
}
/* Make only main scrollable, with space for header+composer+footer */
main { header {
flex: 1 1 auto; position: sticky;
overflow-y: auto; top: 0;
position: relative; z-index: 1020;
padding: 5rem 0 8rem; /* top space for header, bottom space for composer+footer */ }
}
main {
/* Header fixed at top */ flex: 1 1 auto;
header { overflow-y: auto;
position: fixed; position: relative;
top: 0; /* leave room so fixed footer doesn’t overlap last messages */
left: 0; padding-bottom: 3.5rem;
width: 100%; /* ≈ footer height */
z-index: 1020; }
}
.composer {
/* Composer fixed above footer */ position: sticky;
.composer { bottom: 0;
position: fixed; z-index: 1020;
bottom: 2rem; /* leave space for footer */ background: #fff;
left: 0; border-top: 1px solid #dee2e6;
width: 100%; }
z-index: 1020;
background: #fff; footer {
border-top: 1px solid #dee2e6; position: fixed;
} bottom: 0;
left: 0;
/* Footer fixed at very bottom */ width: 100%;
footer { z-index: 1010;
position: fixed; font-size: .75rem;
bottom: 0; color: #6c757d;
left: 0; text-align: center;
width: 100%; padding: .5rem;
z-index: 1020; border-top: 1px solid #dee2e6;
font-size: .75rem; background: #fdfdfd;
color: #6c757d; }
text-align: center;
padding: .5rem; .cat-spinner {
border-top: 1px solid #dee2e6; width: 36px;
background: #fdfdfd; height: 36px;
} object-fit: contain;
.cat-spinner { width: 36px; height: 36px; object-fit: contain; filter: drop-shadow(0 1px 2px rgba(0,0,0,.35)); } filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35));
.thinking-cat { width: 64px; height: 64px; object-fit: contain; display:block; margin:.25rem auto; } }
.message {
max-width: 900px; .thinking-cat {
margin: 0 auto 1rem auto; width: 64px;
padding: 1rem 1.25rem; height: 64px;
border-radius: .75rem; object-fit: contain;
line-height: 1.6; display: block;
font-size: 1rem; margin: .25rem auto;
word-break: break-word; }
}
.user-message { background-color: #ffffff; border: 1px solid #dee2e6; white-space: pre-wrap; } .message {
.assistant-message { background-color: #f8f9fa; border: 1px solid #dee2e6; } max-width: 900px;
.assistant-message h1, .assistant-message h2, .assistant-message h3 { margin-top: 1rem; margin-bottom: .6rem; } margin: 0 auto 1rem auto;
.assistant-message p { margin: .5rem 0; } padding: 1rem 1.25rem;
.assistant-message code { background:#fff; border:1px solid #e9ecef; border-radius:.25rem; padding:.05rem .35rem; } border-radius: .75rem;
.assistant-message pre code { display:block; padding:.75rem; } line-height: 1.6;
.assistant-message ul, .assistant-message ol { padding-left: 1.25rem; } font-size: 1rem;
.assistant-message table { width: 100%; margin: 1rem 0; } word-break: break-word;
}
#mainScroll { display: flex; flex-direction: column; position: relative; }
#welcomeLogo { .user-message {
flex: 1 1 auto; background-color: #ffffff;
display: flex; border: 1px solid #dee2e6;
align-items: center; white-space: pre-wrap;
justify-content: center; }
user-select: none;
} .assistant-message {
#welcomeLogo img { background-color: #f8f9fa;
max-width: 520px; border: 1px solid #dee2e6;
width: 100%; }
height: auto;
filter: drop-shadow(0 6px 16px rgba(0,0,0,.15)); .assistant-message h1,
opacity: .98; .assistant-message h2,
} .assistant-message h3 {
margin-top: 1rem;
/* --- Floating Scroll Button --- */ margin-bottom: .6rem;
.scroll-fab { }
position: fixed;
right: 24px; .assistant-message p {
bottom: 96px; /* sits above the sticky composer */ margin: .5rem 0;
width: 48px; }
height: 48px;
border-radius: 50%; .assistant-message code {
border: none; background: #fff;
background: #0d6efd; border: 1px solid #e9ecef;
color: #fff; border-radius: .25rem;
display: none; /* toggled via JS */ padding: .05rem .35rem;
align-items: center; }
justify-content: center;
box-shadow: 0 8px 18px rgba(13,110,253,.35); .assistant-message pre code {
cursor: pointer; display: block;
z-index: 1030; padding: .75rem;
} }
.scroll-fab:focus { outline: none; box-shadow: 0 0 0 0.25rem rgba(13,110,253,.25); }
.scroll-fab svg { width: 22px; height: 22px; pointer-events: none; } .assistant-message ul,
.scroll-fab.pulse { .assistant-message ol {
animation: scrollPulse 1.5s ease-in-out infinite; padding-left: 1.25rem;
} }
@keyframes scrollPulse {
0% { transform: translateY(0); } .assistant-message table {
50% { transform: translateY(2px); } width: 100%;
100% { transform: translateY(0); } margin: 1rem 0;
} }
</style>
/* First-run logo layout */
#mainScroll {
display: flex;
flex-direction: column;
position: relative;
}
#welcomeLogo {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
#welcomeLogo img {
max-width: 520px;
width: 100%;
height: auto;
filter: drop-shadow(0 6px 16px rgba(0, 0, 0, .15));
opacity: .98;
}
/* Sticky in-content scroll indicator (same pattern as previous file) */
#scrollDownBtn {
position: sticky;
margin-left: auto;
bottom: 1rem;
align-self: flex-end;
right: 1rem;
transform: translate(-1rem, 0);
z-index: 1030;
display: none;
border: none;
border-radius: 999px;
width: 36px;
height: 36px;
background: rgba(13, 110, 253, .95);
color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, .18);
cursor: pointer;
transition: opacity .18s ease-in-out;
opacity: .92;
}
#scrollDownBtn:hover {
opacity: 1;
}
#scrollDownBtn svg {
width: 22px;
height: 22px;
pointer-events: none;
}
#scrollDownBtn.show {
display: inline-flex;
align-items: center;
justify-content: center;
}
</style>
</head> </head>
<body> <body>
<!-- Header --> <!-- Header -->
<header class="py-3 bg-dark text-white shadow-sm"> <header class="py-3 bg-dark text-white shadow-sm">
<div class="container d-flex justify-content-between align-items-center"> <div class="container d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<img id="catIcon" class="cat-spinner" src="/static/images/cat-still.png" alt="Cat logo" /> <img id="catIcon" class="cat-spinner" src="/static/images/cat-still.png" alt="Cat logo" />
<h1 class="h4 mb-0">KotGPT</h1> <h1 class="h4 mb-0">KotGPT</h1>
</div> </div>
</div> </div>
</header> </header>
<!-- Conversation --> <!-- Conversation (scrollable) -->
<main class="container my-3" id="mainScroll"> <main class="container my-3" id="mainScroll">
<div id="welcomeLogo"> <div id="welcomeLogo">
<img id="welcomeImg" src="/static/images/ask-a-cat-en.png" alt="Ask a Cat"> <img id="welcomeImg" src="/static/images/ask-a-cat-en.png" alt="Ask a Cat">
</div> </div>
<div id="thread"></div> <div id="thread"></div>
</main>
<!-- Sticky scroll indicator -->
<!-- Floating Scroll-to-Bottom Button --> <button id="scrollDownBtn" type="button" aria-label="Scroll down">
<button id="scrollFab" class="scroll-fab" type="button" aria-label=""> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<!-- Down arrow icon --> <path
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> d="M12 16a1 1 0 0 1-.7-.29l-6-6a1 1 0 1 1 1.4-1.42L12 13.59l5.3-5.3a1 1 0 0 1 1.4 1.42l-6 6A1 1 0 0 1 12 16z" />
<path d="M12 16a1 1 0 0 1-.7-.29l-7-7a1 1 0 1 1 1.4-1.42L12 13.59l6.3-6.3a1 1 0 0 1 1.4 1.42l-7 7A1 1 0 0 1 12 16z"/> </svg>
</svg> </button>
</button> </main>
<!-- Composer --> <!-- Composer -->
<div class="composer py-3"> <div class="composer py-3">
<div class="container"> <div class="container">
<form id="promptForm" class="d-flex flex-column gap-2"> <form id="promptForm" class="d-flex flex-column gap-2">
<textarea id="inputText" class="form-control" rows="3" placeholder=""></textarea> <textarea id="inputText" class="form-control" rows="3" placeholder=""></textarea>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<button id="goBtn" class="btn btn-primary" type="submit"></button> <button id="goBtn" class="btn btn-primary" type="submit"></button>
<button id="stopBtn" class="btn btn-outline-danger" type="button" style="display:none"></button> <button id="stopBtn" class="btn btn-outline-danger" type="button" style="display:none"></button>
</div>
</form>
</div> </div>
</form>
</div> </div>
</div>
<!-- Disclaimer Footer (fixed, non-scrolling) -->
<!-- Disclaimer Footer --> <footer id="disclaimer"></footer>
<footer id="disclaimer"></footer>
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script> <script>
<script> // --- i18n setup (kept) ---
// --- i18n setup --- const DEFAULT_LOCALE = 'en';
const DEFAULT_LOCALE = 'en'; const SUPPORTED = ['en', 'ru'];
const SUPPORTED = ['en', 'ru']; let I18N = {};
let I18N = {}; let CURRENT_LOCALE = detectLocale();
let CURRENT_LOCALE = detectLocale(); let isRunning = false;
let isRunning = false;
function getLangFromQuery() {
function getLangFromQuery() { const m = location.search.match(/[?&]lang=(en|ru)\b/i);
const m = location.search.match(/[?&]lang=(en|ru)\b/i); return m ? m[1].toLowerCase() : null;
return m ? m[1].toLowerCase() : null; }
} function detectLocale() {
function detectLocale() { const q = getLangFromQuery();
const q = getLangFromQuery(); if (q && SUPPORTED.includes(q)) return q;
if (q && SUPPORTED.includes(q)) return q; const nav = (navigator.language || '').toLowerCase();
const nav = (navigator.language || '').toLowerCase(); if (nav.startsWith('ru')) return 'ru';
if (nav.startsWith('ru')) return 'ru'; return DEFAULT_LOCALE;
return DEFAULT_LOCALE; }
} async function loadLocale(locale) {
async function loadLocale(locale) { try {
try { const res = await fetch(`/locales/${locale}.json`, { cache: 'no-store' });
const res = await fetch(`/locales/${locale}.json`, { cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status);
if (!res.ok) throw new Error('HTTP ' + res.status); I18N = await res.json();
I18N = await res.json(); CURRENT_LOCALE = locale;
CURRENT_LOCALE = locale; document.documentElement.lang = locale;
document.documentElement.lang = locale; applyI18n();
applyI18n(); } catch (e) {
} catch (e) { if (locale !== DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE);
if (locale !== DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE); }
} }
} function t(key, fallback = '') {
function t(key, fallback='') { const parts = key.split('.');
const parts = key.split('.'); let cur = I18N;
let cur = I18N; for (const p of parts) {
for (const p of parts) { if (cur && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
if (cur && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p]; else return fallback || key;
else return fallback || key; }
} return (typeof cur === 'string') ? cur : (fallback || key);
return (typeof cur === 'string') ? cur : (fallback || key); }
} function setWelcomeImage() {
function setWelcomeImage() { const welcomeImg = document.getElementById('welcomeImg');
const welcomeImg = document.getElementById('welcomeImg'); if (!welcomeImg) return;
if (!welcomeImg) return; const srcByLocale = {
const srcByLocale = { en: '/static/images/ask-a-cat-en.png',
en: '/static/images/ask-a-cat-en.png', ru: '/static/images/ask-a-cat-ru.png'
ru: '/static/images/ask-a-cat-ru.png' };
}; welcomeImg.src = srcByLocale[CURRENT_LOCALE] || srcByLocale.en;
welcomeImg.src = srcByLocale[CURRENT_LOCALE] || srcByLocale.en; }
} function applyI18n() {
function applyI18n() { document.title = t('app.title', 'KotGPT');
document.title = t('app.title', 'KotGPT'); const catIcon = document.getElementById('catIcon');
const catIcon = document.getElementById('catIcon'); if (catIcon) catIcon.alt = t('header.logoAlt', 'Cat logo');
if (catIcon) catIcon.alt = t('header.logoAlt', 'Cat logo'); const welcomeImg = document.getElementById('welcomeImg');
const welcomeImg = document.getElementById('welcomeImg'); if (welcomeImg) welcomeImg.alt = t('main.welcomeAlt', 'Ask a Cat');
if (welcomeImg) welcomeImg.alt = t('main.welcomeAlt', 'Ask a Cat'); const inputText = document.getElementById('inputText');
const inputText = document.getElementById('inputText'); if (inputText) {
if (inputText) { inputText.placeholder = isRunning
inputText.placeholder = isRunning ? t('status.thinking', 'Thinking…')
? t('status.thinking', 'Thinking…') : t('composer.placeholder', 'What can I help you with?');
: t('composer.placeholder','What can I help you with?'); }
} const goBtn = document.getElementById('goBtn');
const goBtn = document.getElementById('goBtn'); if (goBtn) goBtn.textContent = t('buttons.go', 'Go');
if (goBtn) goBtn.textContent = t('buttons.go','Go'); const stopBtn = document.getElementById('stopBtn');
const stopBtn = document.getElementById('stopBtn'); if (stopBtn) stopBtn.textContent = t('buttons.stop', 'Stop');
if (stopBtn) stopBtn.textContent = t('buttons.stop','Stop'); const disclaimer = document.getElementById('disclaimer');
const disclaimer = document.getElementById('disclaimer'); if (disclaimer) disclaimer.textContent = t('footer.disclaimer', 'Beware: Cat can make mistakes.');
if (disclaimer) disclaimer.textContent = t('footer.disclaimer','Beware: Cat can make mistakes.'); const scrollDownBtn = document.getElementById('scrollDownBtn');
const scrollFab = document.getElementById('scrollFab'); if (scrollDownBtn) {
if (scrollFab) { scrollDownBtn.setAttribute('aria-label', t('buttons.scrollDown', 'Scroll down'));
scrollFab.setAttribute('aria-label', t('buttons.scrollDown','Scroll to latest')); scrollDownBtn.title = t('buttons.scrollDown', 'Scroll down');
scrollFab.title = t('buttons.scrollDown','Scroll to latest'); }
} setWelcomeImage();
setWelcomeImage(); }
}
// --- UI elements ---
// --- UI elements --- const ENDPOINT = '/api/stream';
const ENDPOINT = '/api/stream'; const STOPPOINT = '/api/stop';
const STOPPOINT = '/api/stop'; const CAT_GIF = '/static/images/cat-progress.gif';
const CAT_GIF = '/static/images/cat-progress.gif'; const thread = document.getElementById('thread');
const thread = document.getElementById('thread'); const promptForm = document.getElementById('promptForm');
const promptForm = document.getElementById('promptForm'); const inputText = document.getElementById('inputText');
const inputText = document.getElementById('inputText'); const goBtn = document.getElementById('goBtn');
const goBtn = document.getElementById('goBtn'); const stopBtn = document.getElementById('stopBtn');
const stopBtn = document.getElementById('stopBtn'); const welcomeLogo = document.getElementById('welcomeLogo');
const welcomeLogo = document.getElementById('welcomeLogo'); const mainScroll = document.getElementById('mainScroll');
const mainScroll = document.getElementById('mainScroll'); const scrollDownBtn = document.getElementById('scrollDownBtn');
const scrollFab = document.getElementById('scrollFab'); const BOTTOM_EPS = 50;
let firstRequestDone = false; let firstRequestDone = false;
let controller = null; let controller = null;
// --- Floating Scroll Button logic --- // --- Content rendering helpers ---
const BOTTOM_EPS = 32; // pixels from bottom considered "at bottom" function renderMarkdown(targetEl, markdownText) {
const html = marked.parse(markdownText, { breaks: true, gfm: true, headerIds: false });
function isOverflowing() { targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
return mainScroll.scrollHeight - mainScroll.clientHeight > 1; }
} function showThinkingCat(targetEl) {
function isNearBottom() { targetEl.innerHTML = '';
return (mainScroll.scrollHeight - mainScroll.clientHeight - mainScroll.scrollTop) <= BOTTOM_EPS; const img = document.createElement('img');
} img.className = 'thinking-cat';
function updateScrollFabVisibility() { img.src = CAT_GIF + '?t=' + Date.now();
if (isOverflowing() && !isNearBottom()) { img.alt = t('status.thinking', 'Thinking…');
scrollFab.style.display = 'flex'; targetEl.appendChild(img);
} else { }
scrollFab.style.display = 'none'; function clearThinkingCatIfPresent(targetEl) {
scrollFab.classList.remove('pulse'); const img = targetEl.querySelector('img.thinking-cat');
} if (img) targetEl.innerHTML = '';
} }
function scrollToBottom(smooth = true) {
mainScroll.scrollTo({ top: mainScroll.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); // --- Scroll indicator logic (mirrors the first script) ---
} function needsScroll() {
scrollFab.addEventListener('click', () => scrollToBottom(true)); return mainScroll.scrollHeight - mainScroll.clientHeight > 8;
mainScroll.addEventListener('scroll', updateScrollFabVisibility); }
window.addEventListener('resize', updateScrollFabVisibility); function atBottom() {
return mainScroll.scrollTop + mainScroll.clientHeight >= mainScroll.scrollHeight - BOTTOM_EPS;
window.addEventListener('DOMContentLoaded', async () => { }
try { function isNearBottom() { return atBottom(); } // alias for clarity
await fetch('/api/clear', { method: 'GET' }); function updateScrollIndicator() {
} catch (err) { /* ignore */ } if (needsScroll() && !atBottom()) {
await loadLocale(CURRENT_LOCALE); scrollDownBtn.classList.add('show');
inputText.focus();
updateScrollFabVisibility();
});
function renderMarkdown(targetEl, markdownText) {
const html = marked.parse(markdownText, { breaks:true, gfm:true, headerIds:false });
targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES:{ html:true } });
}
function showThinkingCat(targetEl) {
targetEl.innerHTML = '';
const img = document.createElement('img');
img.className = 'thinking-cat';
img.src = CAT_GIF + '?t=' + Date.now();
img.alt = t('status.thinking','Thinking…');
targetEl.appendChild(img);
}
function clearThinkingCatIfPresent(targetEl) {
const img = targetEl.querySelector('img.thinking-cat');
if (img) targetEl.innerHTML = '';
}
function setComposerLocked(locked) {
isRunning = locked;
inputText.placeholder = locked
? t('status.thinking','Thinking…')
: t('composer.placeholder','What can I help you with?');
inputText.placeholder = inputText.placeholder;
void inputText.offsetHeight;
inputText.disabled = locked;
goBtn.style.display = locked ? 'none' : 'inline-block';
stopBtn.style.display = locked ? 'inline-block' : 'none';
if (locked) inputText.blur();
else inputText.focus();
}
async function start() {
const prompt = inputText.value.trim();
if (!prompt) return;
if (!firstRequestDone) { welcomeLogo?.remove(); firstRequestDone = true; }
const userMsg = document.createElement('div');
userMsg.className = 'message user-message';
userMsg.textContent = prompt;
thread.appendChild(userMsg);
const assistantMsg = document.createElement('div');
assistantMsg.className = 'message assistant-message';
showThinkingCat(assistantMsg);
thread.appendChild(assistantMsg);
inputText.value = '';
controller = new AbortController();
setComposerLocked(true);
// ensure we evaluate FAB after new nodes
updateScrollFabVisibility();
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: prompt }),
signal: controller.signal
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let rawMarkdown = '';
let receivedAny = false;
let rafPending = false;
const flush = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (!receivedAny) { clearThinkingCatIfPresent(assistantMsg); receivedAny = true; }
renderMarkdown(assistantMsg, rawMarkdown);
// Auto-scroll only if user is already near the bottom.
// If not, show FAB and pulse to indicate new content.
if (isNearBottom()) {
scrollToBottom(true);
} else { } else {
scrollFab.style.display = 'flex'; scrollDownBtn.classList.remove('show');
scrollFab.classList.add('pulse');
} }
updateScrollFabVisibility(); }
}); // scroll one viewport down on click
}; scrollDownBtn.addEventListener('click', () => {
mainScroll.scrollBy({ top: mainScroll.clientHeight, behavior: 'smooth' });
for (;;) { setTimeout(updateScrollIndicator, 320);
const { value, done } = await reader.read(); });
if (done) break; mainScroll.addEventListener('scroll', updateScrollIndicator);
const chunk = decoder.decode(value, { stream:true }); window.addEventListener('resize', updateScrollIndicator);
for (const ch of chunk) { rawMarkdown += ch; }
flush(); // --- Composer lock / buttons ---
} function setComposerLocked(locked) {
flush(); isRunning = locked;
} catch (err) { inputText.placeholder = locked
if (err.name !== 'AbortError') { ? t('status.thinking', 'Thinking…')
clearThinkingCatIfPresent(assistantMsg); : t('composer.placeholder', 'What can I help you with?');
renderMarkdown(assistantMsg, `\n\n**${t('status.errorLabel','Error')}:** ${err.message || err}`); inputText.disabled = locked;
} else { goBtn.style.display = locked ? 'none' : 'inline-block';
if (assistantMsg.querySelector('img.thinking-cat')) { stopBtn.style.display = locked ? 'inline-block' : 'none';
clearThinkingCatIfPresent(assistantMsg); if (locked) inputText.blur(); else inputText.focus();
renderMarkdown(assistantMsg, t('status.stopped','_Stopped._')); }
}
} // --- App flow ---
} finally { async function start() {
setComposerLocked(false); const prompt = inputText.value.trim();
controller = null; if (!prompt) return;
updateScrollFabVisibility();
} if (!firstRequestDone) { welcomeLogo?.remove(); firstRequestDone = true; }
}
const shouldAutoScroll = isNearBottom();
async function stop() {
if (controller) controller.abort(); const userMsg = document.createElement('div');
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) {} userMsg.className = 'message user-message';
} userMsg.textContent = prompt;
thread.appendChild(userMsg);
promptForm.addEventListener('submit', e => {
e.preventDefault(); const assistantMsg = document.createElement('div');
if (goBtn.style.display !== 'none') start(); assistantMsg.className = 'message assistant-message';
}); showThinkingCat(assistantMsg);
inputText.addEventListener('keydown', e => { thread.appendChild(assistantMsg);
if (e.key==='Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault(); if (shouldAutoScroll) assistantMsg.scrollIntoView({ behavior: 'smooth', block: 'start' });
start();
} inputText.value = '';
}); controller = new AbortController();
stopBtn.addEventListener('click', stop); setComposerLocked(true);
</script>
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: prompt }),
signal: controller.signal
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let rawMarkdown = '';
let receivedAny = false;
let rafPending = false;
const flush = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (!receivedAny) { clearThinkingCatIfPresent(assistantMsg); receivedAny = true; }
renderMarkdown(assistantMsg, rawMarkdown);
if (shouldAutoScroll && isNearBottom()) {
mainScroll.scrollTop = mainScroll.scrollHeight;
}
updateScrollIndicator();
});
};
for (; ;) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const ch of chunk) rawMarkdown += ch; // char-by-char
flush();
}
flush();
} catch (err) {
if (err.name !== 'AbortError') {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, `\n\n**${t('status.errorLabel', 'Error')}:** ${err.message || err}`);
} else {
if (assistantMsg.querySelector('img.thinking-cat')) {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, t('status.stopped', '_Stopped._'));
}
}
} finally {
setComposerLocked(false);
controller = null;
updateScrollIndicator();
}
}
async function stop() {
if (controller) controller.abort();
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) { }
}
// Events
document.getElementById('promptForm').addEventListener('submit', e => {
e.preventDefault();
if (goBtn.style.display !== 'none') start();
});
document.getElementById('inputText').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault();
start();
}
});
document.getElementById('stopBtn').addEventListener('click', stop);
// Init
window.addEventListener('DOMContentLoaded', async () => {
try { await fetch('/api/clear', { method: 'GET' }); } catch (_) { }
await loadLocale(CURRENT_LOCALE);
document.getElementById('inputText').focus();
updateScrollIndicator();
});
</script>
</body> </body>
</html>
</html>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment