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
8a98c180
Commit
8a98c180
authored
Aug 18, 2025
by
Michael Pastushkov
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
16bfe12c
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
127 additions
and
199 deletions
+127
-199
static/www/1.html
+127
-199
No files found.
static/www/1.html
View file @
8a98c180
...
@@ -7,52 +7,35 @@
...
@@ -7,52 +7,35 @@
<link
href=
"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel=
"stylesheet"
/>
<link
href=
"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel=
"stylesheet"
/>
<style>
<style>
:root
{
:root
{
/* tune these if footer/composer sizes change */
--footer-h
:
2.75rem
;
/* fixed footer height (approx) */
--footer-h
:
2.75rem
;
/* fixed footer height (approx) */
--composer-h
:
6.5rem
;
/* composer block height (approx) */
--composer-h
:
6.5rem
;
/* composer block height (approx) */
--bottom-pad
:
calc
(
var
(
--footer-h
)
+
var
(
--composer-h
)
+
1rem
);
--bottom-pad
:
calc
(
var
(
--footer-h
)
+
var
(
--composer-h
)
+
1rem
);
}
}
body
{
body
{
min-height
:
100vh
;
display
:
flex
;
flex-direction
:
column
;
background
:
#f8f9fa
;
margin
:
0
;
}
min-height
:
100vh
;
display
:
flex
;
flex-direction
:
column
;
background-color
:
#f8f9fa
;
margin
:
0
;
}
header
{
position
:
sticky
;
top
:
0
;
z-index
:
1020
;
}
header
{
position
:
sticky
;
top
:
0
;
z-index
:
1020
;
}
main
{
main
{
flex
:
1
1
auto
;
flex
:
1
1
auto
;
overflow-y
:
auto
;
overflow-y
:
auto
;
/*
<--
the
scrollable
area
*/
position
:
relative
;
position
:
relative
;
padding-bottom
:
var
(
--bottom-pad
);
/*
leave
room for composer + fixed footer */
padding-bottom
:
var
(
--bottom-pad
);
/* room for composer + fixed footer */
display
:
flex
;
display
:
flex
;
flex-direction
:
column
;
flex-direction
:
column
;
}
}
.composer
{
.composer
{
position
:
sticky
;
position
:
sticky
;
bottom
:
var
(
--footer-h
);
/* sit just above the fixed footer */
bottom
:
var
(
--footer-h
);
z-index
:
1020
;
z-index
:
1020
;
background
:
#fff
;
background
:
#fff
;
border-top
:
1px
solid
#dee2e6
;
border-top
:
1px
solid
#dee2e6
;
}
}
footer
{
footer
{
position
:
fixed
;
position
:
fixed
;
bottom
:
0
;
left
:
0
;
width
:
100%
;
z-index
:
1020
;
bottom
:
0
;
font-size
:
.75rem
;
color
:
#6c757d
;
text-align
:
center
;
padding
:
.5rem
;
left
:
0
;
border-top
:
1px
solid
#dee2e6
;
background
:
#fdfdfd
;
height
:
var
(
--footer-h
);
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
;
box-sizing
:
border-box
;
}
}
...
@@ -63,8 +46,8 @@
...
@@ -63,8 +46,8 @@
max-width
:
900px
;
margin
:
0
auto
1rem
auto
;
padding
:
1rem
1.25rem
;
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
;
border-radius
:
.75rem
;
line-height
:
1.6
;
font-size
:
1rem
;
word-break
:
break-word
;
}
}
.user-message
{
background
-color
:
#fff
;
border
:
1px
solid
#dee2e6
;
white-space
:
pre-wrap
;
}
.user-message
{
background
:
#fff
;
border
:
1px
solid
#dee2e6
;
white-space
:
pre-wrap
;
}
.assistant-message
{
background
-color
:
#f8f9fa
;
border
:
1px
solid
#dee2e6
;
}
.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
h1
,
.assistant-message
h2
,
.assistant-message
h3
{
margin-top
:
1rem
;
margin-bottom
:
.6rem
;
}
.assistant-message
p
{
margin
:
.5rem
0
;
}
.assistant-message
p
{
margin
:
.5rem
0
;
}
.assistant-message
code
{
background
:
#fff
;
border
:
1px
solid
#e9ecef
;
border-radius
:
.25rem
;
padding
:
.05rem
.35rem
;
}
.assistant-message
code
{
background
:
#fff
;
border
:
1px
solid
#e9ecef
;
border-radius
:
.25rem
;
padding
:
.05rem
.35rem
;
}
...
@@ -72,7 +55,6 @@
...
@@ -72,7 +55,6 @@
.assistant-message
ul
,
.assistant-message
ol
{
padding-left
:
1.25rem
;
}
.assistant-message
ul
,
.assistant-message
ol
{
padding-left
:
1.25rem
;
}
.assistant-message
table
{
width
:
100%
;
margin
:
1rem
0
;
}
.assistant-message
table
{
width
:
100%
;
margin
:
1rem
0
;
}
/* First-run logo layout */
#mainScroll
{
display
:
flex
;
flex-direction
:
column
;
position
:
relative
;
}
#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
{
flex
:
1
1
auto
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
user-select
:
none
;
}
#welcomeLogo
img
{
#welcomeLogo
img
{
...
@@ -80,13 +62,13 @@
...
@@ -80,13 +62,13 @@
filter
:
drop-shadow
(
0
6px
16px
rgba
(
0
,
0
,
0
,
.15
));
opacity
:
.98
;
filter
:
drop-shadow
(
0
6px
16px
rgba
(
0
,
0
,
0
,
.15
));
opacity
:
.98
;
}
}
/*
ALWAYS-VISIBLE SCROLL BUTTON (fixed, above composer+footer)
*/
/*
Scroll button: FIXED, toggled by .show
*/
#scrollDownBtn
{
#scrollDownBtn
{
position
:
fixed
;
position
:
fixed
;
right
:
1rem
;
right
:
1rem
;
bottom
:
calc
(
var
(
--footer-h
)
+
var
(
--composer-h
)
+
1rem
);
/* sit above composer + footer */
bottom
:
calc
(
var
(
--footer-h
)
+
var
(
--composer-h
)
+
1rem
);
z-index
:
1100
;
/* above header/composer/footer */
z-index
:
1100
;
display
:
inline-flex
;
display
:
none
;
/* hidden by default */
align-items
:
center
;
align-items
:
center
;
justify-content
:
center
;
justify-content
:
center
;
border
:
none
;
border
:
none
;
...
@@ -100,12 +82,12 @@
...
@@ -100,12 +82,12 @@
transition
:
opacity
.18s
ease-in-out
,
transform
.12s
ease-in-out
;
transition
:
opacity
.18s
ease-in-out
,
transform
.12s
ease-in-out
;
opacity
:
.96
;
opacity
:
.96
;
}
}
#scrollDownBtn
.show
{
display
:
inline-flex
;
}
#scrollDownBtn
:hover
{
opacity
:
1
;
transform
:
translateY
(
-1px
);
}
#scrollDownBtn
:hover
{
opacity
:
1
;
transform
:
translateY
(
-1px
);
}
#scrollDownBtn
svg
{
width
:
22px
;
height
:
22px
;
pointer-events
:
none
;
}
#scrollDownBtn
svg
{
width
:
22px
;
height
:
22px
;
pointer-events
:
none
;
}
</style>
</style>
</head>
</head>
<body>
<body>
<!-- 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"
>
...
@@ -115,7 +97,6 @@
...
@@ -115,7 +97,6 @@
</div>
</div>
</header>
</header>
<!-- 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"
>
...
@@ -123,15 +104,13 @@
...
@@ -123,15 +104,13 @@
<div
id=
"thread"
></div>
<div
id=
"thread"
></div>
</main>
</main>
<!--
ALWAYS VISIBLE SCROLL BUTTON
-->
<!--
visibility toggled by JS
-->
<button
id=
"scrollDownBtn"
type=
"button"
aria-label=
"Scroll down"
title=
"Scroll down"
>
<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"
>
<!-- down chevron -->
<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"
/>
<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>
</svg>
</button>
</button>
<!-- Composer (sticks above fixed footer) -->
<div
class=
"composer py-3"
>
<div
class=
"composer py-3"
>
<div
class=
"container"
>
<div
class=
"container"
>
<form
id=
"promptForm"
class=
"d-flex flex-column gap-2"
>
<form
id=
"promptForm"
class=
"d-flex flex-column gap-2"
>
...
@@ -144,180 +123,148 @@
...
@@ -144,180 +123,148 @@
</div>
</div>
</div>
</div>
<!-- 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 (unchanged) ---
const
DEFAULT_LOCALE
=
'en'
;
const
DEFAULT_LOCALE
=
'en'
;
const
SUPPORTED
=
[
'en'
,
'ru'
];
const
SUPPORTED
=
[
'en'
,
'ru'
];
let
I18N
=
{};
let
CURRENT_LOCALE
=
detectLocale
();
let
isRunning
=
false
;
let
I18N
=
{};
let
CURRENT_LOCALE
=
detectLocale
();
function
getLangFromQuery
(){
const
m
=
location
.
search
.
match
(
/
[
?&
]
lang=
(
en|ru
)\b
/i
);
return
m
?
m
[
1
].
toLowerCase
():
null
;
}
let
isRunning
=
false
;
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
getLangFromQuery
(){
function
t
(
key
,
fallback
=
''
){
const
parts
=
key
.
split
(
'.'
);
let
cur
=
I18N
;
for
(
const
p
of
parts
){
if
(
cur
&&
Object
.
prototype
.
hasOwnProperty
.
call
(
cur
,
p
))
cur
=
cur
[
p
];
else
return
fallback
||
key
;
}
return
(
typeof
cur
===
'string'
)?
cur
:(
fallback
||
key
);
}
const
m
=
location
.
search
.
match
(
/
[
?&
]
lang=
(
en|ru
)\b
/i
);
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
;
}
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
(){
function
applyI18n
(){
document
.
title
=
t
(
'app.title'
,
'KotGPT'
);
document
.
title
=
t
(
'app.title'
,
'KotGPT'
);
const
catIcon
=
document
.
getElementById
(
'catIcon'
);
const
catIcon
=
document
.
getElementById
(
'catIcon'
);
if
(
catIcon
)
catIcon
.
alt
=
t
(
'header.logoAlt'
,
'Cat logo'
);
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
welcomeImg
=
document
.
getElementById
(
'welcomeImg'
);
const
input
=
document
.
getElementById
(
'inputText'
);
if
(
input
)
input
.
placeholder
=
isRunning
?
t
(
'status.thinking'
,
'Thinking…'
):
t
(
'composer.placeholder'
,
'What can I help you with?'
);
if
(
welcomeImg
)
welcomeImg
.
alt
=
t
(
'main.welcomeAlt'
,
'Ask a Cat'
);
const
goBtn
=
document
.
getElementById
(
'goBtn'
);
if
(
goBtn
)
goBtn
.
textContent
=
t
(
'buttons.go'
,
'Go'
);
const
inputText
=
document
.
getElementById
(
'inputText'
);
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
if
(
stopBtn
)
stopBtn
.
textContent
=
t
(
'buttons.stop'
,
'Stop'
);
if
(
inputText
){
const
disclaimer
=
document
.
getElementById
(
'disclaimer'
);
if
(
disclaimer
)
disclaimer
.
textContent
=
t
(
'footer.disclaimer'
,
'Beware: Cat can make mistakes.'
);
inputText
.
placeholder
=
isRunning
const
scrollBtn
=
document
.
getElementById
(
'scrollDownBtn'
);
if
(
scrollBtn
){
scrollBtn
.
setAttribute
(
'aria-label'
,
t
(
'buttons.scrollDown'
,
'Scroll down'
));
scrollBtn
.
title
=
t
(
'buttons.scrollDown'
,
'Scroll down'
);
}
?
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
();
setWelcomeImage
();
}
}
// --- UI refs ---
// --- refs ---
const
ENDPOINT
=
'/api/stream'
;
const
ENDPOINT
=
'/api/stream'
;
const
STOPPOINT
=
'/api/stop'
;
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'
);
// scrollable container
const
welcomeLogo
=
document
.
getElementById
(
'welcomeLogo'
);
const
scrollDownBtn
=
document
.
getElementById
(
'scrollDownBtn'
);
const
mainScroll
=
document
.
getElementById
(
'mainScroll'
);
const
scrollDownBtn
=
document
.
getElementById
(
'scrollDownBtn'
);
let
firstRequestDone
=
false
;
let
controller
=
null
;
let
firstRequestDone
=
false
;
let
controller
=
null
;
// --- render helpers ---
function
renderMarkdown
(
targetEl
,
md
){
// --- helpers ---
const
html
=
marked
.
parse
(
md
,{
breaks
:
true
,
gfm
:
true
,
headerIds
:
false
});
function
renderMarkdown
(
targetEl
,
markdownText
){
targetEl
.
innerHTML
=
DOMPurify
.
sanitize
(
html
,{
USE_PROFILES
:{
html
:
true
}});
const
html
=
marked
.
parse
(
markdownText
,
{
breaks
:
true
,
gfm
:
true
,
headerIds
:
false
});
targetEl
.
innerHTML
=
DOMPurify
.
sanitize
(
html
,
{
USE_PROFILES
:{
html
:
true
}
});
}
}
function
showThinkingCat
(
targetEl
){
function
showThinkingCat
(
targetEl
){
targetEl
.
innerHTML
=
''
;
targetEl
.
innerHTML
=
''
;
const
img
=
document
.
createElement
(
'img'
);
const
img
=
document
.
createElement
(
'img'
);
img
.
className
=
'thinking-cat'
;
img
.
className
=
'thinking-cat'
;
img
.
src
=
CAT_GIF
+
'?t='
+
Date
.
now
();
img
.
src
=
CAT_GIF
+
'?t='
+
Date
.
now
();
img
.
alt
=
t
(
'status.thinking'
,
'Thinking…'
);
img
.
alt
=
t
(
'status.thinking'
,
'Thinking…'
);
targetEl
.
appendChild
(
img
);
targetEl
.
appendChild
(
img
);
}
}
function
clearThinkingCatIfPresent
(
targetEl
){
function
clearThinkingCatIfPresent
(
targetEl
){
const
img
=
targetEl
.
querySelector
(
'img.thinking-cat'
);
const
img
=
targetEl
.
querySelector
(
'img.thinking-cat'
);
if
(
img
)
targetEl
.
innerHTML
=
''
;
if
(
img
)
targetEl
.
innerHTML
=
''
;
}
// --- scroll indicator logic ---
function
needsScroll
(){
// Show only if content taller than container by a small epsilon
return
(
mainScroll
.
scrollHeight
-
mainScroll
.
clientHeight
)
>
4
;
}
function
atBottom
(){
return
(
mainScroll
.
scrollTop
+
mainScroll
.
clientHeight
)
>=
(
mainScroll
.
scrollHeight
-
4
);
}
function
updateScrollIndicator
(){
if
(
needsScroll
()
&&
!
atBottom
())
scrollDownBtn
.
classList
.
add
(
'show'
);
else
scrollDownBtn
.
classList
.
remove
(
'show'
);
}
}
// --- Composer lock ---
// Click: scroll the **conversation area** to bottom (not the page)
scrollDownBtn
.
addEventListener
(
'click'
,
()
=>
{
mainScroll
.
scrollTo
({
top
:
mainScroll
.
scrollHeight
,
behavior
:
'smooth'
});
// re-check after the animation
setTimeout
(
updateScrollIndicator
,
350
);
});
// Keep indicator state in sync
mainScroll
.
addEventListener
(
'scroll'
,
updateScrollIndicator
);
window
.
addEventListener
(
'resize'
,
updateScrollIndicator
);
// --- composer lock ---
function
setComposerLocked
(
locked
){
function
setComposerLocked
(
locked
){
isRunning
=
locked
;
isRunning
=
locked
;
inputText
.
placeholder
=
locked
?
t
(
'status.thinking'
,
'Thinking…'
)
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
inputText
.
placeholder
=
locked
?
t
(
'status.thinking'
,
'Thinking…'
)
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
inputText
.
disabled
=
locked
;
inputText
.
disabled
=
locked
;
goBtn
.
style
.
display
=
locked
?
'none'
:
'inline-block'
;
goBtn
.
style
.
display
=
locked
?
'none'
:
'inline-block'
;
stopBtn
.
style
.
display
=
locked
?
'inline-block'
:
'none'
;
stopBtn
.
style
.
display
=
locked
?
'inline-block'
:
'none'
;
if
(
locked
)
inputText
.
blur
();
else
inputText
.
focus
();
if
(
locked
)
inputText
.
blur
();
else
inputText
.
focus
();
}
}
// ---
F
low ---
// ---
f
low ---
async
function
start
(){
async
function
start
(){
const
prompt
=
inputText
.
value
.
trim
();
const
prompt
=
inputText
.
value
.
trim
();
if
(
!
prompt
)
return
;
if
(
!
prompt
)
return
;
if
(
!
firstRequestDone
){
welcomeLogo
?.
remove
();
firstRequestDone
=
true
;
}
if
(
!
firstRequestDone
){
welcomeLogo
?.
remove
();
firstRequestDone
=
true
;
}
const
shouldAutoScroll
=
atBottom
();
const
auto
=
atBottom
();
const
userMsg
=
document
.
createElement
(
'div'
);
const
userMsg
=
document
.
createElement
(
'div'
);
userMsg
.
className
=
'message user-message'
;
userMsg
.
className
=
'message user-message'
;
userMsg
.
textContent
=
prompt
;
userMsg
.
textContent
=
prompt
;
thread
.
appendChild
(
userMsg
);
thread
.
appendChild
(
userMsg
);
const
assistantMsg
=
document
.
createElement
(
'div'
);
const
assistantMsg
=
document
.
createElement
(
'div'
);
assistantMsg
.
className
=
'message assistant-message'
;
assistantMsg
.
className
=
'message assistant-message'
;
showThinkingCat
(
assistantMsg
);
showThinkingCat
(
assistantMsg
);
thread
.
appendChild
(
assistantMsg
);
thread
.
appendChild
(
assistantMsg
);
if
(
shouldAutoScroll
)
assistantMsg
.
scrollIntoView
({
behavior
:
'smooth'
,
block
:
'start'
});
if
(
auto
)
assistantMsg
.
scrollIntoView
({
behavior
:
'smooth'
,
block
:
'start'
});
updateScrollIndicator
();
inputText
.
value
=
''
;
inputText
.
value
=
''
;
controller
=
new
AbortController
();
controller
=
new
AbortController
();
setComposerLocked
(
true
);
setComposerLocked
(
true
);
try
{
try
{
const
res
=
await
fetch
(
ENDPOINT
,
{
const
res
=
await
fetch
(
ENDPOINT
,
{
method
:
'POST'
,
method
:
'POST'
,
headers
:{
'Content-Type'
:
'application/json'
},
headers
:{
'Content-Type'
:
'application/json'
},
body
:
JSON
.
stringify
({
content
:
prompt
}),
body
:
JSON
.
stringify
({
content
:
prompt
}),
signal
:
controller
.
signal
signal
:
controller
.
signal
});
});
if
(
!
res
.
ok
||
!
res
.
body
)
throw
new
Error
(
`HTTP
${
res
.
status
}
`
);
if
(
!
res
.
ok
||
!
res
.
body
)
throw
new
Error
(
`HTTP
${
res
.
status
}
`
);
const
reader
=
res
.
body
.
getReader
();
const
reader
=
res
.
body
.
getReader
();
const
decoder
=
new
TextDecoder
();
const
decoder
=
new
TextDecoder
();
let
rawMarkdown
=
''
;
let
rawMarkdown
=
''
;
let
receivedAny
=
false
;
let
rafPending
=
false
;
let
receivedAny
=
false
;
let
rafPending
=
false
;
const
flush
=
()
=>
{
const
flush
=
()
=>
{
if
(
rafPending
)
return
;
if
(
rafPending
)
return
;
rafPending
=
true
;
rafPending
=
true
;
requestAnimationFrame
(()
=>
{
requestAnimationFrame
(()
=>
{
rafPending
=
false
;
rafPending
=
false
;
if
(
!
receivedAny
){
clearThinkingCatIfPresent
(
assistantMsg
);
receivedAny
=
true
;
}
if
(
!
receivedAny
){
clearThinkingCatIfPresent
(
assistantMsg
);
receivedAny
=
true
;
}
renderMarkdown
(
assistantMsg
,
rawMarkdown
);
renderMarkdown
(
assistantMsg
,
rawMarkdown
);
if
(
shouldAutoScroll
&&
atBottom
()){
if
(
auto
&&
atBottom
()){
mainScroll
.
scrollTop
=
mainScroll
.
scrollHeight
;
mainScroll
.
scrollTop
=
mainScroll
.
scrollHeight
;
}
}
updateScrollIndicator
();
});
});
};
};
...
@@ -330,9 +277,9 @@
...
@@ -330,9 +277,9 @@
}
}
flush
();
flush
();
}
catch
(
err
){
}
catch
(
err
){
if
(
err
.
name
!==
'AbortError'
){
if
(
err
.
name
!==
'AbortError'
){
clearThinkingCatIfPresent
(
assistantMsg
);
clearThinkingCatIfPresent
(
assistantMsg
);
renderMarkdown
(
assistantMsg
,
`\n\n**
${
t
(
'status.errorLabel'
,
'Error'
)}
:**
${
err
.
message
||
err
}
`
);
renderMarkdown
(
assistantMsg
,
`\n\n**
${
t
(
'status.errorLabel'
,
'Error'
)}
:**
${
err
.
message
||
err
}
`
);
}
else
{
}
else
{
if
(
assistantMsg
.
querySelector
(
'img.thinking-cat'
)){
if
(
assistantMsg
.
querySelector
(
'img.thinking-cat'
)){
clearThinkingCatIfPresent
(
assistantMsg
);
clearThinkingCatIfPresent
(
assistantMsg
);
...
@@ -341,48 +288,29 @@
...
@@ -341,48 +288,29 @@
}
}
}
finally
{
}
finally
{
setComposerLocked
(
false
);
setComposerLocked
(
false
);
controller
=
null
;
controller
=
null
;
updateScrollIndicator
();
}
}
}
}
async
function
stop
(){
async
function
stop
(){
if
(
controller
)
controller
.
abort
();
if
(
controller
)
controller
.
abort
();
try
{
await
fetch
(
STOPPOINT
,
{
method
:
'POST'
});
}
catch
(
_
){}
try
{
await
fetch
(
STOPPOINT
,{
method
:
'POST'
});
}
catch
(
_
){}
}
// --- Utilities for bottom detection and button action ---
function
atBottom
(){
return
(
mainScroll
.
scrollTop
+
mainScroll
.
clientHeight
)
>=
(
mainScroll
.
scrollHeight
-
2
);
}
}
// Click = jump to bottom of the scrollable conversation
// events
scrollDownBtn
.
addEventListener
(
'click'
,
()
=>
{
promptForm
.
addEventListener
(
'submit'
,
e
=>
{
e
.
preventDefault
();
if
(
goBtn
.
style
.
display
!==
'none'
)
start
();
});
mainScroll
.
scrollTop
=
mainScroll
.
scrollHeight
;
inputText
.
addEventListener
(
'keydown'
,
e
=>
{
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
&&
goBtn
.
style
.
display
!==
'none'
){
e
.
preventDefault
();
start
();
}
});
});
stopBtn
.
addEventListener
(
'click'
,
stop
);
// Events
// init
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
()
=>
{
window
.
addEventListener
(
'DOMContentLoaded'
,
async
()
=>
{
try
{
await
fetch
(
'/api/clear'
,
{
method
:
'GET'
});
}
catch
(
_
){}
try
{
await
fetch
(
'/api/clear'
,
{
method
:
'GET'
});
}
catch
(
_
){}
await
loadLocale
(
CURRENT_LOCALE
);
await
loadLocale
(
CURRENT_LOCALE
);
document
.
getElementById
(
'inputText'
).
focus
();
inputText
.
focus
();
});
updateScrollIndicator
();
// initial state
// Keep the fixed button correctly above the composer+footer on resize/orientation
window
.
addEventListener
(
'resize'
,
()
=>
{
// nothing needed; CSS calc() handles it
});
});
</script>
</script>
</body>
</body>
...
...
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