Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
K
KotGPT
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Michael Pastushkov
KotGPT
Commits
fad46d13
Commit
fad46d13
authored
Aug 18, 2025
by
michaelpastushkov
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
6e4cee86
Show whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
492 additions
and
0 deletions
+492
-0
static/www/1.html
+492
-0
No files found.
static/www/1.html
0 → 100644
View file @
fad46d13
<!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
;
overflow-y
:
auto
;
position
:
relative
;
padding-bottom
:
var
(
--bottom-pad
);
/* leave room for composer + fixed footer */
}
.composer
{
position
:
sticky
;
bottom
:
var
(
--footer-h
);
/* sit just above the fixed footer */
z-index
:
1020
;
background
:
#fff
;
border-top
:
1px
solid
#dee2e6
;
}
/* --- Fixed, non-scrollable footer --- */
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-color
:
#ffffff
;
border
:
1px
solid
#dee2e6
;
white-space
:
pre-wrap
;
}
.assistant-message
{
background-color
:
#f8f9fa
;
border
:
1px
solid
#dee2e6
;
}
.assistant-message
h1
,
.assistant-message
h2
,
.assistant-message
h3
{
margin-top
:
1rem
;
margin-bottom
:
.6rem
;
}
.assistant-message
p
{
margin
:
.5rem
0
;
}
.assistant-message
code
{
background
:
#fff
;
border
:
1px
solid
#e9ecef
;
border-radius
:
.25rem
;
padding
:
.05rem
.35rem
;
}
.assistant-message
pre
code
{
display
:
block
;
padding
:
.75rem
;
}
.assistant-message
ul
,
.assistant-message
ol
{
padding-left
:
1.25rem
;
}
.assistant-message
table
{
width
:
100%
;
margin
:
1rem
0
;
}
/* First-run logo layout */
#mainScroll
{
display
:
flex
;
flex-direction
:
column
;
position
:
relative
;
}
#welcomeLogo
{
flex
:
1
1
auto
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
user-select
:
none
;
}
#welcomeLogo
img
{
max-width
:
520px
;
width
:
100%
;
height
:
auto
;
filter
:
drop-shadow
(
0
6px
16px
rgba
(
0
,
0
,
0
,
.15
));
opacity
:
.98
;
}
/* Sticky in-content scroll indicator (fixed to avoid overlap with composer/footer) */
#scrollDownBtn
{
position
:
fixed
;
/* changed from sticky to ensure visibility */
right
:
1rem
;
bottom
:
calc
(
var
(
--footer-h
)
+
var
(
--composer-h
)
+
1rem
);
z-index
:
1031
;
/* above composer/footer */
display
:
none
;
border
:
none
;
border-radius
:
999px
;
width
:
40px
;
height
:
40px
;
background
:
rgba
(
13
,
110
,
253
,
.95
);
color
:
#fff
;
box-shadow
:
0
4px
14px
rgba
(
0
,
0
,
0
,
.18
);
cursor
:
pointer
;
transition
:
opacity
.18s
ease-in-out
;
opacity
:
.92
;
}
#scrollDownBtn
:hover
{
opacity
:
1
;
}
#scrollDownBtn
svg
{
width
:
22px
;
height
:
22px
;
pointer-events
:
none
;
}
#scrollDownBtn
.show
{
display
:
inline-flex
;
align-items
:
center
;
justify-content
:
center
;
}
</style>
</head>
<body>
<!-- Header -->
<header
class=
"py-3 bg-dark text-white shadow-sm"
>
<div
class=
"container d-flex justify-content-between align-items-center"
>
<div
class=
"d-flex align-items-center gap-2"
>
<img
id=
"catIcon"
class=
"cat-spinner"
src=
"/static/images/cat-still.png"
alt=
"Cat logo"
/>
<h1
class=
"h4 mb-0"
>
KotGPT
</h1>
</div>
</div>
</header>
<!-- Conversation (scrollable) -->
<main
class=
"container my-3"
id=
"mainScroll"
>
<div
id=
"welcomeLogo"
>
<img
id=
"welcomeImg"
src=
"/static/images/ask-a-cat-en.png"
alt=
"Ask a Cat"
>
</div>
<div
id=
"thread"
></div>
</main>
<!-- Fixed scroll-to-bottom button -->
<button
id=
"scrollDownBtn"
type=
"button"
aria-label=
"Scroll down"
>
<svg
viewBox=
"0 0 24 24"
fill=
"currentColor"
aria-hidden=
"true"
>
<path
d=
"M12 16a1 1 0 0 1-.7-.29l-6-6a1 1 0 1 1 1.4-1.42L12 13.59l5.3-5.3a1 1 0 0 1 1.4 1.42l-6 6A1 1 0 0 1 12 16z"
/>
</svg>
</button>
<!-- 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=
"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>
<!-- 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
();
if
(
nav
.
startsWith
(
'ru'
))
return
'ru'
;
return
DEFAULT_LOCALE
;
}
async
function
loadLocale
(
locale
)
{
try
{
const
res
=
await
fetch
(
\
`/locales/\${locale}.json\`, { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
I18N = await res.json();
CURRENT_LOCALE = locale;
document.documentElement.lang = locale;
applyI18n();
} catch (e) {
if (locale !== DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE);
}
}
function t(key, fallback='') {
const parts = key.split('.');
let cur = I18N;
for (const p of parts) {
if (cur && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
else return fallback || key;
}
return (typeof cur === 'string') ? cur : (fallback || key);
}
function setWelcomeImage() {
const welcomeImg = document.getElementById('welcomeImg');
if (!welcomeImg) return;
const srcByLocale = {
en: '/static/images/ask-a-cat-en.png',
ru: '/static/images/ask-a-cat-ru.png'
};
welcomeImg.src = srcByLocale[CURRENT_LOCALE] || srcByLocale.en;
}
function applyI18n() {
document.title = t('app.title', 'KotGPT');
const catIcon = document.getElementById('catIcon');
if (catIcon) catIcon.alt = t('header.logoAlt', 'Cat logo');
const welcomeImg = document.getElementById('welcomeImg');
if (welcomeImg) welcomeImg.alt = t('main.welcomeAlt', 'Ask a Cat');
const inputText = document.getElementById('inputText');
if (inputText) {
inputText.placeholder = isRunning
? t('status.thinking', 'Thinking…')
: t('composer.placeholder','What can I help you with?');
}
const goBtn = document.getElementById('goBtn');
if (goBtn) goBtn.textContent = t('buttons.go','Go');
const stopBtn = document.getElementById('stopBtn');
if (stopBtn) stopBtn.textContent = t('buttons.stop','Stop');
const disclaimer = document.getElementById('disclaimer');
if (disclaimer) disclaimer.textContent = t('footer.disclaimer','Beware: Cat can make mistakes.');
const scrollDownBtn = document.getElementById('scrollDownBtn');
if (scrollDownBtn) {
scrollDownBtn.setAttribute('aria-label', t('buttons.scrollDown','Scroll down'));
scrollDownBtn.title = t('buttons.scrollDown','Scroll down');
}
setWelcomeImage();
}
// --- UI refs ---
const ENDPOINT = '/api/stream';
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif';
const thread = document.getElementById('thread');
const promptForm = document.getElementById('promptForm');
const inputText = document.getElementById('inputText');
const goBtn = document.getElementById('goBtn');
const stopBtn = document.getElementById('stopBtn');
const welcomeLogo = document.getElementById('welcomeLogo');
const mainScroll = document.getElementById('mainScroll');
const scrollDownBtn = document.getElementById('scrollDownBtn');
let firstRequestDone = false;
let controller = null;
// --- helpers ---
function renderMarkdown(targetEl, markdownText) {
const html = marked.parse(markdownText, { breaks:true, gfm:true, headerIds:false });
targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES:{ html:true } });
}
function showThinkingCat(targetEl) {
targetEl.innerHTML = '';
const img = document.createElement('img');
img.className = 'thinking-cat';
img.src = CAT_GIF + '?t=' + Date.now();
img.alt = t('status.thinking','Thinking…');
targetEl.appendChild(img);
}
function clearThinkingCatIfPresent(targetEl) {
const img = targetEl.querySelector('img.thinking-cat');
if (img) targetEl.innerHTML = '';
}
// --- Scroll indicator ---
function needsScroll() { return mainScroll.scrollHeight - mainScroll.clientHeight > 8; }
function atBottom() { return mainScroll.scrollTop + mainScroll.clientHeight >= mainScroll.scrollHeight - 50; }
function updateScrollIndicator() {
if (needsScroll() && !atBottom()) scrollDownBtn.classList.add('show');
else scrollDownBtn.classList.remove('show');
}
scrollDownBtn.addEventListener('click', () => {
mainScroll.scrollBy({ top: mainScroll.clientHeight, behavior: 'smooth' });
setTimeout(updateScrollIndicator, 320);
});
mainScroll.addEventListener('scroll', updateScrollIndicator);
window.addEventListener('resize', updateScrollIndicator);
// Observe dynamic changes to keep indicator in sync
const ro = new ResizeObserver(updateScrollIndicator);
ro.observe(mainScroll);
ro.observe(thread);
mainScroll.addEventListener('load', updateScrollIndicator, true);
// --- 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; updateScrollIndicator(); }
const shouldAutoScroll = atBottom();
const userMsg = document.createElement('div');
userMsg.className = 'message user-message';
userMsg.textContent = prompt;
thread.appendChild(userMsg);
updateScrollIndicator();
const assistantMsg = document.createElement('div');
assistantMsg.className = 'message assistant-message';
showThinkingCat(assistantMsg);
thread.appendChild(assistantMsg);
updateScrollIndicator();
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 (e.key==='Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault();
start();
}
});
document.getElementById('stopBtn').addEventListener('click', stop);
// Init
window.addEventListener('DOMContentLoaded', async () => {
try { await fetch('/api/clear', { method: 'GET' }); } catch (_) {}
await loadLocale(CURRENT_LOCALE);
document.getElementById('inputText').focus();
updateScrollIndicator();
});
</script>
</body>
</html>
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment