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
Show 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 {
...
@@ -9,8 +9,8 @@ import {
}
from
"lucide-react"
;
}
from
"lucide-react"
;
const
MODELS
=
[
const
MODELS
=
[
{
id
:
"eu.anthropic.claude-opus-4-6-v1"
,
label
:
"
Claude
Opus 4.6"
},
{
id
:
"eu.anthropic.claude-opus-4-6-v1"
,
label
:
"Opus 4.6"
},
{
id
:
"eu.anthropic.claude-haiku-4-5-20251001-v1:0"
,
label
:
"
Claude
Haiku 4.5"
},
{
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
};
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
...
@@ -20,7 +20,7 @@ const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", doc
function
classifyFile
(
f
)
{
function
classifyFile
(
f
)
{
const
ext
=
(
f
.
name
||
""
).
split
(
"."
).
pop
().
toLowerCase
();
const
ext
=
(
f
.
name
||
""
).
split
(
"."
).
pop
().
toLowerCase
();
const
mime
=
f
.
type
||
""
;
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
.
startsWith
(
"video/"
)
||
[
"mp4"
,
"mov"
,
"avi"
,
"mkv"
,
"webm"
].
includes
(
ext
))
return
"video"
;
if
(
mime
===
"application/pdf"
||
ext
===
"pdf"
)
return
"document"
;
if
(
mime
===
"application/pdf"
||
ext
===
"pdf"
)
return
"document"
;
return
"text"
;
return
"text"
;
...
@@ -64,7 +64,7 @@ export default function ChatView({ chatId }) {
...
@@ -64,7 +64,7 @@ export default function ChatView({ chatId }) {
const
scrollBottom
=
useCallback
(()
=>
{
const
scrollBottom
=
useCallback
(()
=>
{
if
(
!
autoScroll
.
current
||
rafRef
.
current
)
return
;
if
(
!
autoScroll
.
current
||
rafRef
.
current
)
return
;
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
if
(
scrollRef
.
current
)
scrollRef
.
current
.
scrollTop
=
scrollRef
.
current
.
scrollHeight
;
scrollRef
.
current
?.
scrollTo
({
top
:
scrollRef
.
current
.
scrollHeight
})
;
rafRef
.
current
=
null
;
rafRef
.
current
=
null
;
});
});
},
[]);
},
[]);
...
@@ -85,7 +85,6 @@ export default function ChatView({ chatId }) {
...
@@ -85,7 +85,6 @@ export default function ChatView({ chatId }) {
useEffect
(
scrollBottom
,
[
messages
,
streamData
.
text
,
streamData
.
thinking
,
scrollBottom
]);
useEffect
(
scrollBottom
,
[
messages
,
streamData
.
text
,
streamData
.
thinking
,
scrollBottom
]);
useEffect
(()
=>
{
inputRef
.
current
?.
focus
();
},
[
chatId
]);
useEffect
(()
=>
{
inputRef
.
current
?.
focus
();
},
[
chatId
]);
// Sync settings when chat changes
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
currentChat
)
{
if
(
currentChat
)
{
setModel
(
currentChat
.
model
||
MODELS
[
0
].
id
);
setModel
(
currentChat
.
model
||
MODELS
[
0
].
id
);
...
@@ -114,11 +113,21 @@ export default function ChatView({ chatId }) {
...
@@ -114,11 +113,21 @@ export default function ChatView({ chatId }) {
}
}
function
addFiles
(
files
)
{
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
)
{
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
()
{
async
function
handleSend
()
{
...
@@ -137,19 +146,44 @@ export default function ChatView({ chatId }) {
...
@@ -137,19 +146,44 @@ export default function ChatView({ chatId }) {
setUploading
(
false
);
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
(
""
);
setInput
(
""
);
pendingFiles
.
forEach
((
p
)
=>
{
if
(
p
.
preview
)
URL
.
revokeObjectURL
(
p
.
preview
);
});
pendingFiles
.
forEach
((
p
)
=>
{
if
(
p
.
preview
)
URL
.
revokeObjectURL
(
p
.
preview
);
});
setPendingFiles
([]);
setPendingFiles
([]);
autoScroll
.
current
=
true
;
autoScroll
.
current
=
true
;
// Reset textarea height
if
(
inputRef
.
current
)
inputRef
.
current
.
style
.
height
=
"auto"
;
streamManager
.
startStream
({
streamManager
.
startStream
({
token
:
state
.
token
,
chatId
,
token
:
state
.
token
,
body
:
{
content
:
text
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
attachment_ids
:
attIds
},
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
)
{
function
handlePaste
(
e
)
{
const
items
=
Array
.
from
(
e
.
clipboardData
?.
items
||
[]).
filter
((
i
)
=>
i
.
kind
===
"file"
);
const
items
=
Array
.
from
(
e
.
clipboardData
?.
items
||
[]).
filter
((
i
)
=>
i
.
kind
===
"file"
);
...
@@ -159,7 +193,8 @@ export default function ChatView({ chatId }) {
...
@@ -159,7 +193,8 @@ export default function ChatView({ chatId }) {
}
}
function
handleDrop
(
e
)
{
function
handleDrop
(
e
)
{
e
.
preventDefault
();
setDragOver
(
false
);
e
.
preventDefault
();
setDragOver
(
false
);
const
files
=
Array
.
from
(
e
.
dataTransfer
?.
files
||
[]);
const
files
=
Array
.
from
(
e
.
dataTransfer
?.
files
||
[]);
if
(
files
.
length
)
addFiles
(
files
);
if
(
files
.
length
)
addFiles
(
files
);
}
}
...
@@ -167,26 +202,46 @@ export default function ChatView({ chatId }) {
...
@@ -167,26 +202,46 @@ export default function ChatView({ chatId }) {
const
streaming
=
streamData
.
streaming
;
const
streaming
=
streamData
.
streaming
;
return
(
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
&&
(
{
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=
"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"
>
<
div
className=
"text-center"
>
<
Upload
size=
{
40
}
className=
"text-anton-accent mx-auto mb-2 animate-bounce"
/>
<
Upload
size=
{
36
}
className=
"text-anton-accent mx-auto mb-2 animate-bounce"
/>
<
p
className=
"text-white font-semibold"
>
Drop files here
</
p
>
<
p
className=
"text-white font-semibold
text-sm
"
>
Drop files here
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
{
/* Messages */
}
{
/* 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"
>
<
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
}
/>)
}
{
messages
.
map
((
m
)
=>
(
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
/>
))
}
{
streaming
&&
(
streamData
.
thinking
||
streamData
.
text
)
&&
(
{
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
&&
(
{
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"
>
<
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
>
</
div
>
<
span
className=
"text-anton-muted text-sm"
>
Thinking…
</
span
>
<
span
className=
"text-anton-muted text-sm"
>
Thinking…
</
span
>
</
div
>
</
div
>
...
@@ -194,90 +249,170 @@ export default function ChatView({ chatId }) {
...
@@ -194,90 +249,170 @@ export default function ChatView({ chatId }) {
</
div
>
</
div
>
{
/* Input area */
}
{
/* 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
&&
(
{
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"
>
<
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
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
>
<
button
onClick=
{
toggleSettings
}
className=
"text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
<
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
>
<
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
<
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
>)
}
{
MODELS
.
map
((
m
)
=>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>)
}
</
select
>
</
select
>
</
div
>
</
div
>
<
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
>
<
div
className=
"flex justify-between text-xs mb-1.5"
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
className=
"w-full"
/>
<
span
className=
"text-anton-muted"
>
Max Tokens
</
span
>
<
span
className=
"text-anton-accent font-mono"
>
{
maxTokens
.
toLocaleString
()
}
</
span
>
</
div
>
</
div
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
<
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
>
<
div
className=
"flex justify-between text-xs mb-1.5"
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
className=
"w-full"
/>
<
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
>
</
div
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
<
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
BookOpen
size=
{
12
}
/>
Knowledge Base
</
label
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
>
<
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"
>
<
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
>
<
option
value=
""
>
None
</
option
>
{
kbs
.
map
((
kb
)
=>
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs)
</
option
>)
}
{
kbs
.
map
((
kb
)
=>
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs)
</
option
>)
}
</
select
>
</
select
>
{
kbs
.
length
===
0
&&
<
p
className=
"text-[10px] text-anton-muted mt-1"
>
Create knowledge bases in the sidebar → Knowledge tab
</
p
>
}
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
{
/* Pending files */
}
{
pendingFiles
.
length
>
0
&&
(
{
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
)
=>
{
{
pendingFiles
.
map
((
pf
,
i
)
=>
{
const
Icon
=
TYPE_ICONS
[
pf
.
type
]
||
FileText
;
const
Icon
=
TYPE_ICONS
[
pf
.
type
]
||
FileText
;
return
(
return
(
<
div
key=
{
i
}
className=
{
`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`
}
>
<
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
?
(
{
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"
>
<
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
8
}
className=
{
`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`
}
/>
<
Icon
size=
{
1
6
}
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
>
<
span
className=
"text-[
7px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
8
)
}
</
span
>
</
div
>
</
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
>
<
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
>
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
>
</
div
>
)
}
)
}
<
div
className=
"flex items-end gap-1.5 sm:gap-2"
>
{
/* Input row */
}
<
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
>
<
div
className=
"flex items-end gap-1.5"
>
<
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
>
<
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
=
""
;
}
}
/>
onClick=
{
toggleSettings
}
<
div
className=
"flex-1 relative"
>
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
<
textarea
ref=
{
inputRef
}
value=
{
input
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onPaste=
{
handlePaste
}
ref=
{
inputRef
}
placeholder=
{
pendingFiles
.
length
?
"Add a message or send…"
:
"Ask anything…"
}
value=
{
input
}
rows=
{
1
}
style=
{
{
maxHeight
:
"160px"
}
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
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"
onKeyDown=
{
handleKeyDown
}
onInput=
{
(
e
)
=>
{
e
.
target
.
style
.
height
=
"auto"
;
e
.
target
.
style
.
height
=
Math
.
min
(
e
.
target
.
scrollHeight
,
160
)
+
"px"
;
}
}
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
>
</
div
>
{
streaming
?
(
{
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
}
/>
}
{
uploading
?
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
:
<
Send
size=
{
18
}
/>
}
</
button
>
</
button
>
)
}
)
}
</
div
>
</
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
>
{
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
></>
}
{
reasoningBudget
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
</
span
></>
}
{
selectedKbId
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
📚 RAG
</
span
></>
}
{
selectedKbId
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
📚 RAG
</
span
></>
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
</
span
></>
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
</
span
></>
}
{
messages
.
some
((
m
)
=>
m
.
role
===
"assistant"
)
&&
(
{
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
>
</
div
>
</
div
>
...
...
frontend/src/components/CodeBlock.jsx
View file @
459309cd
...
@@ -3,22 +3,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
...
@@ -3,22 +3,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import
{
oneDark
}
from
"react-syntax-highlighter/dist/esm/styles/prism"
;
import
{
oneDark
}
from
"react-syntax-highlighter/dist/esm/styles/prism"
;
import
{
Copy
,
Check
,
Download
,
FileCode
}
from
"lucide-react"
;
import
{
Copy
,
Check
,
Download
,
FileCode
}
from
"lucide-react"
;
// Map common aliases for syntax highlighting
const
LANG_MAP
=
{
const
LANG_MAP
=
{
cs
:
"csharp"
,
cs
:
"csharp"
,
sh
:
"bash"
,
shell
:
"bash"
,
yml
:
"yaml"
,
sh
:
"bash"
,
dockerfile
:
"docker"
,
jsx
:
"jsx"
,
tsx
:
"tsx"
,
py
:
"python"
,
shell
:
"bash"
,
js
:
"javascript"
,
ts
:
"typescript"
,
rb
:
"ruby"
,
rs
:
"rust"
,
yml
:
"yaml"
,
kt
:
"kotlin"
,
gd
:
"gdscript"
,
dockerfile
:
"docker"
,
jsx
:
"jsx"
,
tsx
:
"tsx"
,
py
:
"python"
,
js
:
"javascript"
,
ts
:
"typescript"
,
rb
:
"ruby"
,
rs
:
"rust"
,
kt
:
"kotlin"
,
gd
:
"gdscript"
,
};
};
const
customStyle
=
{
const
customStyle
=
{
...
@@ -28,19 +17,18 @@ const customStyle = {
...
@@ -28,19 +17,18 @@ const customStyle = {
background
:
"#0d0d14"
,
background
:
"#0d0d14"
,
margin
:
0
,
margin
:
0
,
borderRadius
:
0
,
borderRadius
:
0
,
fontSize
:
"0.
82
rem"
,
fontSize
:
"0.
78
rem"
,
lineHeight
:
"1.
6
"
,
lineHeight
:
"1.
55
"
,
},
},
'code[class*="language-"]'
:
{
'code[class*="language-"]'
:
{
...
oneDark
[
'code[class*="language-"]'
],
...
oneDark
[
'code[class*="language-"]'
],
background
:
"none"
,
background
:
"none"
,
fontSize
:
"0.
82
rem"
,
fontSize
:
"0.
78
rem"
,
},
},
};
};
export
default
function
CodeBlock
({
language
,
filename
,
code
})
{
export
default
function
CodeBlock
({
language
,
filename
,
code
})
{
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
hlLang
=
LANG_MAP
[
language
]
||
language
||
"text"
;
const
hlLang
=
LANG_MAP
[
language
]
||
language
||
"text"
;
function
handleCopy
()
{
function
handleCopy
()
{
...
@@ -61,46 +49,44 @@ export default function CodeBlock({ language, filename, code }) {
...
@@ -61,46 +49,44 @@ export default function CodeBlock({ language, filename, code }) {
}
}
return
(
return
(
<
div
className=
"my-
3
rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]"
>
<
div
className=
"my-
2.5
rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]"
>
{
/* Header
bar
*/
}
{
/* Header */
}
<
div
className=
"flex items-center justify-between px-
3 py-1.5 bg-anton-border/30
"
>
<
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-
2 text-xs text-anton-muted
"
>
<
div
className=
"flex items-center gap-
1.5 text-xs text-anton-muted min-w-0
"
>
<
FileCode
size=
{
1
2
}
className=
"text-anton-accent
"
/>
<
FileCode
size=
{
1
1
}
className=
"text-anton-accent shrink-0
"
/>
{
filename
?
(
{
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
>
<
div
className=
"flex items-center gap-1"
>
<
div
className=
"flex items-center gap-0.5 shrink-0"
>
<
button
onClick=
{
handleCopy
}
<
button
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"
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
?
<
Check
size=
{
1
0
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
10
}
/>
}
{
copied
?
"Copied"
:
"Copy"
}
<
span
className=
"hidden sm:inline"
>
{
copied
?
"Copied"
:
"Copy"
}
</
span
>
</
button
>
</
button
>
<
button
onClick=
{
handleDownload
}
<
button
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"
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
size=
{
1
0
}
/>
Download
<
span
className=
"hidden sm:inline"
>
Download
</
span
>
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Code */
}
{
/* Code
with horizontal scroll
*/
}
<
div
className=
"overflow-x-auto"
>
<
div
className=
"overflow-x-auto
overscroll-x-contain -webkit-overflow-scrolling-touch
"
>
<
SyntaxHighlighter
<
SyntaxHighlighter
language=
{
hlLang
}
language=
{
hlLang
}
style=
{
customStyle
}
style=
{
customStyle
}
showLineNumbers
showLineNumbers=
{
code
.
split
(
"
\n
"
).
length
>
3
}
lineNumberStyle=
{
{
lineNumberStyle=
{
{
color
:
"#333"
,
fontSize
:
"0.7rem"
,
minWidth
:
"2em"
,
paddingRight
:
"0.5em"
}
}
minWidth
:
"2.5em"
,
customStyle=
{
{
padding
:
"0.75rem"
,
minWidth
:
"fit-content"
}
}
paddingRight
:
"1em"
,
wrapLongLines=
{
false
}
color
:
"#3a3a4a"
,
userSelect
:
"none"
,
}
}
wrapLines
>
>
{
code
}
{
code
}
</
SyntaxHighlighter
>
</
SyntaxHighlighter
>
...
...
frontend/src/components/MessageBubble.jsx
View file @
459309cd
...
@@ -5,10 +5,12 @@ import CodeBlock from "./CodeBlock";
...
@@ -5,10 +5,12 @@ import CodeBlock from "./CodeBlock";
import
{
getAttachmentUrl
}
from
"../api"
;
import
{
getAttachmentUrl
}
from
"../api"
;
import
{
import
{
User
,
Flame
,
ChevronDown
,
ChevronRight
,
Brain
,
Copy
,
Check
,
User
,
Flame
,
ChevronDown
,
ChevronRight
,
Brain
,
Copy
,
Check
,
Image
,
Film
,
FileText
,
ExternalLink
,
FileCode
,
File
,
Image
,
Film
,
FileText
,
ExternalLink
,
}
from
"lucide-react"
;
}
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
MessageBubble
=
React
.
memo
(
function
MessageBubble
({
message
,
isStreaming
,
isThinking
,
token
})
{
const
{
role
,
content
,
thinking_content
,
input_tokens
,
output_tokens
,
attachments
}
=
message
;
const
{
role
,
content
,
thinking_content
,
input_tokens
,
output_tokens
,
attachments
}
=
message
;
...
@@ -35,20 +37,20 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -35,20 +37,20 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</
div
>
</
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 block */
}
{
thinking_content
&&
(
{
thinking_content
&&
(
<
div
className=
"mb-2"
>
<
div
className=
"mb-2"
>
<
button
<
button
onClick=
{
()
=>
setShowThinking
(
!
showThinking
)
}
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
}
/>
<
Brain
size=
{
12
}
/>
{
showThinking
?
<
ChevronDown
size=
{
12
}
/>
:
<
ChevronRight
size=
{
12
}
/>
}
{
showThinking
?
<
ChevronDown
size=
{
12
}
/>
:
<
ChevronRight
size=
{
12
}
/>
}
{
isThinking
?
<
span
className=
"thinking-pulse"
>
Reasoning…
</
span
>
:
<
span
>
View reasoning
</
span
>
}
{
isThinking
?
<
span
className=
"thinking-pulse"
>
Reasoning…
</
span
>
:
<
span
>
View reasoning
</
span
>
}
</
button
>
</
button
>
{
(
showThinking
||
isThinking
)
&&
(
{
(
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
}
{
thinking_content
}
{
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse"
/>
}
{
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse"
/>
}
</
div
>
</
div
>
...
@@ -60,22 +62,22 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -60,22 +62,22 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{
hasAttachments
&&
(
{
hasAttachments
&&
(
<
div
className=
"mb-2 flex flex-wrap gap-1.5"
>
<
div
className=
"mb-2 flex flex-wrap gap-1.5"
>
{
attachments
.
map
((
att
)
=>
{
{
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
);
const
url
=
getAttachmentUrl
(
att
.
id
);
if
(
att
.
file_type
===
"image"
)
{
if
(
att
.
file_type
===
"image"
)
{
return
(
return
(
<
div
key=
{
att
.
id
}
className=
"relative
group
"
>
<
div
key=
{
att
.
id
}
className=
"relative"
>
<
img
<
img
src=
{
`${url}?token=${token}`
}
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
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
)
}
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
onError=
{
(
e
)
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
onError=
{
(
e
)
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
/>
/>
{
expandedImage
===
att
.
id
&&
(
{
expandedImage
===
att
.
id
&&
(
<
div
<
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
)
}
onClick=
{
()
=>
setExpandedImage
(
null
)
}
>
>
<
img
<
img
...
@@ -85,7 +87,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -85,7 +87,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
/>
/>
</
div
>
</
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
}
{
att
.
original_filename
}
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -98,14 +100,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -98,14 +100,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
href=
{
`${url}?token=${token}`
}
href=
{
`${url}?token=${token}`
}
target=
"_blank"
target=
"_blank"
rel=
"noopener noreferrer"
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"
/>
<
Icon
size=
{
1
4
}
className=
"shrink-0 text-blue-400"
/>
<
div
className=
"min-w-0
flex-1
"
>
<
div
className=
"min-w-0"
>
<
div
className=
"text-
[11px] text-white truncate
"
>
{
att
.
original_filename
}
</
div
>
<
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
className=
"text-[9px] text-anton-muted"
>
{
(
att
.
file_size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
</
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
>
</
a
>
);
);
})
}
})
}
...
@@ -113,14 +115,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -113,14 +115,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)
}
)
}
{
/* Message bubble */
}
{
/* Message bubble */
}
<
div
className=
{
`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${isUser
<
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-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`
}
>
}`
}
>
{
isUser
?
(
{
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
<
ReactMarkdown
remarkPlugins=
{
[
remarkGfm
]
}
remarkPlugins=
{
[
remarkGfm
]
}
components=
{
{
components=
{
{
...
@@ -148,16 +151,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -148,16 +151,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)
}
)
}
</
div
>
</
div
>
{
/*
Meta info
*/
}
{
/*
Actions
*/
}
{
!
isUser
&&
!
isStreaming
&&
content
&&
(
{
!
isUser
&&
!
isStreaming
&&
content
&&
(
<
div
className=
"flex items-center gap-3 mt-1.5 px-1 flex-wrap"
>
<
div
className=
"flex items-center gap-3 mt-1 px-1"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[10px] sm:text-[11px] text-anton-muted hover:text-white transition"
>
<
button
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
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"
}
{
copied
?
"Copied"
:
"Copy"
}
</
button
>
</
button
>
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
<
span
className=
"text-[10px]
sm:text-[11px]
text-anton-muted"
>
<
span
className=
"text-[10px] text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓
/
{
output_tokens
?.
toLocaleString
()
}
↑
{
input_tokens
?.
toLocaleString
()
}
↓
{
output_tokens
?.
toLocaleString
()
}
↑
</
span
>
</
span
>
)
}
)
}
</
div
>
</
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
{
useApp
}
from
"../store"
;
import
{
createChat
,
deleteChat
,
renameChat
}
from
"../api"
;
import
{
import
{
listChats
,
deleteChat
,
renameChat
,
Flame
,
Plus
,
MessageSquare
,
Trash2
,
Edit3
,
Check
,
X
,
listKnowledgeBases
,
createKnowledgeBase
,
deleteKnowledgeBase
,
uploadDocuments
,
LogOut
,
Shield
,
BookOpen
,
ChevronRight
,
}
from
"../api"
;
import
{
Plus
,
Trash2
,
Edit3
,
Check
,
X
,
Flame
,
LogOut
,
Shield
,
MessageSquare
,
BookOpen
,
Upload
,
FolderPlus
,
ChevronRight
,
FileText
,
Loader2
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
export
default
function
Sidebar
({
activeChatId
,
onSelectChat
,
onNewChat
})
{
export
default
function
Sidebar
({
mobile
,
onClose
})
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
navigate
=
useNavigate
();
const
[
editId
,
setEditId
]
=
useState
(
null
);
const
[
editId
,
setEditId
]
=
useState
(
null
);
const
[
editTitle
,
setEditTitle
]
=
useState
(
""
);
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
{
try
{
const
data
=
await
listKnowledgeBases
(
state
.
token
);
const
chat
=
await
createChat
(
state
.
token
);
setKbs
(
data
);
dispatch
({
type
:
"ADD_CHAT"
,
chat
}
);
}
catch
{
/* ignore */
}
}
catch
{
/* ignore */
}
}
}
async
function
handleDelete
Chat
(
e
,
i
d
)
{
async
function
handleDelete
(
e
,
chatI
d
)
{
e
.
stopPropagation
();
e
.
stopPropagation
();
if
(
!
confirm
(
"Delete this chat?"
))
return
;
if
(
!
confirm
(
"Delete this chat?"
))
return
;
try
{
try
{
await
deleteChat
(
state
.
token
,
i
d
);
await
deleteChat
(
state
.
token
,
chatI
d
);
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
:
id
});
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
});
}
catch
{
/* ignore */
}
}
catch
{
/* ignore */
}
}
}
function
start
Rename
(
e
,
chat
)
{
function
start
Edit
(
e
,
chat
)
{
e
.
stopPropagation
();
e
.
stopPropagation
();
setEditId
(
chat
.
id
);
setEditId
(
chat
.
id
);
setEditTitle
(
chat
.
title
);
setEditTitle
(
chat
.
title
);
}
}
async
function
saveRename
(
id
)
{
async
function
confirmEdit
(
e
)
{
if
(
editTitle
.
trim
())
{
e
.
stopPropagation
();
if
(
editTitle
.
trim
()
&&
editId
)
{
try
{
try
{
await
renameChat
(
state
.
token
,
i
d
,
editTitle
.
trim
());
await
renameChat
(
state
.
token
,
editI
d
,
editTitle
.
trim
());
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
,
title
:
editTitle
.
trim
()
}
});
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
editId
,
title
:
editTitle
.
trim
()
}
});
}
catch
{
/* ignore */
}
}
catch
{
/* ignore */
}
}
}
setEditId
(
null
);
setEditId
(
null
);
}
}
async
function
handleCreateKb
()
{
function
cancelEdit
(
e
)
{
const
name
=
kbName
.
trim
();
if
(
!
name
)
return
;
setKbCreating
(
true
);
try
{
await
createKnowledgeBase
(
state
.
token
,
name
);
setKbName
(
""
);
await
loadKbs
();
}
catch
{
/* ignore */
}
setKbCreating
(
false
);
}
async
function
handleDeleteKb
(
e
,
kbId
)
{
e
.
stopPropagation
();
e
.
stopPropagation
();
if
(
!
confirm
(
"Delete this knowledge base and all its documents?"
))
return
;
setEditId
(
null
);
try
{
await
deleteKnowledgeBase
(
state
.
token
,
kbId
);
await
loadKbs
();
if
(
expandedKb
===
kbId
)
setExpandedKb
(
null
);
}
catch
{
/* ignore */
}
}
}
async
function
handleUploadDocs
(
kbId
,
files
)
{
function
selectChat
(
chatId
)
{
if
(
!
files
.
length
)
return
;
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
});
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
(
return
(
<
div
className=
"h-full flex flex-col bg-anton-surface border-r border-anton-border"
>
<
div
className=
{
`flex flex-col bg-anton-surface border-r border-anton-border h-full ${mobile ? "w-full" : "w-64"}`
}
>
{
/*
Logo
*/
}
{
/*
Header
*/
}
<
div
className=
"p-
4 pb-3
"
>
<
div
className=
"p-
3 border-b border-anton-border
"
>
<
div
className=
"flex items-center gap-3"
>
<
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"
>
<
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"
/>
<
Flame
size=
{
18
}
className=
"text-white"
/>
</
div
>
</
div
>
<
div
>
<
div
className=
"flex-1 min-w-0"
>
<
h1
className=
"text-sm font-bold text-white leading-tight"
>
Son of Anton
</
h1
>
<
h1
className=
"text-sm font-bold text-white truncate"
>
Son of Anton
</
h1
>
<
p
className=
"text-[10px] text-anton-muted"
>
Avatar of All Elements of Code
</
p
>
<
p
className=
"text-[10px] text-anton-muted truncate"
>
{
state
.
user
?.
username
||
""
}
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
{
mobile
&&
(
{
/* Tab switcher */
}
<
button
onClick=
{
onClose
}
className=
"p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<
div
className=
"px-3 pb-2 flex gap-1"
>
<
X
size=
{
18
}
/>
<
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"
}`
}
>
<
BookOpen
size=
{
13
}
/>
Knowledge
</
button
>
</
button
>
)
}
</
div
>
</
div
>
{
/* Content based on tab */
}
<
div
className=
"flex-1 overflow-y-auto px-3 pb-2"
>
{
tab
===
"chats"
?
(
<>
<
button
<
button
onClick=
{
onNewChat
}
onClick=
{
handleNew
}
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
"
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]
"
>
>
<
Plus
size=
{
16
}
/>
New Chat
<
Plus
size=
{
16
}
/>
New Chat
</
button
>
</
button
>
</
div
>
{
/* 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
;
<
div
className=
"space-y-0.5"
>
return
(
{
state
.
chats
.
map
((
chat
)
=>
(
<
div
<
div
key=
{
chat
.
id
}
key=
{
chat
.
id
}
onClick=
{
()
=>
onSelectChat
(
chat
.
id
)
}
onClick=
{
()
=>
!
editing
&&
selectChat
(
chat
.
id
)
}
className=
{
`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition text-sm ${chat.id === activeChatId
className=
{
`group flex items-center gap-2 px-2.5 py-2.5 rounded-lg cursor-pointer transition min-h-[44px] ${
? "bg-anton-accent/15 text-white"
active
: "text-anton-muted hover:bg-anton-card hover:text-white"
? "bg-anton-accent/15 text-white border border-anton-accent/30"
: "text-anton-muted hover:bg-anton-card hover:text-white border border-transparent"
}`
}
}`
}
>
>
<
MessageSquare
size=
{
14
}
className=
"shrink-0 opacity-50"
/>
<
MessageSquare
size=
{
14
}
className=
"shrink-0 opacity-50"
/>
{
editId
===
chat
.
id
?
(
<
div
className=
"flex-1 flex items-center gap-1"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
{
editing
?
(
<
div
className=
"flex-1 flex items-center gap-1 min-w-0"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
input
<
input
value=
{
editTitle
}
value=
{
editTitle
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
saveRename
(
chat
.
id
)
}
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-0.5 text-xs text-white focus:outline-none focus:border-anton-accent
"
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
autoFocus
/>
/>
<
button
onClick=
{
()
=>
saveRename
(
chat
.
id
)
}
className=
"text-anton-success"
><
Check
size=
{
12
}
/></
button
>
<
button
onClick=
{
confirmEdit
}
className=
"p-1 text-anton-success"
><
Check
size=
{
14
}
/></
button
>
<
button
onClick=
{
()
=>
setEditId
(
null
)
}
className=
"text-anton-muted"
><
X
size=
{
12
}
/></
button
>
<
button
onClick=
{
cancelEdit
}
className=
"p-1 text-anton-muted"
><
X
size=
{
14
}
/></
button
>
</
div
>
</
div
>
)
:
(
)
:
(
<>
<>
<
span
className=
"flex-1 truncate text-xs
"
>
{
chat
.
title
}
</
span
>
<
span
className=
"flex-1 text-sm truncate
"
>
{
chat
.
title
}
</
span
>
<
div
className=
"hidden group-hover:flex items-center gap-0.5
shrink-0"
>
<
div
className=
"flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity
shrink-0"
>
<
button
onClick=
{
(
e
)
=>
startRename
(
e
,
chat
)
}
className=
"p-1 rounded hover:bg-anton-bg transition"
><
Edit3
size=
{
11
}
/></
button
>
<
button
onClick=
{
(
e
)
=>
startEdit
(
e
,
chat
)
}
className=
"p-1 rounded hover:bg-anton-bg transition"
><
Edit3
size=
{
12
}
/></
button
>
<
button
onClick=
{
(
e
)
=>
handleDeleteChat
(
e
,
chat
.
id
)
}
className=
"p-1 rounded hover:bg-anton-bg text-anton-danger transition"
><
Trash2
size=
{
11
}
/></
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
>
))
}
);
})
}
</
div
>
</
div
>
</>
)
:
(
{
/* Footer nav */
}
/* Knowledge Base tab */
<
div
className=
"p-2 border-t border-anton-border space-y-0.5"
>
<>
{
/* 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
<
button
onClick=
{
handleCreateKb
}
onClick=
{
()
=>
{
navigate
(
"/knowledge"
);
onClose
?.();
}
}
disabled=
{
!
kbName
.
trim
()
||
kbCreating
}
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]"
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
}
/>
}
<
BookOpen
size=
{
16
}
/>
Knowledge Bases
<
ChevronRight
size=
{
14
}
className=
"ml-auto opacity-40"
/>
</
button
>
</
button
>
</
div
>
{
isSuperadmin
&&
(
<
button
{
kbs
.
length
===
0
?
(
onClick=
{
()
=>
{
navigate
(
"/admin"
);
onClose
?.();
}
}
<
div
className=
"text-center py-8 text-anton-muted text-xs"
>
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=
{
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" : ""}`
}
/>
<
Shield
size=
{
16
}
/>
Admin Panel
<
ChevronRight
size=
{
14
}
className=
"ml-auto opacity-40"
/>
<
BookOpen
size=
{
13
}
className=
"text-green-400 shrink-0"
/>
</
button
>
<
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
<
button
onClick=
{
(
e
)
=>
handleDeleteKb
(
e
,
kb
.
id
)
}
onClick=
{
handleLogout
}
className=
"p-1 rounded hover:bg-anton-bg text-anton-muted hover:text-anton-danger transition opacity-0 group-hover:opacity-100
"
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]
"
>
>
<
Trash2
size=
{
11
}
/>
<
LogOut
size=
{
16
}
/>
Sign Out
</
button
>
</
button
>
</
div
>
{
expandedKb
===
kb
.
id
&&
(
{
/* Quota display */
}
<
div
className=
"px-3 pb-3 pt-1 border-t border-anton-border/50 space-y-2 animate-fade-in"
>
{
state
.
user
&&
(
<
div
className=
"text-[10px] text-anton-muted space-y-0.5"
>
<
div
className=
"px-3 py-2 text-[10px] text-anton-muted"
>
<
p
>
~
{
kb
.
estimated_tokens
?.
toLocaleString
()
||
0
}
tokens
</
p
>
<
div
className=
"flex justify-between mb-1"
>
{
kb
.
description
&&
<
p
>
{
kb
.
description
}
</
p
>
}
<
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
>
<
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"
>
<
div
className=
"h-1 bg-anton-border rounded-full overflow-hidden"
>
{
kbUploading
?
<
Loader2
size=
{
13
}
className=
"animate-spin"
/>
:
<
Upload
size=
{
13
}
/>
}
<
div
{
kbUploading
?
"Uploading…"
:
"Upload documents"
}
className=
"h-full bg-anton-accent rounded-full transition-all"
<
input
style=
{
{
width
:
`${Math.min(100, ((state.user.tokens_used_this_month || 0) / (state.user.quota_tokens_monthly || 1)) * 100)}%`
}
}
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
>
)
}
</
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
>
</
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
>
)
}
)
}
<
button
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
className=
"p-1.5 rounded-lg hover:bg-anton-card transition"
title=
"Logout"
>
<
LogOut
size=
{
14
}
/>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
);
);
...
...
frontend/src/index.css
View file @
459309cd
...
@@ -2,251 +2,284 @@
...
@@ -2,251 +2,284 @@
@tailwind
components
;
@tailwind
components
;
@tailwind
utilities
;
@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-tap-highlight-color
:
transparent
;
-webkit-touch-callout
:
none
;
}
}
html
{
html
{
overflow
:
hidden
;
overflow
:
hidden
;
height
:
100%
;
height
:
100%
;
height
:
100
dvh
;
}
}
body
{
body
{
overflow
:
hidden
;
overflow
:
hidden
;
height
:
100%
;
height
:
100%
;
height
:
100
dvh
;
overscroll-behavior
:
none
;
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
{
#root
{
height
:
100%
;
height
:
100%
;
height
:
100
dvh
;
overflow
:
hidden
;
display
:
flex
;
flex-direction
:
column
;
}
}
/* ─── Scrollbar ────────────────────────────────── */
/* ═══════════════════════════════════════════════════
::-webkit-scrollbar
{
SAFE AREA UTILITIES
width
:
6px
;
═══════════════════════════════════════════════════ */
height
:
6px
;
}
::-webkit-scrollbar-track
{
.safe-top
{
padding-top
:
var
(
--sat
);
}
background
:
transparent
;
.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
;
SCROLLBAR
border-radius
:
3px
;
═══════════════════════════════════════════════════ */
}
::-webkit-scrollbar-thumb:hover
{
::-webkit-scrollbar
{
width
:
4px
;
height
:
4px
;
}
background
:
#3a3a4a
;
::-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
{
ANIMATIONS
color
:
#e0e0e0
;
═══════════════════════════════════════════════════ */
word-break
:
break-word
;
overflow-wrap
:
break-word
;
@keyframes
fadeIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
6px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
}
.prose-anton
p
{
@keyframes
slideInLeft
{
margin
:
0.5em
0
;
from
{
transform
:
translateX
(
-100%
);
}
line-height
:
1.7
;
to
{
transform
:
translateX
(
0
);
}
}
}
.prose-anton
p
:first-child
{
@keyframes
slideOutLeft
{
margin-top
:
0
;
from
{
transform
:
translateX
(
0
);
}
to
{
transform
:
translateX
(
-100%
);
}
}
}
.prose-anton
p
:last-child
{
@keyframes
fadeOverlayIn
{
margin-bottom
:
0
;
from
{
opacity
:
0
;
}
to
{
opacity
:
1
;
}
}
}
.prose-anton
strong
{
@keyframes
fadeOverlayOut
{
color
:
#fff
;
from
{
opacity
:
1
;
}
font-weight
:
600
;
to
{
opacity
:
0
;
}
}
}
.prose-anton
em
{
.animate-fade-in
{
animation
:
fadeIn
0.2s
ease-out
both
;
}
color
:
#c0c0d0
;
.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
{
@media
(
min-width
:
640px
)
{
color
:
#ff4444
;
textarea
,
input
,
select
{
text-decoration
:
underline
;
font-size
:
14px
!important
;
text-underline-offset
:
2px
;
}
}
}
.prose-anton
a
:hover
{
textarea
{
color
:
#ff6666
;
-webkit-appearance
:
none
;
appearance
:
none
;
}
}
.prose-anton
ul
,
select
{
.prose-anton
ol
{
-webkit-appearance
:
none
;
margin
:
0.5em
0
;
appearance
:
none
;
padding-left
:
1.5em
;
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
;
TOUCH-FRIENDLY RANGE SLIDER
line-height
:
1.6
;
═══════════════════════════════════════════════════ */
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
{
input
[
type
=
"range"
]
::-webkit-slider-thumb
{
color
:
#ff4444
;
-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
)
{
input
[
type
=
"range"
]
::-moz-range-thumb
{
background
:
#1a1a2e
;
width
:
22px
;
color
:
#ff6b6b
;
height
:
22px
;
padding
:
0.15em
0.4em
;
border-radius
:
50%
;
b
order-radius
:
4px
;
b
ackground
:
#e53e3e
;
font-size
:
0.88em
;
border
:
2px
solid
#1a1a2e
;
font-family
:
'JetBrains Mono'
,
monospace
;
cursor
:
pointer
;
}
}
.prose-anton
blockquote
{
/* ═══════════════════════════════════════════════════
border-left
:
3px
solid
#ff4444
;
MARKDOWN PROSE
padding-left
:
1em
;
═══════════════════════════════════════════════════ */
margin
:
0.75em
0
;
color
:
#a0a0b0
;
.prose-anton
{
font-style
:
italic
;
color
:
#e2e2ea
;
line-height
:
1.65
;
word-break
:
break-word
;
overflow-wrap
:
anywhere
;
}
}
.prose-anton
h1
,
.prose-anton
h1
,
.prose-anton
h2
,
.prose-anton
h3
,
.prose-anton
h2
,
.prose-anton
h4
,
.prose-anton
h5
,
.prose-anton
h6
{
.prose-anton
h3
,
.prose-anton
h4
{
color
:
#fff
;
color
:
#fff
;
font-weight
:
700
;
font-weight
:
600
;
margin
:
1em
0
0.5em
;
margin-top
:
1.2em
;
margin-bottom
:
0.5em
;
}
}
.prose-anton
h1
{
.prose-anton
h1
{
font-size
:
1.4em
;
}
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
h2
{
.prose-anton
li
{
margin-bottom
:
0.25em
;
}
font-size
:
1.2em
;
.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
h3
{
.prose-anton
a
{
font-size
:
1.1em
;
color
:
#e53e3e
;
text-decoration
:
underline
;
text-underline-offset
:
2px
;
}
.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
{
.prose-anton
hr
{
border-color
:
#2a2a3a
;
border
:
none
;
border-top
:
1px
solid
rgba
(
255
,
255
,
255
,
0.08
);
margin
:
1.5em
0
;
margin
:
1.5em
0
;
}
}
.prose-anton
table
{
.prose-anton
table
{
width
:
100%
;
border-collapse
:
collapse
;
border-collapse
:
collapse
;
font-size
:
0.85em
;
margin
:
0.75em
0
;
margin
:
0.75em
0
;
width
:
100%
;
display
:
block
;
font-size
:
0.9em
;
overflow-x
:
auto
;
-webkit-overflow-scrolling
:
touch
;
}
}
.prose-anton
th
,
.prose-anton
th
,
.prose-anton
td
{
.prose-anton
td
{
border
:
1px
solid
rgba
(
255
,
255
,
255
,
0.08
);
border
:
1px
solid
#2a2a3a
;
padding
:
0.4em
0.7em
;
padding
:
0.4em
0.75em
;
text-align
:
left
;
text-align
:
left
;
white-space
:
nowrap
;
}
}
.prose-anton
th
{
.prose-anton
th
{
background
:
#12121c
;
background
:
rgba
(
255
,
255
,
255
,
0.04
);
color
:
#fff
;
font-weight
:
600
;
font-weight
:
600
;
}
}
.prose-anton
img
{
.prose-anton
strong
{
color
:
#fff
;
font-weight
:
600
;
}
max-width
:
100%
;
.prose-anton
em
{
font-style
:
italic
;
}
border-radius
:
8px
;
}
/* ─── Animations ───────────────────────────────── */
/* ═══════════════════════════════════════════════════
@keyframes
fade-in
{
THINKING PULSE
from
{
═══════════════════════════════════════════════════ */
opacity
:
0
;
transform
:
translateY
(
4px
);
}
to
{
opacity
:
1
;
transform
:
none
;
}
}
.animate-fade-in
{
@keyframes
thinkPulse
{
animation
:
fade-in
0.2s
ease-out
;
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.5
;
}
}
}
@keyframes
thinking-pulse
{
.thinking-pulse
{
animation
:
thinkPulse
1.5s
ease-in-out
infinite
;
}
0
%,
/* ═══════════════════════════════════════════════════
100
%
{
MOBILE-SPECIFIC OVERRIDES
opacity
:
1
;
═══════════════════════════════════════════════════ */
}
50
%
{
opacity
:
0.5
;
}
}
.thinking-pulse
{
@media
(
max-width
:
639px
)
{
animation
:
thinking-pulse
1.5s
ease-in-out
infinite
;
.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 ─────────────────────────────── */
/* Prevent body scroll when modal/drawer is open */
input
[
type
=
"range"
]
{
body
.drawer-open
{
-webkit-appearance
:
none
;
touch-action
:
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
;
}
}
}
\ 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
{
useApp
}
from
"../store"
;
import
{
listChats
,
createChat
}
from
"../api"
;
import
{
listChats
,
createChat
}
from
"../api"
;
import
Sidebar
from
"../components/Sidebar"
;
import
Sidebar
from
"../components/Sidebar"
;
import
ChatView
from
"../components/ChatView"
;
import
ChatView
from
"../components/ChatView"
;
import
{
Flame
,
BookOpen
,
Shield
}
from
"lucide-react"
;
import
{
Flame
,
Menu
,
Plus
,
MessageSquare
}
from
"lucide-react"
;
import
{
useNavigate
}
from
"react-router-dom"
;
export
default
function
ChatPage
()
{
export
default
function
ChatPage
()
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
navigate
=
useNavigate
();
const
[
activeChatId
,
setActiveChatId
]
=
useState
(
null
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
false
);
useEffect
(()
=>
{
useEffect
(()
=>
{
(
async
()
=>
{
(
async
()
=>
{
try
{
try
{
const
chats
=
await
listChats
(
state
.
token
);
const
chats
=
await
listChats
(
state
.
token
);
dispatch
({
type
:
"SET_CHATS"
,
chats
});
dispatch
({
type
:
"SET_CHATS"
,
chats
});
if
(
chats
.
length
>
0
&&
!
activeChatId
)
{
if
(
!
state
.
activeChatId
&&
chats
.
length
>
0
)
{
setActiveChatId
(
chats
[
0
].
id
);
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
:
chats
[
0
].
id
}
);
}
}
}
catch
{
}
}
catch
{
/* ignore */
}
})();
})();
},
[
state
.
token
,
dispatch
]);
},
[
state
.
token
]);
async
function
handleNewChat
()
{
async
function
handleNewChat
()
{
try
{
try
{
const
chat
=
await
createChat
(
state
.
token
);
const
chat
=
await
createChat
(
state
.
token
);
dispatch
({
type
:
"ADD_CHAT"
,
chat
});
dispatch
({
type
:
"ADD_CHAT"
,
chat
});
setActiveChatId
(
chat
.
id
);
}
catch
{
/* ignore */
}
setSidebarOpen
(
false
);
}
catch
{
}
}
function
handleSelectChat
(
chatId
)
{
setActiveChatId
(
chatId
);
setSidebarOpen
(
false
);
}
}
return
(
return
(
<
div
className=
"h-dvh flex bg-anton-bg text-anton-text overflow-hidden"
>
<
div
className=
"h-full h-dvh flex overflow-hidden bg-anton-bg"
>
{
/* Sidebar */
}
{
/* Desktop sidebar */
}
<
Sidebar
<
div
className=
"hidden sm:flex"
>
activeChatId=
{
activeChatId
}
<
Sidebar
/>
onSelectChat=
{
handleSelectChat
}
</
div
>
onNewChat=
{
handleNewChat
}
isOpen=
{
sidebarOpen
}
onClose=
{
()
=>
setSidebarOpen
(
false
)
}
/>
{
/* M
ain
*/
}
{
/* M
obile sidebar overlay
*/
}
<
div
className=
"flex-1 flex flex-col min-h-0 min-w-0"
>
{
state
.
sidebarOpen
&&
(
{
/* Top bar */
}
<>
<
div
className=
"border-b border-anton-border bg-anton-surface px-3 py-2 flex items-center gap-2"
>
<
div
<
button
onClick=
{
()
=>
setSidebarOpen
(
true
)
}
className=
"sm:hidden p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
>
className=
"sm:hidden fixed inset-0 z-40 bg-black/60 animate-overlay-in"
<
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
>
onClick=
{
()
=>
dispatch
({
type
:
"SET_SIDEBAR_OPEN"
,
open
:
false
})
}
</
button
>
/
>
<
div
className=
"
w-7 h-7 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center
"
>
<
div
className=
"
sm:hidden fixed inset-y-0 left-0 z-50 w-[280px] animate-slide-in safe-top safe-bottom
"
>
<
Flame
size=
{
14
}
className=
"text-white"
/>
<
Sidebar
mobile
onClose=
{
()
=>
dispatch
({
type
:
"SET_SIDEBAR_OPEN"
,
open
:
false
})
}
/>
</
div
>
</
div
>
<
span
className=
"text-sm font-semibold text-white truncate flex-1"
>
</>
{
state
.
chats
.
find
((
c
)
=>
c
.
id
===
activeChatId
)?.
title
||
"Son of Anton"
}
)
}
</
span
>
{
/* 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
<
button
onClick=
{
()
=>
navigate
(
"/knowledge"
)
}
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
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"
className=
"p-2 -ml-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
title=
"Knowledge Bases"
>
>
<
BookOpen
size=
{
14
}
/>
<
Menu
size=
{
20
}
/>
<
span
className=
"hidden sm:inline"
>
Knowledge
</
span
>
</
button
>
</
button
>
{
state
.
user
?.
role
===
"superadmin"
&&
(
<
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
>
<
button
<
button
onClick=
{
()
=>
navigate
(
"/admin"
)
}
onClick=
{
handleNewChat
}
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"
className=
"p-2 -mr-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
title=
"Admin Panel"
>
>
<
Shield
size=
{
14
}
/>
<
Plus
size=
{
20
}
/>
<
span
className=
"hidden sm:inline"
>
Admin
</
span
>
</
button
>
</
button
>
)
}
</
div
>
</
div
>
{
/* Chat
area
*/
}
{
/* Chat
or empty state
*/
}
{
activeChatId
?
(
{
state
.
activeChatId
?
(
<
ChatView
chatId=
{
activeChatId
}
/>
<
ChatView
chatId=
{
state
.
activeChatId
}
/>
)
:
(
)
:
(
<
div
className=
"flex-1 flex items-center justify-center"
>
<
div
className=
"flex-1 flex items-center justify-center
p-6
"
>
<
div
className=
"text-center"
>
<
div
className=
"text-center
max-w-sm
"
>
<
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=
"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"
/>
<
Flame
size=
{
32
}
className=
"text-white"
/>
</
div
>
</
div
>
<
h2
className=
"text-xl font-bold text-white mb-2"
>
Son of Anton
</
h2
>
<
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
>
<
p
className=
"text-anton-muted text-sm mb-6"
>
<
button
onClick=
{
handleNewChat
}
className=
"px-6 py-2.5 rounded-xl bg-anton-accent text-white font-medium hover:opacity-80 transition"
>
Avatar of All Elements of Code
Start a Chat
</
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
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
frontend/src/pages/LoginPage.jsx
View file @
459309cd
import
React
,
{
useState
}
from
"react"
;
import
React
,
{
useState
}
from
"react"
;
import
{
Flame
,
LogIn
,
UserPlus
,
Eye
,
EyeOff
}
from
"lucide-react"
;
import
{
login
,
register
}
from
"../api"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
login
,
register
}
from
"../api"
;
import
{
Flame
,
Eye
,
EyeOff
,
Loader2
}
from
"lucide-react"
;
export
default
function
LoginPage
()
{
export
default
function
LoginPage
()
{
const
{
dispatch
}
=
useApp
();
const
{
dispatch
}
=
useApp
();
...
@@ -18,132 +18,104 @@ export default function LoginPage() {
...
@@ -18,132 +18,104 @@ export default function LoginPage() {
setError
(
""
);
setError
(
""
);
setLoading
(
true
);
setLoading
(
true
);
try
{
try
{
let
res
;
const
res
=
isRegister
if
(
isRegister
)
{
?
await
register
(
username
,
email
,
password
)
res
=
await
register
(
username
,
email
,
password
);
:
await
login
(
username
,
password
);
}
else
{
res
=
await
login
(
username
,
password
);
}
dispatch
({
type
:
"LOGIN"
,
token
:
res
.
token
,
user
:
res
.
user
});
dispatch
({
type
:
"LOGIN"
,
token
:
res
.
token
,
user
:
res
.
user
});
}
catch
(
err
)
{
}
catch
(
err
)
{
setError
(
err
.
message
);
setError
(
err
.
message
||
"Authentication failed"
);
}
finally
{
}
finally
{
setLoading
(
false
);
setLoading
(
false
);
}
}
}
}
return
(
return
(
<
div
className=
"h-full flex items-center justify-center bg-anton-bg p-4"
>
<
div
className=
"h-full h-dvh flex items-center justify-center bg-anton-bg px-4 safe-top safe-bottom"
>
{
/* Glow effect */
}
<
div
className=
"w-full max-w-sm"
>
<
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"
/>
{
/* Logo */
}
<
div
className=
"relative w-full max-w-md animate-fade-in"
>
{
/* Header */
}
<
div
className=
"text-center mb-8"
>
<
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"
>
<
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=
{
40
}
className=
"text-white"
/>
<
Flame
size=
{
32
}
className=
"text-white"
/>
</
div
>
</
div
>
<
h1
className=
"text-3xl font-bold text-white tracking-tight"
>
<
h1
className=
"text-2xl font-bold text-white"
>
Son of Anton
</
h1
>
Son of Anton
<
p
className=
"text-anton-muted text-sm mt-1"
>
Avatar of All Elements of Code
</
p
>
</
h1
>
<
p
className=
"text-anton-muted mt-2 text-sm"
>
Avatar of All Elements of Code
</
p
>
</
div
>
</
div
>
{
/* Form Card */
}
{
/* Form */
}
<
form
<
form
onSubmit=
{
handleSubmit
}
className=
"space-y-4"
>
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
>
)
}
<
div
>
<
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
<
input
type=
"text"
type=
"text"
value=
{
username
}
value=
{
username
}
onChange=
{
(
e
)
=>
setUsername
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setUsername
(
e
.
target
.
value
)
}
required
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"
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=
"Enter username"
placeholder=
"Enter username"
required
autoComplete=
"username"
autoCapitalize=
"off"
/>
/>
</
div
>
</
div
>
{
isRegister
&&
(
{
isRegister
&&
(
<
div
>
<
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
<
input
type=
"email"
type=
"email"
value=
{
email
}
value=
{
email
}
onChange=
{
(
e
)
=>
setEmail
(
e
.
target
.
value
)
}
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
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"
autoComplete=
"email"
placeholder=
"you@example.com"
/>
/>
</
div
>
</
div
>
)
}
)
}
<
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"
>
<
div
className=
"relative"
>
<
input
<
input
type=
{
showPw
?
"text"
:
"password"
}
type=
{
showPw
?
"text"
:
"password"
}
value=
{
password
}
value=
{
password
}
onChange=
{
(
e
)
=>
setPassword
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setPassword
(
e
.
target
.
value
)
}
required
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"
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"
placeholder=
"••••••••"
placeholder=
"••••••••"
required
autoComplete=
{
isRegister
?
"new-password"
:
"current-password"
}
/>
/>
<
button
<
button
type=
"button"
type=
"button"
onClick=
{
()
=>
setShowPw
(
!
showPw
)
}
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
>
</
button
>
</
div
>
</
div
>
</
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
<
button
type=
"submit"
type=
"submit"
disabled=
{
loading
}
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
?
(
{
loading
&&
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
}
<
div
className=
"w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
{
isRegister
?
"Create Account"
:
"Sign In"
}
)
:
isRegister
?
(
<>
<
UserPlus
size=
{
18
}
/>
Create Account
</>
)
:
(
<>
<
LogIn
size=
{
18
}
/>
Sign In
</>
)
}
</
button
>
</
button
>
<
p
className=
"text-center text-sm text-anton-muted"
>
{
isRegister
?
"Already have an account?"
:
"Don't have an account?"
}{
" "
}
<
button
<
button
type=
"button"
type=
"button"
onClick=
{
()
=>
{
onClick=
{
()
=>
{
setIsRegister
(
!
isRegister
);
setError
(
""
);
}
}
setIsRegister
(
!
isRegister
);
className=
"w-full text-center text-sm text-anton-muted hover:text-white transition py-2"
setError
(
""
);
}
}
className=
"text-anton-accent hover:underline"
>
>
{
isRegister
?
"Sign in"
:
"
Register"
}
{
isRegister
?
"Already have an account? Sign in"
:
"Need an account?
Register"
}
</
button
>
</
button
>
</
p
>
</
form
>
</
form
>
</
div
>
</
div
>
</
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
);
const
AppContext
=
createContext
(
null
);
...
@@ -6,10 +6,10 @@ const initialState = {
...
@@ -6,10 +6,10 @@ const initialState = {
token
:
localStorage
.
getItem
(
"token"
)
||
null
,
token
:
localStorage
.
getItem
(
"token"
)
||
null
,
user
:
null
,
user
:
null
,
chats
:
[],
chats
:
[],
activeChatId
:
null
,
chatMessages
:
{},
chatMessages
:
{},
activeStreams
:
{},
activeStreams
:
{},
sidebarOpen
:
false
,
sidebarOpen
:
false
,
sidebarTab
:
"chats"
,
// "chats" | "knowledge"
};
};
function
reducer
(
state
,
action
)
{
function
reducer
(
state
,
action
)
{
...
@@ -17,18 +17,23 @@ function reducer(state, action) {
...
@@ -17,18 +17,23 @@ function reducer(state, action) {
case
"LOGIN"
:
case
"LOGIN"
:
localStorage
.
setItem
(
"token"
,
action
.
token
);
localStorage
.
setItem
(
"token"
,
action
.
token
);
return
{
...
state
,
token
:
action
.
token
,
user
:
action
.
user
};
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"
:
case
"LOGOUT"
:
localStorage
.
removeItem
(
"token"
);
localStorage
.
removeItem
(
"token"
);
return
{
...
initialState
,
token
:
null
};
return
{
...
initialState
,
token
:
null
};
case
"SET_USER"
:
return
{
...
state
,
user
:
action
.
user
};
case
"SET_CHATS"
:
case
"SET_CHATS"
:
return
{
...
state
,
chats
:
action
.
chats
};
return
{
...
state
,
chats
:
action
.
chats
};
case
"SET_ACTIVE_CHAT"
:
return
{
...
state
,
activeChatId
:
action
.
chatId
,
sidebarOpen
:
false
};
case
"ADD_CHAT"
:
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"
:
case
"UPDATE_CHAT"
:
return
{
return
{
...
state
,
...
state
,
...
@@ -36,47 +41,49 @@ function reducer(state, action) {
...
@@ -36,47 +41,49 @@ function reducer(state, action) {
c
.
id
===
action
.
chat
.
id
?
{
...
c
,
...
action
.
chat
}
:
c
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
{
return
{
...
state
,
...
state
,
chats
:
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
action
.
chatId
),
chats
:
remaining
,
chatMessages
:
(()
=>
{
activeChatId
:
const
m
=
{
...
state
.
chatMessages
};
state
.
activeChatId
===
action
.
chatId
delete
m
[
action
.
chatId
];
?
remaining
[
0
]?.
id
||
null
return
m
;
:
state
.
activeChatId
,
})(),
};
};
}
case
"SET_MESSAGES"
:
case
"SET_MESSAGES"
:
return
{
return
{
...
state
,
...
state
,
chatMessages
:
{
...
state
.
chatMessages
,
[
action
.
chatId
]:
action
.
messages
},
chatMessages
:
{
...
state
.
chatMessages
,
[
action
.
chatId
]:
action
.
messages
},
};
};
case
"ADD_MESSAGE"
:
case
"ADD_MESSAGE"
:
{
const
prev
=
state
.
chatMessages
[
action
.
chatId
]
||
[];
return
{
return
{
...
state
,
...
state
,
chatMessages
:
{
chatMessages
:
{
...
state
.
chatMessages
,
...
state
.
chatMessages
,
[
action
.
chatId
]:
[
[
action
.
chatId
]:
[...
prev
,
action
.
message
],
...(
state
.
chatMessages
[
action
.
chatId
]
||
[]),
action
.
message
,
],
},
},
};
};
}
case
"SET_STREAMING"
:
case
"SET_STREAMING"
:
return
{
return
{
...
state
,
...
state
,
activeStreams
:
action
.
streaming
activeStreams
:
action
.
streaming
?
{
...
state
.
activeStreams
,
[
action
.
chatId
]:
true
}
?
{
...
state
.
activeStreams
,
[
action
.
chatId
]:
true
}
:
(()
=>
{
:
Object
.
fromEntries
(
const
s
=
{
...
state
.
activeStreams
};
Object
.
entries
(
state
.
activeStreams
).
filter
(([
k
])
=>
k
!==
action
.
chatId
)
delete
s
[
action
.
chatId
];
),
return
s
;
})(),
};
};
case
"SET_SIDEBAR_OPEN"
:
case
"SET_SIDEBAR_OPEN"
:
return
{
...
state
,
sidebarOpen
:
action
.
open
};
return
{
...
state
,
sidebarOpen
:
action
.
open
};
case
"SET_SIDEBAR_TAB"
:
case
"TOGGLE_SIDEBAR"
:
return
{
...
state
,
sidebarTab
:
action
.
tab
};
return
{
...
state
,
sidebarOpen
:
!
state
.
sidebarOpen
};
default
:
default
:
return
state
;
return
state
;
}
}
...
@@ -93,6 +100,6 @@ export function AppProvider({ children }) {
...
@@ -93,6 +100,6 @@ export function AppProvider({ children }) {
export
function
useApp
()
{
export
function
useApp
()
{
const
ctx
=
useContext
(
AppContext
);
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
;
return
ctx
;
}
}
\ No newline at end of file
frontend/tailwind.config.js
View file @
459309cd
...
@@ -5,18 +5,21 @@ export default {
...
@@ -5,18 +5,21 @@ export default {
extend
:
{
extend
:
{
colors
:
{
colors
:
{
"anton-bg"
:
"#09090f"
,
"anton-bg"
:
"#09090f"
,
"anton-surface"
:
"#0
d0d14
"
,
"anton-surface"
:
"#0
f0f1a
"
,
"anton-card"
:
"#1
2121c
"
,
"anton-card"
:
"#1
6162a
"
,
"anton-border"
:
"#1e1e
2e
"
,
"anton-border"
:
"#1e1e
3a
"
,
"anton-text"
:
"#e
0e0e0
"
,
"anton-text"
:
"#e
2e2ea
"
,
"anton-muted"
:
"#6b6b8
0
"
,
"anton-muted"
:
"#6b6b8
a
"
,
"anton-accent"
:
"#
ff4444
"
,
"anton-accent"
:
"#
e53e3e
"
,
"anton-success"
:
"#
22c55e
"
,
"anton-success"
:
"#
48bb78
"
,
"anton-danger"
:
"#e
f4444
"
,
"anton-danger"
:
"#e
53e3e
"
,
},
},
fontFamily
:
{
fontFamily
:
{
sans
:
[
"Inter"
,
"system-ui"
,
"sans-serif"
],
sans
:
[
"Inter"
,
"system-ui"
,
"-apple-system"
,
"sans-serif"
],
mono
:
[
"JetBrains Mono"
,
"Consolas"
,
"monospace"
],
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