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
3f4ec0c9
Commit
3f4ec0c9
authored
Aug 18, 2025
by
michaelpastushkov
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
8264d894
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
201 additions
and
622 deletions
+201
-622
static/www/1.html
+0
-487
static/www/index.html
+201
-135
static/www/index6.html
+0
-0
No files found.
static/www/1.html
deleted
100644 → 0
View file @
8264d894
<!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>
/* Layout: header + main (scrolls) + composer; fixed footer */
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
;
/* leave room so fixed footer doesn’t overlap last messages */
padding-bottom
:
3.5rem
;
/* ≈ footer height */
}
.composer
{
position
:
sticky
;
bottom
:
0
;
z-index
:
1020
;
background
:
#fff
;
border-top
:
1px
solid
#dee2e6
;
}
footer
{
position
:
fixed
;
bottom
:
0
;
left
:
0
;
width
:
100%
;
z-index
:
1010
;
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
;
}
/* 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 pattern as previous file) */
#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 (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 -->
<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 (fixed, non-scrolling) -->
<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 (kept) ---
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 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
scrollDownBtn
=
document
.
getElementById
(
'scrollDownBtn'
);
const
BOTTOM_EPS
=
50
;
let
firstRequestDone
=
false
;
let
controller
=
null
;
// --- Content rendering 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 logic (mirrors the first script) ---
function
needsScroll
()
{
return
mainScroll
.
scrollHeight
-
mainScroll
.
clientHeight
>
8
;
}
function
atBottom
()
{
return
mainScroll
.
scrollTop
+
mainScroll
.
clientHeight
>=
mainScroll
.
scrollHeight
-
BOTTOM_EPS
;
}
function
isNearBottom
()
{
return
atBottom
();
}
// alias for clarity
function
updateScrollIndicator
()
{
if
(
needsScroll
()
&&
!
atBottom
())
{
scrollDownBtn
.
classList
.
add
(
'show'
);
}
else
{
scrollDownBtn
.
classList
.
remove
(
'show'
);
}
}
// scroll one viewport down on click
scrollDownBtn
.
addEventListener
(
'click'
,
()
=>
{
mainScroll
.
scrollBy
({
top
:
mainScroll
.
clientHeight
,
behavior
:
'smooth'
});
setTimeout
(
updateScrollIndicator
,
320
);
});
mainScroll
.
addEventListener
(
'scroll'
,
updateScrollIndicator
);
window
.
addEventListener
(
'resize'
,
updateScrollIndicator
);
// --- Composer lock / buttons ---
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
();
}
// --- App flow ---
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
;
}
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
static/www/index.html
100755 → 100644
View file @
3f4ec0c9
<!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>
/* Layout: header + main (scrolls) + composer; fixed footer */
body
{
body
{
min-height
:
100vh
;
min-height
:
100vh
;
display
:
flex
;
display
:
flex
;
...
@@ -14,41 +16,35 @@
...
@@ -14,41 +16,35 @@
margin
:
0
;
margin
:
0
;
}
}
/* Make only main scrollable, with space for header+composer+footer */
header
{
position
:
sticky
;
top
:
0
;
z-index
:
1020
;
}
main
{
main
{
flex
:
1
1
auto
;
flex
:
1
1
auto
;
overflow-y
:
auto
;
overflow-y
:
auto
;
position
:
relative
;
position
:
relative
;
padding
:
5rem
0
8rem
;
/* top space for header, bottom space for composer+footer */
/* leave room so fixed footer doesn’t overlap last messages */
padding-bottom
:
3.5rem
;
/* ≈ footer height */
}
}
/* Header fixed at top */
header
{
position
:
fixed
;
top
:
0
;
left
:
0
;
width
:
100%
;
z-index
:
1020
;
}
/* Composer fixed above footer */
.composer
{
.composer
{
position
:
fixed
;
position
:
sticky
;
bottom
:
2rem
;
/* leave space for footer */
bottom
:
0
;
left
:
0
;
width
:
100%
;
z-index
:
1020
;
z-index
:
1020
;
background
:
#fff
;
background
:
#fff
;
border-top
:
1px
solid
#dee2e6
;
border-top
:
1px
solid
#dee2e6
;
}
}
/* Footer fixed at very bottom */
footer
{
footer
{
position
:
fixed
;
position
:
fixed
;
bottom
:
0
;
bottom
:
0
;
left
:
0
;
left
:
0
;
width
:
100%
;
width
:
100%
;
z-index
:
1020
;
z-index
:
1010
;
font-size
:
.75rem
;
font-size
:
.75rem
;
color
:
#6c757d
;
color
:
#6c757d
;
text-align
:
center
;
text-align
:
center
;
...
@@ -56,8 +52,22 @@
...
@@ -56,8 +52,22 @@
border-top
:
1px
solid
#dee2e6
;
border-top
:
1px
solid
#dee2e6
;
background
:
#fdfdfd
;
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
;
}
.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
{
.message
{
max-width
:
900px
;
max-width
:
900px
;
margin
:
0
auto
1rem
auto
;
margin
:
0
auto
1rem
auto
;
...
@@ -67,16 +77,58 @@
...
@@ -67,16 +77,58 @@
font-size
:
1rem
;
font-size
:
1rem
;
word-break
:
break-word
;
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
;
}
.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
{
#welcomeLogo
{
flex
:
1
1
auto
;
flex
:
1
1
auto
;
display
:
flex
;
display
:
flex
;
...
@@ -84,44 +136,55 @@
...
@@ -84,44 +136,55 @@
justify-content
:
center
;
justify-content
:
center
;
user-select
:
none
;
user-select
:
none
;
}
}
#welcomeLogo
img
{
#welcomeLogo
img
{
max-width
:
520px
;
max-width
:
520px
;
width
:
100%
;
width
:
100%
;
height
:
auto
;
height
:
auto
;
filter
:
drop-shadow
(
0
6px
16px
rgba
(
0
,
0
,
0
,
.15
));
filter
:
drop-shadow
(
0
6px
16px
rgba
(
0
,
0
,
0
,
.15
));
opacity
:
.98
;
opacity
:
.98
;
}
}
/* --- Floating Scroll Button --- */
/* Sticky in-content scroll indicator (same pattern as previous file) */
.scroll-fab
{
#scrollDownBtn
{
position
:
fixed
;
position
:
sticky
;
right
:
24px
;
margin-left
:
auto
;
bottom
:
96px
;
/* sits above the sticky composer */
bottom
:
1rem
;
width
:
48px
;
align-self
:
flex-end
;
height
:
48px
;
right
:
1rem
;
border-radius
:
50%
;
transform
:
translate
(
-1rem
,
0
);
z-index
:
1030
;
display
:
none
;
border
:
none
;
border
:
none
;
background
:
#0d6efd
;
border-radius
:
999px
;
width
:
36px
;
height
:
36px
;
background
:
rgba
(
13
,
110
,
253
,
.95
);
color
:
#fff
;
color
:
#fff
;
display
:
none
;
/* toggled via JS */
box-shadow
:
0
4px
14px
rgba
(
0
,
0
,
0
,
.18
);
align-items
:
center
;
justify-content
:
center
;
box-shadow
:
0
8px
18px
rgba
(
13
,
110
,
253
,
.35
);
cursor
:
pointer
;
cursor
:
pointer
;
z-index
:
1030
;
transition
:
opacity
.18s
ease-in-out
;
opacity
:
.92
;
}
}
.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
;
}
#scrollDownBtn
:hover
{
.scroll-fab.pulse
{
opacity
:
1
;
animation
:
scrollPulse
1.5s
ease-in-out
infinite
;
}
}
@keyframes
scrollPulse
{
0
%
{
transform
:
translateY
(
0
);
}
#scrollDownBtn
svg
{
50
%
{
transform
:
translateY
(
2px
);
}
width
:
22px
;
100
%
{
transform
:
translateY
(
0
);
}
height
:
22px
;
pointer-events
:
none
;
}
#scrollDownBtn
.show
{
display
:
inline-flex
;
align-items
:
center
;
justify-content
:
center
;
}
}
</style>
</style>
</head>
</head>
<body>
<body>
<!-- Header -->
<!-- Header -->
<header
class=
"py-3 bg-dark text-white shadow-sm"
>
<header
class=
"py-3 bg-dark text-white shadow-sm"
>
...
@@ -133,21 +196,21 @@
...
@@ -133,21 +196,21 @@
</div>
</div>
</header>
</header>
<!-- Conversation
-->
<!-- 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>
<!-- Floating Scroll-to-Bottom Button -->
<!-- Sticky scroll indicator -->
<button
id=
"scrollFab"
class=
"scroll-fab"
type=
"button"
aria-label=
""
>
<button
id=
"scrollDownBtn"
type=
"button"
aria-label=
"Scroll down"
>
<!-- Down arrow icon -->
<svg
viewBox=
"0 0 24 24"
fill=
"currentColor"
aria-hidden=
"true"
>
<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"
/>
<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>
</main>
<!-- Composer -->
<!-- Composer -->
<div
class=
"composer py-3"
>
<div
class=
"composer py-3"
>
...
@@ -162,13 +225,13 @@
...
@@ -162,13 +225,13 @@
</div>
</div>
</div>
</div>
<!-- Disclaimer Footer
-->
<!-- Disclaimer Footer (fixed, non-scrolling)
-->
<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
---
// --- i18n setup (kept)
---
const
DEFAULT_LOCALE
=
'en'
;
const
DEFAULT_LOCALE
=
'en'
;
const
SUPPORTED
=
[
'en'
,
'ru'
];
const
SUPPORTED
=
[
'en'
,
'ru'
];
let
I18N
=
{};
let
I18N
=
{};
...
@@ -198,7 +261,7 @@
...
@@ -198,7 +261,7 @@
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
)
{
...
@@ -226,18 +289,18 @@
...
@@ -226,18 +289,18 @@
if
(
inputText
)
{
if
(
inputText
)
{
inputText
.
placeholder
=
isRunning
inputText
.
placeholder
=
isRunning
?
t
(
'status.thinking'
,
'Thinking…'
)
?
t
(
'status.thinking'
,
'Thinking…'
)
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
}
}
const
goBtn
=
document
.
getElementById
(
'goBtn'
);
const
goBtn
=
document
.
getElementById
(
'goBtn'
);
if
(
goBtn
)
goBtn
.
textContent
=
t
(
'buttons.go'
,
'Go'
);
if
(
goBtn
)
goBtn
.
textContent
=
t
(
'buttons.go'
,
'Go'
);
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
if
(
stopBtn
)
stopBtn
.
textContent
=
t
(
'buttons.stop'
,
'Stop'
);
if
(
stopBtn
)
stopBtn
.
textContent
=
t
(
'buttons.stop'
,
'Stop'
);
const
disclaimer
=
document
.
getElementById
(
'disclaimer'
);
const
disclaimer
=
document
.
getElementById
(
'disclaimer'
);
if
(
disclaimer
)
disclaimer
.
textContent
=
t
(
'footer.disclaimer'
,
'Beware: Cat can make mistakes.'
);
if
(
disclaimer
)
disclaimer
.
textContent
=
t
(
'footer.disclaimer'
,
'Beware: Cat can make mistakes.'
);
const
scrollFab
=
document
.
getElementById
(
'scrollFab
'
);
const
scrollDownBtn
=
document
.
getElementById
(
'scrollDownBtn
'
);
if
(
scrollFab
)
{
if
(
scrollDownBtn
)
{
scrollFab
.
setAttribute
(
'aria-label'
,
t
(
'buttons.scrollDown'
,
'Scroll to latest
'
));
scrollDownBtn
.
setAttribute
(
'aria-label'
,
t
(
'buttons.scrollDown'
,
'Scroll down
'
));
scrollFab
.
title
=
t
(
'buttons.scrollDown'
,
'Scroll to latest
'
);
scrollDownBtn
.
title
=
t
(
'buttons.scrollDown'
,
'Scroll down
'
);
}
}
setWelcomeImage
();
setWelcomeImage
();
}
}
...
@@ -253,54 +316,23 @@
...
@@ -253,54 +316,23 @@
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
const
stopBtn
=
document
.
getElementById
(
'stopBtn'
);
const
welcomeLogo
=
document
.
getElementById
(
'welcomeLogo'
);
const
welcomeLogo
=
document
.
getElementById
(
'welcomeLogo'
);
const
mainScroll
=
document
.
getElementById
(
'mainScroll'
);
const
mainScroll
=
document
.
getElementById
(
'mainScroll'
);
const
scrollFab
=
document
.
getElementById
(
'scrollFab'
);
const
scrollDownBtn
=
document
.
getElementById
(
'scrollDownBtn'
);
const
BOTTOM_EPS
=
50
;
let
firstRequestDone
=
false
;
let
firstRequestDone
=
false
;
let
controller
=
null
;
let
controller
=
null
;
// --- Floating Scroll Button logic ---
// --- Content rendering helpers ---
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
)
{
function
renderMarkdown
(
targetEl
,
markdownText
)
{
const
html
=
marked
.
parse
(
markdownText
,
{
breaks
:
true
,
gfm
:
true
,
headerIds
:
false
});
const
html
=
marked
.
parse
(
markdownText
,
{
breaks
:
true
,
gfm
:
true
,
headerIds
:
false
});
targetEl
.
innerHTML
=
DOMPurify
.
sanitize
(
html
,
{
USE_PROFILES
:{
html
:
true
}
});
targetEl
.
innerHTML
=
DOMPurify
.
sanitize
(
html
,
{
USE_PROFILES
:
{
html
:
true
}
});
}
}
function
showThinkingCat
(
targetEl
)
{
function
showThinkingCat
(
targetEl
)
{
targetEl
.
innerHTML
=
''
;
targetEl
.
innerHTML
=
''
;
const
img
=
document
.
createElement
(
'img'
);
const
img
=
document
.
createElement
(
'img'
);
img
.
className
=
'thinking-cat'
;
img
.
className
=
'thinking-cat'
;
img
.
src
=
CAT_GIF
+
'?t='
+
Date
.
now
();
img
.
src
=
CAT_GIF
+
'?t='
+
Date
.
now
();
img
.
alt
=
t
(
'status.thinking'
,
'Thinking…'
);
img
.
alt
=
t
(
'status.thinking'
,
'Thinking…'
);
targetEl
.
appendChild
(
img
);
targetEl
.
appendChild
(
img
);
}
}
function
clearThinkingCatIfPresent
(
targetEl
)
{
function
clearThinkingCatIfPresent
(
targetEl
)
{
...
@@ -308,39 +340,66 @@
...
@@ -308,39 +340,66 @@
if
(
img
)
targetEl
.
innerHTML
=
''
;
if
(
img
)
targetEl
.
innerHTML
=
''
;
}
}
// --- Scroll indicator logic (mirrors the first script) ---
function
needsScroll
()
{
return
mainScroll
.
scrollHeight
-
mainScroll
.
clientHeight
>
8
;
}
function
atBottom
()
{
return
mainScroll
.
scrollTop
+
mainScroll
.
clientHeight
>=
mainScroll
.
scrollHeight
-
BOTTOM_EPS
;
}
function
isNearBottom
()
{
return
atBottom
();
}
// alias for clarity
function
updateScrollIndicator
()
{
if
(
needsScroll
()
&&
!
atBottom
())
{
scrollDownBtn
.
classList
.
add
(
'show'
);
}
else
{
scrollDownBtn
.
classList
.
remove
(
'show'
);
}
}
// scroll one viewport down on click
scrollDownBtn
.
addEventListener
(
'click'
,
()
=>
{
mainScroll
.
scrollBy
({
top
:
mainScroll
.
clientHeight
,
behavior
:
'smooth'
});
setTimeout
(
updateScrollIndicator
,
320
);
});
mainScroll
.
addEventListener
(
'scroll'
,
updateScrollIndicator
);
window
.
addEventListener
(
'resize'
,
updateScrollIndicator
);
// --- Composer lock / buttons ---
function
setComposerLocked
(
locked
)
{
function
setComposerLocked
(
locked
)
{
isRunning
=
locked
;
isRunning
=
locked
;
inputText
.
placeholder
=
locked
inputText
.
placeholder
=
locked
?
t
(
'status.thinking'
,
'Thinking…'
)
?
t
(
'status.thinking'
,
'Thinking…'
)
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
:
t
(
'composer.placeholder'
,
'What can I help you with?'
);
inputText
.
placeholder
=
inputText
.
placeholder
;
void
inputText
.
offsetHeight
;
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
();
if
(
locked
)
inputText
.
blur
();
else
inputText
.
focus
();
else
inputText
.
focus
();
}
}
// --- App flow ---
async
function
start
()
{
async
function
start
()
{
const
prompt
=
inputText
.
value
.
trim
();
const
prompt
=
inputText
.
value
.
trim
();
if
(
!
prompt
)
return
;
if
(
!
prompt
)
return
;
if
(
!
firstRequestDone
)
{
welcomeLogo
?.
remove
();
firstRequestDone
=
true
;
}
if
(
!
firstRequestDone
)
{
welcomeLogo
?.
remove
();
firstRequestDone
=
true
;
}
const
shouldAutoScroll
=
isNearBottom
();
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'
});
inputText
.
value
=
''
;
inputText
.
value
=
''
;
controller
=
new
AbortController
();
controller
=
new
AbortController
();
setComposerLocked
(
true
);
setComposerLocked
(
true
);
// ensure we evaluate FAB after new nodes
updateScrollFabVisibility
();
try
{
try
{
const
res
=
await
fetch
(
ENDPOINT
,
{
const
res
=
await
fetch
(
ENDPOINT
,
{
method
:
'POST'
,
method
:
'POST'
,
...
@@ -349,6 +408,7 @@
...
@@ -349,6 +408,7 @@
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
=
''
;
...
@@ -363,59 +423,64 @@
...
@@ -363,59 +423,64 @@
if
(
!
receivedAny
)
{
clearThinkingCatIfPresent
(
assistantMsg
);
receivedAny
=
true
;
}
if
(
!
receivedAny
)
{
clearThinkingCatIfPresent
(
assistantMsg
);
receivedAny
=
true
;
}
renderMarkdown
(
assistantMsg
,
rawMarkdown
);
renderMarkdown
(
assistantMsg
,
rawMarkdown
);
// Auto-scroll only if user is already near the bottom.
if
(
shouldAutoScroll
&&
isNearBottom
())
{
// If not, show FAB and pulse to indicate new content.
mainScroll
.
scrollTop
=
mainScroll
.
scrollHeight
;
if
(
isNearBottom
())
{
scrollToBottom
(
true
);
}
else
{
scrollFab
.
style
.
display
=
'flex'
;
scrollFab
.
classList
.
add
(
'pulse'
);
}
}
updateScrollFabVisibility
();
updateScrollIndicator
();
});
});
};
};
for
(;
;)
{
for
(;
;)
{
const
{
value
,
done
}
=
await
reader
.
read
();
const
{
value
,
done
}
=
await
reader
.
read
();
if
(
done
)
break
;
if
(
done
)
break
;
const
chunk
=
decoder
.
decode
(
value
,
{
stream
:
true
});
const
chunk
=
decoder
.
decode
(
value
,
{
stream
:
true
});
for
(
const
ch
of
chunk
)
{
rawMarkdown
+=
ch
;
}
for
(
const
ch
of
chunk
)
rawMarkdown
+=
ch
;
// char-by-char
flush
();
flush
();
}
}
flush
();
flush
();
}
catch
(
err
)
{
}
catch
(
err
)
{
if
(
err
.
name
!==
'AbortError'
)
{
if
(
err
.
name
!==
'AbortError'
)
{
clearThinkingCatIfPresent
(
assistantMsg
);
clearThinkingCatIfPresent
(
assistantMsg
);
renderMarkdown
(
assistantMsg
,
`\n\n**
${
t
(
'status.errorLabel'
,
'Error'
)}
:**
${
err
.
message
||
err
}
`
);
renderMarkdown
(
assistantMsg
,
`\n\n**
${
t
(
'status.errorLabel'
,
'Error'
)}
:**
${
err
.
message
||
err
}
`
);
}
else
{
}
else
{
if
(
assistantMsg
.
querySelector
(
'img.thinking-cat'
))
{
if
(
assistantMsg
.
querySelector
(
'img.thinking-cat'
))
{
clearThinkingCatIfPresent
(
assistantMsg
);
clearThinkingCatIfPresent
(
assistantMsg
);
renderMarkdown
(
assistantMsg
,
t
(
'status.stopped'
,
'_Stopped._'
));
renderMarkdown
(
assistantMsg
,
t
(
'status.stopped'
,
'_Stopped._'
));
}
}
}
}
}
finally
{
}
finally
{
setComposerLocked
(
false
);
setComposerLocked
(
false
);
controller
=
null
;
controller
=
null
;
updateScrollFabVisibility
();
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
(
_
)
{
}
}
}
promptForm
.
addEventListener
(
'submit'
,
e
=>
{
// Events
document
.
getElementById
(
'promptForm'
).
addEventListener
(
'submit'
,
e
=>
{
e
.
preventDefault
();
e
.
preventDefault
();
if
(
goBtn
.
style
.
display
!==
'none'
)
start
();
if
(
goBtn
.
style
.
display
!==
'none'
)
start
();
});
});
inputText
.
addEventListener
(
'keydown'
,
e
=>
{
document
.
getElementById
(
'inputText'
)
.
addEventListener
(
'keydown'
,
e
=>
{
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
&&
goBtn
.
style
.
display
!==
'none'
)
{
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
&&
goBtn
.
style
.
display
!==
'none'
)
{
e
.
preventDefault
();
e
.
preventDefault
();
start
();
start
();
}
}
});
});
stopBtn
.
addEventListener
(
'click'
,
stop
);
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>
</script>
</body>
</body>
</html>
</html>
\ No newline at end of file
static/www/index
copy
.html
→
static/www/index
6
.html
View file @
3f4ec0c9
File moved
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