Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
Son Of Anton
Project
Project
Details
Activity
Releases
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
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Son Of Anton
Commits
459309cd
Commit
459309cd
authored
Mar 23, 2026
by
AGLANPC\aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fgg dfjdfghj dfkj df
parent
e636c018
Changes
9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
727 additions
and
687 deletions
+727
-687
ChatView.jsx
frontend/src/components/ChatView.jsx
+189
-54
CodeBlock.jsx
frontend/src/components/CodeBlock.jsx
+31
-45
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+33
-27
Sidebar.jsx
frontend/src/components/Sidebar.jsx
+123
-221
index.css
frontend/src/index.css
+199
-166
ChatPage.jsx
frontend/src/pages/ChatPage.jsx
+58
-62
LoginPage.jsx
frontend/src/pages/LoginPage.jsx
+46
-74
store.jsx
frontend/src/store.jsx
+35
-28
tailwind.config.js
frontend/tailwind.config.js
+13
-10
No files found.
frontend/src/components/ChatView.jsx
View file @
459309cd
...
...
@@ -9,8 +9,8 @@ import {
}
from
"lucide-react"
;
const
MODELS
=
[
{
id
:
"eu.anthropic.claude-opus-4-6-v1"
,
label
:
"
Claude
Opus 4.6"
},
{
id
:
"eu.anthropic.claude-haiku-4-5-20251001-v1:0"
,
label
:
"
Claude
Haiku 4.5"
},
{
id
:
"eu.anthropic.claude-opus-4-6-v1"
,
label
:
"Opus 4.6"
},
{
id
:
"eu.anthropic.claude-haiku-4-5-20251001-v1:0"
,
label
:
"Haiku 4.5"
},
];
const
TYPE_ICONS
=
{
image
:
ImageIcon
,
video
:
Film
,
document
:
FileText
,
text
:
FileCode
};
...
...
@@ -20,7 +20,7 @@ const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", doc
function
classifyFile
(
f
)
{
const
ext
=
(
f
.
name
||
""
).
split
(
"."
).
pop
().
toLowerCase
();
const
mime
=
f
.
type
||
""
;
if
(
mime
.
startsWith
(
"image/"
)
||
[
"jpg"
,
"jpeg"
,
"png"
,
"gif"
,
"webp"
,
"bmp"
,
"svg"
].
includes
(
ext
))
return
"image"
;
if
(
mime
.
startsWith
(
"image/"
)
||
[
"jpg"
,
"jpeg"
,
"png"
,
"gif"
,
"webp"
,
"bmp"
].
includes
(
ext
))
return
"image"
;
if
(
mime
.
startsWith
(
"video/"
)
||
[
"mp4"
,
"mov"
,
"avi"
,
"mkv"
,
"webm"
].
includes
(
ext
))
return
"video"
;
if
(
mime
===
"application/pdf"
||
ext
===
"pdf"
)
return
"document"
;
return
"text"
;
...
...
@@ -64,7 +64,7 @@ export default function ChatView({ chatId }) {
const
scrollBottom
=
useCallback
(()
=>
{
if
(
!
autoScroll
.
current
||
rafRef
.
current
)
return
;
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
if
(
scrollRef
.
current
)
scrollRef
.
current
.
scrollTop
=
scrollRef
.
current
.
scrollHeight
;
scrollRef
.
current
?.
scrollTo
({
top
:
scrollRef
.
current
.
scrollHeight
})
;
rafRef
.
current
=
null
;
});
},
[]);
...
...
@@ -85,7 +85,6 @@ export default function ChatView({ chatId }) {
useEffect
(
scrollBottom
,
[
messages
,
streamData
.
text
,
streamData
.
thinking
,
scrollBottom
]);
useEffect
(()
=>
{
inputRef
.
current
?.
focus
();
},
[
chatId
]);
// Sync settings when chat changes
useEffect
(()
=>
{
if
(
currentChat
)
{
setModel
(
currentChat
.
model
||
MODELS
[
0
].
id
);
...
...
@@ -114,11 +113,21 @@ export default function ChatView({ chatId }) {
}
function
addFiles
(
files
)
{
setPendingFiles
((
prev
)
=>
[...
prev
,
...
files
.
map
((
f
)
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
}))]);
setPendingFiles
((
prev
)
=>
[
...
prev
,
...
files
.
map
((
f
)
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
,
})),
]);
}
function
removePending
(
i
)
{
setPendingFiles
((
prev
)
=>
{
if
(
prev
[
i
]?.
preview
)
URL
.
revokeObjectURL
(
prev
[
i
].
preview
);
return
prev
.
filter
((
_
,
j
)
=>
j
!==
i
);
});
setPendingFiles
((
prev
)
=>
{
if
(
prev
[
i
]?.
preview
)
URL
.
revokeObjectURL
(
prev
[
i
].
preview
);
return
prev
.
filter
((
_
,
j
)
=>
j
!==
i
);
});
}
async
function
handleSend
()
{
...
...
@@ -137,19 +146,44 @@ export default function ChatView({ chatId }) {
setUploading
(
false
);
}
dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
{
id
:
`tmp-
${
Date
.
now
()}
`
,
role
:
"user"
,
content
:
text
,
created_at
:
new
Date
().
toISOString
(),
attachments
:
uploaded
}
});
dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
{
id
:
`tmp-
${
Date
.
now
()}
`
,
role
:
"user"
,
content
:
text
,
created_at
:
new
Date
().
toISOString
(),
attachments
:
uploaded
,
},
});
setInput
(
""
);
pendingFiles
.
forEach
((
p
)
=>
{
if
(
p
.
preview
)
URL
.
revokeObjectURL
(
p
.
preview
);
});
setPendingFiles
([]);
autoScroll
.
current
=
true
;
// Reset textarea height
if
(
inputRef
.
current
)
inputRef
.
current
.
style
.
height
=
"auto"
;
streamManager
.
startStream
({
token
:
state
.
token
,
chatId
,
body
:
{
content
:
text
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
attachment_ids
:
attIds
},
token
:
state
.
token
,
chatId
,
body
:
{
content
:
text
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
attachment_ids
:
attIds
,
},
});
}
function
handleKeyDown
(
e
)
{
if
(
e
.
key
===
"Enter"
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
function
handleKeyDown
(
e
)
{
if
(
e
.
key
===
"Enter"
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
function
handlePaste
(
e
)
{
const
items
=
Array
.
from
(
e
.
clipboardData
?.
items
||
[]).
filter
((
i
)
=>
i
.
kind
===
"file"
);
...
...
@@ -159,7 +193,8 @@ export default function ChatView({ chatId }) {
}
function
handleDrop
(
e
)
{
e
.
preventDefault
();
setDragOver
(
false
);
e
.
preventDefault
();
setDragOver
(
false
);
const
files
=
Array
.
from
(
e
.
dataTransfer
?.
files
||
[]);
if
(
files
.
length
)
addFiles
(
files
);
}
...
...
@@ -167,26 +202,46 @@ export default function ChatView({ chatId }) {
const
streaming
=
streamData
.
streaming
;
return
(
<
div
className=
"flex-1 flex flex-col min-h-0 relative"
onDrop=
{
handleDrop
}
onDragOver=
{
(
e
)
=>
{
e
.
preventDefault
();
setDragOver
(
true
);
}
}
onDragLeave=
{
(
e
)
=>
{
if
(
!
e
.
currentTarget
.
contains
(
e
.
relatedTarget
))
setDragOver
(
false
);
}
}
>
<
div
className=
"flex-1 flex flex-col min-h-0 relative"
onDrop=
{
handleDrop
}
onDragOver=
{
(
e
)
=>
{
e
.
preventDefault
();
setDragOver
(
true
);
}
}
onDragLeave=
{
(
e
)
=>
{
if
(
!
e
.
currentTarget
.
contains
(
e
.
relatedTarget
))
setDragOver
(
false
);
}
}
>
{
/* Drag overlay */
}
{
dragOver
&&
(
<
div
className=
"absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"
>
<
div
className=
"text-center"
>
<
Upload
size=
{
40
}
className=
"text-anton-accent mx-auto mb-2 animate-bounce"
/>
<
p
className=
"text-white font-semibold"
>
Drop files here
</
p
>
<
Upload
size=
{
36
}
className=
"text-anton-accent mx-auto mb-2 animate-bounce"
/>
<
p
className=
"text-white font-semibold
text-sm
"
>
Drop files here
</
p
>
</
div
>
</
div
>
)
}
{
/* Messages */
}
<
div
ref=
{
scrollRef
}
onScroll=
{
onScroll
}
className=
"flex-1 overflow-y-auto px-3 sm:px-4 py-4 space-y-3 sm:space-y-4"
>
{
messages
.
map
((
m
)
=>
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
/>)
}
<
div
ref=
{
scrollRef
}
onScroll=
{
onScroll
}
className=
"flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3"
>
{
messages
.
map
((
m
)
=>
(
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
/>
))
}
{
streaming
&&
(
streamData
.
thinking
||
streamData
.
text
)
&&
(
<
MessageBubble
message=
{
{
id
:
"streaming"
,
role
:
"assistant"
,
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
attachments
:
[]
}
}
isStreaming
isThinking=
{
streamData
.
isThinking
}
token=
{
state
.
token
}
/>
<
MessageBubble
message=
{
{
id
:
"streaming"
,
role
:
"assistant"
,
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
attachments
:
[],
}
}
isStreaming
isThinking=
{
streamData
.
isThinking
}
token=
{
state
.
token
}
/>
)
}
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
<
div
className=
"flex items-center gap-2 px-
4
py-3 animate-fade-in"
>
<
div
className=
"flex items-center gap-2 px-
3
py-3 animate-fade-in"
>
<
div
className=
"flex gap-1"
>
{
[
0
,
150
,
300
].
map
((
d
)
=>
<
span
key=
{
d
}
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
d
+
"ms"
}
}
/>)
}
{
[
0
,
150
,
300
].
map
((
d
)
=>
(
<
span
key=
{
d
}
className=
"w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
d
+
"ms"
}
}
/>
))
}
</
div
>
<
span
className=
"text-anton-muted text-sm"
>
Thinking…
</
span
>
</
div
>
...
...
@@ -194,90 +249,170 @@ export default function ChatView({ chatId }) {
</
div
>
{
/* Input area */
}
<
div
className=
"border-t border-anton-border bg-anton-surface p-3 sm:p-4 safe-bottom"
>
<
div
className=
"border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom"
>
{
/* Settings panel */
}
{
showSettings
&&
(
<
div
className=
"mb-
3 bg-anton-card border border-anton-border rounded-xl p-3 sm:p-4 space-y-3 animate-fade-in
"
>
<
div
className=
"mb-
2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto
"
>
<
div
className=
"flex items-center justify-between"
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
><
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Settings
</
h3
>
<
button
onClick=
{
toggleSettings
}
className=
"text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
>
<
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Settings
</
h3
>
<
button
onClick=
{
toggleSettings
}
className=
"p-1 text-anton-muted hover:text-white"
>
<
X
size=
{
14
}
/>
</
button
>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
<
select
value=
{
model
}
onChange=
{
(
e
)
=>
setModel
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2
text-white text-sm
focus:outline-none focus:border-anton-accent"
>
<
select
value=
{
model
}
onChange=
{
(
e
)
=>
setModel
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2
.5 text-white
focus:outline-none focus:border-anton-accent"
>
{
MODELS
.
map
((
m
)
=>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>)
}
</
select
>
</
div
>
<
div
>
<
div
className=
"flex justify-between text-xs mb-1"
><
span
className=
"text-anton-muted"
>
Max Tokens
</
span
><
span
className=
"text-anton-accent font-mono"
>
{
maxTokens
.
toLocaleString
()
}
</
span
></
div
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
className=
"w-full"
/>
<
div
className=
"flex justify-between text-xs mb-1.5"
>
<
span
className=
"text-anton-muted"
>
Max Tokens
</
span
>
<
span
className=
"text-anton-accent font-mono"
>
{
maxTokens
.
toLocaleString
()
}
</
span
>
</
div
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
<
div
>
<
div
className=
"flex justify-between text-xs mb-1"
><
span
className=
"text-anton-muted flex items-center gap-1"
><
Brain
size=
{
12
}
className=
"text-purple-400"
/>
Reasoning
</
span
><
span
className=
"text-purple-400 font-mono"
>
{
reasoningBudget
===
0
?
"Off"
:
reasoningBudget
.
toLocaleString
()
}
</
span
></
div
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
className=
"w-full"
/>
<
div
className=
"flex justify-between text-xs mb-1.5"
>
<
span
className=
"text-anton-muted flex items-center gap-1"
>
<
Brain
size=
{
12
}
className=
"text-purple-400"
/>
Reasoning
</
span
>
<
span
className=
"text-purple-400 font-mono"
>
{
reasoningBudget
===
0
?
"Off"
:
reasoningBudget
.
toLocaleString
()
}
</
span
>
</
div
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
BookOpen
size=
{
12
}
/>
Knowledge Base
</
label
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
>
<
BookOpen
size=
{
12
}
/>
Knowledge Base
</
label
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"
>
<
option
value=
""
>
None
</
option
>
{
kbs
.
map
((
kb
)
=>
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs)
</
option
>)
}
</
select
>
{
kbs
.
length
===
0
&&
<
p
className=
"text-[10px] text-anton-muted mt-1"
>
Create knowledge bases in the sidebar → Knowledge tab
</
p
>
}
</
div
>
</
div
>
)
}
{
/* Pending files */
}
{
pendingFiles
.
length
>
0
&&
(
<
div
className=
"mb-
3
flex flex-wrap gap-1.5 animate-fade-in"
>
<
div
className=
"mb-
2
flex flex-wrap gap-1.5 animate-fade-in"
>
{
pendingFiles
.
map
((
pf
,
i
)
=>
{
const
Icon
=
TYPE_ICONS
[
pf
.
type
]
||
FileText
;
return
(
<
div
key=
{
i
}
className=
{
`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`
}
>
{
pf
.
type
===
"image"
&&
pf
.
preview
?
(
<
img
src=
{
pf
.
preview
}
alt=
""
className=
"w-1
6 h-16 sm:w-20 sm:h-20
object-cover"
/>
<
img
src=
{
pf
.
preview
}
alt=
""
className=
"w-1
4 h-14 sm:w-16 sm:h-16
object-cover"
/>
)
:
(
<
div
className=
"w-1
6 h-16 sm:w-20 sm:h-20
flex flex-col items-center justify-center px-1"
>
<
Icon
size=
{
1
8
}
className=
{
`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`
}
/>
<
span
className=
"text-[
8px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
10
)
}
</
span
>
<
div
className=
"w-1
4 h-14 sm:w-16 sm:h-16
flex flex-col items-center justify-center px-1"
>
<
Icon
size=
{
1
6
}
className=
{
`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`
}
/>
<
span
className=
"text-[
7px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
8
)
}
</
span
>
</
div
>
)
}
<
button
onClick=
{
()
=>
removePending
(
i
)
}
className=
"absolute -top-1 -right-1 w-4 h-4 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity shadow"
><
X
size=
{
8
}
/></
button
>
<
div
className=
"absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px"
>
{
fmtSize
(
pf
.
file
.
size
)
}
</
div
>
<
button
onClick=
{
()
=>
removePending
(
i
)
}
className=
"absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
>
<
X
size=
{
10
}
/>
</
button
>
<
div
className=
"absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px"
>
{
fmtSize
(
pf
.
file
.
size
)
}
</
div
>
</
div
>
);
})
}
</
div
>
)
}
<
div
className=
"flex items-end gap-1.5 sm:gap-2"
>
<
button
onClick=
{
toggleSettings
}
className=
{
`p-2 sm:p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`
}
><
Settings2
size=
{
18
}
/></
button
>
<
button
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
className=
{
`p-2 sm:p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`
}
title=
"Attach files"
><
Paperclip
size=
{
18
}
/></
button
>
<
input
ref=
{
fileRef
}
type=
"file"
multiple
className=
"hidden"
accept=
"image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
onChange=
{
(
e
)
=>
{
addFiles
(
Array
.
from
(
e
.
target
.
files
||
[]));
e
.
target
.
value
=
""
;
}
}
/>
<
div
className=
"flex-1 relative"
>
{
/* Input row */
}
<
div
className=
"flex items-end gap-1.5"
>
<
button
onClick=
{
toggleSettings
}
className=
{
`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
}`
}
>
<
Settings2
size=
{
18
}
/>
</
button
>
<
button
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
className=
{
`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
}`
}
title=
"Attach files"
>
<
Paperclip
size=
{
18
}
/>
</
button
>
<
input
ref=
{
fileRef
}
type=
"file"
multiple
className=
"hidden"
accept=
"image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
onChange=
{
(
e
)
=>
{
addFiles
(
Array
.
from
(
e
.
target
.
files
||
[]));
e
.
target
.
value
=
""
;
}
}
/>
<
div
className=
"flex-1 min-w-0"
>
<
textarea
ref=
{
inputRef
}
value=
{
input
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onPaste=
{
handlePaste
}
placeholder=
{
pendingFiles
.
length
?
"Add a message or send…"
:
"Ask anything…"
}
rows=
{
1
}
style=
{
{
maxHeight
:
"160px"
}
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 sm:px-4 sm:py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput=
{
(
e
)
=>
{
e
.
target
.
style
.
height
=
"auto"
;
e
.
target
.
style
.
height
=
Math
.
min
(
e
.
target
.
scrollHeight
,
160
)
+
"px"
;
}
}
ref=
{
inputRef
}
value=
{
input
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onPaste=
{
handlePaste
}
placeholder=
{
pendingFiles
.
length
?
"Add a message…"
:
"Ask anything…"
}
rows=
{
1
}
style=
{
{
maxHeight
:
"120px"
}
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
onInput=
{
(
e
)
=>
{
e
.
target
.
style
.
height
=
"auto"
;
e
.
target
.
style
.
height
=
Math
.
min
(
e
.
target
.
scrollHeight
,
120
)
+
"px"
;
}
}
/>
</
div
>
{
streaming
?
(
<
button
onClick=
{
()
=>
streamManager
.
abortStream
(
chatId
)
}
className=
"p-2 sm:p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
><
Square
size=
{
18
}
/></
button
>
<
button
onClick=
{
()
=>
streamManager
.
abortStream
(
chatId
)
}
className=
"p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
>
<
Square
size=
{
18
}
/>
</
button
>
)
:
(
<
button
onClick=
{
handleSend
}
disabled=
{
(
!
input
.
trim
()
&&
!
pendingFiles
.
length
)
||
uploading
}
className=
"p-2 sm:p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30"
>
<
button
onClick=
{
handleSend
}
disabled=
{
(
!
input
.
trim
()
&&
!
pendingFiles
.
length
)
||
uploading
}
className=
"p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
>
{
uploading
?
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
:
<
Send
size=
{
18
}
/>
}
</
button
>
)
}
</
div
>
<
div
className=
"flex items-center gap-2 mt-1.5 text-[10px] text-anton-muted flex-wrap"
>
{
/* Status bar */
}
<
div
className=
"flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap"
>
<
span
>
{
MODELS
.
find
((
m
)
=>
m
.
id
===
model
)?.
label
}
</
span
>
<
span
>
•
</
span
><
span
>
{
maxTokens
.
toLocaleString
()
}
tok
</
span
>
<
span
>
•
</
span
>
<
span
>
{
maxTokens
.
toLocaleString
()
}
tok
</
span
>
{
reasoningBudget
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
</
span
></>
}
{
selectedKbId
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
📚 RAG
</
span
></>
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
</
span
></>
}
{
messages
.
some
((
m
)
=>
m
.
role
===
"assistant"
)
&&
(
<
button
onClick=
{
async
()
=>
{
const
all
=
messages
.
filter
((
m
)
=>
m
.
role
===
"assistant"
).
map
((
m
)
=>
m
.
content
).
join
(
"
\n\n
---
\n\n
"
);
if
(
all
)
try
{
await
downloadZip
(
state
.
token
,
all
);
}
catch
{
}
}
}
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Code
</
button
>
<
button
onClick=
{
async
()
=>
{
const
all
=
messages
.
filter
((
m
)
=>
m
.
role
===
"assistant"
).
map
((
m
)
=>
m
.
content
).
join
(
"
\n\n
---
\n\n
"
);
if
(
all
)
try
{
await
downloadZip
(
state
.
token
,
all
);
}
catch
{
/* */
}
}
}
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Code
</
button
>
)
}
</
div
>
</
div
>
...
...
frontend/src/components/CodeBlock.jsx
View file @
459309cd
...
...
@@ -3,22 +3,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import
{
oneDark
}
from
"react-syntax-highlighter/dist/esm/styles/prism"
;
import
{
Copy
,
Check
,
Download
,
FileCode
}
from
"lucide-react"
;
// Map common aliases for syntax highlighting
const
LANG_MAP
=
{
cs
:
"csharp"
,
sh
:
"bash"
,
shell
:
"bash"
,
yml
:
"yaml"
,
dockerfile
:
"docker"
,
jsx
:
"jsx"
,
tsx
:
"tsx"
,
py
:
"python"
,
js
:
"javascript"
,
ts
:
"typescript"
,
rb
:
"ruby"
,
rs
:
"rust"
,
kt
:
"kotlin"
,
gd
:
"gdscript"
,
cs
:
"csharp"
,
sh
:
"bash"
,
shell
:
"bash"
,
yml
:
"yaml"
,
dockerfile
:
"docker"
,
jsx
:
"jsx"
,
tsx
:
"tsx"
,
py
:
"python"
,
js
:
"javascript"
,
ts
:
"typescript"
,
rb
:
"ruby"
,
rs
:
"rust"
,
kt
:
"kotlin"
,
gd
:
"gdscript"
,
};
const
customStyle
=
{
...
...
@@ -28,19 +17,18 @@ const customStyle = {
background
:
"#0d0d14"
,
margin
:
0
,
borderRadius
:
0
,
fontSize
:
"0.
82
rem"
,
lineHeight
:
"1.
6
"
,
fontSize
:
"0.
78
rem"
,
lineHeight
:
"1.
55
"
,
},
'code[class*="language-"]'
:
{
...
oneDark
[
'code[class*="language-"]'
],
background
:
"none"
,
fontSize
:
"0.
82
rem"
,
fontSize
:
"0.
78
rem"
,
},
};
export
default
function
CodeBlock
({
language
,
filename
,
code
})
{
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
hlLang
=
LANG_MAP
[
language
]
||
language
||
"text"
;
function
handleCopy
()
{
...
...
@@ -61,46 +49,44 @@ export default function CodeBlock({ language, filename, code }) {
}
return
(
<
div
className=
"my-
3
rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]"
>
{
/* Header
bar
*/
}
<
div
className=
"flex items-center justify-between px-
3 py-1.5 bg-anton-border/30
"
>
<
div
className=
"flex items-center gap-
2 text-xs text-anton-muted
"
>
<
FileCode
size=
{
1
2
}
className=
"text-anton-accent
"
/>
<
div
className=
"my-
2.5
rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]"
>
{
/* Header */
}
<
div
className=
"flex items-center justify-between px-
2.5 sm:px-3 py-1.5 bg-anton-border/30 gap-2
"
>
<
div
className=
"flex items-center gap-
1.5 text-xs text-anton-muted min-w-0
"
>
<
FileCode
size=
{
1
1
}
className=
"text-anton-accent shrink-0
"
/>
{
filename
?
(
<
span
className=
"text-anton-text font-mono"
>
{
filename
}
</
span
>
<
span
className=
"text-anton-text font-mono
truncate text-[11px]
"
>
{
filename
}
</
span
>
)
:
(
<
span
>
{
hlLang
}
</
span
>
<
span
className=
"text-[11px]"
>
{
hlLang
}
</
span
>
)
}
</
div
>
<
div
className=
"flex items-center gap-1"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 px-2 py-0.5 rounded text-[11px] text-anton-muted hover:text-white hover:bg-anton-card transition"
<
div
className=
"flex items-center gap-0.5 shrink-0"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 px-2 py-1 rounded text-[10px] text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[28px]"
>
{
copied
?
<
Check
size=
{
1
1
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
"Copied"
:
"Copy"
}
{
copied
?
<
Check
size=
{
1
0
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
10
}
/>
}
<
span
className=
"hidden sm:inline"
>
{
copied
?
"Copied"
:
"Copy"
}
</
span
>
</
button
>
<
button
onClick=
{
handleDownload
}
className=
"flex items-center gap-1 px-2 py-0.5 rounded text-[11px] text-anton-muted hover:text-anton-accent hover:bg-anton-accent/10 transition"
<
button
onClick=
{
handleDownload
}
className=
"flex items-center gap-1 px-2 py-1 rounded text-[10px] text-anton-muted hover:text-anton-accent hover:bg-anton-accent/10 transition min-h-[28px]"
>
<
Download
size=
{
1
1
}
/>
Download
<
Download
size=
{
1
0
}
/>
<
span
className=
"hidden sm:inline"
>
Download
</
span
>
</
button
>
</
div
>
</
div
>
{
/* Code */
}
<
div
className=
"overflow-x-auto"
>
{
/* Code
with horizontal scroll
*/
}
<
div
className=
"overflow-x-auto
overscroll-x-contain -webkit-overflow-scrolling-touch
"
>
<
SyntaxHighlighter
language=
{
hlLang
}
style=
{
customStyle
}
showLineNumbers
lineNumberStyle=
{
{
minWidth
:
"2.5em"
,
paddingRight
:
"1em"
,
color
:
"#3a3a4a"
,
userSelect
:
"none"
,
}
}
wrapLines
showLineNumbers=
{
code
.
split
(
"
\n
"
).
length
>
3
}
lineNumberStyle=
{
{
color
:
"#333"
,
fontSize
:
"0.7rem"
,
minWidth
:
"2em"
,
paddingRight
:
"0.5em"
}
}
customStyle=
{
{
padding
:
"0.75rem"
,
minWidth
:
"fit-content"
}
}
wrapLongLines=
{
false
}
>
{
code
}
</
SyntaxHighlighter
>
...
...
frontend/src/components/MessageBubble.jsx
View file @
459309cd
...
...
@@ -5,10 +5,12 @@ import CodeBlock from "./CodeBlock";
import
{
getAttachmentUrl
}
from
"../api"
;
import
{
User
,
Flame
,
ChevronDown
,
ChevronRight
,
Brain
,
Copy
,
Check
,
Image
,
Film
,
FileText
,
ExternalLink
,
FileCode
,
File
,
Image
,
Film
,
FileText
,
ExternalLink
,
}
from
"lucide-react"
;
const
FILE_TYPE_ICONS
=
{
image
:
Image
,
video
:
Film
,
document
:
FileText
,
text
:
FileCode
};
const
FILE_TYPE_ICONS
=
{
image
:
Image
,
video
:
Film
,
document
:
FileText
,
text
:
FileText
,
};
const
MessageBubble
=
React
.
memo
(
function
MessageBubble
({
message
,
isStreaming
,
isThinking
,
token
})
{
const
{
role
,
content
,
thinking_content
,
input_tokens
,
output_tokens
,
attachments
}
=
message
;
...
...
@@ -35,20 +37,20 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</
div
>
)
}
<
div
className=
{
`m
ax-w-[92%] sm:max-w-[80%] min-w-0 ${isUser ? "order-first" : "
"}`
}
>
<
div
className=
{
`m
in-w-0 ${isUser ? "max-w-[85%] sm:max-w-[75%]" : "max-w-[90%] sm:max-w-[80%]
"}`
}
>
{
/* Thinking block */
}
{
thinking_content
&&
(
<
div
className=
"mb-2"
>
<
button
onClick=
{
()
=>
setShowThinking
(
!
showThinking
)
}
className=
"flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
className=
"flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1
min-h-[32px]
"
>
<
Brain
size=
{
12
}
/>
{
showThinking
?
<
ChevronDown
size=
{
12
}
/>
:
<
ChevronRight
size=
{
12
}
/>
}
{
isThinking
?
<
span
className=
"thinking-pulse"
>
Reasoning…
</
span
>
:
<
span
>
View reasoning
</
span
>
}
</
button
>
{
(
showThinking
||
isThinking
)
&&
(
<
div
className=
"bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5
text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto
"
>
<
div
className=
"bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5
sm:p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto overscroll-contain break-words
"
>
{
thinking_content
}
{
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse"
/>
}
</
div
>
...
...
@@ -60,22 +62,22 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{
hasAttachments
&&
(
<
div
className=
"mb-2 flex flex-wrap gap-1.5"
>
{
attachments
.
map
((
att
)
=>
{
const
Icon
=
FILE_TYPE_ICONS
[
att
.
file_type
]
||
File
;
const
Icon
=
FILE_TYPE_ICONS
[
att
.
file_type
]
||
File
Text
;
const
url
=
getAttachmentUrl
(
att
.
id
);
if
(
att
.
file_type
===
"image"
)
{
return
(
<
div
key=
{
att
.
id
}
className=
"relative
group
"
>
<
div
key=
{
att
.
id
}
className=
"relative"
>
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
className=
"max-w-[
180px] sm:max-w-[260px] max-h-[140px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-md
"
className=
"max-w-[
200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition
"
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
onError=
{
(
e
)
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
/>
{
expandedImage
===
att
.
id
&&
(
<
div
className=
"fixed inset-0 z-50 bg-black/8
5 flex items-center justify-center p-4
cursor-pointer"
className=
"fixed inset-0 z-50 bg-black/8
0 flex items-center justify-center p-4 sm:p-8
cursor-pointer"
onClick=
{
()
=>
setExpandedImage
(
null
)
}
>
<
img
...
...
@@ -85,7 +87,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
/>
</
div
>
)
}
<
div
className=
"absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1
.5 py-0.5 rounded max-w-[90%] truncate
"
>
<
div
className=
"absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1
py-0.5 rounded
"
>
{
att
.
original_filename
}
</
div
>
</
div
>
...
...
@@ -98,14 +100,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
href=
{
`${url}?token=${token}`
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-2 hover:border-anton-accent transition group m
ax-w-[200
px]"
className=
"flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-2 hover:border-anton-accent transition group m
in-h-[44
px]"
>
<
Icon
size=
{
1
5
}
className=
"shrink-0 text-blue-400"
/>
<
div
className=
"min-w-0
flex-1
"
>
<
div
className=
"text-
[11px] text-white truncate
"
>
{
att
.
original_filename
}
</
div
>
<
Icon
size=
{
1
4
}
className=
"shrink-0 text-blue-400"
/>
<
div
className=
"min-w-0"
>
<
div
className=
"text-
xs text-white truncate max-w-[120px] sm:max-w-[160px]
"
>
{
att
.
original_filename
}
</
div
>
<
div
className=
"text-[9px] text-anton-muted"
>
{
(
att
.
file_size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
</
div
>
<
ExternalLink
size=
{
1
1
}
className=
"text-anton-muted group-hover:text-anton-accent shrink-0"
/>
<
ExternalLink
size=
{
1
0
}
className=
"text-anton-muted group-hover:text-anton-accent shrink-0"
/>
</
a
>
);
})
}
...
...
@@ -113,14 +115,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)
}
{
/* Message bubble */
}
<
div
className=
{
`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${isUser
? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`
}
>
<
div
className=
{
`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${
isUser
? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`
}
>
{
isUser
?
(
<
div
className=
"text-sm whitespace-pre-wrap break-words"
>
{
_stripPrefixes
(
content
)
}
</
div
>
<
div
className=
"text-sm whitespace-pre-wrap break-words
leading-relaxed
"
>
{
_stripPrefixes
(
content
)
}
</
div
>
)
:
(
<
div
className=
"prose-anton text-sm
break-words
"
>
<
div
className=
"prose-anton text-sm"
>
<
ReactMarkdown
remarkPlugins=
{
[
remarkGfm
]
}
components=
{
{
...
...
@@ -148,16 +151,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)
}
</
div
>
{
/*
Meta info
*/
}
{
/*
Actions
*/
}
{
!
isUser
&&
!
isStreaming
&&
content
&&
(
<
div
className=
"flex items-center gap-3 mt-1.5 px-1 flex-wrap"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[10px] sm:text-[11px] text-anton-muted hover:text-white transition"
>
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
<
div
className=
"flex items-center gap-3 mt-1 px-1"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition min-h-[28px]"
>
{
copied
?
<
Check
size=
{
10
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
10
}
/>
}
{
copied
?
"Copied"
:
"Copy"
}
</
button
>
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
<
span
className=
"text-[10px]
sm:text-[11px]
text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓
/
{
output_tokens
?.
toLocaleString
()
}
↑
<
span
className=
"text-[10px] text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓
{
output_tokens
?.
toLocaleString
()
}
↑
</
span
>
)
}
</
div
>
...
...
frontend/src/components/Sidebar.jsx
View file @
459309cd
import
React
,
{
useState
,
useEffect
}
from
"react"
;
import
React
,
{
useState
}
from
"react"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useApp
}
from
"../store"
;
import
{
createChat
,
deleteChat
,
renameChat
}
from
"../api"
;
import
{
listChats
,
deleteChat
,
renameChat
,
listKnowledgeBases
,
createKnowledgeBase
,
deleteKnowledgeBase
,
uploadDocuments
,
}
from
"../api"
;
import
{
Plus
,
Trash2
,
Edit3
,
Check
,
X
,
Flame
,
LogOut
,
Shield
,
MessageSquare
,
BookOpen
,
Upload
,
FolderPlus
,
ChevronRight
,
FileText
,
Loader2
,
Flame
,
Plus
,
MessageSquare
,
Trash2
,
Edit3
,
Check
,
X
,
LogOut
,
Shield
,
BookOpen
,
ChevronRight
,
}
from
"lucide-react"
;
export
default
function
Sidebar
({
activeChatId
,
onSelectChat
,
onNewChat
})
{
export
default
function
Sidebar
({
mobile
,
onClose
})
{
const
{
state
,
dispatch
}
=
useApp
();
const
navigate
=
useNavigate
();
const
[
editId
,
setEditId
]
=
useState
(
null
);
const
[
editTitle
,
setEditTitle
]
=
useState
(
""
);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
kbName
,
setKbName
]
=
useState
(
""
);
const
[
expandedKb
,
setExpandedKb
]
=
useState
(
null
);
const
[
kbDetail
,
setKbDetail
]
=
useState
(
null
);
const
[
kbUploading
,
setKbUploading
]
=
useState
(
false
);
const
[
kbCreating
,
setKbCreating
]
=
useState
(
false
);
const
tab
=
state
.
sidebarTab
||
"chats"
;
useEffect
(()
=>
{
if
(
tab
===
"knowledge"
)
loadKbs
();
},
[
tab
,
state
.
token
]);
async
function
loadKbs
()
{
async
function
handleNew
()
{
try
{
const
data
=
await
listKnowledgeBases
(
state
.
token
);
setKbs
(
data
);
const
chat
=
await
createChat
(
state
.
token
);
dispatch
({
type
:
"ADD_CHAT"
,
chat
}
);
}
catch
{
/* ignore */
}
}
async
function
handleDelete
Chat
(
e
,
i
d
)
{
async
function
handleDelete
(
e
,
chatI
d
)
{
e
.
stopPropagation
();
if
(
!
confirm
(
"Delete this chat?"
))
return
;
try
{
await
deleteChat
(
state
.
token
,
i
d
);
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
:
id
});
await
deleteChat
(
state
.
token
,
chatI
d
);
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
});
}
catch
{
/* ignore */
}
}
function
start
Rename
(
e
,
chat
)
{
function
start
Edit
(
e
,
chat
)
{
e
.
stopPropagation
();
setEditId
(
chat
.
id
);
setEditTitle
(
chat
.
title
);
}
async
function
saveRename
(
id
)
{
if
(
editTitle
.
trim
())
{
async
function
confirmEdit
(
e
)
{
e
.
stopPropagation
();
if
(
editTitle
.
trim
()
&&
editId
)
{
try
{
await
renameChat
(
state
.
token
,
i
d
,
editTitle
.
trim
());
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
,
title
:
editTitle
.
trim
()
}
});
await
renameChat
(
state
.
token
,
editI
d
,
editTitle
.
trim
());
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
editId
,
title
:
editTitle
.
trim
()
}
});
}
catch
{
/* ignore */
}
}
setEditId
(
null
);
}
async
function
handleCreateKb
()
{
const
name
=
kbName
.
trim
();
if
(
!
name
)
return
;
setKbCreating
(
true
);
try
{
await
createKnowledgeBase
(
state
.
token
,
name
);
setKbName
(
""
);
await
loadKbs
();
}
catch
{
/* ignore */
}
setKbCreating
(
false
);
function
cancelEdit
(
e
)
{
e
.
stopPropagation
();
setEditId
(
null
);
}
async
function
handleDeleteKb
(
e
,
kbId
)
{
e
.
stopPropagation
();
if
(
!
confirm
(
"Delete this knowledge base and all its documents?"
))
return
;
try
{
await
deleteKnowledgeBase
(
state
.
token
,
kbId
);
await
loadKbs
();
if
(
expandedKb
===
kbId
)
setExpandedKb
(
null
);
}
catch
{
/* ignore */
}
function
selectChat
(
chatId
)
{
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
});
}
async
function
handleUploadDocs
(
kbId
,
files
)
{
if
(
!
files
.
length
)
return
;
setKbUploading
(
true
);
try
{
await
uploadDocuments
(
state
.
token
,
kbId
,
files
);
await
loadKbs
();
}
catch
(
err
)
{
alert
(
"Upload failed: "
+
err
.
message
);
}
setKbUploading
(
false
);
function
handleLogout
()
{
dispatch
({
type
:
"LOGOUT"
});
}
const
isSuperadmin
=
state
.
user
?.
role
===
"superadmin"
;
return
(
<
div
className=
"h-full flex flex-col bg-anton-surface border-r border-anton-border"
>
{
/*
Logo
*/
}
<
div
className=
"p-
4 pb-3
"
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20"
>
<
div
className=
{
`flex flex-col bg-anton-surface border-r border-anton-border h-full ${mobile ? "w-full" : "w-64"}`
}
>
{
/*
Header
*/
}
<
div
className=
"p-
3 border-b border-anton-border
"
>
<
div
className=
"flex items-center gap-
2.5 mb-
3"
>
<
div
className=
"w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20
shrink-0
"
>
<
Flame
size=
{
18
}
className=
"text-white"
/>
</
div
>
<
div
>
<
h1
className=
"text-sm font-bold text-white
leading-tight
"
>
Son of Anton
</
h1
>
<
p
className=
"text-[10px] text-anton-muted
"
>
Avatar of All Elements of Code
</
p
>
<
div
className=
"flex-1 min-w-0"
>
<
h1
className=
"text-sm font-bold text-white
truncate
"
>
Son of Anton
</
h1
>
<
p
className=
"text-[10px] text-anton-muted
truncate"
>
{
state
.
user
?.
username
||
""
}
</
p
>
</
div
>
{
mobile
&&
(
<
button
onClick=
{
onClose
}
className=
"p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<
X
size=
{
18
}
/>
</
button
>
)
}
</
div
>
</
div
>
{
/* Tab switcher */
}
<
div
className=
"px-3 pb-2 flex gap-1"
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"SET_SIDEBAR_TAB"
,
tab
:
"chats"
})
}
className=
{
`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition ${tab === "chats" ? "bg-anton-accent/15 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"
}`
}
>
<
MessageSquare
size=
{
13
}
/>
Chats
</
button
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"SET_SIDEBAR_TAB"
,
tab
:
"knowledge"
})
}
className=
{
`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition ${tab === "knowledge" ? "bg-green-500/15 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"
}`
}
onClick=
{
handleNew
}
className=
"w-full flex items-center justify-center gap-2 py-2.5 px-3 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition active:scale-[0.98]"
>
<
BookOpen
size=
{
13
}
/>
Knowledge
<
Plus
size=
{
16
}
/>
New Chat
</
button
>
</
div
>
{
/* Content based on tab */
}
<
div
className=
"flex-1 overflow-y-auto px-3 pb-2"
>
{
tab
===
"chats"
?
(
<>
<
button
onClick=
{
onNewChat
}
className=
"w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-dashed border-anton-border text-anton-muted hover:text-white hover:border-anton-accent hover:bg-anton-accent/5 transition text-sm mb-2"
{
/* Chat list */
}
<
div
className=
"flex-1 overflow-y-auto py-1.5 px-1.5 space-y-0.5"
>
{
state
.
chats
.
length
===
0
&&
(
<
p
className=
"text-center text-anton-muted text-xs py-8 px-4"
>
No chats yet. Start a new conversation!
</
p
>
)
}
{
state
.
chats
.
map
((
chat
)
=>
{
const
active
=
chat
.
id
===
state
.
activeChatId
;
const
editing
=
editId
===
chat
.
id
;
return
(
<
div
key=
{
chat
.
id
}
onClick=
{
()
=>
!
editing
&&
selectChat
(
chat
.
id
)
}
className=
{
`group flex items-center gap-2 px-2.5 py-2.5 rounded-lg cursor-pointer transition min-h-[44px] ${
active
? "bg-anton-accent/15 text-white border border-anton-accent/30"
: "text-anton-muted hover:bg-anton-card hover:text-white border border-transparent"
}`
}
>
<
Plus
size=
{
16
}
/>
New Chat
</
button
>
<
div
className=
"space-y-0.5"
>
{
state
.
chats
.
map
((
chat
)
=>
(
<
div
key=
{
chat
.
id
}
onClick=
{
()
=>
onSelectChat
(
chat
.
id
)
}
className=
{
`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition text-sm ${chat.id === activeChatId
? "bg-anton-accent/15 text-white"
: "text-anton-muted hover:bg-anton-card hover:text-white"
}`
}
>
<
MessageSquare
size=
{
14
}
className=
"shrink-0 opacity-50"
/>
{
editId
===
chat
.
id
?
(
<
div
className=
"flex-1 flex items-center gap-1"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
input
value=
{
editTitle
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
saveRename
(
chat
.
id
)
}
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-anton-accent"
autoFocus
/>
<
button
onClick=
{
()
=>
saveRename
(
chat
.
id
)
}
className=
"text-anton-success"
><
Check
size=
{
12
}
/></
button
>
<
button
onClick=
{
()
=>
setEditId
(
null
)
}
className=
"text-anton-muted"
><
X
size=
{
12
}
/></
button
>
</
div
>
)
:
(
<>
<
span
className=
"flex-1 truncate text-xs"
>
{
chat
.
title
}
</
span
>
<
div
className=
"hidden group-hover:flex items-center gap-0.5 shrink-0"
>
<
button
onClick=
{
(
e
)
=>
startRename
(
e
,
chat
)
}
className=
"p-1 rounded hover:bg-anton-bg transition"
><
Edit3
size=
{
11
}
/></
button
>
<
button
onClick=
{
(
e
)
=>
handleDeleteChat
(
e
,
chat
.
id
)
}
className=
"p-1 rounded hover:bg-anton-bg text-anton-danger transition"
><
Trash2
size=
{
11
}
/></
button
>
</
div
>
</>
)
}
<
MessageSquare
size=
{
14
}
className=
"shrink-0 opacity-50"
/>
{
editing
?
(
<
div
className=
"flex-1 flex items-center gap-1 min-w-0"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
input
value=
{
editTitle
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
)
confirmEdit
(
e
);
if
(
e
.
key
===
"Escape"
)
cancelEdit
(
e
);
}
}
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
autoFocus
/>
<
button
onClick=
{
confirmEdit
}
className=
"p-1 text-anton-success"
><
Check
size=
{
14
}
/></
button
>
<
button
onClick=
{
cancelEdit
}
className=
"p-1 text-anton-muted"
><
X
size=
{
14
}
/></
button
>
</
div
>
))
}
</
div
>
</>
)
:
(
/* Knowledge Base tab */
<>
{
/* Create new KB */
}
<
div
className=
"flex gap-1.5 mb-3"
>
<
input
value=
{
kbName
}
onChange=
{
(
e
)
=>
setKbName
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleCreateKb
()
}
placeholder=
"New knowledge base name…"
className=
"flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-green-500 placeholder:text-anton-muted/50"
/>
<
button
onClick=
{
handleCreateKb
}
disabled=
{
!
kbName
.
trim
()
||
kbCreating
}
className=
"px-3 py-2 rounded-lg bg-green-600 text-white text-xs hover:bg-green-500 transition disabled:opacity-40"
>
{
kbCreating
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
FolderPlus
size=
{
14
}
/>
}
</
button
>
</
div
>
{
kbs
.
length
===
0
?
(
<
div
className=
"text-center py-8 text-anton-muted text-xs"
>
<
BookOpen
size=
{
24
}
className=
"mx-auto mb-2 opacity-30"
/>
<
p
>
No knowledge bases yet.
</
p
>
<
p
className=
"mt-1 text-[10px]"
>
Create one above, then upload documents.
</
p
>
</
div
>
)
:
(
<
div
className=
"space-y-1.5"
>
{
kbs
.
map
((
kb
)
=>
(
<
div
key=
{
kb
.
id
}
className=
"bg-anton-card border border-anton-border rounded-xl overflow-hidden"
>
<
div
className=
"flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-anton-bg/50 transition"
onClick=
{
()
=>
setExpandedKb
(
expandedKb
===
kb
.
id
?
null
:
kb
.
id
)
}
>
<
ChevronRight
size=
{
12
}
className=
{
`text-anton-muted transition-transform ${expandedKb === kb.id ? "rotate-90" : ""}`
}
/>
<
BookOpen
size=
{
13
}
className=
"text-green-400 shrink-0"
/>
<
div
className=
"flex-1 min-w-0"
>
<
div
className=
"text-xs text-white truncate"
>
{
kb
.
name
}
</
div
>
<
div
className=
"text-[10px] text-anton-muted"
>
{
kb
.
document_count
}
docs ·
{
kb
.
chunk_count
}
chunks
</
div
>
</
div
>
<
button
onClick=
{
(
e
)
=>
handleDeleteKb
(
e
,
kb
.
id
)
}
className=
"p-1 rounded hover:bg-anton-bg text-anton-muted hover:text-anton-danger transition opacity-0 group-hover:opacity-100"
>
<
Trash2
size=
{
11
}
/>
</
button
>
</
div
>
{
expandedKb
===
kb
.
id
&&
(
<
div
className=
"px-3 pb-3 pt-1 border-t border-anton-border/50 space-y-2 animate-fade-in"
>
<
div
className=
"text-[10px] text-anton-muted space-y-0.5"
>
<
p
>
~
{
kb
.
estimated_tokens
?.
toLocaleString
()
||
0
}
tokens
</
p
>
{
kb
.
description
&&
<
p
>
{
kb
.
description
}
</
p
>
}
</
div
>
<
label
className=
"flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-green-500/30 text-green-400 text-xs cursor-pointer hover:bg-green-500/5 transition"
>
{
kbUploading
?
<
Loader2
size=
{
13
}
className=
"animate-spin"
/>
:
<
Upload
size=
{
13
}
/>
}
{
kbUploading
?
"Uploading…"
:
"Upload documents"
}
<
input
type=
"file"
multiple
className=
"hidden"
accept=
".txt,.md,.pdf,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql"
onChange=
{
(
e
)
=>
{
const
files
=
Array
.
from
(
e
.
target
.
files
||
[]);
if
(
files
.
length
)
handleUploadDocs
(
kb
.
id
,
files
);
e
.
target
.
value
=
""
;
}
}
disabled=
{
kbUploading
}
/>
</
label
>
</
div
>
)
}
)
:
(
<>
<
span
className=
"flex-1 text-sm truncate"
>
{
chat
.
title
}
</
span
>
<
div
className=
"flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
<
button
onClick=
{
(
e
)
=>
startEdit
(
e
,
chat
)
}
className=
"p-1 rounded hover:bg-anton-bg transition"
><
Edit3
size=
{
12
}
/></
button
>
<
button
onClick=
{
(
e
)
=>
handleDelete
(
e
,
chat
.
id
)
}
className=
"p-1 rounded hover:bg-anton-danger/20 text-anton-danger transition"
><
Trash2
size=
{
12
}
/></
button
>
</
div
>
))
}
</
div
>
)
}
</>
)
}
</>
)
}
</
div
>
);
}
)
}
</
div
>
{
/* Footer */
}
<
div
className=
"p-3 border-t border-anton-border"
>
<
div
className=
"flex items-center gap-2 text-xs text-anton-muted"
>
<
div
className=
"flex-1 min-w-0"
>
<
div
className=
"text-white font-medium truncate"
>
{
state
.
user
?.
username
}
</
div
>
<
div
className=
"text-[10px] truncate"
>
{
((
state
.
user
?.
tokens_used_this_month
||
0
)
/
1000
).
toFixed
(
0
)
}
k /
{
((
state
.
user
?.
quota_tokens_monthly
||
0
)
/
1000
).
toFixed
(
0
)
}
k tokens
</
div
>
</
div
>
{
state
.
user
?.
role
===
"superadmin"
&&
(
<
a
href=
"/admin"
className=
"p-1.5 rounded-lg hover:bg-anton-card transition"
title=
"Admin"
>
<
Shield
size=
{
14
}
/>
</
a
>
)
}
{
/* Footer nav */
}
<
div
className=
"p-2 border-t border-anton-border space-y-0.5"
>
<
button
onClick=
{
()
=>
{
navigate
(
"/knowledge"
);
onClose
?.();
}
}
className=
"w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
>
<
BookOpen
size=
{
16
}
/>
Knowledge Bases
<
ChevronRight
size=
{
14
}
className=
"ml-auto opacity-40"
/>
</
button
>
{
isSuperadmin
&&
(
<
button
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
className=
"p-1.5 rounded-lg hover:bg-anton-card transition"
title=
"Logout"
onClick=
{
()
=>
{
navigate
(
"/admin"
);
onClose
?.();
}
}
className=
"w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
>
<
LogOut
size=
{
14
}
/>
<
Shield
size=
{
16
}
/>
Admin Panel
<
ChevronRight
size=
{
14
}
className=
"ml-auto opacity-40"
/>
</
button
>
</
div
>
)
}
<
button
onClick=
{
handleLogout
}
className=
"w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition min-h-[44px]"
>
<
LogOut
size=
{
16
}
/>
Sign Out
</
button
>
{
/* Quota display */
}
{
state
.
user
&&
(
<
div
className=
"px-3 py-2 text-[10px] text-anton-muted"
>
<
div
className=
"flex justify-between mb-1"
>
<
span
>
Tokens used
</
span
>
<
span
>
{
((
state
.
user
.
tokens_used_this_month
||
0
)
/
1000
).
toFixed
(
0
)
}
K /
{
((
state
.
user
.
quota_tokens_monthly
||
0
)
/
1000
).
toFixed
(
0
)
}
K
</
span
>
</
div
>
<
div
className=
"h-1 bg-anton-border rounded-full overflow-hidden"
>
<
div
className=
"h-full bg-anton-accent rounded-full transition-all"
style=
{
{
width
:
`${Math.min(100, ((state.user.tokens_used_this_month || 0) / (state.user.quota_tokens_monthly || 1)) * 100)}%`
}
}
/>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
);
...
...
frontend/src/index.css
View file @
459309cd
...
...
@@ -2,251 +2,284 @@
@tailwind
components
;
@tailwind
utilities
;
/* ─── Base ─────────────────────────────────────── */
*
{
/* ═══════════════════════════════════════════════════
ROOT VARIABLES & BASE
═══════════════════════════════════════════════════ */
:root
{
--sat
:
env
(
safe-area-inset-top
,
0px
);
--sar
:
env
(
safe-area-inset-right
,
0px
);
--sab
:
env
(
safe-area-inset-bottom
,
0px
);
--sal
:
env
(
safe-area-inset-left
,
0px
);
--header-h
:
3.25rem
;
color-scheme
:
dark
;
}
/* ═══════════════════════════════════════════════════
GLOBAL RESETS FOR MOBILE
═══════════════════════════════════════════════════ */
*,
*
::before
,
*
::after
{
-webkit-tap-highlight-color
:
transparent
;
-webkit-touch-callout
:
none
;
}
html
{
overflow
:
hidden
;
height
:
100%
;
height
:
100
dvh
;
}
body
{
overflow
:
hidden
;
height
:
100%
;
height
:
100
dvh
;
overscroll-behavior
:
none
;
-webkit-overflow-scrolling
:
touch
;
font-family
:
'Inter'
,
system-ui
,
-apple-system
,
sans-serif
;
position
:
fixed
;
width
:
100%
;
top
:
0
;
left
:
0
;
}
#root
{
height
:
100%
;
height
:
100
dvh
;
overflow
:
hidden
;
display
:
flex
;
flex-direction
:
column
;
}
/* ─── Scrollbar ────────────────────────────────── */
::-webkit-scrollbar
{
width
:
6px
;
height
:
6px
;
}
/* ═══════════════════════════════════════════════════
SAFE AREA UTILITIES
═══════════════════════════════════════════════════ */
::-webkit-scrollbar-track
{
background
:
transparent
;
}
.safe-top
{
padding-top
:
var
(
--sat
);
}
.safe-bottom
{
padding-bottom
:
max
(
var
(
--sab
),
8px
);
}
.safe-left
{
padding-left
:
var
(
--sal
);
}
.safe-right
{
padding-right
:
var
(
--sar
);
}
::-webkit-scrollbar-thumb
{
background
:
#2a2a3a
;
border-radius
:
3px
;
}
/* ═══════════════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════════════ */
::-webkit-scrollbar-thumb:hover
{
background
:
#3a3a4a
;
}
::-webkit-scrollbar
{
width
:
4px
;
height
:
4px
;
}
::-webkit-scrollbar-track
{
background
:
transparent
;
}
::-webkit-scrollbar-thumb
{
background
:
rgba
(
255
,
255
,
255
,
0.08
);
border-radius
:
4px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
rgba
(
255
,
255
,
255
,
0.15
);
}
/* ─── Prose for AI messages ────────────────────── */
.prose-anton
{
color
:
#e0e0e0
;
word-break
:
break-word
;
overflow-wrap
:
break-word
;
/* ═══════════════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════════ */
@keyframes
fadeIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
6px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
.prose-anton
p
{
margin
:
0.5em
0
;
line-height
:
1.7
;
@keyframes
slideInLeft
{
from
{
transform
:
translateX
(
-100%
);
}
to
{
transform
:
translateX
(
0
);
}
}
.prose-anton
p
:first-child
{
margin-top
:
0
;
@keyframes
slideOutLeft
{
from
{
transform
:
translateX
(
0
);
}
to
{
transform
:
translateX
(
-100%
);
}
}
.prose-anton
p
:last-child
{
margin-bottom
:
0
;
@keyframes
fadeOverlayIn
{
from
{
opacity
:
0
;
}
to
{
opacity
:
1
;
}
}
.prose-anton
strong
{
color
:
#fff
;
font-weight
:
600
;
@keyframes
fadeOverlayOut
{
from
{
opacity
:
1
;
}
to
{
opacity
:
0
;
}
}
.prose-anton
em
{
color
:
#c0c0d0
;
.animate-fade-in
{
animation
:
fadeIn
0.2s
ease-out
both
;
}
.animate-slide-in
{
animation
:
slideInLeft
0.25s
cubic-bezier
(
0.16
,
1
,
0.3
,
1
)
both
;
}
.animate-slide-out
{
animation
:
slideOutLeft
0.2s
ease-in
both
;
}
.animate-overlay-in
{
animation
:
fadeOverlayIn
0.2s
ease-out
both
;
}
.animate-overlay-out
{
animation
:
fadeOverlayOut
0.15s
ease-in
both
;
}
/* ═══════════════════════════════════════════════════
MOBILE INPUT FIXES
═══════════════════════════════════════════════════ */
textarea
,
input
,
select
{
font-size
:
16px
!important
;
/* Prevents iOS zoom on focus */
}
.prose-anton
a
{
color
:
#ff4444
;
text-decoration
:
underline
;
text-underline-offset
:
2px
;
@media
(
min-width
:
640px
)
{
textarea
,
input
,
select
{
font-size
:
14px
!important
;
}
}
.prose-anton
a
:hover
{
color
:
#ff6666
;
textarea
{
-webkit-appearance
:
none
;
appearance
:
none
;
}
.prose-anton
ul
,
.prose-anton
ol
{
margin
:
0.5em
0
;
padding-left
:
1.5em
;
select
{
-webkit-appearance
:
none
;
appearance
:
none
;
background-image
:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23666' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E")
;
background-repeat
:
no-repeat
;
background-position
:
right
10px
center
;
padding-right
:
28px
;
}
.prose-anton
li
{
margin
:
0.25em
0
;
line-height
:
1.6
;
/* ═══════════════════════════════════════════════════
TOUCH-FRIENDLY RANGE SLIDER
═══════════════════════════════════════════════════ */
input
[
type
=
"range"
]
{
-webkit-appearance
:
none
;
appearance
:
none
;
width
:
100%
;
height
:
6px
;
border-radius
:
3px
;
background
:
rgba
(
255
,
255
,
255
,
0.08
);
outline
:
none
;
cursor
:
pointer
;
}
.prose-anton
li
::marker
{
color
:
#ff4444
;
input
[
type
=
"range"
]
::-webkit-slider-thumb
{
-webkit-appearance
:
none
;
appearance
:
none
;
width
:
22px
;
height
:
22px
;
border-radius
:
50%
;
background
:
#e53e3e
;
border
:
2px
solid
#1a1a2e
;
cursor
:
pointer
;
box-shadow
:
0
0
8px
rgba
(
229
,
62
,
62
,
0.3
);
}
.prose-anton
code
:not
(
pre
code
)
{
background
:
#1a1a2e
;
color
:
#ff6b6b
;
padding
:
0.15em
0.4em
;
b
order-radius
:
4px
;
font-size
:
0.88em
;
font-family
:
'JetBrains Mono'
,
monospace
;
input
[
type
=
"range"
]
::-moz-range-thumb
{
width
:
22px
;
height
:
22px
;
border-radius
:
50%
;
b
ackground
:
#e53e3e
;
border
:
2px
solid
#1a1a2e
;
cursor
:
pointer
;
}
.prose-anton
blockquote
{
border-left
:
3px
solid
#ff4444
;
padding-left
:
1em
;
margin
:
0.75em
0
;
color
:
#a0a0b0
;
font-style
:
italic
;
/* ═══════════════════════════════════════════════════
MARKDOWN PROSE
═══════════════════════════════════════════════════ */
.prose-anton
{
color
:
#e2e2ea
;
line-height
:
1.65
;
word-break
:
break-word
;
overflow-wrap
:
anywhere
;
}
.prose-anton
h1
,
.prose-anton
h2
,
.prose-anton
h3
,
.prose-anton
h4
{
.prose-anton
h1
,
.prose-anton
h2
,
.prose-anton
h3
,
.prose-anton
h4
,
.prose-anton
h5
,
.prose-anton
h6
{
color
:
#fff
;
font-weight
:
700
;
margin
:
1em
0
0.5em
;
font-weight
:
600
;
margin-top
:
1.2em
;
margin-bottom
:
0.5em
;
}
.prose-anton
h1
{
font-size
:
1.4em
;
}
.prose-anton
h2
{
font-size
:
1.2em
;
}
.prose-anton
h3
{
font-size
:
1.05em
;
}
.prose-anton
p
{
margin-bottom
:
0.75em
;
}
.prose-anton
ul
,
.prose-anton
ol
{
padding-left
:
1.4em
;
margin-bottom
:
0.75em
;
}
.prose-anton
h1
{
font-size
:
1.4em
;
.prose-anton
li
{
margin-bottom
:
0.25em
;
}
.prose-anton
li
::marker
{
color
:
#555
;
}
.prose-anton
code
:not
(
pre
code
)
{
background
:
rgba
(
255
,
255
,
255
,
0.06
);
border
:
1px
solid
rgba
(
255
,
255
,
255
,
0.08
);
border-radius
:
4px
;
padding
:
0.15em
0.35em
;
font-family
:
'JetBrains Mono'
,
monospace
;
font-size
:
0.85em
;
color
:
#ff6b6b
;
word-break
:
break-all
;
}
.prose-anton
h2
{
font-size
:
1.2em
;
.prose-anton
a
{
color
:
#e53e3e
;
text-decoration
:
underline
;
text-underline-offset
:
2px
;
}
.prose-anton
h3
{
font-size
:
1.1em
;
.prose-anton
blockquote
{
border-left
:
3px
solid
#e53e3e
;
padding
:
0.5em
1em
;
margin
:
0.75em
0
;
background
:
rgba
(
229
,
62
,
62
,
0.04
);
border-radius
:
0
6px
6px
0
;
color
:
#aaa
;
}
.prose-anton
hr
{
border-color
:
#2a2a3a
;
border
:
none
;
border-top
:
1px
solid
rgba
(
255
,
255
,
255
,
0.08
);
margin
:
1.5em
0
;
}
.prose-anton
table
{
width
:
100%
;
border-collapse
:
collapse
;
font-size
:
0.85em
;
margin
:
0.75em
0
;
width
:
100%
;
font-size
:
0.9em
;
display
:
block
;
overflow-x
:
auto
;
-webkit-overflow-scrolling
:
touch
;
}
.prose-anton
th
,
.prose-anton
td
{
border
:
1px
solid
#2a2a3a
;
padding
:
0.4em
0.75em
;
.prose-anton
th
,
.prose-anton
td
{
border
:
1px
solid
rgba
(
255
,
255
,
255
,
0.08
);
padding
:
0.4em
0.7em
;
text-align
:
left
;
white-space
:
nowrap
;
}
.prose-anton
th
{
background
:
#12121c
;
color
:
#fff
;
background
:
rgba
(
255
,
255
,
255
,
0.04
);
font-weight
:
600
;
}
.prose-anton
img
{
max-width
:
100%
;
border-radius
:
8px
;
}
.prose-anton
strong
{
color
:
#fff
;
font-weight
:
600
;
}
.prose-anton
em
{
font-style
:
italic
;
}
/* ─── Animations ───────────────────────────────── */
@keyframes
fade-in
{
from
{
opacity
:
0
;
transform
:
translateY
(
4px
);
}
to
{
opacity
:
1
;
transform
:
none
;
}
}
/* ═══════════════════════════════════════════════════
THINKING PULSE
═══════════════════════════════════════════════════ */
.animate-fade-in
{
animation
:
fade-in
0.2s
ease-out
;
@keyframes
thinkPulse
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.5
;
}
}
@keyframes
thinking-pulse
{
.thinking-pulse
{
animation
:
thinkPulse
1.5s
ease-in-out
infinite
;
}
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.5
;
}
}
/* ═══════════════════════════════════════════════════
MOBILE-SPECIFIC OVERRIDES
═══════════════════════════════════════════════════ */
.thinking-pulse
{
animation
:
thinking-pulse
1.5s
ease-in-out
infinite
;
@media
(
max-width
:
639px
)
{
.prose-anton
{
font-size
:
0.9rem
;
line-height
:
1.6
;
}
.prose-anton
h1
{
font-size
:
1.25em
;
}
.prose-anton
h2
{
font-size
:
1.15em
;
}
}
/* ─── Range slider ─────────────────────────────── */
input
[
type
=
"range"
]
{
-webkit-appearance
:
none
;
appearance
:
none
;
width
:
100%
;
height
:
6px
;
background
:
#1a1a2e
;
border-radius
:
3px
;
outline
:
none
;
}
input
[
type
=
"range"
]
::-webkit-slider-thumb
{
-webkit-appearance
:
none
;
appearance
:
none
;
width
:
16px
;
height
:
16px
;
background
:
#ff4444
;
border-radius
:
50%
;
cursor
:
pointer
;
border
:
2px
solid
#0d0d14
;
}
input
[
type
=
"range"
]
::-moz-range-thumb
{
width
:
16px
;
height
:
16px
;
background
:
#ff4444
;
border-radius
:
50%
;
cursor
:
pointer
;
border
:
2px
solid
#0d0d14
;
}
/* ─── Safe area for mobile ─────────────────────── */
.safe-bottom
{
padding-bottom
:
max
(
env
(
safe-area-inset-bottom
,
0px
),
0.75rem
);
}
/* ─── Selection ────────────────────────────────── */
::selection
{
background
:
rgba
(
255
,
68
,
68
,
0.3
);
color
:
#fff
;
}
/* ─── Mobile keyboard fix ──────────────────────── */
@supports
(
height
:
100
dvh
)
{
.h-dvh
{
height
:
100
dvh
;
}
}
@supports
not
(
height
:
100
dvh
)
{
.h-dvh
{
height
:
100vh
;
}
/* Prevent body scroll when modal/drawer is open */
body
.drawer-open
{
touch-action
:
none
;
}
\ No newline at end of file
frontend/src/pages/ChatPage.jsx
View file @
459309cd
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
React
,
{
useEffect
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
listChats
,
createChat
}
from
"../api"
;
import
Sidebar
from
"../components/Sidebar"
;
import
ChatView
from
"../components/ChatView"
;
import
{
Flame
,
BookOpen
,
Shield
}
from
"lucide-react"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
Flame
,
Menu
,
Plus
,
MessageSquare
}
from
"lucide-react"
;
export
default
function
ChatPage
()
{
const
{
state
,
dispatch
}
=
useApp
();
const
navigate
=
useNavigate
();
const
[
activeChatId
,
setActiveChatId
]
=
useState
(
null
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
false
);
useEffect
(()
=>
{
(
async
()
=>
{
try
{
const
chats
=
await
listChats
(
state
.
token
);
dispatch
({
type
:
"SET_CHATS"
,
chats
});
if
(
chats
.
length
>
0
&&
!
activeChatId
)
{
setActiveChatId
(
chats
[
0
].
id
);
if
(
!
state
.
activeChatId
&&
chats
.
length
>
0
)
{
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
:
chats
[
0
].
id
}
);
}
}
catch
{
}
}
catch
{
/* ignore */
}
})();
},
[
state
.
token
,
dispatch
]);
},
[
state
.
token
]);
async
function
handleNewChat
()
{
try
{
const
chat
=
await
createChat
(
state
.
token
);
dispatch
({
type
:
"ADD_CHAT"
,
chat
});
setActiveChatId
(
chat
.
id
);
setSidebarOpen
(
false
);
}
catch
{
}
}
function
handleSelectChat
(
chatId
)
{
setActiveChatId
(
chatId
);
setSidebarOpen
(
false
);
}
catch
{
/* ignore */
}
}
return
(
<
div
className=
"h-dvh flex bg-anton-bg text-anton-text overflow-hidden"
>
{
/* Sidebar */
}
<
Sidebar
activeChatId=
{
activeChatId
}
onSelectChat=
{
handleSelectChat
}
onNewChat=
{
handleNewChat
}
isOpen=
{
sidebarOpen
}
onClose=
{
()
=>
setSidebarOpen
(
false
)
}
/>
<
div
className=
"h-full h-dvh flex overflow-hidden bg-anton-bg"
>
{
/* Desktop sidebar */
}
<
div
className=
"hidden sm:flex"
>
<
Sidebar
/>
</
div
>
{
/* Mobile sidebar overlay */
}
{
state
.
sidebarOpen
&&
(
<>
<
div
className=
"sm:hidden fixed inset-0 z-40 bg-black/60 animate-overlay-in"
onClick=
{
()
=>
dispatch
({
type
:
"SET_SIDEBAR_OPEN"
,
open
:
false
})
}
/>
<
div
className=
"sm:hidden fixed inset-y-0 left-0 z-50 w-[280px] animate-slide-in safe-top safe-bottom"
>
<
Sidebar
mobile
onClose=
{
()
=>
dispatch
({
type
:
"SET_SIDEBAR_OPEN"
,
open
:
false
})
}
/>
</
div
>
</>
)
}
{
/* Main */
}
<
div
className=
"flex-1 flex flex-col min-h-0 min-w-0"
>
{
/* Top bar */
}
<
div
className=
"border-b border-anton-border bg-anton-surface px-3 py-2 flex items-center gap-2"
>
<
button
onClick=
{
()
=>
setSidebarOpen
(
true
)
}
className=
"sm:hidden p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<
svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
><
line
x1=
"3"
y1=
"6"
x2=
"21"
y2=
"6"
/><
line
x1=
"3"
y1=
"12"
x2=
"21"
y2=
"12"
/><
line
x1=
"3"
y1=
"18"
x2=
"21"
y2=
"18"
/></
svg
>
{
/* Main content */
}
<
div
className=
"flex-1 flex flex-col min-w-0"
>
{
/* Mobile header */
}
<
div
className=
"sm:hidden flex items-center gap-2 px-3 py-2.5 border-b border-anton-border bg-anton-surface safe-top"
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
className=
"p-2 -ml-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<
Menu
size=
{
20
}
/>
</
button
>
<
div
className=
"w-7 h-7 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center"
>
<
Flame
size=
{
14
}
className=
"text-white"
/>
<
div
className=
"flex-1 min-w-0 flex items-center gap-2"
>
<
div
className=
"w-6 h-6 rounded-md bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shrink-0"
>
<
Flame
size=
{
12
}
className=
"text-white"
/>
</
div
>
<
span
className=
"text-sm font-medium text-white truncate"
>
{
state
.
chats
.
find
((
c
)
=>
c
.
id
===
state
.
activeChatId
)?.
title
||
"Son of Anton"
}
</
span
>
</
div
>
<
span
className=
"text-sm font-semibold text-white truncate flex-1"
>
{
state
.
chats
.
find
((
c
)
=>
c
.
id
===
activeChatId
)?.
title
||
"Son of Anton"
}
</
span
>
<
button
onClick=
{
()
=>
navigate
(
"/knowledge"
)
}
className=
"flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-anton-muted hover:text-green-400 hover:bg-green-500/10 transition"
title=
"Knowledge Bases"
onClick=
{
handleNewChat
}
className=
"p-2 -mr-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<
BookOpen
size=
{
14
}
/>
<
span
className=
"hidden sm:inline"
>
Knowledge
</
span
>
<
Plus
size=
{
20
}
/>
</
button
>
{
state
.
user
?.
role
===
"superadmin"
&&
(
<
button
onClick=
{
()
=>
navigate
(
"/admin"
)
}
className=
"flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-anton-muted hover:text-anton-accent hover:bg-anton-accent/10 transition"
title=
"Admin Panel"
>
<
Shield
size=
{
14
}
/>
<
span
className=
"hidden sm:inline"
>
Admin
</
span
>
</
button
>
)
}
</
div
>
{
/* Chat
area
*/
}
{
activeChatId
?
(
<
ChatView
chatId=
{
activeChatId
}
/>
{
/* Chat
or empty state
*/
}
{
state
.
activeChatId
?
(
<
ChatView
chatId=
{
state
.
activeChatId
}
/>
)
:
(
<
div
className=
"flex-1 flex items-center justify-center"
>
<
div
className=
"text-center"
>
<
div
className=
"w-16 h-16
rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center mx-auto mb-4
shadow-lg shadow-anton-accent/20"
>
<
div
className=
"flex-1 flex items-center justify-center
p-6
"
>
<
div
className=
"text-center
max-w-sm
"
>
<
div
className=
"w-16 h-16
mx-auto mb-4 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center
shadow-lg shadow-anton-accent/20"
>
<
Flame
size=
{
32
}
className=
"text-white"
/>
</
div
>
<
h2
className=
"text-xl font-bold text-white mb-2"
>
Son of Anton
</
h2
>
<
p
className=
"text-anton-muted text-sm mb-6"
>
Avatar of All Elements of Code
</
p
>
<
button
onClick=
{
handleNewChat
}
className=
"px-6 py-2.5 rounded-xl bg-anton-accent text-white font-medium hover:opacity-80 transition"
>
Start a Chat
<
p
className=
"text-anton-muted text-sm mb-6"
>
Avatar of All Elements of Code
</
p
>
<
button
onClick=
{
handleNewChat
}
className=
"inline-flex items-center gap-2 px-5 py-3 bg-anton-accent text-white rounded-xl font-medium hover:opacity-90 transition active:scale-95"
>
<
MessageSquare
size=
{
18
}
/>
Start a conversation
</
button
>
</
div
>
</
div
>
...
...
frontend/src/pages/LoginPage.jsx
View file @
459309cd
import
React
,
{
useState
}
from
"react"
;
import
{
Flame
,
LogIn
,
UserPlus
,
Eye
,
EyeOff
}
from
"lucide-react"
;
import
{
login
,
register
}
from
"../api"
;
import
{
useApp
}
from
"../store"
;
import
{
login
,
register
}
from
"../api"
;
import
{
Flame
,
Eye
,
EyeOff
,
Loader2
}
from
"lucide-react"
;
export
default
function
LoginPage
()
{
const
{
dispatch
}
=
useApp
();
...
...
@@ -18,132 +18,104 @@ export default function LoginPage() {
setError
(
""
);
setLoading
(
true
);
try
{
let
res
;
if
(
isRegister
)
{
res
=
await
register
(
username
,
email
,
password
);
}
else
{
res
=
await
login
(
username
,
password
);
}
const
res
=
isRegister
?
await
register
(
username
,
email
,
password
)
:
await
login
(
username
,
password
);
dispatch
({
type
:
"LOGIN"
,
token
:
res
.
token
,
user
:
res
.
user
});
}
catch
(
err
)
{
setError
(
err
.
message
);
setError
(
err
.
message
||
"Authentication failed"
);
}
finally
{
setLoading
(
false
);
}
}
return
(
<
div
className=
"h-full flex items-center justify-center bg-anton-bg p-4"
>
{
/* Glow effect */
}
<
div
className=
"absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-anton-accent/5 rounded-full blur-[120px] pointer-events-none"
/>
<
div
className=
"relative w-full max-w-md animate-fade-in"
>
{
/* Header */
}
<
div
className=
"h-full h-dvh flex items-center justify-center bg-anton-bg px-4 safe-top safe-bottom"
>
<
div
className=
"w-full max-w-sm"
>
{
/* Logo */
}
<
div
className=
"text-center mb-8"
>
<
div
className=
"
inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 mb-4
shadow-lg shadow-anton-accent/20"
>
<
Flame
size=
{
40
}
className=
"text-white"
/>
<
div
className=
"
w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center
shadow-lg shadow-anton-accent/20"
>
<
Flame
size=
{
32
}
className=
"text-white"
/>
</
div
>
<
h1
className=
"text-3xl font-bold text-white tracking-tight"
>
Son of Anton
</
h1
>
<
p
className=
"text-anton-muted mt-2 text-sm"
>
Avatar of All Elements of Code
</
p
>
<
h1
className=
"text-2xl font-bold text-white"
>
Son of Anton
</
h1
>
<
p
className=
"text-anton-muted text-sm mt-1"
>
Avatar of All Elements of Code
</
p
>
</
div
>
{
/* Form Card */
}
<
form
onSubmit=
{
handleSubmit
}
className=
"bg-anton-surface border border-anton-border rounded-2xl p-8 space-y-5 shadow-2xl"
>
<
h2
className=
"text-xl font-semibold text-white text-center"
>
{
isRegister
?
"Create Account"
:
"Welcome Back"
}
</
h2
>
{
error
&&
(
<
div
className=
"bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3"
>
{
error
}
</
div
>
)
}
{
/* Form */
}
<
form
onSubmit=
{
handleSubmit
}
className=
"space-y-4"
>
<
div
>
<
label
className=
"
block text-sm text-anton-muted mb-1.5
"
>
Username
</
label
>
<
label
className=
"
text-xs text-anton-muted mb-1.5 block
"
>
Username
</
label
>
<
input
type=
"text"
value=
{
username
}
onChange=
{
(
e
)
=>
setUsername
(
e
.
target
.
value
)
}
required
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-anton-accent transition"
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white focus:outline-none focus:border-anton-accent transition"
placeholder=
"Enter username"
required
autoComplete=
"username"
autoCapitalize=
"off"
/>
</
div
>
{
isRegister
&&
(
<
div
>
<
label
className=
"
block text-sm text-anton-muted mb-1.5
"
>
Email
</
label
>
<
label
className=
"
text-xs text-anton-muted mb-1.5 block
"
>
Email
</
label
>
<
input
type=
"email"
value=
{
email
}
onChange=
{
(
e
)
=>
setEmail
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white focus:outline-none focus:border-anton-accent transition"
placeholder=
"your@email.com"
required
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-anton-accent transition"
placeholder=
"you@example.com"
autoComplete=
"email"
/>
</
div
>
)
}
<
div
>
<
label
className=
"
block text-sm text-anton-muted mb-1.5
"
>
Password
</
label
>
<
label
className=
"
text-xs text-anton-muted mb-1.5 block
"
>
Password
</
label
>
<
div
className=
"relative"
>
<
input
type=
{
showPw
?
"text"
:
"password"
}
value=
{
password
}
onChange=
{
(
e
)
=>
setPassword
(
e
.
target
.
value
)
}
required
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-4 py-2.5 pr-10 text-white focus:outline-none focus:border-anton-accent transition"
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 pr-12 text-white focus:outline-none focus:border-anton-accent transition"
placeholder=
"••••••••"
required
autoComplete=
{
isRegister
?
"new-password"
:
"current-password"
}
/>
<
button
type=
"button"
onClick=
{
()
=>
setShowPw
(
!
showPw
)
}
className=
"absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white transition"
className=
"absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white transition
p-1
"
>
{
showPw
?
<
EyeOff
size=
{
1
6
}
/>
:
<
Eye
size=
{
16
}
/>
}
{
showPw
?
<
EyeOff
size=
{
1
8
}
/>
:
<
Eye
size=
{
18
}
/>
}
</
button
>
</
div
>
</
div
>
{
error
&&
(
<
div
className=
"bg-anton-danger/10 border border-anton-danger/30 text-anton-danger text-sm rounded-lg px-3 py-2.5"
>
{
error
}
</
div
>
)
}
<
button
type=
"submit"
disabled=
{
loading
}
className=
"w-full
bg-gradient-to-r from-anton-accent to-orange-600 text-white font-semibold rounded-lg py-2.5 hover:opacity-90 transition disabled:opacity-50
flex items-center justify-center gap-2"
className=
"w-full
py-3.5 bg-anton-accent text-white rounded-xl font-semibold hover:opacity-90 transition disabled:opacity-50 active:scale-[0.98]
flex items-center justify-center gap-2"
>
{
loading
?
(
<
div
className=
"w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
)
:
isRegister
?
(
<>
<
UserPlus
size=
{
18
}
/>
Create Account
</>
)
:
(
<>
<
LogIn
size=
{
18
}
/>
Sign In
</>
)
}
{
loading
&&
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
}
{
isRegister
?
"Create Account"
:
"Sign In"
}
</
button
>
<
p
className=
"text-center text-sm text-anton-muted"
>
{
isRegister
?
"Already have an account?"
:
"Don't have an account?"
}{
" "
}
<
button
type=
"button"
onClick=
{
()
=>
{
setIsRegister
(
!
isRegister
);
setError
(
""
);
}
}
className=
"text-anton-accent hover:underline"
>
{
isRegister
?
"Sign in"
:
"Register"
}
</
button
>
</
p
>
<
button
type=
"button"
onClick=
{
()
=>
{
setIsRegister
(
!
isRegister
);
setError
(
""
);
}
}
className=
"w-full text-center text-sm text-anton-muted hover:text-white transition py-2"
>
{
isRegister
?
"Already have an account? Sign in"
:
"Need an account? Register"
}
</
button
>
</
form
>
</
div
>
</
div
>
...
...
frontend/src/store.jsx
View file @
459309cd
import
React
,
{
createContext
,
useContext
,
useReducer
,
use
Effect
}
from
"react"
;
import
React
,
{
createContext
,
useContext
,
useReducer
,
use
Callback
}
from
"react"
;
const
AppContext
=
createContext
(
null
);
...
...
@@ -6,10 +6,10 @@ const initialState = {
token
:
localStorage
.
getItem
(
"token"
)
||
null
,
user
:
null
,
chats
:
[],
activeChatId
:
null
,
chatMessages
:
{},
activeStreams
:
{},
sidebarOpen
:
false
,
sidebarTab
:
"chats"
,
// "chats" | "knowledge"
};
function
reducer
(
state
,
action
)
{
...
...
@@ -17,18 +17,23 @@ function reducer(state, action) {
case
"LOGIN"
:
localStorage
.
setItem
(
"token"
,
action
.
token
);
return
{
...
state
,
token
:
action
.
token
,
user
:
action
.
user
};
case
"SET_TOKEN"
:
localStorage
.
setItem
(
"token"
,
action
.
token
);
return
{
...
state
,
token
:
action
.
token
};
case
"SET_USER"
:
return
{
...
state
,
user
:
action
.
user
};
case
"LOGOUT"
:
localStorage
.
removeItem
(
"token"
);
return
{
...
initialState
,
token
:
null
};
case
"SET_USER"
:
return
{
...
state
,
user
:
action
.
user
};
case
"SET_CHATS"
:
return
{
...
state
,
chats
:
action
.
chats
};
case
"SET_ACTIVE_CHAT"
:
return
{
...
state
,
activeChatId
:
action
.
chatId
,
sidebarOpen
:
false
};
case
"ADD_CHAT"
:
return
{
...
state
,
chats
:
[
action
.
chat
,
...
state
.
chats
]
};
return
{
...
state
,
chats
:
[
action
.
chat
,
...
state
.
chats
],
activeChatId
:
action
.
chat
.
id
,
sidebarOpen
:
false
,
};
case
"UPDATE_CHAT"
:
return
{
...
state
,
...
...
@@ -36,47 +41,49 @@ function reducer(state, action) {
c
.
id
===
action
.
chat
.
id
?
{
...
c
,
...
action
.
chat
}
:
c
),
};
case
"REMOVE_CHAT"
:
case
"REMOVE_CHAT"
:
{
const
remaining
=
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
action
.
chatId
);
return
{
...
state
,
chats
:
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
action
.
chatId
),
chatMessages
:
(()
=>
{
const
m
=
{
...
state
.
chatMessages
};
delete
m
[
action
.
chatId
];
return
m
;
})(),
chats
:
remaining
,
activeChatId
:
state
.
activeChatId
===
action
.
chatId
?
remaining
[
0
]?.
id
||
null
:
state
.
activeChatId
,
};
}
case
"SET_MESSAGES"
:
return
{
...
state
,
chatMessages
:
{
...
state
.
chatMessages
,
[
action
.
chatId
]:
action
.
messages
},
};
case
"ADD_MESSAGE"
:
case
"ADD_MESSAGE"
:
{
const
prev
=
state
.
chatMessages
[
action
.
chatId
]
||
[];
return
{
...
state
,
chatMessages
:
{
...
state
.
chatMessages
,
[
action
.
chatId
]:
[
...(
state
.
chatMessages
[
action
.
chatId
]
||
[]),
action
.
message
,
],
[
action
.
chatId
]:
[...
prev
,
action
.
message
],
},
};
}
case
"SET_STREAMING"
:
return
{
...
state
,
activeStreams
:
action
.
streaming
?
{
...
state
.
activeStreams
,
[
action
.
chatId
]:
true
}
:
(()
=>
{
const
s
=
{
...
state
.
activeStreams
};
delete
s
[
action
.
chatId
];
return
s
;
})(),
:
Object
.
fromEntries
(
Object
.
entries
(
state
.
activeStreams
).
filter
(([
k
])
=>
k
!==
action
.
chatId
)
),
};
case
"SET_SIDEBAR_OPEN"
:
return
{
...
state
,
sidebarOpen
:
action
.
open
};
case
"SET_SIDEBAR_TAB"
:
return
{
...
state
,
sidebarTab
:
action
.
tab
};
case
"TOGGLE_SIDEBAR"
:
return
{
...
state
,
sidebarOpen
:
!
state
.
sidebarOpen
};
default
:
return
state
;
}
...
...
@@ -93,6 +100,6 @@ export function AppProvider({ children }) {
export
function
useApp
()
{
const
ctx
=
useContext
(
AppContext
);
if
(
!
ctx
)
throw
new
Error
(
"useApp must be
used within
AppProvider"
);
if
(
!
ctx
)
throw
new
Error
(
"useApp must be
inside
AppProvider"
);
return
ctx
;
}
\ No newline at end of file
frontend/tailwind.config.js
View file @
459309cd
...
...
@@ -5,18 +5,21 @@ export default {
extend
:
{
colors
:
{
"anton-bg"
:
"#09090f"
,
"anton-surface"
:
"#0
d0d14
"
,
"anton-card"
:
"#1
2121c
"
,
"anton-border"
:
"#1e1e
2e
"
,
"anton-text"
:
"#e
0e0e0
"
,
"anton-muted"
:
"#6b6b8
0
"
,
"anton-accent"
:
"#
ff4444
"
,
"anton-success"
:
"#
22c55e
"
,
"anton-danger"
:
"#e
f4444
"
,
"anton-surface"
:
"#0
f0f1a
"
,
"anton-card"
:
"#1
6162a
"
,
"anton-border"
:
"#1e1e
3a
"
,
"anton-text"
:
"#e
2e2ea
"
,
"anton-muted"
:
"#6b6b8
a
"
,
"anton-accent"
:
"#
e53e3e
"
,
"anton-success"
:
"#
48bb78
"
,
"anton-danger"
:
"#e
53e3e
"
,
},
fontFamily
:
{
sans
:
[
"Inter"
,
"system-ui"
,
"sans-serif"
],
mono
:
[
"JetBrains Mono"
,
"Consolas"
,
"monospace"
],
sans
:
[
"Inter"
,
"system-ui"
,
"-apple-system"
,
"sans-serif"
],
mono
:
[
"JetBrains Mono"
,
"Fira Code"
,
"monospace"
],
},
screens
:
{
"xs"
:
"400px"
,
},
},
},
...
...
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