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
53c0815c
Commit
53c0815c
authored
Aug 18, 2025
by
Michael Pastushkov
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
69378d52
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
964 additions
and
361 deletions
+964
-361
static/www/1.html
+476
-361
static/www/index copy.html
+488
-0
No files found.
static/www/1.html
View file @
53c0815c
<!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
{
--footer-h
:
2.75rem
;
/* fixed footer height (approx) */
/* tune these if footer/composer sizes change */
--composer-h
:
6.5rem
;
/* composer block height (approx) */
--footer-h
:
2.75rem
;
--bottom-pad
:
calc
(
var
(
--footer-h
)
+
var
(
--composer-h
)
+
1rem
);
/* fixed footer height (approx) */
}
--composer-h
:
6.5rem
;
body
{
/* composer block height (approx) */
min-height
:
100vh
;
--bottom-pad
:
calc
(
var
(
--footer-h
)
+
var
(
--composer-h
)
+
1rem
);
display
:
flex
;
}
flex-direction
:
column
;
background-color
:
#f8f9fa
;
body
{
margin
:
0
;
min-height
:
100vh
;
}
display
:
flex
;
header
{
position
:
sticky
;
top
:
0
;
z-index
:
1020
;
}
flex-direction
:
column
;
background-color
:
#f8f9fa
;
main
{
margin
:
0
;
flex
:
1
1
auto
;
}
overflow-y
:
auto
;
position
:
relative
;
header
{
padding-bottom
:
var
(
--bottom-pad
);
/* leave room for composer + fixed footer */
position
:
sticky
;
scroll-behavior
:
auto
;
/* avoid jitter during streaming, we’ll smooth when needed */
top
:
0
;
}
z-index
:
1020
;
}
.composer
{
position
:
sticky
;
main
{
bottom
:
var
(
--footer-h
);
/* sit just above the fixed footer */
flex
:
1
1
auto
;
z-index
:
1020
;
overflow-y
:
auto
;
background
:
#fff
;
position
:
relative
;
border-top
:
1px
solid
#dee2e6
;
padding-bottom
:
var
(
--bottom-pad
);
}
/* leave room for composer + fixed footer */
}
footer
{
position
:
fixed
;
bottom
:
0
;
left
:
0
;
width
:
100%
;
.composer
{
z-index
:
1020
;
font-size
:
.75rem
;
color
:
#6c757d
;
text-align
:
center
;
position
:
sticky
;
padding
:
.5rem
;
border-top
:
1px
solid
#dee2e6
;
background
:
#fdfdfd
;
bottom
:
var
(
--footer-h
);
height
:
var
(
--footer-h
);
box-sizing
:
border-box
;
/* sit just above the fixed footer */
}
z-index
:
1020
;
background
:
#fff
;
.cat-spinner
{
width
:
36px
;
height
:
36px
;
object-fit
:
contain
;
filter
:
drop-shadow
(
0
1px
2px
rgba
(
0
,
0
,
0
,
.35
));
}
border-top
:
1px
solid
#dee2e6
;
.thinking-cat
{
width
:
64px
;
height
:
64px
;
object-fit
:
contain
;
display
:
block
;
margin
:
.25rem
auto
;
}
}
.message
{
/* --- Footer from your snippet (fixed, non-scrollable) --- */
max-width
:
900px
;
margin
:
0
auto
1rem
auto
;
padding
:
1rem
1.25rem
;
border-radius
:
.75rem
;
footer
{
line-height
:
1.6
;
font-size
:
1rem
;
word-break
:
break-word
;
position
:
fixed
;
}
bottom
:
0
;
.user-message
{
background-color
:
#ffffff
;
border
:
1px
solid
#dee2e6
;
white-space
:
pre-wrap
;
}
left
:
0
;
.assistant-message
{
background-color
:
#f8f9fa
;
border
:
1px
solid
#dee2e6
;
}
width
:
100%
;
.assistant-message
h1
,
.assistant-message
h2
,
.assistant-message
h3
{
margin-top
:
1rem
;
margin-bottom
:
.6rem
;
}
z-index
:
1020
;
.assistant-message
p
{
margin
:
.5rem
0
;
}
font-size
:
.75rem
;
.assistant-message
code
{
background
:
#fff
;
border
:
1px
solid
#e9ecef
;
border-radius
:
.25rem
;
padding
:
.05rem
.35rem
;
}
color
:
#6c757d
;
.assistant-message
pre
code
{
display
:
block
;
padding
:
.75rem
;
}
text-align
:
center
;
.assistant-message
ul
,
.assistant-message
ol
{
padding-left
:
1.25rem
;
}
padding
:
.5rem
;
.assistant-message
table
{
width
:
100%
;
margin
:
1rem
0
;
}
border-top
:
1px
solid
#dee2e6
;
background
:
#fdfdfd
;
/* First-run logo layout */
height
:
var
(
--footer-h
);
#mainScroll
{
display
:
flex
;
flex-direction
:
column
;
position
:
relative
;
}
box-sizing
:
border-box
;
#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
;
}
.cat-spinner
{
/* (kept, but the button is unused by logic now) */
width
:
36px
;
#scrollDownBtn
{
display
:
none
;
}
height
:
36px
;
</style>
object-fit
:
contain
;
filter
:
drop-shadow
(
0
1px
2px
rgba
(
0
,
0
,
0
,
.35
));
}
.thinking-cat
{
width
:
64px
;
height
:
64px
;
object-fit
:
contain
;
display
:
block
;
margin
:
.25rem
auto
;
}
.message
{
max-width
:
900px
;
margin
:
0
auto
1rem
auto
;
padding
:
1rem
1.25rem
;
border-radius
:
.75rem
;
line-height
:
1.6
;
font-size
:
1rem
;
word-break
:
break-word
;
}
.user-message
{
background-color
:
#ffffff
;
border
:
1px
solid
#dee2e6
;
white-space
:
pre-wrap
;
}
.assistant-message
{
background-color
:
#f8f9fa
;
border
:
1px
solid
#dee2e6
;
}
.assistant-message
h1
,
.assistant-message
h2
,
.assistant-message
h3
{
margin-top
:
1rem
;
margin-bottom
:
.6rem
;
}
.assistant-message
p
{
margin
:
.5rem
0
;
}
.assistant-message
code
{
background
:
#fff
;
border
:
1px
solid
#e9ecef
;
border-radius
:
.25rem
;
padding
:
.05rem
.35rem
;
}
.assistant-message
pre
code
{
display
:
block
;
padding
:
.75rem
;
}
.assistant-message
ul
,
.assistant-message
ol
{
padding-left
:
1.25rem
;
}
.assistant-message
table
{
width
:
100%
;
margin
:
1rem
0
;
}
/* First-run logo layout */
#mainScroll
{
display
:
flex
;
flex-direction
:
column
;
position
:
relative
;
}
#welcomeLogo
{
flex
:
1
1
auto
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
user-select
:
none
;
}
#welcomeLogo
img
{
max-width
:
520px
;
width
:
100%
;
height
:
auto
;
filter
:
drop-shadow
(
0
6px
16px
rgba
(
0
,
0
,
0
,
.15
));
opacity
:
.98
;
}
/* Sticky in-content scroll indicator (same 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>
<!-- Sentinel used to detect if user is at the bottom -->
<!-- Sticky scroll indicator -->
<div
id=
"bottomSentinel"
style=
"height:1px;"
></div>
<button
id=
"scrollDownBtn"
type=
"button"
aria-label=
"Scroll down"
>
<svg
viewBox=
"0 0 24 24"
fill=
"currentColor"
aria-hidden=
"true"
>
<!-- (Unused button) -->
<path
<button
id=
"scrollDownBtn"
type=
"button"
aria-label=
"Scroll down"
></button>
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"
/>
</main>
</svg>
</button>
<!-- Composer (sticks above fixed footer) -->
</main>
<div
class=
"composer py-3"
>
<div
class=
"container"
>
<!-- Composer (sticks above fixed footer) -->
<form
id=
"promptForm"
class=
"d-flex flex-column gap-2"
>
<div
class=
"composer py-3"
>
<textarea
id=
"inputText"
class=
"form-control"
rows=
"1"
placeholder=
""
></textarea>
<div
class=
"container"
>
<div
class=
"d-flex align-items-center gap-2"
>
<form
id=
"promptForm"
class=
"d-flex flex-column gap-2"
>
<button
id=
"goBtn"
class=
"btn btn-primary"
type=
"submit"
></button>
<textarea
id=
"inputText"
class=
"form-control"
rows=
"1"
placeholder=
""
></textarea>
<button
id=
"stopBtn"
class=
"btn btn-outline-danger"
type=
"button"
style=
"display:none"
></button>
<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) -->
<!-- Fixed, non-scrollable footer -->
<footer
id=
"disclaimer"
></footer>
<footer
id=
"disclaimer"
></footer>
<script
src=
"https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"
></script>
<script
src=
"https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"
></script>
<script
src=
"https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"
></script>
<script
src=
"https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"
></script>
<script>
<script>
// --- i18n setup (unchanged) ---
// --- i18n setup (unchanged) ---
const
DEFAULT_LOCALE
=
'en'
;
const
DEFAULT_LOCALE
=
'en'
;
const
SUPPORTED
=
[
'en'
,
'ru'
];
const
SUPPORTED
=
[
'en'
,
'ru'
];
let
I18N
=
{};
let
I18N
=
{};
let
CURRENT_LOCALE
=
detectLocale
();
let
CURRENT_LOCALE
=
detectLocale
();
let
isRunning
=
false
;
let
isRunning
=
false
;
function
getLangFromQuery
()
{
function
getLangFromQuery
()
{
const
m
=
location
.
search
.
match
(
/
[
?&
]
lang=
(
en|ru
)\b
/i
);
const
m
=
location
.
search
.
match
(
/
[
?&
]
lang=
(
en|ru
)\b
/i
);
return
m
?
m
[
1
].
toLowerCase
()
:
null
;
return
m
?
m
[
1
].
toLowerCase
()
:
null
;
}
}
function
detectLocale
()
{
function
detectLocale
()
{
const
q
=
getLangFromQuery
();
const
q
=
getLangFromQuery
();
if
(
q
&&
SUPPORTED
.
includes
(
q
))
return
q
;
if
(
q
&&
SUPPORTED
.
includes
(
q
))
return
q
;
const
nav
=
(
navigator
.
language
||
''
).
toLowerCase
();
const
nav
=
(
navigator
.
language
||
''
).
toLowerCase
();
if
(
nav
.
startsWith
(
'ru'
))
return
'ru'
;
if
(
nav
.
startsWith
(
'ru'
))
return
'ru'
;
return
DEFAULT_LOCALE
;
return
DEFAULT_LOCALE
;
}
}
async
function
loadLocale
(
locale
)
{
async
function
loadLocale
(
locale
)
{
try
{
try
{
const
res
=
await
fetch
(
`/locales/
${
locale
}
.json`
,
{
cache
:
'no-store'
});
const
res
=
await
fetch
(
`/locales/
${
locale
}
.json`
,
{
cache
:
'no-store'
});
if
(
!
res
.
ok
)
throw
new
Error
(
'HTTP '
+
res
.
status
);
if
(
!
res
.
ok
)
throw
new
Error
(
'HTTP '
+
res
.
status
);
I18N
=
await
res
.
json
();
I18N
=
await
res
.
json
();
CURRENT_LOCALE
=
locale
;
CURRENT_LOCALE
=
locale
;
document
.
documentElement
.
lang
=
locale
;
document
.
documentElement
.
lang
=
locale
;
applyI18n
();
applyI18n
();
}
catch
(
e
)
{
}
catch
(
e
)
{
if
(
locale
!==
DEFAULT_LOCALE
)
await
loadLocale
(
DEFAULT_LOCALE
);
if
(
locale
!==
DEFAULT_LOCALE
)
await
loadLocale
(
DEFAULT_LOCALE
);
}
}
}
}
function
t
(
key
,
fallback
=
''
)
{
function
t
(
key
,
fallback
=
''
)
{
const
parts
=
key
.
split
(
'.'
);
const
parts
=
key
.
split
(
'.'
);
let
cur
=
I18N
;
let
cur
=
I18N
;
for
(
const
p
of
parts
)
{
for
(
const
p
of
parts
)
{
if
(
cur
&&
Object
.
prototype
.
hasOwnProperty
.
call
(
cur
,
p
))
cur
=
cur
[
p
];
if
(
cur
&&
Object
.
prototype
.
hasOwnProperty
.
call
(
cur
,
p
))
cur
=
cur
[
p
];
else
return
fallback
||
key
;
else
return
fallback
||
key
;
}
}
return
(
typeof
cur
===
'string'
)
?
cur
:
(
fallback
||
key
);
return
(
typeof
cur
===
'string'
)
?
cur
:
(
fallback
||
key
);
}
}
function
setWelcomeImage
()
{
function
setWelcomeImage
()
{
const
welcomeImg
=
document
.
getElementById
(
'welcomeImg'
);
const
welcomeImg
=
document
.
getElementById
(
'welcomeImg'
);
if
(
!
welcomeImg
)
return
;
if
(
!
welcomeImg
)
return
;
const
srcByLocale
=
{
const
srcByLocale
=
{
en
:
'/static/images/ask-a-cat-en.png'
,
en
:
'/static/images/ask-a-cat-en.png'
,
ru
:
'/static/images/ask-a-cat-ru.png'
ru
:
'/static/images/ask-a-cat-ru.png'
};
};
welcomeImg
.
src
=
srcByLocale
[
CURRENT_LOCALE
]
||
srcByLocale
.
en
;
welcomeImg
.
src
=
srcByLocale
[
CURRENT_LOCALE
]
||
srcByLocale
.
en
;
}
// If we’re sticking to bottom, ensure any img load doesn’t break it
function
applyI18n
()
{
welcomeImg
.
addEventListener
(
'load'
,
()
=>
{
if
(
autoStick
)
scrollToBottom
();
},
{
once
:
true
});
document
.
title
=
t
(
'app.title'
,
'KotGPT'
);
}
const
catIcon
=
document
.
getElementById
(
'catIcon'
);
function
applyI18n
()
{
if
(
catIcon
)
catIcon
.
alt
=
t
(
'header.logoAlt'
,
'Cat logo'
);
document
.
title
=
t
(
'app.title'
,
'KotGPT'
);
const
welcomeImg
=
document
.
getElementById
(
'welcomeImg'
);
const
catIcon
=
document
.
getElementById
(
'catIcon'
);
if
(
welcomeImg
)
welcomeImg
.
alt
=
t
(
'main.welcomeAlt'
,
'Ask a Cat'
);
if
(
catIcon
)
catIcon
.
alt
=
t
(
'header.logoAlt'
,
'Cat logo'
);
const
inputText
=
document
.
getElementById
(
'inputText'
);
const
welcomeImg
=
document
.
getElementById
(
'welcomeImg'
);
if
(
inputText
)
{
if
(
welcomeImg
)
welcomeImg
.
alt
=
t
(
'main.welcomeAlt'
,
'Ask a Cat'
);
inputText
.
placeholder
=
isRunning
const
inputText
=
document
.
getElementById
(
'inputText'
);
?
t
(
'status.thinking'
,
'Thinking…'
)
if
(
inputText
)
{
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
inputText
.
placeholder
=
isRunning
}
?
t
(
'status.thinking'
,
'Thinking…'
)
const
goBtn
=
document
.
getElementById
(
'goBtn'
);
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
if
(
goBtn
)
goBtn
.
textContent
=
t
(
'buttons.go'
,
'Go'
);
}
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
const
goBtn
=
document
.
getElementById
(
'goBtn'
);
if
(
stopBtn
)
stopBtn
.
textContent
=
t
(
'buttons.stop'
,
'Stop'
);
if
(
goBtn
)
goBtn
.
textContent
=
t
(
'buttons.go'
,
'Go'
);
const
disclaimer
=
document
.
getElementById
(
'disclaimer'
);
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
if
(
disclaimer
)
disclaimer
.
textContent
=
t
(
'footer.disclaimer'
,
'Beware: Cat can make mistakes.'
);
if
(
stopBtn
)
stopBtn
.
textContent
=
t
(
'buttons.stop'
,
'Stop'
);
const
scrollDownBtn
=
document
.
getElementById
(
'scrollDownBtn'
);
const
disclaimer
=
document
.
getElementById
(
'disclaimer'
);
if
(
scrollDownBtn
)
{
if
(
disclaimer
)
disclaimer
.
textContent
=
t
(
'footer.disclaimer'
,
'Beware: Cat can make mistakes.'
);
scrollDownBtn
.
setAttribute
(
'aria-label'
,
t
(
'buttons.scrollDown'
,
'Scroll down'
));
setWelcomeImage
();
scrollDownBtn
.
title
=
t
(
'buttons.scrollDown'
,
'Scroll down'
);
if
(
autoStick
)
scrollToBottom
();
}
}
setWelcomeImage
();
}
// --- UI refs ---
const
ENDPOINT
=
'/api/stream'
;
// --- UI refs ---
const
STOPPOINT
=
'/api/stop'
;
const
ENDPOINT
=
'/api/stream'
;
const
CAT_GIF
=
'/static/images/cat-progress.gif'
;
const
STOPPOINT
=
'/api/stop'
;
const
thread
=
document
.
getElementById
(
'thread'
);
const
CAT_GIF
=
'/static/images/cat-progress.gif'
;
const
promptForm
=
document
.
getElementById
(
'promptForm'
);
const
thread
=
document
.
getElementById
(
'thread'
);
const
inputText
=
document
.
getElementById
(
'inputText'
);
const
promptForm
=
document
.
getElementById
(
'promptForm'
);
const
goBtn
=
document
.
getElementById
(
'goBtn'
);
const
inputText
=
document
.
getElementById
(
'inputText'
);
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
const
goBtn
=
document
.
getElementById
(
'goBtn'
);
const
welcomeLogo
=
document
.
getElementById
(
'welcomeLogo'
);
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
const
mainScroll
=
document
.
getElementById
(
'mainScroll'
);
const
welcomeLogo
=
document
.
getElementById
(
'welcomeLogo'
);
const
bottomSentinel
=
document
.
getElementById
(
'bottomSentinel'
);
const
mainScroll
=
document
.
getElementById
(
'mainScroll'
);
const
scrollDownBtn
=
document
.
getElementById
(
'scrollDownBtn'
);
let
firstRequestDone
=
false
;
let
controller
=
null
;
let
firstRequestDone
=
false
;
let
controller
=
null
;
// --- Stick-to-bottom logic ---
let
autoStick
=
true
;
// follow the bottom unless user scrolls up
// --- helpers ---
function
scrollToBottom
(
smooth
=
false
)
{
function
renderMarkdown
(
targetEl
,
markdownText
)
{
if
(
!
mainScroll
)
return
;
const
html
=
marked
.
parse
(
markdownText
,
{
breaks
:
true
,
gfm
:
true
,
headerIds
:
false
});
// Use instant scroll during streaming to avoid stutter; smooth for jumps like new send
targetEl
.
innerHTML
=
DOMPurify
.
sanitize
(
html
,
{
USE_PROFILES
:
{
html
:
true
}
});
if
(
smooth
)
{
}
mainScroll
.
scrollTo
({
top
:
mainScroll
.
scrollHeight
,
behavior
:
'smooth'
});
function
showThinkingCat
(
targetEl
)
{
}
else
{
targetEl
.
innerHTML
=
''
;
mainScroll
.
scrollTop
=
mainScroll
.
scrollHeight
;
const
img
=
document
.
createElement
(
'img'
);
}
img
.
className
=
'thinking-cat'
;
}
img
.
src
=
CAT_GIF
+
'?t='
+
Date
.
now
();
img
.
alt
=
t
(
'status.thinking'
,
'Thinking…'
);
// Observe if sentinel is visible to determine if we’re at bottom
targetEl
.
appendChild
(
img
);
const
observer
=
new
IntersectionObserver
((
entries
)
=>
{
}
for
(
const
entry
of
entries
)
{
function
clearThinkingCatIfPresent
(
targetEl
)
{
// If sentinel visible, we are at bottom -> enable autoStick
const
img
=
targetEl
.
querySelector
(
'img.thinking-cat'
);
if
(
entry
.
target
===
bottomSentinel
)
{
if
(
img
)
targetEl
.
innerHTML
=
''
;
autoStick
=
entry
.
isIntersecting
;
}
}
}
// --- Scroll indicator (same behavior as before) ---
},
{
root
:
mainScroll
,
threshold
:
1.0
});
function
needsScroll
()
{
return
mainScroll
.
scrollHeight
-
mainScroll
.
clientHeight
>
8
;
}
observer
.
observe
(
bottomSentinel
);
function
atBottom
()
{
return
mainScroll
.
scrollTop
+
mainScroll
.
clientHeight
>=
mainScroll
.
scrollHeight
-
50
;
}
function
updateScrollIndicator
()
{
// When user scrolls up manually, autoStick will be turned off by observer.
if
(
needsScroll
()
&&
!
atBottom
())
scrollDownBtn
.
classList
.
add
(
'show'
);
// When they scroll back down (sentinel visible), it turns back on automatically.
else
scrollDownBtn
.
classList
.
remove
(
'show'
);
}
// --- helpers ---
scrollDownBtn
.
addEventListener
(
'click'
,
()
=>
{
function
renderMarkdown
(
targetEl
,
markdownText
)
{
mainScroll
.
scrollBy
({
top
:
mainScroll
.
clientHeight
,
behavior
:
'smooth'
});
const
html
=
marked
.
parse
(
markdownText
,
{
breaks
:
true
,
gfm
:
true
,
headerIds
:
false
});
setTimeout
(
updateScrollIndicator
,
320
);
targetEl
.
innerHTML
=
DOMPurify
.
sanitize
(
html
,
{
USE_PROFILES
:
{
html
:
true
}
});
// If the assistant content produced
<
img
>
,
make
sure
their
eventual
load
keeps
us
at
bottom
.
const
imgs
=
targetEl
.
querySelectorAll
(
'img'
);
imgs
.
forEach
(
img
=>
{
img
.
addEventListener
(
'load'
,
()
=>
{
if
(
autoStick
)
scrollToBottom
();
});
});
}
function
showThinkingCat
(
targetEl
)
{
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
);
if
(
autoStick
)
scrollToBottom
();
}
function
clearThinkingCatIfPresent
(
targetEl
)
{
const
img
=
targetEl
.
querySelector
(
'img.thinking-cat'
);
if
(
img
)
targetEl
.
innerHTML
=
''
;
}
// --- 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
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
);
// New message -> we want a smooth jump to the end
if
(
autoStick
)
scrollToBottom
(
true
);
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
}
`
);
mainScroll
.
addEventListener
(
'scroll'
,
updateScrollIndicator
);
window
.
addEventListener
(
'resize'
,
updateScrollIndicator
);
const
reader
=
res
.
body
.
getReader
();
const
decoder
=
new
TextDecoder
();
// --- Composer lock ---
let
rawMarkdown
=
''
;
function
setComposerLocked
(
locked
)
{
let
receivedAny
=
false
;
isRunning
=
locked
;
let
rafPending
=
false
;
inputText
.
placeholder
=
locked
?
t
(
'status.thinking'
,
'Thinking…'
)
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
inputText
.
disabled
=
locked
;
const
flush
=
()
=>
{
goBtn
.
style
.
display
=
locked
?
'none'
:
'inline-block'
;
if
(
rafPending
)
return
;
stopBtn
.
style
.
display
=
locked
?
'inline-block'
:
'none'
;
rafPending
=
true
;
if
(
locked
)
inputText
.
blur
();
else
inputText
.
focus
();
requestAnimationFrame
(()
=>
{
}
rafPending
=
false
;
if
(
!
receivedAny
)
{
clearThinkingCatIfPresent
(
assistantMsg
);
receivedAny
=
true
;
}
// --- Flow ---
renderMarkdown
(
assistantMsg
,
rawMarkdown
);
async
function
start
()
{
if
(
autoStick
)
scrollToBottom
();
// keep stuck during streaming
const
prompt
=
inputText
.
value
.
trim
();
});
if
(
!
prompt
)
return
;
};
if
(
!
firstRequestDone
)
{
welcomeLogo
?.
remove
();
firstRequestDone
=
true
;
}
for
(;;)
{
const
{
value
,
done
}
=
await
reader
.
read
();
const
shouldAutoScroll
=
atBottom
();
if
(
done
)
break
;
const
chunk
=
decoder
.
decode
(
value
,
{
stream
:
true
});
const
userMsg
=
document
.
createElement
(
'div'
);
// char-by-char accumulation to mirror your behavior
userMsg
.
className
=
'message user-message'
;
for
(
const
ch
of
chunk
)
rawMarkdown
+=
ch
;
userMsg
.
textContent
=
prompt
;
flush
();
thread
.
appendChild
(
userMsg
);
}
flush
();
const
assistantMsg
=
document
.
createElement
(
'div'
);
}
catch
(
err
)
{
assistantMsg
.
className
=
'message assistant-message'
;
if
(
err
.
name
!==
'AbortError'
)
{
showThinkingCat
(
assistantMsg
);
clearThinkingCatIfPresent
(
assistantMsg
);
thread
.
appendChild
(
assistantMsg
);
renderMarkdown
(
assistantMsg
,
`\n\n**
${
t
(
'status.errorLabel'
,
'Error'
)}
:**
${
err
.
message
||
err
}
`
);
}
else
{
if
(
shouldAutoScroll
)
assistantMsg
.
scrollIntoView
({
behavior
:
'smooth'
,
block
:
'start'
});
if
(
assistantMsg
.
querySelector
(
'img.thinking-cat'
))
{
clearThinkingCatIfPresent
(
assistantMsg
);
inputText
.
value
=
''
;
renderMarkdown
(
assistantMsg
,
t
(
'status.stopped'
,
'_Stopped._'
));
controller
=
new
AbortController
();
}
setComposerLocked
(
true
);
}
}
finally
{
try
{
setComposerLocked
(
false
);
const
res
=
await
fetch
(
ENDPOINT
,
{
controller
=
null
;
method
:
'POST'
,
if
(
autoStick
)
scrollToBottom
();
headers
:
{
'Content-Type'
:
'application/json'
},
}
body
:
JSON
.
stringify
({
content
:
prompt
}),
}
signal
:
controller
.
signal
});
async
function
stop
()
{
if
(
!
res
.
ok
||
!
res
.
body
)
throw
new
Error
(
`HTTP
${
res
.
status
}
`
);
if
(
controller
)
controller
.
abort
();
try
{
await
fetch
(
STOPPOINT
,
{
method
:
'POST'
});
}
catch
(
_
)
{
}
const
reader
=
res
.
body
.
getReader
();
}
const
decoder
=
new
TextDecoder
();
let
rawMarkdown
=
''
;
// Events
let
receivedAny
=
false
;
promptForm
.
addEventListener
(
'submit'
,
e
=>
{
let
rafPending
=
false
;
e
.
preventDefault
();
if
(
goBtn
.
style
.
display
!==
'none'
)
start
();
const
flush
=
()
=>
{
});
if
(
rafPending
)
return
;
inputText
.
addEventListener
(
'keydown'
,
e
=>
{
rafPending
=
true
;
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
&&
goBtn
.
style
.
display
!==
'none'
)
{
requestAnimationFrame
(()
=>
{
e
.
preventDefault
();
rafPending
=
false
;
start
();
if
(
!
receivedAny
)
{
clearThinkingCatIfPresent
(
assistantMsg
);
receivedAny
=
true
;
}
}
renderMarkdown
(
assistantMsg
,
rawMarkdown
);
});
if
(
shouldAutoScroll
&&
atBottom
())
{
document
.
getElementById
(
'stopBtn'
).
addEventListener
(
'click'
,
stop
);
mainScroll
.
scrollTop
=
mainScroll
.
scrollHeight
;
}
// Keep to bottom on container resizes (e.g., window size changes)
updateScrollIndicator
();
window
.
addEventListener
(
'resize'
,
()
=>
{
if
(
autoStick
)
scrollToBottom
();
});
});
};
// 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
;
inputText
.
focus
();
const
chunk
=
decoder
.
decode
(
value
,
{
stream
:
true
});
// First paint, ensure bottom (welcome image can be tall)
for
(
const
ch
of
chunk
)
rawMarkdown
+=
ch
;
// char-by-char
if
(
autoStick
)
scrollToBottom
();
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
;
updateScrollIndicator
();
}
}
async
function
stop
()
{
if
(
controller
)
controller
.
abort
();
try
{
await
fetch
(
STOPPOINT
,
{
method
:
'POST'
});
}
catch
(
_
)
{
}
}
// Events
document
.
getElementById
(
'promptForm'
).
addEventListener
(
'submit'
,
e
=>
{
e
.
preventDefault
();
if
(
goBtn
.
style
.
display
!==
'none'
)
start
();
});
document
.
getElementById
(
'inputText'
).
addEventListener
(
'keydown'
,
e
=>
{
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
&&
goBtn
.
style
.
display
!==
'none'
)
{
e
.
preventDefault
();
start
();
}
});
document
.
getElementById
(
'stopBtn'
).
addEventListener
(
'click'
,
stop
);
// Init
window
.
addEventListener
(
'DOMContentLoaded'
,
async
()
=>
{
try
{
await
fetch
(
'/api/clear'
,
{
method
:
'GET'
});
}
catch
(
_
)
{
}
await
loadLocale
(
CURRENT_LOCALE
);
document
.
getElementById
(
'inputText'
).
focus
();
updateScrollIndicator
();
});
</script>
</body>
</body>
</html>
</html>
\ No newline at end of file
static/www/index copy.html
0 → 100644
View file @
53c0815c
<!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
;
}
/* --- Footer from your snippet (fixed, non-scrollable) --- */
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 (same behavior as the earlier file) */
#scrollDownBtn
{
position
:
sticky
;
margin-left
:
auto
;
bottom
:
1rem
;
align-self
:
flex-end
;
right
:
1rem
;
transform
:
translate
(
-1rem
,
0
);
z-index
:
1030
;
display
:
inline-flex
;
/*
<---
force
visible
*/
align-items
:
center
;
justify-content
:
center
;
border
:
none
;
border-radius
:
999px
;
width
:
36px
;
height
:
36px
;
background
:
rgba
(
13
,
110
,
253
,
.95
);
color
:
#fff
;
box-shadow
:
0
4px
14px
rgba
(
0
,
0
,
0
,
.18
);
cursor
:
pointer
;
transition
:
opacity
.18s
ease-in-out
;
opacity
:
.92
;
}
#scrollDownBtn
:hover
{
opacity
:
1
;
}
#scrollDownBtn
svg
{
width
:
22px
;
height
:
22px
;
pointer-events
:
none
;
}
#scrollDownBtn
.show
{
display
:
inline-flex
;
align-items
:
center
;
justify-content
:
center
;
}
</style>
</head>
<body>
<!-- Header -->
<header
class=
"py-3 bg-dark text-white shadow-sm"
>
<div
class=
"container d-flex justify-content-between align-items-center"
>
<div
class=
"d-flex align-items-center gap-2"
>
<img
id=
"catIcon"
class=
"cat-spinner"
src=
"/static/images/cat-still.png"
alt=
"Cat logo"
/>
<h1
class=
"h4 mb-0"
>
KotGPT
</h1>
</div>
</div>
</header>
<!-- Conversation (scrollable) -->
<main
class=
"container my-3"
id=
"mainScroll"
>
<div
id=
"welcomeLogo"
>
<img
id=
"welcomeImg"
src=
"/static/images/ask-a-cat-en.png"
alt=
"Ask a Cat"
>
</div>
<div
id=
"thread"
></div>
<!-- Sticky scroll indicator -->
<button
id=
"scrollDownBtn"
type=
"button"
aria-label=
"Scroll down"
>
<svg
viewBox=
"0 0 24 24"
fill=
"currentColor"
aria-hidden=
"true"
>
<path
d=
"M12 16a1 1 0 0 1-.7-.29l-6-6a1 1 0 1 1 1.4-1.42L12 13.59l5.3-5.3a1 1 0 0 1 1.4 1.42l-6 6A1 1 0 0 1 12 16z"
/>
</svg>
</button>
</main>
<!-- Composer (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>
<!-- Fixed, non-scrollable footer (from your snippet) -->
<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 (unchanged) ---
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 (same behavior as before) ---
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
);
// --- 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
shouldAutoScroll
=
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
(
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