Commit 1d791a9e by michaelpastushkov

fix

parent b15588f3
<!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>
:root{
/* tune these if footer/composer sizes change */
--footer-h: 2.75rem; /* fixed footer height (approx) */
--composer-h: 6.5rem; /* composer block height (approx) */
--bottom-pad: calc(var(--footer-h) + var(--composer-h) + 1rem);
--row-h: 42px; /* unified control height */
}
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;
min-height:0; /* allow flex child to actually scroll */
overflow-y:auto; /* scrollable area */
position:relative;
padding-bottom:var(--bottom-pad); /* room for composer + fixed footer */
display:flex;
flex-direction:column;
scroll-behavior:smooth; /* nudge for smooth scrolling */
}
.composer{
position:sticky;
bottom:var(--footer-h); /* sits just above the fixed footer */
z-index:1020;
background:#fff;
border-top:1px solid #dee2e6;
}
footer{
position:fixed; bottom:0; left:0; width:100%; 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{ 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:#fff; border:1px solid #dee2e6; white-space:pre-wrap; }
.assistant-message{ background:#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;
}
/* Single-row composer: textarea + Go/Stop + Scroll */
.composer-row { display:flex; align-items:stretch; gap:.5rem; }
.composer-row > * { height:var(--row-h); }
#inputText {
flex:1 1 auto;
height:var(--row-h);
resize:vertical; /* keep manual expand if desired */
min-height:var(--row-h);
}
#goBtn, #stopBtn {
flex:0 0 auto;
min-width:90px;
display:inline-flex;
align-items:center;
justify-content:center;
border-radius:.5rem;
}
/* Less-bold, gray scroll button (only visible when needed) */
#scrollDownBtn{
display:none;
align-items:center;
justify-content:center;
border:1px solid #ced4da;
border-radius:.5rem;
width:var(--row-h);
height:var(--row-h);
background:#e9ecef;
color:#495057;
box-shadow:0 1px 3px rgba(0,0,0,.06);
cursor:pointer;
transition:opacity .15s ease-in-out, transform .12s ease-in-out, box-shadow .15s;
opacity:.9;
flex:0 0 auto;
}
#scrollDownBtn:hover{ opacity:1; transform:translateY(-1px); box-shadow:0 2px 6px rgba(0,0,0,.08); }
#scrollDownBtn svg{ width:20px; height:20px; pointer-events:none; }
</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>
</main>
<!-- Composer (sticks above fixed footer) -->
<div class="composer py-3">
<div class="container">
<form id="promptForm" class="d-flex flex-column gap-2">
<!-- Single row: textarea + Go/Stop + Scroll -->
<div class="composer-row">
<textarea id="inputText" class="form-control" rows="1" placeholder=""></textarea>
<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="scrollDownBtn" type="button" aria-label="Scroll down" title="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>
</div>
</form>
</div>
</div>
<!-- Fixed, non-scrollable 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/dompurify@3.1.6/dist/purify.min.js"></script>
<script>
// --- i18n setup ---
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(); return nav.startsWith('ru')?'ru':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 img=document.getElementById('welcomeImg'); if(!img) return; const src={en:'/static/images/ask-a-cat-en.png', ru:'/static/images/ask-a-cat-ru.png'}; img.src=src[CURRENT_LOCALE]||src.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 input=document.getElementById('inputText'); if(input) input.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 sdb=document.getElementById('scrollDownBtn'); if (sdb){ sdb.setAttribute('aria-label', t('buttons.scrollDown','Scroll down')); sdb.title=t('buttons.scrollDown','Scroll down'); }
setWelcomeImage();
}
// --- refs ---
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');
let firstRequestDone=false;
let controller=null;
// --- render helpers ---
function renderMarkdown(targetEl, md){
const html=marked.parse(md,{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='';
}
// --- find the active scroller (main vs page) ---
function activeScroller(){
const docEl = document.scrollingElement || document.documentElement;
const mainOverflows = mainScroll && (mainScroll.scrollHeight - mainScroll.clientHeight) > 1;
return mainOverflows ? mainScroll : docEl;
}
function atBottom(){
const el = activeScroller();
return (el.scrollTop + el.clientHeight) >= (el.scrollHeight - 4);
}
function isOverflowingAny(){
const docEl = document.scrollingElement || document.documentElement;
const mainOver = mainScroll && (mainScroll.scrollHeight - mainScroll.clientHeight) > 1;
const pageOver = (docEl.scrollHeight - docEl.clientHeight) > 1;
return mainOver || pageOver;
}
function scrollToBottom(){
const el = activeScroller();
const top = el.scrollHeight;
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollTo({ top, behavior:'smooth' });
} else {
el.scrollTo({ top, behavior:'smooth' });
}
// ensure
requestAnimationFrame(()=>{
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollTo(0, el.scrollHeight);
} else {
el.scrollTop = el.scrollHeight;
}
});
}
function updateScrollButton(){
const needs = isOverflowingAny() && !atBottom();
scrollDownBtn.style.display = needs ? 'inline-flex' : 'none';
}
// --- composer lock ---
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-flex';
stopBtn.style.display=locked?'inline-flex':'none';
if (locked) inputText.blur(); else inputText.focus();
requestAnimationFrame(updateScrollButton);
}
// --- flow ---
async function start(){
const prompt=inputText.value.trim();
if(!prompt) return;
if(!firstRequestDone){ welcomeLogo?.remove(); firstRequestDone=true; }
const autoStick = atBottom();
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 (autoStick) 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 (autoStick && atBottom()){
const el = activeScroller();
el.scrollTop = el.scrollHeight;
}
updateScrollButton();
});
};
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;
updateScrollButton();
}
}
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);
// scroll button action + toggling
scrollDownBtn.addEventListener('click', scrollToBottom);
// Listen on both potential scroll containers
mainScroll.addEventListener('scroll', updateScrollButton, { passive:true });
window.addEventListener('scroll', updateScrollButton, { passive:true });
window.addEventListener('resize', updateScrollButton);
// Observe content changes to recalc button visibility
const threadObserver = new MutationObserver(() => {
requestAnimationFrame(updateScrollButton);
});
threadObserver.observe(document.getElementById('thread'), { childList: true, subtree: true });
// init
window.addEventListener('DOMContentLoaded', async () => {
try{ await fetch('/api/clear',{method:'GET'}); }catch(_){}
await loadLocale(CURRENT_LOCALE);
inputText.focus();
updateScrollButton(); // initial
});
</script>
</body>
</html>
<!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>
:root{ :root {
/* tune these if footer/composer sizes change */ /* 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);
--row-h: 42px; /* unified control height */ --row-h: 42px;
/* unified control height */
}
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;
min-height: 0;
/* allow flex child to actually scroll */
overflow-y: auto;
/* scrollable area */
position: relative;
padding-bottom: var(--bottom-pad);
/* room for composer + fixed footer */
display: flex;
flex-direction: column;
scroll-behavior: smooth;
/* nudge for smooth scrolling */
}
.composer {
position: sticky;
bottom: var(--footer-h);
/* sits just above the fixed footer */
z-index: 1020;
background: #fff;
border-top: 1px solid #dee2e6;
}
footer {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
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 {
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: #fff;
border: 1px solid #dee2e6;
white-space: pre-wrap;
} }
body{ .assistant-message {
min-height:100vh; background: #f8f9fa;
display:flex; border: 1px solid #dee2e6;
flex-direction:column;
background-color:#f8f9fa;
margin:0;
} }
header{ position:sticky; top:0; z-index:1020; } .assistant-message h1,
.assistant-message h2,
.assistant-message h3 {
margin-top: 1rem;
margin-bottom: .6rem;
}
main{ .assistant-message p {
flex:1 1 auto; margin: .5rem 0;
min-height:0; /* allow flex child to actually scroll */
overflow-y:auto; /* scrollable area */
position:relative;
padding-bottom:var(--bottom-pad); /* room for composer + fixed footer */
display:flex;
flex-direction:column;
scroll-behavior:smooth; /* nudge for smooth scrolling */
} }
.composer{ .assistant-message code {
position:sticky; background: #fff;
bottom:var(--footer-h); /* sits just above the fixed footer */ border: 1px solid #e9ecef;
z-index:1020; border-radius: .25rem;
background:#fff; padding: .05rem .35rem;
border-top:1px solid #dee2e6;
} }
footer{ .assistant-message pre code {
position:fixed; bottom:0; left:0; width:100%; z-index:1020; display: block;
font-size:.75rem; color:#6c757d; text-align:center; padding:.5rem; padding: .75rem;
border-top:1px solid #dee2e6; background:#fdfdfd; height:var(--footer-h);
box-sizing:border-box;
} }
.cat-spinner{ width:36px; height:36px; object-fit:contain; filter:drop-shadow(0 1px 2px rgba(0,0,0,.35)); } .assistant-message ul,
.thinking-cat{ width:64px; height:64px; object-fit:contain; display:block; margin:.25rem auto; } .assistant-message ol {
padding-left: 1.25rem;
}
.message{ .assistant-message table {
max-width:900px; margin:0 auto 1rem auto; padding:1rem 1.25rem; width: 100%;
border-radius:.75rem; line-height:1.6; font-size:1rem; word-break:break-word; margin: 1rem 0;
} }
.user-message{ background:#fff; border:1px solid #dee2e6; white-space:pre-wrap; }
.assistant-message{ background:#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{ display:flex; flex-direction:column; position:relative; } #mainScroll {
#welcomeLogo{ flex:1 1 auto; display:flex; align-items:center; justify-content:center; user-select:none; } display: flex;
#welcomeLogo img{ flex-direction: column;
max-width:520px; width:100%; height:auto; position: relative;
filter:drop-shadow(0 6px 16px rgba(0,0,0,.15)); opacity:.98; }
#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;
} }
/* Single-row composer: textarea + Go/Stop + Scroll */ /* Single-row composer: textarea + Go/Stop */
.composer-row { display:flex; align-items:stretch; gap:.5rem; } .composer-row {
.composer-row > * { height:var(--row-h); } display: flex;
align-items: stretch;
gap: .5rem;
}
.composer-row>* {
height: var(--row-h);
}
#inputText { #inputText {
flex:1 1 auto; flex: 1 1 auto;
height:var(--row-h); height: var(--row-h);
resize:vertical; /* keep manual expand if desired */ resize: vertical;
min-height:var(--row-h); /* keep manual expand if desired */
} min-height: var(--row-h);
}
#goBtn, #stopBtn {
flex:0 0 auto; #goBtn,
min-width:90px; #stopBtn {
display:inline-flex; flex: 0 0 auto;
align-items:center; min-width: 90px;
justify-content:center; display: inline-flex;
border-radius:.5rem; align-items: center;
} justify-content: center;
border-radius: .5rem;
/* Less-bold, gray scroll button (only visible when needed) */ }
#scrollDownBtn{
display:none; /* Floating scroll button (outside composer) */
align-items:center; #scrollDownBtn {
justify-content:center; position: fixed;
border:1px solid #ced4da; right: 1rem;
border-radius:.5rem; bottom: calc(var(--footer-h) + .75rem + var(--composer-h));
width:var(--row-h); /* hover above composer+footer */
height:var(--row-h); z-index: 1100;
background:#e9ecef; /* above composer/footer */
color:#495057; display: none;
box-shadow:0 1px 3px rgba(0,0,0,.06); /* hidden until armed + needed */
cursor:pointer; align-items: center;
transition:opacity .15s ease-in-out, transform .12s ease-in-out, box-shadow .15s; justify-content: center;
opacity:.9; border: 1px solid #ced4da;
flex:0 0 auto; border-radius: .75rem;
} width: 44px;
#scrollDownBtn:hover{ opacity:1; transform:translateY(-1px); box-shadow:0 2px 6px rgba(0,0,0,.08); } height: 44px;
#scrollDownBtn svg{ width:20px; height:20px; pointer-events:none; } background: #e9ecef;
color: #495057;
box-shadow: 0 4px 10px rgba(0, 0, 0, .12);
cursor: pointer;
transition: opacity .15s ease-in-out, transform .12s ease-in-out, box-shadow .15s;
opacity: .95;
}
#scrollDownBtn:hover {
opacity: 1;
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(0, 0, 0, .16);
}
#scrollDownBtn svg {
width: 22px;
height: 22px;
pointer-events: none;
}
</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">
...@@ -138,18 +254,11 @@ ...@@ -138,18 +254,11 @@
<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">
<!-- Single row: textarea + Go/Stop + Scroll --> <!-- Single row: textarea + Go/Stop -->
<div class="composer-row"> <div class="composer-row">
<textarea id="inputText" class="form-control" rows="1" placeholder=""></textarea> <textarea id="inputText" class="form-control" rows="1" placeholder=""></textarea>
<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>
<button id="scrollDownBtn" type="button" aria-label="Scroll down" title="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>
</div> </div>
</form> </form>
</div> </div>
...@@ -158,93 +267,135 @@ ...@@ -158,93 +267,135 @@
<!-- Fixed, non-scrollable footer --> <!-- Fixed, non-scrollable footer -->
<footer id="disclaimer"></footer> <footer id="disclaimer"></footer>
<!-- Floating scroll button -->
<button id="scrollDownBtn" type="button" aria-label="Scroll down" title="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>
<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 ---
const DEFAULT_LOCALE='en'; const SUPPORTED=['en','ru']; const DEFAULT_LOCALE = 'en'; const SUPPORTED = ['en', 'ru'];
let I18N={}; let CURRENT_LOCALE=detectLocale(); let isRunning=false; 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 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(); return nav.startsWith('ru')?'ru':DEFAULT_LOCALE; } function detectLocale() { const q = getLangFromQuery(); if (q && SUPPORTED.includes(q)) return q; const nav = (navigator.language || '').toLowerCase(); return nav.startsWith('ru') ? 'ru' : 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); } } async function loadLocale(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); } try {
function setWelcomeImage(){ const img=document.getElementById('welcomeImg'); if(!img) return; const src={en:'/static/images/ask-a-cat-en.png', ru:'/static/images/ask-a-cat-ru.png'}; img.src=src[CURRENT_LOCALE]||src.en; } const res = await fetch(`/locales/${locale}.json`, { cache: 'no-store' });
function applyI18n(){ if (!res.ok) throw new Error('HTTP ' + res.status);
document.title=t('app.title','KotGPT'); I18N = await res.json();
const catIcon=document.getElementById('catIcon'); if(catIcon) catIcon.alt=t('header.logoAlt','Cat logo'); CURRENT_LOCALE = locale; document.documentElement.lang = locale; applyI18n();
const welcomeImg=document.getElementById('welcomeImg'); if(welcomeImg) welcomeImg.alt=t('main.welcomeAlt','Ask a Cat'); } catch (e) { if (locale !== DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE); }
const input=document.getElementById('inputText'); if(input) input.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'); 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); }
const stopBtn=document.getElementById('stopBtn'); if(stopBtn) stopBtn.textContent=t('buttons.stop','Stop'); function setWelcomeImage() {
const disclaimer=document.getElementById('disclaimer'); if(disclaimer) disclaimer.textContent=t('footer.disclaimer','Beware: Cat can make mistakes.'); const img = document.getElementById('welcomeImg'); if (!img) return;
const sdb=document.getElementById('scrollDownBtn'); if (sdb){ sdb.setAttribute('aria-label', t('buttons.scrollDown','Scroll down')); sdb.title=t('buttons.scrollDown','Scroll down'); } const src = { en: '/static/images/ask-a-cat-en.png', ru: '/static/images/ask-a-cat-ru.png' };
img.src = src[CURRENT_LOCALE] || src.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 input = document.getElementById('inputText'); if (input) input.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 sdb = document.getElementById('scrollDownBtn'); if (sdb) { sdb.setAttribute('aria-label', t('buttons.scrollDown', 'Scroll down')); sdb.title = t('buttons.scrollDown', 'Scroll down'); }
setWelcomeImage(); setWelcomeImage();
} }
// --- refs --- // --- refs ---
const ENDPOINT='/api/stream'; const STOPPOINT='/api/stop'; const CAT_GIF='/static/images/cat-progress.gif'; const ENDPOINT = '/api/stream'; const STOPPOINT = '/api/stop'; 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 scrollDownBtn = document.getElementById('scrollDownBtn');
let firstRequestDone=false; let firstRequestDone = false;
let controller=null; let controller = null;
// Guard: the scroll button arms only when real assistant text arrives.
// It is disarmed on page load and at the start of every new request.
let scrollBtnArmed = false;
// --- render helpers --- // --- render helpers ---
function renderMarkdown(targetEl, md){ function renderMarkdown(targetEl, md) {
const html=marked.parse(md,{breaks:true,gfm:true,headerIds:false}); const html = marked.parse(md, { breaks: true, gfm: true, headerIds: false });
targetEl.innerHTML=DOMPurify.sanitize(html,{USE_PROFILES:{html:true}}); targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
} }
function showThinkingCat(targetEl){ function showThinkingCat(targetEl) {
targetEl.innerHTML=''; targetEl.innerHTML = '';
const img=document.createElement('img'); const img = document.createElement('img');
img.className='thinking-cat'; img.className = 'thinking-cat';
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);
} }
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 = '';
} }
// --- find the active scroller (main vs page) --- // --- active scroller helpers ---
function activeScroller(){ function activeScroller() {
const docEl = document.scrollingElement || document.documentElement; const docEl = document.scrollingElement || document.documentElement;
const mainOverflows = mainScroll && (mainScroll.scrollHeight - mainScroll.clientHeight) > 1; const mainOverflows = mainScroll && (mainScroll.scrollHeight - mainScroll.clientHeight) > 1;
return mainOverflows ? mainScroll : docEl; return mainOverflows ? mainScroll : docEl;
} }
function atBottom() {
function atBottom(){
const el = activeScroller(); const el = activeScroller();
return (el.scrollTop + el.clientHeight) >= (el.scrollHeight - 4); return (el.scrollTop + el.clientHeight) >= (el.scrollHeight - 4);
} }
function isOverflowingAny() {
function isOverflowingAny(){
const docEl = document.scrollingElement || document.documentElement; const docEl = document.scrollingElement || document.documentElement;
const mainOver = mainScroll && (mainScroll.scrollHeight - mainScroll.clientHeight) > 1; const mainOver = mainScroll && (mainScroll.scrollHeight - mainScroll.clientHeight) > 1;
const pageOver = (docEl.scrollHeight - docEl.clientHeight) > 1; const pageOver = (docEl.scrollHeight - docEl.clientHeight) > 1;
return mainOver || pageOver; return mainOver || pageOver;
} }
function scrollToBottom(){ // Partial scroll: one viewport of the active scroll area; snap to bottom if close
function scrollOnePageDown() {
const el = activeScroller(); const el = activeScroller();
const top = el.scrollHeight; const delta = el.clientHeight;
const remaining = el.scrollHeight - (el.scrollTop + el.clientHeight);
if (remaining <= delta * 0.5) {
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
} else {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}
return;
}
const target = Math.min(el.scrollTop + delta, el.scrollHeight);
if (el === document.scrollingElement || el === document.documentElement || el === document.body) { if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollTo({ top, behavior:'smooth' }); window.scrollTo({ top: target, behavior: 'smooth' });
} else { } else {
el.scrollTo({ top, behavior:'smooth' }); el.scrollTo({ top: target, behavior: 'smooth' });
}
} }
// ensure // Fall-back full scroll (unused by button, but kept if you need it elsewhere)
requestAnimationFrame(()=>{ function scrollToBottom() {
const el = activeScroller();
const top = el.scrollHeight;
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollTo({ top, behavior: 'smooth' });
} else {
el.scrollTo({ top, behavior: 'smooth' });
}
requestAnimationFrame(() => {
if (el === document.scrollingElement || el === document.documentElement || el === document.body) { if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollTo(0, el.scrollHeight); window.scrollTo(0, el.scrollHeight);
} else { } else {
...@@ -253,68 +404,85 @@ ...@@ -253,68 +404,85 @@
}); });
} }
function updateScrollButton(){ function updateScrollButton() {
// Hard rule: do not show unless armed (i.e., actual assistant text seen this turn)
if (!scrollBtnArmed) {
scrollDownBtn.style.display = 'none';
return;
}
const needs = isOverflowingAny() && !atBottom(); const needs = isOverflowingAny() && !atBottom();
scrollDownBtn.style.display = needs ? 'inline-flex' : 'none'; scrollDownBtn.style.display = needs ? 'inline-flex' : 'none';
} }
// --- composer lock --- // --- composer lock ---
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?');
inputText.disabled=locked; inputText.disabled = locked;
goBtn.style.display=locked?'none':'inline-flex'; goBtn.style.display = locked ? 'none' : 'inline-flex';
stopBtn.style.display=locked?'inline-flex':'none'; stopBtn.style.display = locked ? 'inline-flex' : 'none';
if (locked) inputText.blur(); else inputText.focus(); if (locked) inputText.blur(); else inputText.focus();
requestAnimationFrame(updateScrollButton); requestAnimationFrame(updateScrollButton);
} }
// --- flow --- // --- flow ---
async function start(){ async function start() {
const prompt=inputText.value.trim(); const prompt = inputText.value.trim();
if(!prompt) return; if (!prompt) return;
if (!firstRequestDone) { welcomeLogo?.remove(); firstRequestDone = true; }
if(!firstRequestDone){ welcomeLogo?.remove(); firstRequestDone=true; } // Disarm the button for this request until real content arrives
const hadHistory = thread.children.length > 0;
scrollBtnArmed = false;
updateScrollButton();
const autoStick = atBottom(); const autoStick = atBottom();
const userMsg=document.createElement('div'); const userMsg = document.createElement('div');
userMsg.className='message user-message'; userMsg.className = 'message user-message';
userMsg.textContent=prompt; userMsg.textContent = prompt;
thread.appendChild(userMsg); thread.appendChild(userMsg);
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);
if (autoStick) assistantMsg.scrollIntoView({ behavior:'smooth', block:'start' }); if (autoStick) assistantMsg.scrollIntoView({ behavior: 'smooth', block: 'start' });
inputText.value=''; inputText.value = '';
controller=new AbortController(); controller = new AbortController();
setComposerLocked(true); setComposerLocked(true);
try{ try {
const res=await fetch(ENDPOINT,{ const res = await fetch(ENDPOINT, {
method:'POST', method: 'POST',
headers:{'Content-Type':'application/json'}, headers: { 'Content-Type': 'application/json' },
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();
let rawMarkdown=''; let receivedAny=false; let rafPending=false; let rawMarkdown = '';
let receivedAny = false;
let rafPending = false;
const flush=() => { const flush = () => {
if (rafPending) return; if (rafPending) return;
rafPending=true; rafPending = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
rafPending=false; rafPending = false;
if(!receivedAny){ clearThinkingCatIfPresent(assistantMsg); receivedAny=true; } if (!receivedAny) {
// First real content chunk: clear thinking cat, arm button
clearThinkingCatIfPresent(assistantMsg);
receivedAny = true;
scrollBtnArmed = true;
}
renderMarkdown(assistantMsg, rawMarkdown); renderMarkdown(assistantMsg, rawMarkdown);
if (autoStick && atBottom()){ if (autoStick && atBottom()) {
const el = activeScroller(); const el = activeScroller();
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
} }
...@@ -322,49 +490,59 @@ ...@@ -322,49 +490,59 @@
}); });
}; };
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 if (chunk) { rawMarkdown += chunk; }
flush(); flush();
} }
flush(); flush();
}catch(err){ } catch (err) {
if (err.name!=='AbortError'){ if (err.name !== 'AbortError') {
clearThinkingCatIfPresent(assistantMsg); clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, `\n\n**${t('status.errorLabel','Error')}:** ${err.message||err}`); renderMarkdown(assistantMsg, "\n\n**" + t('status.errorLabel', 'Error') + ":** " + (err.message || err));
}else{ } else {
if (assistantMsg.querySelector('img.thinking-cat')){ if (assistantMsg.querySelector('img.thinking-cat')) {
clearThinkingCatIfPresent(assistantMsg); clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, t('status.stopped','_Stopped._')); renderMarkdown(assistantMsg, t('status.stopped', '_Stopped._'));
}
} }
} finally {
// If nothing arrived, re-arm only if there was history before this turn
if (assistantMsg.textContent.trim() === '' && assistantMsg.querySelector('img') === null) {
// (unlikely branch) but keep behavior predictable
scrollBtnArmed = hadHistory;
} else {
// If assistant has any text, we already armed it in flush()
// If it failed with only "Stopped." or "Error", allow based on history
const hasRealText = assistantMsg.textContent.trim().length > 0 && assistantMsg.textContent.trim() !== '_Stopped._';
if (!hasRealText) scrollBtnArmed = hadHistory;
} }
}finally{
setComposerLocked(false); setComposerLocked(false);
controller=null; controller = null;
updateScrollButton(); updateScrollButton();
} }
} }
async function stop(){ async function stop() {
if(controller) controller.abort(); if (controller) controller.abort();
try{ await fetch(STOPPOINT,{method:'POST'}); }catch(_){} try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) { }
} }
// events // events
document.getElementById('promptForm').addEventListener('submit', e => { e.preventDefault(); if (goBtn.style.display!=='none') start(); }); document.getElementById('promptForm').addEventListener('submit', e => { e.preventDefault(); if (goBtn.style.display !== 'none') start(); });
document.getElementById('inputText').addEventListener('keydown', e => { document.getElementById('inputText').addEventListener('keydown', e => {
if (e.key==='Enter' && !e.shiftKey && goBtn.style.display!=='none'){ e.preventDefault(); start(); } if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none') { e.preventDefault(); start(); }
}); });
document.getElementById('stopBtn').addEventListener('click', stop); document.getElementById('stopBtn').addEventListener('click', stop);
// scroll button action + toggling // scroll button action + toggling (partial scroll)
scrollDownBtn.addEventListener('click', scrollToBottom); scrollDownBtn.addEventListener('click', scrollOnePageDown);
// Listen on both potential scroll containers // Listen on both potential scroll containers
mainScroll.addEventListener('scroll', updateScrollButton, { passive:true }); mainScroll.addEventListener('scroll', updateScrollButton, { passive: true });
window.addEventListener('scroll', updateScrollButton, { passive:true }); window.addEventListener('scroll', updateScrollButton, { passive: true });
window.addEventListener('resize', updateScrollButton); window.addEventListener('resize', updateScrollButton);
// Observe content changes to recalc button visibility // Observe content changes to recalc button visibility
...@@ -375,11 +553,15 @@ ...@@ -375,11 +553,15 @@
// 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);
inputText.focus(); inputText.focus();
updateScrollButton(); // initial // Force hard-hide on startup
scrollBtnArmed = false;
scrollDownBtn.style.display = 'none';
updateScrollButton(); // will keep hidden until armed after first real chunk
}); });
</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