Commit 69378d52 by Michael Pastushkov

fix

parent f4230d27
<!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>
...@@ -8,14 +7,10 @@ ...@@ -8,14 +7,10 @@
<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>
:root { :root {
/* tune these if footer/composer sizes change */ --footer-h: 2.75rem; /* fixed footer height (approx) */
--footer-h: 2.75rem; --composer-h: 6.5rem; /* composer block height (approx) */
/* fixed footer height (approx) */
--composer-h: 6.5rem;
/* composer block height (approx) */
--bottom-pad: calc(var(--footer-h) + var(--composer-h) + 1rem); --bottom-pad: calc(var(--footer-h) + var(--composer-h) + 1rem);
} }
body { body {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
...@@ -23,141 +18,56 @@ ...@@ -23,141 +18,56 @@
background-color: #f8f9fa; background-color: #f8f9fa;
margin: 0; margin: 0;
} }
header { position: sticky; top: 0; z-index: 1020; }
header {
position: sticky;
top: 0;
z-index: 1020;
}
main { main {
flex: 1 1 auto; flex: 1 1 auto;
overflow-y: auto; overflow-y: auto;
position: relative; position: relative;
padding-bottom: var(--bottom-pad); padding-bottom: var(--bottom-pad); /* leave room for composer + fixed footer */
/* leave room for composer + fixed footer */ scroll-behavior: auto; /* avoid jitter during streaming, we’ll smooth when needed */
} }
.composer { .composer {
position: sticky; position: sticky;
bottom: var(--footer-h); bottom: var(--footer-h); /* sit just above the fixed footer */
/* sit just above the fixed footer */
z-index: 1020; z-index: 1020;
background: #fff; background: #fff;
border-top: 1px solid #dee2e6; border-top: 1px solid #dee2e6;
} }
/* --- Fixed, non-scrollable footer --- */
footer { footer {
position: fixed; position: fixed; bottom: 0; left: 0; width: 100%;
bottom: 0; z-index: 1020; font-size: .75rem; color: #6c757d; text-align: center;
left: 0; padding: .5rem; border-top: 1px solid #dee2e6; background: #fdfdfd;
width: 100%; height: var(--footer-h); box-sizing: border-box;
z-index: 1020;
font-size: .75rem;
color: #6c757d;
text-align: center;
padding: .5rem;
border-top: 1px solid #dee2e6;
background: #fdfdfd;
height: var(--footer-h);
box-sizing: border-box;
} }
.cat-spinner { .cat-spinner { width: 36px; height: 36px; object-fit: contain; filter: drop-shadow(0 1px 2px rgba(0,0,0,.35)); }
width: 36px; .thinking-cat { width: 64px; height: 64px; object-fit: contain; display: block; margin: .25rem auto; }
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 { .message {
max-width: 900px; max-width: 900px; margin: 0 auto 1rem auto; padding: 1rem 1.25rem; border-radius: .75rem;
margin: 0 auto 1rem auto; line-height: 1.6; font-size: 1rem; word-break: break-word;
padding: 1rem 1.25rem; }
border-radius: .75rem; .user-message { background-color: #ffffff; border: 1px solid #dee2e6; white-space: pre-wrap; }
line-height: 1.6; .assistant-message { background-color: #f8f9fa; border: 1px solid #dee2e6; }
font-size: 1rem; .assistant-message h1, .assistant-message h2, .assistant-message h3 { margin-top: 1rem; margin-bottom: .6rem; }
word-break: break-word; .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; }
.user-message { .assistant-message ul, .assistant-message ol { padding-left: 1.25rem; }
background-color: #ffffff; .assistant-message table { width: 100%; margin: 1rem 0; }
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 */ /* First-run logo layout */
#mainScroll { #mainScroll { display: flex; flex-direction: column; position: relative; }
display: flex; #welcomeLogo { flex: 1 1 auto; display: flex; align-items: center; justify-content: center; user-select: none; }
flex-direction: column; #welcomeLogo img { max-width: 520px; width: 100%; height: auto; filter: drop-shadow(0 6px 16px rgba(0,0,0,.15)); opacity: .98; }
position: relative;
}
#welcomeLogo { /* (kept, but the button is unused by logic now) */
flex: 1 1 auto; #scrollDownBtn { display: none; }
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;
}
</style> </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">
...@@ -175,13 +85,19 @@ ...@@ -175,13 +85,19 @@
<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>
<!-- Sentinel used to detect if user is at the bottom -->
<div id="bottomSentinel" style="height:1px;"></div>
<!-- (Unused button) -->
<button id="scrollDownBtn" type="button" aria-label="Scroll down"></button>
</main> </main>
<!-- Composer (sticks above fixed footer) --> <!-- Composer (sticks above fixed footer) -->
<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="1" 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>
...@@ -196,7 +112,7 @@ ...@@ -196,7 +112,7 @@
<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 --- // --- i18n setup (unchanged) ---
const DEFAULT_LOCALE = 'en'; const DEFAULT_LOCALE = 'en';
const SUPPORTED = ['en', 'ru']; const SUPPORTED = ['en', 'ru'];
let I18N = {}; let I18N = {};
...@@ -243,6 +159,8 @@ ...@@ -243,6 +159,8 @@
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;
// If we’re sticking to bottom, ensure any img load doesn’t break it
welcomeImg.addEventListener('load', () => { if (autoStick) scrollToBottom(); }, { once: true });
} }
function applyI18n() { function applyI18n() {
document.title = t('app.title', 'KotGPT'); document.title = t('app.title', 'KotGPT');
...@@ -263,6 +181,7 @@ ...@@ -263,6 +181,7 @@
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.');
setWelcomeImage(); setWelcomeImage();
if (autoStick) scrollToBottom();
} }
// --- UI refs --- // --- UI refs ---
...@@ -276,13 +195,47 @@ ...@@ -276,13 +195,47 @@
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 bottomSentinel = document.getElementById('bottomSentinel');
let firstRequestDone = false; let firstRequestDone = false;
let controller = null; let controller = null;
// --- Stick-to-bottom logic ---
let autoStick = true; // follow the bottom unless user scrolls up
function scrollToBottom(smooth = false) {
if (!mainScroll) return;
// Use instant scroll during streaming to avoid stutter; smooth for jumps like new send
if (smooth) {
mainScroll.scrollTo({ top: mainScroll.scrollHeight, behavior: 'smooth' });
} else {
mainScroll.scrollTop = mainScroll.scrollHeight;
}
}
// Observe if sentinel is visible to determine if we’re at bottom
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
// If sentinel visible, we are at bottom -> enable autoStick
if (entry.target === bottomSentinel) {
autoStick = entry.isIntersecting;
}
}
}, { root: mainScroll, threshold: 1.0 });
observer.observe(bottomSentinel);
// When user scrolls up manually, autoStick will be turned off by observer.
// When they scroll back down (sentinel visible), it turns back on automatically.
// --- helpers ---
function renderMarkdown(targetEl, markdownText) { function renderMarkdown(targetEl, markdownText) {
const html = marked.parse(markdownText, { breaks: true, gfm: true, headerIds: false }); const html = marked.parse(markdownText, { breaks: true, gfm: true, headerIds: false });
targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
// If the assistant content produced <img>, make sure their eventual load keeps us at bottom.
const imgs = targetEl.querySelectorAll('img');
imgs.forEach(img => {
img.addEventListener('load', () => { if (autoStick) scrollToBottom(); });
});
} }
function showThinkingCat(targetEl) { function showThinkingCat(targetEl) {
targetEl.innerHTML = ''; targetEl.innerHTML = '';
...@@ -291,17 +244,14 @@ ...@@ -291,17 +244,14 @@
img.src = CAT_GIF + '?t=' + Date.now(); img.src = CAT_GIF + '?t=' + Date.now();
img.alt = t('status.thinking', 'Thinking…'); img.alt = t('status.thinking', 'Thinking…');
targetEl.appendChild(img); targetEl.appendChild(img);
if (autoStick) scrollToBottom();
} }
function clearThinkingCatIfPresent(targetEl) { function clearThinkingCatIfPresent(targetEl) {
const img = targetEl.querySelector('img.thinking-cat'); const img = targetEl.querySelector('img.thinking-cat');
if (img) targetEl.innerHTML = ''; if (img) targetEl.innerHTML = '';
} }
function scrollToBottom(smooth = false) { // --- Composer lock ---
if (!mainScroll) return;
const top = mainScroll.scrollHeight - mainScroll.clientHeight;
mainScroll.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
}
function setComposerLocked(locked) { function setComposerLocked(locked) {
isRunning = locked; isRunning = locked;
inputText.placeholder = locked ? t('status.thinking', 'Thinking…') : t('composer.placeholder', 'What can I help you with?'); inputText.placeholder = locked ? t('status.thinking', 'Thinking…') : t('composer.placeholder', 'What can I help you with?');
...@@ -311,6 +261,7 @@ ...@@ -311,6 +261,7 @@
if (locked) inputText.blur(); else inputText.focus(); if (locked) inputText.blur(); else inputText.focus();
} }
// --- Flow ---
async function start() { async function start() {
const prompt = inputText.value.trim(); const prompt = inputText.value.trim();
if (!prompt) return; if (!prompt) return;
...@@ -321,13 +272,14 @@ ...@@ -321,13 +272,14 @@
userMsg.className = 'message user-message'; userMsg.className = 'message user-message';
userMsg.textContent = prompt; userMsg.textContent = prompt;
thread.appendChild(userMsg); thread.appendChild(userMsg);
scrollToBottom();
const assistantMsg = document.createElement('div'); const assistantMsg = document.createElement('div');
assistantMsg.className = 'message assistant-message'; assistantMsg.className = 'message assistant-message';
showThinkingCat(assistantMsg); showThinkingCat(assistantMsg);
thread.appendChild(assistantMsg); thread.appendChild(assistantMsg);
scrollToBottom();
// New message -> we want a smooth jump to the end
if (autoStick) scrollToBottom(true);
inputText.value = ''; inputText.value = '';
controller = new AbortController(); controller = new AbortController();
...@@ -340,7 +292,7 @@ ...@@ -340,7 +292,7 @@
body: JSON.stringify({ content: prompt }), body: JSON.stringify({ content: prompt }),
signal: controller.signal signal: controller.signal
}); });
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status); if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
...@@ -355,15 +307,16 @@ ...@@ -355,15 +307,16 @@
rafPending = false; rafPending = false;
if (!receivedAny) { clearThinkingCatIfPresent(assistantMsg); receivedAny = true; } if (!receivedAny) { clearThinkingCatIfPresent(assistantMsg); receivedAny = true; }
renderMarkdown(assistantMsg, rawMarkdown); renderMarkdown(assistantMsg, rawMarkdown);
scrollToBottom(); if (autoStick) scrollToBottom(); // keep stuck during streaming
}); });
}; };
for (; ;) { for (;;) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
const chunk = decoder.decode(value, { stream: true }); const chunk = decoder.decode(value, { stream: true });
for (const ch of chunk) rawMarkdown += ch; // char-by-char // char-by-char accumulation to mirror your behavior
for (const ch of chunk) rawMarkdown += ch;
flush(); flush();
} }
flush(); flush();
...@@ -380,7 +333,7 @@ ...@@ -380,7 +333,7 @@
} finally { } finally {
setComposerLocked(false); setComposerLocked(false);
controller = null; controller = null;
scrollToBottom(); if (autoStick) scrollToBottom();
} }
} }
...@@ -390,11 +343,11 @@ ...@@ -390,11 +343,11 @@
} }
// Events // Events
document.getElementById('promptForm').addEventListener('submit', e => { promptForm.addEventListener('submit', e => {
e.preventDefault(); e.preventDefault();
if (goBtn.style.display !== 'none') start(); if (goBtn.style.display !== 'none') start();
}); });
document.getElementById('inputText').addEventListener('keydown', e => { inputText.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none') { if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault(); e.preventDefault();
start(); start();
...@@ -402,14 +355,17 @@ ...@@ -402,14 +355,17 @@
}); });
document.getElementById('stopBtn').addEventListener('click', stop); document.getElementById('stopBtn').addEventListener('click', stop);
// Keep to bottom on container resizes (e.g., window size changes)
window.addEventListener('resize', () => { if (autoStick) scrollToBottom(); });
// Init // Init
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
try { await fetch('/api/clear', { method: 'GET' }); } catch (_) { } try { await fetch('/api/clear', { method: 'GET' }); } catch (_) { }
await loadLocale(CURRENT_LOCALE); await loadLocale(CURRENT_LOCALE);
document.getElementById('inputText').focus(); inputText.focus();
scrollToBottom(); // First paint, ensure bottom (welcome image can be tall)
if (autoStick) scrollToBottom();
}); });
</script> </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