Commit 651e3539 by michaelpastushkov
parents 387aeee4 81741ae5
...@@ -12,6 +12,13 @@ ...@@ -12,6 +12,13 @@
"port": 3011 "port": 3011
}, },
"ai": {
"endpoint": "http://127.0.0.1:11434",
"model": "gpt-oss:120b",
"embed_model": "nomic-embed-text",
"label_model": "llama3:8b"
},
"housekeeping": { "housekeeping": {
"cronTime": "0 0 * * * *", "cronTime": "0 0 * * * *",
"on_start": false "on_start": false
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
* Created by: Michael Pastushkov <michael@pastushkov.com> * Created by: Michael Pastushkov <michael@pastushkov.com>
*/ */
const config = require('config');
const HistoryManager = require('./HistoryManager'); const HistoryManager = require('./HistoryManager');
const TopicRouter = require('./TopicRouter'); const TopicRouter = require('./TopicRouter');
const utils = require('../utils/utils'); const utils = require('../utils/utils');
...@@ -75,7 +76,7 @@ class ConversationManager { ...@@ -75,7 +76,7 @@ class ConversationManager {
}); });
// Making the main request to model // Making the main request to model
const res = await fetch('http://127.0.0.1:11434/api/chat', { const res = await fetch(`${config.ai.endpoint}/api/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: body body: body
......
...@@ -3,13 +3,14 @@ ...@@ -3,13 +3,14 @@
* Created by: Michael Pastushkov <michael@pastushkov.com> * Created by: Michael Pastushkov <michael@pastushkov.com>
*/ */
const config = require('config');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const utils = require('../utils/utils'); const utils = require('../utils/utils');
const ConversationManager = require('./ConversationManager'); const ConversationManager = require('./ConversationManager');
const MODEL = 'gpt-oss:20b'; // 'llama3:8b'; // 'gpt-oss:20b'; const MODEL = config.ai.model || 'gpt-oss:20b';
const EMBED_MODEL = 'nomic-embed-text'; const EMBED_MODEL = config.ai.embed_model || 'nomic-embed-text';
const LABEL_MODEL = 'llama3:8b'; const LABEL_MODEL = config.ai.label_model || 'llama3:8b';
const sessions = new Map(); // cookie -> ConversationManager const sessions = new Map(); // cookie -> ConversationManager
//const queues = new Map(); // cookie -> Promise (to serialize) //const queues = new Map(); // cookie -> Promise (to serialize)
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
* Created by: Michael Pastushkov <michael@pastushkov.com> * Created by: Michael Pastushkov <michael@pastushkov.com>
*/ */
const config = require('config');
const utils = require('../utils/utils'); const utils = require('../utils/utils');
class TopicRouter { class TopicRouter {
...@@ -24,7 +25,7 @@ class TopicRouter { ...@@ -24,7 +25,7 @@ class TopicRouter {
utils.log('_mintLabelWithLLM', 5); utils.log('_mintLabelWithLLM', 5);
utils.log(`model: ${this.labelModel}`, 5); utils.log(`model: ${this.labelModel}`, 5);
const sys = `Return a short topic key (1–3 words, lowercase, hyphenated). Only the key.`; const sys = `Return a short topic key (1–3 words, lowercase, hyphenated). Only the key.`;
const r = await fetch('http://127.0.0.1:11434/api/chat', { const r = await fetch(`${config.ai.endpoint}/api/chat`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: this.labelModel, model: this.labelModel,
...@@ -92,7 +93,7 @@ class TopicRouter { ...@@ -92,7 +93,7 @@ class TopicRouter {
async embed(text) { async embed(text) {
utils.log(`Embed ${text}`, 5); utils.log(`Embed ${text}`, 5);
utils.log(`model: ${this.embedModel}`, 5); utils.log(`model: ${this.embedModel}`, 5);
const r = await fetch('http://127.0.0.1:11434/api/embeddings', { const r = await fetch(`${config.ai.endpoint}/api/embeddings`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: this.embedModel, model: this.embedModel,
......
<!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);
}
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;
}
.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;
}
</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">
<div class="d-flex align-items-stretch gap-2">
<textarea id="inputText" class="form-control" rows="1" placeholder=""></textarea>
<button id="goBtn" class="btn btn-primary" type="submit" style="min-width: 80px;"></button>
<button id="stopBtn" class="btn btn-outline-danger" type="button" style="display:none; min-width: 80px;"></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.');
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');
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='';
}
// --- bottom helpers ---
function atBottom(){
return (mainScroll.scrollTop + mainScroll.clientHeight) >= (mainScroll.scrollHeight - 4);
}
// --- 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-block';
stopBtn.style.display=locked?'inline-block':'none';
if (locked) inputText.blur(); else inputText.focus();
}
// --- flow ---
async function start(){
const prompt=inputText.value.trim();
if(!prompt) return;
if(!firstRequestDone){ welcomeLogo?.remove(); firstRequestDone=true; }
const auto = 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 (auto) 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 (auto && atBottom()){
mainScroll.scrollTop = mainScroll.scrollHeight;
}
});
};
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;
}
}
async function stop(){
if(controller) controller.abort();
try{ await fetch(STOPPOINT,{method:'POST'}); }catch(_){}
}
// events
promptForm.addEventListener('submit', e => { e.preventDefault(); if (goBtn.style.display!=='none') start(); });
inputText.addEventListener('keydown', e => {
if (e.key==='Enter' && !e.shiftKey && goBtn.style.display!=='none'){ e.preventDefault(); start(); }
});
stopBtn.addEventListener('click', stop);
// init
window.addEventListener('DOMContentLoaded', async () => {
try{ await fetch('/api/clear',{method:'GET'}); }catch(_){}
await loadLocale(CURRENT_LOCALE);
inputText.focus();
});
</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; --footer-h: 2.75rem; /* fixed footer height (approx) */
/* fixed footer height (approx) */ --composer-h: 6.5rem; /* composer block height (approx) */
--composer-h: 6.5rem; --bottom-pad: calc(var(--footer-h) + var(--composer-h) + 1rem);
/* composer block height (approx) */ --row-h: 42px; /* unified control height */
--bottom-pad: calc(var(--footer-h) + var(--composer-h) + 1rem); }
}
body{
body { min-height:100vh;
min-height: 100vh; display:flex;
display: flex; flex-direction:column;
flex-direction: column; background-color:#f8f9fa;
background-color: #f8f9fa; margin:0;
margin: 0; }
}
header{ position:sticky; top:0; z-index:1020; }
header {
position: sticky; main{
top: 0; flex:1 1 auto;
z-index: 1020; min-height:0; /* allow flex child to actually scroll */
} overflow-y:auto; /* scrollable area */
position:relative;
main { padding-bottom:var(--bottom-pad); /* room for composer + fixed footer */
flex: 1 1 auto; display:flex;
overflow-y: auto; flex-direction:column;
position: relative; scroll-behavior:smooth; /* nudge for smooth scrolling */
padding-bottom: var(--bottom-pad); }
/* leave room for composer + fixed footer */
} .composer{
position:sticky;
.composer { bottom:var(--footer-h); /* sits just above the fixed footer */
position: sticky; z-index:1020;
bottom: var(--footer-h); background:#fff;
/* sit just above the fixed footer */ border-top:1px solid #dee2e6;
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;
/* --- Footer from your snippet (fixed, non-scrollable) --- */ border-top:1px solid #dee2e6; background:#fdfdfd; height:var(--footer-h);
footer { box-sizing:border-box;
position: fixed; }
bottom: 0;
left: 0; .cat-spinner{ width:36px; height:36px; object-fit:contain; filter:drop-shadow(0 1px 2px rgba(0,0,0,.35)); }
width: 100%; .thinking-cat{ width:64px; height:64px; object-fit:contain; display:block; margin:.25rem auto; }
z-index: 1020;
font-size: .75rem; .message{
color: #6c757d; max-width:900px; margin:0 auto 1rem auto; padding:1rem 1.25rem;
text-align: center; border-radius:.75rem; line-height:1.6; font-size:1rem; word-break:break-word;
padding: .5rem; }
border-top: 1px solid #dee2e6; .user-message{ background:#fff; border:1px solid #dee2e6; white-space:pre-wrap; }
background: #fdfdfd; .assistant-message{ background:#f8f9fa; border:1px solid #dee2e6; }
height: var(--footer-h); .assistant-message h1,.assistant-message h2,.assistant-message h3{ margin-top:1rem; margin-bottom:.6rem; }
box-sizing: border-box; .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; }
.cat-spinner { .assistant-message ul,.assistant-message ol{ padding-left:1.25rem; }
width: 36px; .assistant-message table{ width:100%; margin:1rem 0; }
height: 36px;
object-fit: contain; /* First-run logo layout */
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35)); #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{
.thinking-cat { max-width:520px; width:100%; height:auto;
width: 64px; filter:drop-shadow(0 6px 16px rgba(0,0,0,.15)); opacity:.98;
height: 64px; }
object-fit: contain;
display: block; /* Single-row composer: textarea + Go/Stop + Scroll */
margin: .25rem auto; .composer-row { display:flex; align-items:stretch; gap:.5rem; }
} .composer-row > * { height:var(--row-h); }
.message { #inputText {
max-width: 900px; flex:1 1 auto;
margin: 0 auto 1rem auto; height:var(--row-h);
padding: 1rem 1.25rem; resize:vertical; /* keep manual expand if desired */
border-radius: .75rem; min-height:var(--row-h);
line-height: 1.6; }
font-size: 1rem;
word-break: break-word; #goBtn, #stopBtn {
} flex:0 0 auto;
min-width:90px;
.user-message { display:inline-flex;
background-color: #ffffff; align-items:center;
border: 1px solid #dee2e6; justify-content:center;
white-space: pre-wrap; border-radius:.5rem;
} }
.assistant-message { /* Less-bold, gray scroll button (only visible when needed) */
background-color: #f8f9fa; #scrollDownBtn{
border: 1px solid #dee2e6; display:none;
} align-items:center;
justify-content:center;
.assistant-message h1, border:1px solid #ced4da;
.assistant-message h2, border-radius:.5rem;
.assistant-message h3 { width:var(--row-h);
margin-top: 1rem; height:var(--row-h);
margin-bottom: .6rem; background:#e9ecef;
} color:#495057;
box-shadow:0 1px 3px rgba(0,0,0,.06);
.assistant-message p { cursor:pointer;
margin: .5rem 0; transition:opacity .15s ease-in-out, transform .12s ease-in-out, box-shadow .15s;
} opacity:.9;
flex:0 0 auto;
.assistant-message code { }
background: #fff; #scrollDownBtn:hover{ opacity:1; transform:translateY(-1px); box-shadow:0 2px 6px rgba(0,0,0,.08); }
border: 1px solid #e9ecef; #scrollDownBtn svg{ width:20px; height:20px; pointer-events:none; }
border-radius: .25rem; </style>
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 behavior as the earlier file) */
#scrollDownBtn {
position: sticky;
/* inside scrollable main */
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 (scrollable) --> <!-- 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 --> <!-- Composer (sticks above fixed footer) -->
<button id="scrollDownBtn" type="button" aria-label="Scroll down"> <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"> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path <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"/>
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> </svg>
</button> </button>
</main>
<!-- Composer (sticks above fixed footer) -->
<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="1" 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>
</form>
</div> </div>
</div>
<!-- Fixed, non-scrollable footer (from your snippet) -->
<footer id="disclaimer"></footer> <!-- 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 src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<script> <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
// --- i18n setup (unchanged) --- <script>
const DEFAULT_LOCALE = 'en'; // --- i18n setup ---
const SUPPORTED = ['en', 'ru']; const DEFAULT_LOCALE='en'; const SUPPORTED=['en','ru'];
let I18N = {}; let I18N={}; let CURRENT_LOCALE=detectLocale(); let isRunning=false;
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; }
function getLangFromQuery() { 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); } }
const m = location.search.match(/[?&]lang=(en|ru)\b/i); 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); }
return m ? m[1].toLowerCase() : null; 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(){
function detectLocale() { document.title=t('app.title','KotGPT');
const q = getLangFromQuery(); const catIcon=document.getElementById('catIcon'); if(catIcon) catIcon.alt=t('header.logoAlt','Cat logo');
if (q && SUPPORTED.includes(q)) return q; const welcomeImg=document.getElementById('welcomeImg'); if(welcomeImg) welcomeImg.alt=t('main.welcomeAlt','Ask a Cat');
const nav = (navigator.language || '').toLowerCase(); const input=document.getElementById('inputText'); if(input) input.placeholder=isRunning?t('status.thinking','Thinking…'):t('composer.placeholder','What can I help you with?');
if (nav.startsWith('ru')) return 'ru'; const goBtn=document.getElementById('goBtn'); if(goBtn) goBtn.textContent=t('buttons.go','Go');
return DEFAULT_LOCALE; 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.');
async function loadLocale(locale) { const sdb=document.getElementById('scrollDownBtn'); if (sdb){ sdb.setAttribute('aria-label', t('buttons.scrollDown','Scroll down')); sdb.title=t('buttons.scrollDown','Scroll down'); }
try { setWelcomeImage();
const res = await fetch(`/locales/${locale}.json`, { cache: 'no-store' }); }
if (!res.ok) throw new Error('HTTP ' + res.status);
I18N = await res.json(); // --- refs ---
CURRENT_LOCALE = locale; const ENDPOINT='/api/stream'; const STOPPOINT='/api/stop'; const CAT_GIF='/static/images/cat-progress.gif';
document.documentElement.lang = locale; const thread=document.getElementById('thread');
applyI18n(); const promptForm=document.getElementById('promptForm');
} catch (e) { const inputText=document.getElementById('inputText');
if (locale !== DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE); const goBtn=document.getElementById('goBtn');
} const stopBtn=document.getElementById('stopBtn');
} const welcomeLogo=document.getElementById('welcomeLogo');
function t(key, fallback = '') { const mainScroll=document.getElementById('mainScroll');
const parts = key.split('.'); const scrollDownBtn=document.getElementById('scrollDownBtn');
let cur = I18N;
for (const p of parts) { let firstRequestDone=false;
if (cur && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p]; let controller=null;
else return fallback || key;
} // --- render helpers ---
return (typeof cur === 'string') ? cur : (fallback || key); function renderMarkdown(targetEl, md){
} const html=marked.parse(md,{breaks:true,gfm:true,headerIds:false});
function setWelcomeImage() { targetEl.innerHTML=DOMPurify.sanitize(html,{USE_PROFILES:{html:true}});
const welcomeImg = document.getElementById('welcomeImg'); }
if (!welcomeImg) return; function showThinkingCat(targetEl){
const srcByLocale = { targetEl.innerHTML='';
en: '/static/images/ask-a-cat-en.png', const img=document.createElement('img');
ru: '/static/images/ask-a-cat-ru.png' img.className='thinking-cat';
}; img.src=CAT_GIF+'?t='+Date.now();
welcomeImg.src = srcByLocale[CURRENT_LOCALE] || srcByLocale.en; img.alt=t('status.thinking','Thinking…');
} targetEl.appendChild(img);
function applyI18n() { }
document.title = t('app.title', 'KotGPT'); function clearThinkingCatIfPresent(targetEl){
const catIcon = document.getElementById('catIcon'); const img=targetEl.querySelector('img.thinking-cat');
if (catIcon) catIcon.alt = t('header.logoAlt', 'Cat logo'); if(img) targetEl.innerHTML='';
const welcomeImg = document.getElementById('welcomeImg'); }
if (welcomeImg) welcomeImg.alt = t('main.welcomeAlt', 'Ask a Cat');
const inputText = document.getElementById('inputText'); // --- find the active scroller (main vs page) ---
if (inputText) { function activeScroller(){
inputText.placeholder = isRunning const docEl = document.scrollingElement || document.documentElement;
? t('status.thinking', 'Thinking…') const mainOverflows = mainScroll && (mainScroll.scrollHeight - mainScroll.clientHeight) > 1;
: t('composer.placeholder', 'What can I help you with?'); return mainOverflows ? mainScroll : docEl;
} }
const goBtn = document.getElementById('goBtn');
if (goBtn) goBtn.textContent = t('buttons.go', 'Go'); function atBottom(){
const stopBtn = document.getElementById('stopBtn'); const el = activeScroller();
if (stopBtn) stopBtn.textContent = t('buttons.stop', 'Stop'); return (el.scrollTop + el.clientHeight) >= (el.scrollHeight - 4);
const disclaimer = document.getElementById('disclaimer'); }
if (disclaimer) disclaimer.textContent = t('footer.disclaimer', 'Beware: Cat can make mistakes.');
const scrollDownBtn = document.getElementById('scrollDownBtn'); function isOverflowingAny(){
if (scrollDownBtn) { const docEl = document.scrollingElement || document.documentElement;
scrollDownBtn.setAttribute('aria-label', t('buttons.scrollDown', 'Scroll down')); const mainOver = mainScroll && (mainScroll.scrollHeight - mainScroll.clientHeight) > 1;
scrollDownBtn.title = t('buttons.scrollDown', 'Scroll down'); const pageOver = (docEl.scrollHeight - docEl.clientHeight) > 1;
} return mainOver || pageOver;
setWelcomeImage(); }
}
function scrollToBottom(){
// --- UI refs --- const el = activeScroller();
const ENDPOINT = '/api/stream'; const top = el.scrollHeight;
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif'; if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
const thread = document.getElementById('thread'); window.scrollTo({ top, behavior:'smooth' });
const promptForm = document.getElementById('promptForm'); } else {
const inputText = document.getElementById('inputText'); el.scrollTo({ top, behavior:'smooth' });
const goBtn = document.getElementById('goBtn'); }
const stopBtn = document.getElementById('stopBtn');
const welcomeLogo = document.getElementById('welcomeLogo'); // ensure
const mainScroll = document.getElementById('mainScroll'); requestAnimationFrame(()=>{
const scrollDownBtn = document.getElementById('scrollDownBtn'); if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollTo(0, el.scrollHeight);
let firstRequestDone = false; } else {
let controller = null; el.scrollTop = el.scrollHeight;
}
// --- 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 updateScrollButton(){
} const needs = isOverflowingAny() && !atBottom();
function showThinkingCat(targetEl) { scrollDownBtn.style.display = needs ? 'inline-flex' : 'none';
targetEl.innerHTML = ''; }
const img = document.createElement('img');
img.className = 'thinking-cat'; // --- composer lock ---
img.src = CAT_GIF + '?t=' + Date.now(); function setComposerLocked(locked){
img.alt = t('status.thinking', 'Thinking…'); isRunning=locked;
targetEl.appendChild(img); inputText.placeholder = locked ? t('status.thinking','Thinking…') : t('composer.placeholder','What can I help you with?');
} inputText.disabled=locked;
function clearThinkingCatIfPresent(targetEl) { goBtn.style.display=locked?'none':'inline-flex';
const img = targetEl.querySelector('img.thinking-cat'); stopBtn.style.display=locked?'inline-flex':'none';
if (img) targetEl.innerHTML = ''; if (locked) inputText.blur(); else inputText.focus();
} requestAnimationFrame(updateScrollButton);
}
// --- Scroll indicator (same behavior as before) ---
function needsScroll() { return mainScroll.scrollHeight - mainScroll.clientHeight > 8; } // --- flow ---
function atBottom() { return mainScroll.scrollTop + mainScroll.clientHeight >= mainScroll.scrollHeight - 50; } async function start(){
function updateScrollIndicator() { const prompt=inputText.value.trim();
if (needsScroll() && !atBottom()) scrollDownBtn.classList.add('show'); if(!prompt) return;
else scrollDownBtn.classList.remove('show');
} if(!firstRequestDone){ welcomeLogo?.remove(); firstRequestDone=true; }
scrollDownBtn.addEventListener('click', () => {
mainScroll.scrollBy({ top: mainScroll.clientHeight, behavior: 'smooth' }); const autoStick = atBottom();
setTimeout(updateScrollIndicator, 320);
}); const userMsg=document.createElement('div');
mainScroll.addEventListener('scroll', updateScrollIndicator); userMsg.className='message user-message';
window.addEventListener('resize', updateScrollIndicator); userMsg.textContent=prompt;
thread.appendChild(userMsg);
// --- Composer lock ---
function setComposerLocked(locked) { const assistantMsg=document.createElement('div');
isRunning = locked; assistantMsg.className='message assistant-message';
inputText.placeholder = locked ? t('status.thinking', 'Thinking…') : t('composer.placeholder', 'What can I help you with?'); showThinkingCat(assistantMsg);
inputText.disabled = locked; thread.appendChild(assistantMsg);
goBtn.style.display = locked ? 'none' : 'inline-block';
stopBtn.style.display = locked ? 'inline-block' : 'none'; if (autoStick) assistantMsg.scrollIntoView({ behavior:'smooth', block:'start' });
if (locked) inputText.blur(); else inputText.focus();
} inputText.value='';
controller=new AbortController();
// --- Flow --- setComposerLocked(true);
async function start() {
const prompt = inputText.value.trim(); try{
if (!prompt) return; const res=await fetch(ENDPOINT,{
method:'POST',
if (!firstRequestDone) { welcomeLogo?.remove(); firstRequestDone = true; } headers:{'Content-Type':'application/json'},
body:JSON.stringify({ content: prompt }),
const shouldAutoScroll = atBottom(); signal:controller.signal
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 && atBottom()) {
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(!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault(); const reader=res.body.getReader();
start(); 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();
document.getElementById('stopBtn').addEventListener('click', stop); });
};
// Init
window.addEventListener('DOMContentLoaded', async () => { for(;;){
try { await fetch('/api/clear', { method: 'GET' }); } catch (_) { } const { value, done } = await reader.read();
await loadLocale(CURRENT_LOCALE); if (done) break;
document.getElementById('inputText').focus(); const chunk = decoder.decode(value, { stream:true });
updateScrollIndicator(); for (const ch of chunk) rawMarkdown += ch; // char-by-char
}); flush();
</script> }
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> </body>
</html>
</html>
\ No newline at end of file
<!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>
body { min-height: 100vh; display: flex; flex-direction: column; background-color: #f8f9fa; }
main { flex: 1 1 auto; overflow-y: auto; position: relative; }
header { position: sticky; top: 0; z-index: 1020; }
.composer { position: sticky; bottom: 0; z-index: 1020; background: #fff; border-top: 1px solid #dee2e6; }
.status-dot { display:inline-block; width:.6rem; height:.6rem; border-radius:50%; margin-right:.4rem; vertical-align:middle; }
.status-live{ background-color:#0d6efd; box-shadow:0 0 0 .2rem rgba(13,110,253,.25); }
.status-error{ background-color:#dc3545; }
.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;
}
/* SCROLL INDICATOR */
#scrollDownBtn {
position: sticky; /* sticks to bottom edge inside the scrollable main */
margin-left: auto;
bottom: 1rem;
align-self: flex-end;
right: 1rem; /* works with a transform for offset */
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 -->
<main class="container my-3" id="mainScroll">
<div id="welcomeLogo">
<img src="/static/images/ask-a-cat.png" alt="Ask a Cat">
</div>
<div id="thread"></div>
<!-- SCROLL INDICATOR -->
<button id="scrollDownBtn" type="button" aria-label="Scroll down one page" 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>
</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="Type your message… (Enter to send, Shift+Enter for newline)"></textarea>
<div class="d-flex align-items-center gap-2">
<button id="goBtn" class="btn btn-primary" type="submit">Go</button>
<button id="stopBtn" class="btn btn-outline-danger" type="button" disabled>Stop</button>
<div id="status" class="d-flex align-items-center gap-2 d-none">
<span id="statusDot" class="status-dot"></span>
<span id="statusText" class="small"></span>
</div>
</div>
</form>
</div>
</div>
<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>
const ENDPOINT = '/api/stream';
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif';
const CAT_STILL = '/static/images/cat-still.png';
const mainScroll = document.getElementById('mainScroll');
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 statusWrap = document.getElementById('status');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const catIcon = document.getElementById('catIcon');
const welcomeLogo = document.getElementById('welcomeLogo');
// SCROLL INDICATOR
const scrollDownBtn = document.getElementById('scrollDownBtn');
const SCROLL_THRESH = 8;
let firstRequestDone = false;
let controller = null;
window.addEventListener('DOMContentLoaded', () => inputText.focus());
function showStatus(kind, text) {
if (kind === 'live' || kind === 'error') {
statusWrap.classList.remove('d-none');
statusText.textContent = text || (kind === 'live' ? 'Thinking…' : 'Error');
statusDot.className = 'status-dot ' + (kind === 'live' ? 'status-live' : 'status-error');
} else {
statusWrap.classList.add('d-none');
statusText.textContent = '';
statusDot.className = 'status-dot';
}
catIcon.src = CAT_STILL;
}
function prettifyTables(scopeEl) {
scopeEl.querySelectorAll('table').forEach(t => {
t.classList.add('table', 'table-sm', 'table-bordered', 'table-striped');
t.querySelectorAll('th').forEach(th => th.classList.add('fw-semibold'));
});
}
function renderMarkdown(targetEl, markdownText) {
const html = marked.parse(markdownText, { breaks:true, gfm:true, headerIds:false });
const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
targetEl.innerHTML = clean;
prettifyTables(targetEl);
}
function isNearBottom() {
const threshold = 50;
return mainScroll.scrollHeight - mainScroll.scrollTop - mainScroll.clientHeight < threshold;
}
function showThinkingCat(targetEl) {
targetEl.innerHTML = '';
const img = document.createElement('img');
img.className = 'thinking-cat';
img.src = CAT_GIF + '?t=' + Date.now();
img.alt = 'Thinking…';
targetEl.appendChild(img);
}
function clearThinkingCatIfPresent(targetEl) {
const img = targetEl.querySelector('img.thinking-cat');
if (img) targetEl.innerHTML = '';
}
// ===== SCROLL INDICATOR LOGIC =====
function needsScroll() {
return mainScroll.scrollHeight - mainScroll.clientHeight > SCROLL_THRESH;
}
function atBottom() {
return mainScroll.scrollTop + mainScroll.clientHeight >= mainScroll.scrollHeight - SCROLL_THRESH;
}
function updateScrollIndicator() {
// show when content overflows and we are NOT at bottom
if (needsScroll() && !atBottom()) {
scrollDownBtn.classList.add('show');
} else {
scrollDownBtn.classList.remove('show');
}
}
// Click: scroll down one viewport
scrollDownBtn.addEventListener('click', () => {
mainScroll.scrollBy({ top: mainScroll.clientHeight, left: 0, behavior: 'smooth' });
// post-scroll check (after smooth scroll finishes)
setTimeout(updateScrollIndicator, 320);
});
// Keep indicator in sync with scrolling/resize
mainScroll.addEventListener('scroll', updateScrollIndicator);
window.addEventListener('resize', updateScrollIndicator);
// ===== COMPOSER LOCK =====
function setComposerLocked(locked) {
inputText.disabled = locked;
goBtn.disabled = locked;
stopBtn.disabled = !locked;
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 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);
showStatus('live', 'Thinking…');
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(); // keep arrow state fresh during stream
});
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const ch of chunk) {
rawMarkdown += ch; // symbol-by-symbol
flush();
}
}
flush();
showStatus('idle');
} catch (err) {
if (err.name !== 'AbortError') {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, `\n\n**Error:** ${err.message || err}`);
showStatus('error', 'Error');
} else {
if (assistantMsg.querySelector('img.thinking-cat')) {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, '_Stopped._');
}
showStatus('idle');
}
} finally {
setComposerLocked(false);
controller = null;
updateScrollIndicator(); // final state after completion
}
}
async function stop() {
if (controller) controller.abort();
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) {}
}
promptForm.addEventListener('submit', e => {
e.preventDefault();
if (!goBtn.disabled) start();
});
inputText.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && !goBtn.disabled) {
e.preventDefault();
start();
}
});
stopBtn.addEventListener('click', stop);
// Initial indicator state (e.g., when welcome is removed later)
updateScrollIndicator();
</script>
</body>
</html>
<!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>
body { min-height: 100vh; display: flex; flex-direction: column; background-color: #f8f9fa; }
main { flex: 1 1 auto; overflow-y: auto; position: relative; }
header { position: sticky; top: 0; z-index: 1020; }
.composer { position: sticky; bottom: 0; z-index: 1020; background: #fff; border-top: 1px solid #dee2e6; }
.status-dot { display:inline-block; width:.6rem; height:.6rem; border-radius:50%; margin-right:.4rem; vertical-align:middle; }
.status-live{ background-color:#0d6efd; box-shadow:0 0 0 .2rem rgba(13,110,253,.25); }
.status-error{ background-color:#dc3545; }
.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; }
#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;
}
/* SCROLL INDICATOR */
#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 -->
<main class="container my-3" id="mainScroll">
<div id="welcomeLogo">
<img id="welcomeImg" src="/static/images/ask-a-cat.png" alt="Ask a Cat">
</div>
<div id="thread"></div>
<!-- SCROLL INDICATOR -->
<button id="scrollDownBtn" type="button" aria-label="Scroll down one page" 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>
</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" disabled></button>
<div id="status" class="d-flex align-items-center gap-2 d-none">
<span id="statusDot" class="status-dot"></span>
<span id="statusText" class="small"></span>
</div>
</div>
</form>
</div>
</div>
<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 BOOTSTRAP =====
const DEFAULT_LOCALE = 'en';
const SUPPORTED = ['en', 'ru'];
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;
}
let I18N = {};
let CURRENT_LOCALE = detectLocale();
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) {
// fallback to English
await loadLocale(DEFAULT_LOCALE);
} else {
console.error('Failed to load locale', e);
}
}
}
function t(key, fallback = '') {
// nested keys like "status.errorLabel"
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 applyI18n() {
// Title / alts / aria
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 scrollDownBtn = document.getElementById('scrollDownBtn');
if (scrollDownBtn) {
scrollDownBtn.setAttribute('aria-label', t('scroll.ariaLabel', 'Scroll down one page'));
scrollDownBtn.setAttribute('title', t('scroll.title', 'Scroll down'));
}
// Composer + buttons
const inputText = document.getElementById('inputText');
if (inputText) inputText.placeholder = t('composer.placeholder', 'Type your message… (Enter to send, Shift+Enter for newline)');
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');
// Status (runtime text set in showStatus)
// Nothing else to set here immediately.
}
// ===== APP CODE =====
const ENDPOINT = '/api/stream';
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif';
const CAT_STILL = '/static/images/cat-still.png';
const mainScroll = document.getElementById('mainScroll');
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 statusWrap = document.getElementById('status');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const catIcon = document.getElementById('catIcon');
const welcomeLogo = document.getElementById('welcomeLogo');
// SCROLL INDICATOR
const scrollDownBtn = document.getElementById('scrollDownBtn');
const SCROLL_THRESH = 8;
let firstRequestDone = false;
let controller = null;
window.addEventListener('DOMContentLoaded', async () => {
await loadLocale(CURRENT_LOCALE);
inputText.focus();
});
function showStatus(kind, text) {
if (kind === 'live' || kind === 'error') {
statusWrap.classList.remove('d-none');
statusText.textContent = text || (kind === 'live' ? t('status.thinking', 'Thinking…') : t('status.error', 'Error'));
statusDot.className = 'status-dot ' + (kind === 'live' ? 'status-live' : 'status-error');
} else {
statusWrap.classList.add('d-none');
statusText.textContent = '';
statusDot.className = 'status-dot';
}
catIcon.src = CAT_STILL;
}
function prettifyTables(scopeEl) {
scopeEl.querySelectorAll('table').forEach(t => {
t.classList.add('table', 'table-sm', 'table-bordered', 'table-striped');
t.querySelectorAll('th').forEach(th => th.classList.add('fw-semibold'));
});
}
function renderMarkdown(targetEl, markdownText) {
const html = marked.parse(markdownText, { breaks:true, gfm:true, headerIds:false });
const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
targetEl.innerHTML = clean;
prettifyTables(targetEl);
}
function isNearBottom() {
const threshold = 50;
return mainScroll.scrollHeight - mainScroll.scrollTop - mainScroll.clientHeight < threshold;
}
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 =====
function needsScroll() {
return mainScroll.scrollHeight - mainScroll.clientHeight > SCROLL_THRESH;
}
function atBottom() {
return mainScroll.scrollTop + mainScroll.clientHeight >= mainScroll.scrollHeight - SCROLL_THRESH;
}
function updateScrollIndicator() {
if (needsScroll() && !atBottom()) {
scrollDownBtn.classList.add('show');
} else {
scrollDownBtn.classList.remove('show');
}
}
scrollDownBtn.addEventListener('click', () => {
mainScroll.scrollBy({ top: mainScroll.clientHeight, left: 0, behavior: 'smooth' });
setTimeout(updateScrollIndicator, 320);
});
mainScroll.addEventListener('scroll', updateScrollIndicator);
window.addEventListener('resize', updateScrollIndicator);
// ===== COMPOSER LOCK =====
function setComposerLocked(locked) {
inputText.disabled = locked;
goBtn.disabled = locked;
stopBtn.disabled = !locked;
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 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);
showStatus('live', t('status.thinking', 'Thinking…'));
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; // symbol-by-symbol
flush();
}
}
flush();
showStatus('idle');
} catch (err) {
if (err.name !== 'AbortError') {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, `\n\n**${t('status.errorLabel', 'Error')}:** ${err.message || err}`);
showStatus('error', t('status.error', 'Error'));
} else {
if (assistantMsg.querySelector('img.thinking-cat')) {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, t('status.stopped', '_Stopped._'));
}
showStatus('idle');
}
} finally {
setComposerLocked(false);
controller = null;
updateScrollIndicator();
}
}
async function stop() {
if (controller) controller.abort();
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) {}
}
promptForm.addEventListener('submit', e => {
e.preventDefault();
if (!goBtn.disabled) start();
});
inputText.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && !goBtn.disabled) {
e.preventDefault();
start();
}
});
stopBtn.addEventListener('click', stop);
updateScrollIndicator();
</script>
</body>
</html>
<script>
// --- i18n setup ---
const DEFAULT_LOCALE = 'en';
const SUPPORTED = ['en', 'ru'];
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;
}
let I18N = {};
let CURRENT_LOCALE = detectLocale();
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 = 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');
setWelcomeImage();
}
// --- UI elements ---
const ENDPOINT = '/api/stream';
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif';
const CAT_STILL = '/static/images/cat-still.png';
const mainScroll = document.getElementById('mainScroll');
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 catIcon = document.getElementById('catIcon');
const welcomeLogo = document.getElementById('welcomeLogo');
let firstRequestDone = false;
let controller = null;
window.addEventListener('DOMContentLoaded', async () => {
await loadLocale(CURRENT_LOCALE);
inputText.focus();
});
// 🔑 Центральная функция: управление кнопками и плейсхолдером
function setComposerLocked(locked) {
inputText.disabled = locked;
goBtn.style.display = locked ? 'none' : 'inline-block';
stopBtn.style.display = locked ? 'inline-block' : 'none';
if (locked) {
// заменить плейсхолдер на "Thinking..."
inputText.placeholder = t('status.thinking','Thinking…');
inputText.blur();
} else {
// вернуть исходный плейсхолдер
inputText.placeholder = t('composer.placeholder','What can I help you with?');
inputText.focus();
}
}
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 isNearBottom() {
const threshold = 50;
return mainScroll.scrollHeight - mainScroll.scrollTop - mainScroll.clientHeight < threshold;
}
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 = '';
}
// --- основной цикл ---
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;
});
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const ch of chunk) {
rawMarkdown += ch;
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;
}
}
async function stop() {
if (controller) controller.abort();
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) {}
}
promptForm.addEventListener('submit', (e) => {
e.preventDefault();
if (goBtn.style.display !== 'none') start();
});
inputText.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault();
start();
}
});
stopBtn.addEventListener('click', stop);
</script>
<!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>
body { min-height: 100vh; display: flex; flex-direction: column; background-color: #f8f9fa; }
main { flex: 1 1 auto; overflow-y: auto; position: relative; }
header { position: sticky; top: 0; z-index: 1020; }
.composer { position: sticky; bottom: 0; z-index: 1020; background: #fff; border-top: 1px solid #dee2e6; }
.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; }
#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;
}
</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 -->
<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 -->
<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>
<!-- bottom status removed as requested -->
</div>
</form>
</div>
</div>
<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'];
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;
}
let I18N = {};
let CURRENT_LOCALE = detectLocale();
let isRunning = false; // <-- state flag
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) {
const desired = isRunning
? t('status.thinking', 'Thinking…')
: t('composer.placeholder', 'What can I help you with?');
if (inputText.placeholder !== desired) inputText.placeholder = desired;
}
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');
setWelcomeImage();
}
// --- UI elements ---
const ENDPOINT = '/api/stream';
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif';
const CAT_STILL = '/static/images/cat-still.png';
const mainScroll = document.getElementById('mainScroll');
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 catIconEl = document.getElementById('catIcon');
const welcomeLogo = document.getElementById('welcomeLogo');
let firstRequestDone = false;
let controller = null;
window.addEventListener('DOMContentLoaded', async () => {
await loadLocale(CURRENT_LOCALE);
inputText.placeholder = t('composer.placeholder','What can I help you with?');
inputText.focus();
});
// 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 isNearBottom() {
const threshold = 50;
return mainScroll.scrollHeight - mainScroll.scrollTop - mainScroll.clientHeight < threshold;
}
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 = '';
}
// Lock/unlock composer: toggle buttons + localized placeholder
function setComposerLocked(locked) {
isRunning = locked;
// Set placeholder first & force repaint
inputText.placeholder = locked
? t('status.thinking','Thinking…')
: t('composer.placeholder','What can I help you with?');
inputText.placeholder = inputText.placeholder; // nudge repaint
void inputText.offsetHeight;
// Toggle UI
inputText.disabled = locked;
goBtn.style.display = locked ? 'none' : 'inline-block';
stopBtn.style.display = locked ? 'inline-block' : 'none';
if (locked) inputText.blur();
else inputText.focus();
}
// Actions
async function start() {
const prompt = inputText.value.trim();
if (!prompt) return;
if (!firstRequestDone) { welcomeLogo?.remove(); firstRequestDone = true; }
const shouldAutoScroll = isNearBottom();
// user msg
const userMsg = document.createElement('div');
userMsg.className = 'message user-message';
userMsg.textContent = prompt;
thread.appendChild(userMsg);
// assistant placeholder
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;
});
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const ch of chunk) {
rawMarkdown += ch;
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;
}
}
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();
});
inputText.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault();
start();
}
});
document.getElementById('stopBtn').addEventListener('click', stop);
</script>
</body>
</html>
<!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>
body {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
}
main { flex: 1 1 auto; overflow-y: auto; position: relative; }
header { position: sticky; top: 0; z-index: 1020; }
.composer { position: sticky; bottom: 0; z-index: 1020; background: #fff; border-top: 1px solid #dee2e6; }
footer { 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; }
#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;
}
</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 -->
<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 -->
<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 -->
<footer id="disclaimer">
<!-- text filled by i18n -->
</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();
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.');
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');
let firstRequestDone = false;
let controller = null;
window.addEventListener('DOMContentLoaded', async () => {
await loadLocale(CURRENT_LOCALE);
inputText.focus();
});
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);
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);
});
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream:true });
for (const ch of chunk) { rawMarkdown += ch; 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;
}
}
async function stop() {
if (controller) controller.abort();
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) {}
}
promptForm.addEventListener('submit', e => {
e.preventDefault();
if (goBtn.style.display !== 'none') start();
});
inputText.addEventListener('keydown', e => {
if (e.key==='Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault();
start();
}
});
stopBtn.addEventListener('click', stop);
</script>
</body>
</html>
<!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>
body {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
margin: 0;
}
/* Make only main scrollable, with space for header+composer+footer */
main {
flex: 1 1 auto;
overflow-y: auto;
position: relative;
padding: 5rem 0 8rem; /* top space for header, bottom space for composer+footer */
}
/* Header fixed at top */
header {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 1020;
}
/* Composer fixed above footer */
.composer {
position: fixed;
bottom: 2rem; /* leave space for footer */
left: 0;
width: 100%;
z-index: 1020;
background: #fff;
border-top: 1px solid #dee2e6;
}
/* Footer fixed at very bottom */
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;
}
.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; }
#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;
}
/* --- Floating Scroll Button --- */
.scroll-fab {
position: fixed;
right: 24px;
bottom: 96px; /* sits above the sticky composer */
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: #0d6efd;
color: #fff;
display: none; /* toggled via JS */
align-items: center;
justify-content: center;
box-shadow: 0 8px 18px rgba(13,110,253,.35);
cursor: pointer;
z-index: 1030;
}
.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; }
.scroll-fab.pulse {
animation: scrollPulse 1.5s ease-in-out infinite;
}
@keyframes scrollPulse {
0% { transform: translateY(0); }
50% { transform: translateY(2px); }
100% { transform: translateY(0); }
}
</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 -->
<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>
<!-- Floating Scroll-to-Bottom Button -->
<button id="scrollFab" class="scroll-fab" type="button" aria-label="">
<!-- Down arrow icon -->
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<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>
</button>
<!-- 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 -->
<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();
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 scrollFab = document.getElementById('scrollFab');
if (scrollFab) {
scrollFab.setAttribute('aria-label', t('buttons.scrollDown','Scroll to latest'));
scrollFab.title = t('buttons.scrollDown','Scroll to latest');
}
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 scrollFab = document.getElementById('scrollFab');
let firstRequestDone = false;
let controller = null;
// --- Floating Scroll Button logic ---
const BOTTOM_EPS = 32; // pixels from bottom considered "at bottom"
function isOverflowing() {
return mainScroll.scrollHeight - mainScroll.clientHeight > 1;
}
function isNearBottom() {
return (mainScroll.scrollHeight - mainScroll.clientHeight - mainScroll.scrollTop) <= BOTTOM_EPS;
}
function updateScrollFabVisibility() {
if (isOverflowing() && !isNearBottom()) {
scrollFab.style.display = 'flex';
} else {
scrollFab.style.display = 'none';
scrollFab.classList.remove('pulse');
}
}
function scrollToBottom(smooth = true) {
mainScroll.scrollTo({ top: mainScroll.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
}
scrollFab.addEventListener('click', () => scrollToBottom(true));
mainScroll.addEventListener('scroll', updateScrollFabVisibility);
window.addEventListener('resize', updateScrollFabVisibility);
window.addEventListener('DOMContentLoaded', async () => {
try {
await fetch('/api/clear', { method: 'GET' });
} catch (err) { /* ignore */ }
await loadLocale(CURRENT_LOCALE);
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 {
scrollFab.style.display = 'flex';
scrollFab.classList.add('pulse');
}
updateScrollFabVisibility();
});
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream:true });
for (const ch of chunk) { rawMarkdown += ch; }
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;
updateScrollFabVisibility();
}
}
async function stop() {
if (controller) controller.abort();
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) {}
}
promptForm.addEventListener('submit', e => {
e.preventDefault();
if (goBtn.style.display !== 'none') start();
});
inputText.addEventListener('keydown', e => {
if (e.key==='Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault();
start();
}
});
stopBtn.addEventListener('click', stop);
</script>
</body>
</html>
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