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
99edb79c
Commit
99edb79c
authored
Mar 19, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
cool on mobile
parent
c0e521e8
Changes
12
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
1723 additions
and
1391 deletions
+1723
-1391
FULL_CODEBASE.txt
FULL_CODEBASE.txt
+1106
-791
main.py
backend/main.py
+49
-6
index.html
frontend/index.html
+32
-16
App.jsx
frontend/src/App.jsx
+7
-1
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+27
-78
Sidebar.jsx
frontend/src/components/Sidebar.jsx
+144
-272
index.css
frontend/src/index.css
+227
-77
main.jsx
frontend/src/main.jsx
+1
-1
ChatPage.jsx
frontend/src/pages/ChatPage.jsx
+67
-67
store.jsx
frontend/src/store.jsx
+33
-56
tailwind.config.js
frontend/tailwind.config.js
+19
-24
vite.config.js
frontend/vite.config.js
+11
-2
No files found.
FULL_CODEBASE.txt
View file @
99edb79c
This source diff could not be displayed because it is too large. You can
view the blob
instead.
backend/main.py
View file @
99edb79c
...
@@ -3,10 +3,11 @@ Son of Anton — Main FastAPI Application
...
@@ -3,10 +3,11 @@ Son of Anton — Main FastAPI Application
"""
"""
import
os
import
os
import
time
from
pathlib
import
Path
from
pathlib
import
Path
from
contextlib
import
asynccontextmanager
from
contextlib
import
asynccontextmanager
from
fastapi
import
FastAPI
,
HTTPException
from
fastapi
import
FastAPI
,
HTTPException
,
Request
,
Response
from
fastapi.staticfiles
import
StaticFiles
from
fastapi.staticfiles
import
StaticFiles
from
fastapi.responses
import
FileResponse
from
fastapi.responses
import
FileResponse
from
fastapi.middleware.cors
import
CORSMiddleware
from
fastapi.middleware.cors
import
CORSMiddleware
...
@@ -21,6 +22,9 @@ from backend.routes.files_routes import router as files_router
...
@@ -21,6 +22,9 @@ from backend.routes.files_routes import router as files_router
from
backend.routes.attachment_routes
import
router
as
attachment_router
from
backend.routes.attachment_routes
import
router
as
attachment_router
from
backend.services.bedrock_service
import
close_http_client
from
backend.services.bedrock_service
import
close_http_client
APP_VERSION
=
"2.1.0"
APP_BUILD_TIME
=
str
(
int
(
time
.
time
()))
def
_run_migrations
():
def
_run_migrations
():
"""Add new columns/tables to existing DB if they're missing."""
"""Add new columns/tables to existing DB if they're missing."""
...
@@ -54,7 +58,7 @@ async def lifespan(app: FastAPI):
...
@@ -54,7 +58,7 @@ async def lifespan(app: FastAPI):
Base
.
metadata
.
create_all
(
bind
=
engine
)
Base
.
metadata
.
create_all
(
bind
=
engine
)
_run_migrations
()
_run_migrations
()
seed_superadmin
()
seed_superadmin
()
print
(
"Son of Anton
is online."
)
print
(
f
"Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME})
is online."
)
yield
yield
await
close_http_client
()
await
close_http_client
()
print
(
"Son of Anton shutting down."
)
print
(
"Son of Anton shutting down."
)
...
@@ -63,7 +67,7 @@ async def lifespan(app: FastAPI):
...
@@ -63,7 +67,7 @@ async def lifespan(app: FastAPI):
app
=
FastAPI
(
app
=
FastAPI
(
title
=
"Son of Anton"
,
title
=
"Son of Anton"
,
description
=
"Avatar of All Elements of Code"
,
description
=
"Avatar of All Elements of Code"
,
version
=
"2.0.0"
,
version
=
APP_VERSION
,
lifespan
=
lifespan
,
lifespan
=
lifespan
,
)
)
...
@@ -75,6 +79,38 @@ app.add_middleware(
...
@@ -75,6 +79,38 @@ app.add_middleware(
allow_headers
=
[
"*"
],
allow_headers
=
[
"*"
],
)
)
@
app
.
middleware
(
"http"
)
async
def
add_cache_headers
(
request
:
Request
,
call_next
):
response
:
Response
=
await
call_next
(
request
)
path
=
request
.
url
.
path
# API responses: never cache
if
path
.
startswith
(
"/api"
):
response
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
response
.
headers
[
"Pragma"
]
=
"no-cache"
response
.
headers
[
"Expires"
]
=
"0"
# Hashed assets (contain hash in filename): cache aggressively
elif
path
.
startswith
(
"/assets/"
)
and
any
(
c
in
path
for
c
in
[
".js"
,
".css"
]):
response
.
headers
[
"Cache-Control"
]
=
"public, max-age=31536000, immutable"
# HTML and everything else: never cache
elif
path
.
endswith
(
".html"
)
or
not
path
.
startswith
(
"/assets"
):
response
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
response
.
headers
[
"Pragma"
]
=
"no-cache"
response
.
headers
[
"Expires"
]
=
"0"
# Always add version header for debugging
response
.
headers
[
"X-App-Version"
]
=
APP_VERSION
response
.
headers
[
"X-Build-Time"
]
=
APP_BUILD_TIME
return
response
# Version endpoint for frontend to check
@
app
.
get
(
"/api/version"
)
def
get_version
():
return
{
"version"
:
APP_VERSION
,
"build"
:
APP_BUILD_TIME
}
app
.
include_router
(
auth_router
,
prefix
=
"/api/auth"
,
tags
=
[
"Auth"
])
app
.
include_router
(
auth_router
,
prefix
=
"/api/auth"
,
tags
=
[
"Auth"
])
app
.
include_router
(
chat_router
,
prefix
=
"/api/chats"
,
tags
=
[
"Chats"
])
app
.
include_router
(
chat_router
,
prefix
=
"/api/chats"
,
tags
=
[
"Chats"
])
app
.
include_router
(
admin_router
,
prefix
=
"/api/admin"
,
tags
=
[
"Admin"
])
app
.
include_router
(
admin_router
,
prefix
=
"/api/admin"
,
tags
=
[
"Admin"
])
...
@@ -98,8 +134,15 @@ async def serve_frontend(full_path: str):
...
@@ -98,8 +134,15 @@ async def serve_frontend(full_path: str):
raise
HTTPException
(
status_code
=
404
,
detail
=
"Not found"
)
raise
HTTPException
(
status_code
=
404
,
detail
=
"Not found"
)
file_path
=
FRONTEND_DIR
/
full_path
file_path
=
FRONTEND_DIR
/
full_path
if
full_path
and
file_path
.
is_file
():
if
full_path
and
file_path
.
is_file
():
return
FileResponse
(
str
(
file_path
))
resp
=
FileResponse
(
str
(
file_path
))
# Don't cache non-hashed static files
resp
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
return
resp
index
=
FRONTEND_DIR
/
"index.html"
index
=
FRONTEND_DIR
/
"index.html"
if
index
.
is_file
():
if
index
.
is_file
():
return
FileResponse
(
str
(
index
))
resp
=
FileResponse
(
str
(
index
))
return
{
"message"
:
"Son of Anton API is running. Frontend not built."
}
resp
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
resp
.
headers
[
"Pragma"
]
=
"no-cache"
resp
.
headers
[
"Expires"
]
=
"0"
return
resp
return
{
"message"
:
"Son of Anton API is running. Frontend not built."
}
\ No newline at end of file
frontend/index.html
View file @
99edb79c
<!DOCTYPE html>
<!DOCTYPE html>
<html
lang=
"en"
class=
"dark"
>
<html
lang=
"en"
class=
"dark"
>
<head>
<meta
charset=
"UTF-8"
/>
<head>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
/>
<meta
charset=
"UTF-8"
/>
<title>
Son of Anton
</title>
<meta
name=
"viewport"
<link
rel=
"preconnect"
href=
"https://fonts.googleapis.com"
/>
content=
"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link
rel=
"preconnect"
href=
"https://fonts.gstatic.com"
crossorigin
/>
<title>
Son of Anton
</title>
<link
href=
"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
<!-- KILL BROWSER CACHE FOR THIS HTML -->
rel=
"stylesheet"
<meta
http-equiv=
"Cache-Control"
content=
"no-store, no-cache, must-revalidate, max-age=0"
/>
/>
<meta
http-equiv=
"Pragma"
content=
"no-cache"
/>
<link
rel=
"icon"
href=
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>"
/>
<meta
http-equiv=
"Expires"
content=
"0"
/>
</head>
<body
class=
"bg-anton-bg text-anton-text font-sans"
>
<!-- PWA / Mobile -->
<div
id=
"root"
></div>
<meta
name=
"apple-mobile-web-app-capable"
content=
"yes"
/>
<script
type=
"module"
src=
"/src/main.jsx"
></script>
<meta
name=
"apple-mobile-web-app-status-bar-style"
content=
"black-translucent"
/>
</body>
<meta
name=
"theme-color"
content=
"#09090f"
/>
<meta
name=
"mobile-web-app-capable"
content=
"yes"
/>
<link
rel=
"preconnect"
href=
"https://fonts.googleapis.com"
/>
<link
rel=
"preconnect"
href=
"https://fonts.gstatic.com"
crossorigin
/>
<link
href=
"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel=
"stylesheet"
/>
<link
rel=
"icon"
href=
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>"
/>
</head>
<body
class=
"bg-anton-bg text-anton-text font-sans overscroll-none"
>
<div
id=
"root"
></div>
<script
type=
"module"
src=
"/src/main.jsx"
></script>
</body>
</html>
</html>
\ No newline at end of file
frontend/src/App.jsx
View file @
99edb79c
...
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
...
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
import
{
Routes
,
Route
}
from
"react-router-dom"
;
import
{
Routes
,
Route
}
from
"react-router-dom"
;
import
{
useApp
}
from
"./store"
;
import
{
useApp
}
from
"./store"
;
import
{
getMe
}
from
"./api"
;
import
{
getMe
}
from
"./api"
;
import
*
as
streamManager
from
"./streamManager"
;
import
LoginPage
from
"./pages/LoginPage"
;
import
LoginPage
from
"./pages/LoginPage"
;
import
ChatPage
from
"./pages/ChatPage"
;
import
ChatPage
from
"./pages/ChatPage"
;
import
AdminPage
from
"./pages/AdminPage"
;
import
AdminPage
from
"./pages/AdminPage"
;
...
@@ -11,6 +12,11 @@ export default function App() {
...
@@ -11,6 +12,11 @@ export default function App() {
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
[
authChecked
,
setAuthChecked
]
=
useState
(
!
state
.
token
);
const
[
authChecked
,
setAuthChecked
]
=
useState
(
!
state
.
token
);
// Connect streamManager to store dispatch
useEffect
(()
=>
{
streamManager
.
setDispatch
(
dispatch
);
},
[
dispatch
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
state
.
token
)
{
if
(
!
state
.
token
)
{
setAuthChecked
(
true
);
setAuthChecked
(
true
);
...
@@ -34,7 +40,7 @@ export default function App() {
...
@@ -34,7 +40,7 @@ export default function App() {
if
(
!
authChecked
)
{
if
(
!
authChecked
)
{
return
(
return
(
<
div
className=
"h-
full
flex items-center justify-center bg-anton-bg"
>
<
div
className=
"h-
dvh
flex items-center justify-center bg-anton-bg"
>
<
div
className=
"flex flex-col items-center gap-4 animate-fade-in"
>
<
div
className=
"flex flex-col items-center gap-4 animate-fade-in"
>
<
div
className=
"w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20"
>
<
div
className=
"w-16 h-16 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 animate-pulse"
/>
<
Flame
size=
{
32
}
className=
"text-white animate-pulse"
/>
...
...
frontend/src/components/MessageBubble.jsx
View file @
99edb79c
...
@@ -15,13 +15,6 @@ const FILE_TYPE_ICONS = {
...
@@ -15,13 +15,6 @@ const FILE_TYPE_ICONS = {
text
:
FileCode
,
text
:
FileCode
,
};
};
const
FILE_TYPE_BADGE_COLORS
=
{
image
:
"bg-blue-500/20 text-blue-400"
,
video
:
"bg-purple-500/20 text-purple-400"
,
document
:
"bg-amber-500/20 text-amber-400"
,
text
:
"bg-green-500/20 text-green-400"
,
};
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
;
const
isUser
=
role
===
"user"
;
const
isUser
=
role
===
"user"
;
...
@@ -38,16 +31,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -38,16 +31,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const
hasAttachments
=
attachments
&&
attachments
.
length
>
0
;
const
hasAttachments
=
attachments
&&
attachments
.
length
>
0
;
return
(
return
(
<
div
className=
{
`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`
}
>
<
div
className=
{
`flex gap-
2 sm:gap-
3 animate-fade-in ${isUser ? "justify-end" : ""}`
}
>
{
!
isUser
&&
(
{
!
isUser
&&
(
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"w-
8
h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"
>
<
div
className=
"w-
7 h-7 sm:w-8 sm:
h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"
>
<
Flame
size=
{
1
6
}
className=
"text-white
"
/>
<
Flame
size=
{
1
4
}
className=
"text-white sm:w-4 sm:h-4
"
/>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
<
div
className=
{
`max-w-[80%] ${isUser ? "order-first" : ""}`
}
>
<
div
className=
{
`max-w-[
90%] sm:max-w-[
80%] ${isUser ? "order-first" : ""}`
}
>
{
/* Thinking block */
}
{
/* Thinking block */
}
{
thinking_content
&&
(
{
thinking_content
&&
(
<
div
className=
"mb-2"
>
<
div
className=
"mb-2"
>
...
@@ -60,7 +53,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -60,7 +53,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{
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-
3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap
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"
>
{
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
>
...
@@ -70,10 +63,9 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -70,10 +63,9 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{
/* Attachments */
}
{
/* Attachments */
}
{
hasAttachments
&&
(
{
hasAttachments
&&
(
<
div
className=
"mb-2 flex flex-wrap gap-2"
>
<
div
className=
"mb-2 flex flex-wrap gap-
1.5 sm:gap-
2"
>
{
attachments
.
map
((
att
)
=>
{
{
attachments
.
map
((
att
)
=>
{
const
Icon
=
FILE_TYPE_ICONS
[
att
.
file_type
]
||
File
;
const
Icon
=
FILE_TYPE_ICONS
[
att
.
file_type
]
||
File
;
const
badgeColor
=
FILE_TYPE_BADGE_COLORS
[
att
.
file_type
]
||
"bg-anton-border text-anton-muted"
;
const
url
=
getAttachmentUrl
(
att
.
id
);
const
url
=
getAttachmentUrl
(
att
.
id
);
if
(
att
.
file_type
===
"image"
)
{
if
(
att
.
file_type
===
"image"
)
{
...
@@ -82,101 +74,58 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -82,101 +74,58 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<
img
<
img
src=
{
`${url}?token=${token}`
}
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
alt=
{
att
.
original_filename
}
className=
"max-w-[2
80px]
max-h-[220px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-lg"
className=
"max-w-[2
00px] sm:max-w-[280px] max-h-[160px] sm:
max-h-[220px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-lg"
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
onError=
{
(
e
)
=>
{
onError=
{
(
e
)
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
e
.
target
.
style
.
display
=
"none"
;
e
.
target
.
nextSibling
&&
(
e
.
target
.
nextSibling
.
style
.
display
=
"flex"
);
}
}
/>
/>
{
/* Fallback if image fails */
}
<
div
className=
"hidden items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2"
>
<
Image
size=
{
16
}
className=
"text-blue-400"
/>
<
span
className=
"text-xs text-white"
>
{
att
.
original_filename
}
</
span
>
</
div
>
{
expandedImage
===
att
.
id
&&
(
{
expandedImage
===
att
.
id
&&
(
<
div
<
div
className=
"fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-8 cursor-pointer"
className=
"fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-
4 sm:p-
8 cursor-pointer"
onClick=
{
()
=>
setExpandedImage
(
null
)
}
onClick=
{
()
=>
setExpandedImage
(
null
)
}
>
>
<
img
<
img
src=
{
`${url}?token=${token}`
}
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
alt=
{
att
.
original_filename
}
className=
"max-w-full max-h-full object-contain rounded-lg
shadow-2xl
"
className=
"max-w-full max-h-full object-contain rounded-lg"
/>
/>
<
div
className=
"absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/70 text-white text-sm px-4 py-2 rounded-lg"
>
{
att
.
original_filename
}
— Click anywhere to close
</
div
>
</
div
>
</
div
>
)
}
)
}
<
div
className=
"absolute bottom-1 left-1 bg-black/70 text-[9px] text-white px-1.5 py-0.5 rounded flex items-center gap-1"
>
<
div
className=
"absolute bottom-1 left-1 bg-black/60 text-[8px] sm:text-[9px] text-white px-1.5 py-0.5 rounded"
>
<
Image
size=
{
8
}
/>
{
att
.
original_filename
}
{
att
.
original_filename
}
</
div
>
</
div
>
</
div
>
</
div
>
);
);
}
}
if
(
att
.
file_type
===
"video"
)
{
return
(
<
a
key=
{
att
.
id
}
href=
{
`${url}?token=${token}`
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"flex items-center gap-3 bg-anton-card border border-purple-500/30 rounded-lg px-4 py-3 hover:border-purple-400 transition group shadow-sm"
>
<
div
className=
"w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0"
>
<
Film
size=
{
20
}
className=
"text-purple-400"
/>
</
div
>
<
div
className=
"min-w-0"
>
<
div
className=
"text-xs text-white truncate max-w-[180px] font-medium"
>
{
att
.
original_filename
}
</
div
>
<
div
className=
"text-[10px] text-anton-muted flex items-center gap-1.5 mt-0.5"
>
<
span
className=
{
`px-1 py-px rounded text-[8px] font-bold uppercase ${badgeColor}`
}
>
Video
</
span
>
<
span
>
{
att
.
file_size
?
(
att
.
file_size
/
1024
/
1024
).
toFixed
(
1
)
+
" MB"
:
""
}
</
span
>
</
div
>
</
div
>
<
ExternalLink
size=
{
12
}
className=
"text-anton-muted group-hover:text-purple-400 shrink-0"
/>
</
a
>
);
}
return
(
return
(
<
a
<
a
key=
{
att
.
id
}
key=
{
att
.
id
}
href=
{
`${url}?token=${token}`
}
href=
{
`${url}?token=${token}`
}
target=
"_blank"
target=
"_blank"
rel=
"noopener noreferrer"
rel=
"noopener noreferrer"
className=
"flex items-center gap-
3 bg-anton-card border border-anton-border rounded-lg px-4 py-3 hover:border-anton-accent transition group shadow-sm
"
className=
"flex items-center gap-
2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 sm:px-3 sm:py-2 hover:border-anton-accent transition group
"
>
>
<
div
className=
{
`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${att.file_type === "document" ? "bg-amber-500/20" : "bg-green-500/20"}`
}
>
<
Icon
size=
{
14
}
className=
"shrink-0 text-blue-400 sm:w-4 sm:h-4"
/>
<
Icon
size=
{
20
}
className=
{
att
.
file_type
===
"document"
?
"text-amber-400"
:
"text-green-400"
}
/>
</
div
>
<
div
className=
"min-w-0"
>
<
div
className=
"min-w-0"
>
<
div
className=
"text-xs text-white truncate max-w-[180px] font-medium"
>
{
att
.
original_filename
}
</
div
>
<
div
className=
"text-[11px] sm:text-xs text-white truncate max-w-[120px] sm:max-w-[160px]"
>
{
att
.
original_filename
}
</
div
>
<
div
className=
"text-[10px] text-anton-muted flex items-center gap-1.5 mt-0.5"
>
<
div
className=
"text-[9px] sm:text-[10px] text-anton-muted"
>
{
(
att
.
file_size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
<
span
className=
{
`px-1 py-px rounded text-[8px] font-bold uppercase ${badgeColor}`
}
>
{
att
.
file_type
}
</
span
>
<
span
>
{
att
.
file_size
?
(
att
.
file_size
/
1024
).
toFixed
(
0
)
+
" KB"
:
""
}
</
span
>
</
div
>
</
div
>
</
div
>
<
ExternalLink
size=
{
1
2
}
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 sm:w-3 sm:h-3
"
/>
</
a
>
</
a
>
);
);
})
}
})
}
</
div
>
</
div
>
)
}
)
}
{
/* Message
bubble
*/
}
{
/* Message
content
*/
}
<
div
className=
{
`rounded-2xl px-
4
py-3 ${isUser
<
div
className=
{
`rounded-2xl px-
3 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"
>
{
_stripPrefixes
(
content
)
}
</
div
>
<
div
className=
"text-sm whitespace-pre-wrap
break-words
"
>
{
_stripPrefixes
(
content
)
}
</
div
>
)
:
(
)
:
(
<
div
className=
"prose-anton text-sm"
>
<
div
className=
"prose-anton text-sm
break-words
"
>
<
ReactMarkdown
<
ReactMarkdown
remarkPlugins=
{
[
remarkGfm
]
}
remarkPlugins=
{
[
remarkGfm
]
}
components=
{
{
components=
{
{
...
@@ -204,19 +153,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -204,19 +153,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)
}
)
}
</
div
>
</
div
>
{
/*
Meta info
*/
}
{
/*
Actions bar
*/
}
{
!
isUser
&&
!
isStreaming
&&
content
&&
(
{
!
isUser
&&
!
isStreaming
&&
content
&&
(
<
div
className=
"flex items-center gap-3 mt-1
.5 px-1
"
>
<
div
className=
"flex items-center gap-3 mt-1
px-1 flex-wrap
"
>
<
button
<
button
onClick=
{
handleCopy
}
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"
className=
"flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition
p-1 -ml-1 rounded active:scale-95
"
>
>
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
"Copied"
:
"Copy"
}
{
copied
?
"Copied"
:
"Copy"
}
</
button
>
</
button
>
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
<
span
className=
"text-[11px] text-anton-muted"
>
<
span
className=
"text-[1
0px] sm:text-[1
1px] text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓ /
{
output_tokens
?.
toLocaleString
()
}
↑
tokens
{
input_tokens
?.
toLocaleString
()
}
↓ /
{
output_tokens
?.
toLocaleString
()
}
↑
</
span
>
</
span
>
)
}
)
}
</
div
>
</
div
>
...
@@ -225,8 +174,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -225,8 +174,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{
isUser
&&
(
{
isUser
&&
(
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"w-
8
h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"
>
<
div
className=
"w-
7 h-7 sm:w-8 sm:
h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"
>
<
User
size=
{
1
6
}
className=
"text-anton-muted
"
/>
<
User
size=
{
1
4
}
className=
"text-anton-muted sm:w-4 sm:h-4
"
/>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
...
...
frontend/src/components/Sidebar.jsx
View file @
99edb79c
import
React
,
{
useState
}
from
"react"
;
import
React
,
{
useState
}
from
"react"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
createChat
,
deleteChat
,
renameChat
}
from
"../api"
;
import
{
import
{
createChat
,
deleteChat
,
renameChat
,
Flame
,
Plus
,
MessageSquare
,
Trash2
,
Pencil
,
Check
,
X
,
listKnowledgeBases
,
createKnowledgeBase
,
deleteKnowledgeBase
,
uploadDocuments
,
Settings
,
LogOut
,
BookOpen
,
Shield
,
ChevronLeft
,
}
from
"../api"
;
import
*
as
streamManager
from
"../streamManager"
;
import
{
Plus
,
Trash2
,
Flame
,
LogOut
,
Shield
,
PanelLeftClose
,
PanelLeftOpen
,
MessageSquare
,
BookOpen
,
Upload
,
X
,
ChevronDown
,
ChevronRight
,
Edit2
,
Check
,
Radio
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
export
default
function
Sidebar
(
{
onRefresh
}
)
{
export
default
function
Sidebar
()
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
[
tab
,
setTab
]
=
useState
(
"chats"
);
const
[
editingId
,
setEditingId
]
=
useState
(
null
);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
editTitle
,
setEditTitle
]
=
useState
(
""
);
const
[
kbLoaded
,
setKbLoaded
]
=
useState
(
false
);
const
[
deletingId
,
setDeletingId
]
=
useState
(
null
);
const
[
newKbName
,
setNewKbName
]
=
useState
(
""
);
const
[
showNewKb
,
setShowNewKb
]
=
useState
(
false
);
const
[
expandedKb
,
setExpandedKb
]
=
useState
(
null
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
uploadCount
,
setUploadCount
]
=
useState
(
0
);
const
[
renamingId
,
setRenamingId
]
=
useState
(
null
);
const
[
renameVal
,
setRenameVal
]
=
useState
(
""
);
const
open
=
state
.
sidebarOpen
;
const
streamingChatIds
=
Object
.
keys
(
state
.
activeStreams
);
const
streamingCount
=
streamingChatIds
.
length
;
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
});
onRefresh
();
}
catch
{
}
}
catch
{
/* */
}
}
async
function
handleDelete
(
id
)
{
try
{
streamManager
.
abortStream
(
id
);
await
deleteChat
(
state
.
token
,
id
);
dispatch
({
type
:
"DELETE_CHAT"
,
chatId
:
id
});
}
catch
{
/* */
}
}
async
function
handleRename
(
id
)
{
if
(
!
renameVal
.
trim
())
return
;
try
{
await
renameChat
(
state
.
token
,
id
,
renameVal
.
trim
());
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
,
title
:
renameVal
.
trim
()
}
});
setRenamingId
(
null
);
}
catch
{
/* */
}
}
async
function
loadKbs
()
{
try
{
const
data
=
await
listKnowledgeBases
(
state
.
token
);
setKbs
(
data
);
setKbLoaded
(
true
);
}
catch
{
/* */
}
}
async
function
handleCreateKb
()
{
if
(
!
newKbName
.
trim
())
return
;
try
{
await
createKnowledgeBase
(
state
.
token
,
newKbName
.
trim
());
setNewKbName
(
""
);
setShowNewKb
(
false
);
loadKbs
();
}
catch
{
/* */
}
}
}
async
function
handleDeleteKb
(
id
)
{
async
function
handleDelete
(
chatId
)
{
if
(
!
confirm
(
"Delete this knowledge base?"
))
return
;
try
{
try
{
await
deleteKnowledgeBase
(
state
.
token
,
id
);
await
deleteChat
(
state
.
token
,
chatId
);
loadKbs
();
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
});
}
catch
{
/* */
}
}
catch
{
}
setDeletingId
(
null
);
}
}
async
function
handleUpload
(
kbId
,
files
)
{
async
function
handleRename
(
chatId
)
{
setUploading
(
true
);
if
(
!
editTitle
.
trim
())
{
setUploadCount
(
files
.
length
);
setEditingId
(
null
);
try
{
return
;
const
result
=
await
uploadDocuments
(
state
.
token
,
kbId
,
files
);
const
errors
=
(
result
.
files
||
[]).
filter
((
f
)
=>
f
.
error
);
if
(
errors
.
length
>
0
)
{
alert
(
`Uploaded
${
result
.
files
.
length
-
errors
.
length
}
of
${
result
.
files
.
length
}
files.\n\nErrors:\n`
+
errors
.
map
((
e
)
=>
`•
${
e
.
filename
}
:
${
e
.
error
}
`
).
join
(
"
\n
"
)
);
}
loadKbs
();
}
catch
(
e
)
{
alert
(
e
.
message
);
}
finally
{
setUploading
(
false
);
setUploadCount
(
0
);
}
}
try
{
await
renameChat
(
state
.
token
,
chatId
,
editTitle
.
trim
());
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
title
:
editTitle
.
trim
()
}
});
}
catch
{
}
setEditingId
(
null
);
}
}
function
s
witchTab
(
t
)
{
function
s
tartEdit
(
cha
t
)
{
set
Tab
(
t
);
set
EditingId
(
chat
.
id
);
if
(
t
===
"knowledge"
&&
!
kbLoaded
)
loadKbs
(
);
setEditTitle
(
chat
.
title
);
}
}
if
(
!
open
)
{
const
isSuperadmin
=
state
.
user
?.
role
===
"superadmin"
;
return
(
<
div
className=
"w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0"
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
className=
"p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<
PanelLeftOpen
size=
{
18
}
/>
</
button
>
<
button
onClick=
{
handleNewChat
}
className=
"p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"
>
<
Plus
size=
{
18
}
/>
</
button
>
{
streamingCount
>
0
&&
(
<
div
className=
"w-7 h-7 rounded-full bg-anton-accent/20 flex items-center justify-center"
title=
{
`${streamingCount} chat${streamingCount !== 1 ? "s" : ""} streaming`
}
>
<
Radio
size=
{
14
}
className=
"text-anton-accent animate-pulse"
/>
</
div
>
)
}
</
div
>
);
}
return
(
return
(
<
div
className=
"
w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0
"
>
<
div
className=
"
h-full flex flex-col bg-anton-surface border-r border-anton-border w-full
"
>
{
/* Header */
}
{
/* Header */
}
<
div
className=
"p-3 border-b border-anton-border flex items-center justify-between"
>
<
div
className=
"p-4 border-b border-anton-border"
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex items-center justify-between mb-4"
>
<
Flame
size=
{
20
}
className=
"text-anton-accent"
/>
<
div
className=
"flex items-center gap-2.5"
>
<
span
className=
"font-bold text-white text-sm"
>
Son of Anton
</
span
>
<
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
>
<
Flame
size=
{
18
}
className=
"text-white"
/>
<
div
className=
"flex items-center gap-1"
>
</
div
>
{
streamingCount
>
0
&&
(
<
div
>
<
span
className=
"text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse flex items-center gap-1"
>
<
h1
className=
"text-sm font-bold text-white leading-tight"
>
Son of Anton
</
h1
>
<
Radio
size=
{
10
}
/>
{
streamingCount
}
<
span
className=
"text-[10px] text-anton-muted"
>
v2.1.0
</
span
>
</
span
>
</
div
>
)
}
</
div
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
className=
"p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
{
/* Close button — mobile only */
}
<
PanelLeftClose
size=
{
16
}
/>
</
button
>
</
div
>
</
div
>
{
/* Tab bar */
}
<
div
className=
"flex border-b border-anton-border"
>
{
[
{
key
:
"chats"
,
label
:
"Chats"
,
icon
:
MessageSquare
},
{
key
:
"knowledge"
,
label
:
"Knowledge"
,
icon
:
BookOpen
},
].
map
((
t
)
=>
(
<
button
<
button
key=
{
t
.
key
}
onClick=
{
()
=>
dispatch
({
type
:
"CLOSE_SIDEBAR"
})
}
onClick=
{
()
=>
switchTab
(
t
.
key
)
}
className=
"lg:hidden p-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
className=
{
`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${tab === t.key ? "text-anton-accent border-b-2 border-anton-accent" : "text-anton-muted hover:text-white"
}`
}
>
>
<
t
.
icon
size=
{
13
}
/>
<
ChevronLeft
size=
{
18
}
/>
{
t
.
label
}
</
button
>
</
button
>
))
}
</
div
>
<
button
onClick=
{
handleNewChat
}
className=
"w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-anton-accent text-white rounded-xl hover:opacity-90 transition text-sm font-medium active:scale-[0.98]"
>
<
Plus
size=
{
16
}
/>
New Chat
</
button
>
</
div
>
</
div
>
{
/* Content */
}
{
/* Chat list */
}
<
div
className=
"flex-1 overflow-y-auto p-2 space-y-1"
>
<
div
className=
"flex-1 overflow-y-auto p-2 space-y-0.5"
>
{
tab
===
"chats"
&&
(
{
state
.
chats
.
length
===
0
&&
(
<>
<
div
className=
"text-center py-10 text-anton-muted text-xs"
>
<
button
No chats yet. Start a new one!
onClick=
{
handleNewChat
}
</
div
>
className=
"w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
>
<
Plus
size=
{
15
}
/>
New Chat
</
button
>
{
state
.
chats
.
map
((
c
)
=>
{
const
chatStreaming
=
!!
state
.
activeStreams
[
c
.
id
];
const
isActive
=
state
.
activeChatId
===
c
.
id
;
return
(
<
div
key=
{
c
.
id
}
className=
{
`group flex items-center rounded-lg cursor-pointer transition ${isActive
? "bg-anton-accent/10 text-anton-accent"
: chatStreaming
? "bg-purple-500/5 text-anton-text hover:bg-purple-500/10"
: "text-anton-text hover:bg-anton-card"
}`
}
>
{
renamingId
===
c
.
id
?
(
<
div
className=
"flex items-center gap-1 flex-1 p-1"
>
<
input
value=
{
renameVal
}
onChange=
{
(
e
)
=>
setRenameVal
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleRename
(
c
.
id
)
}
autoFocus
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
/>
<
button
onClick=
{
()
=>
handleRename
(
c
.
id
)
}
className=
"p-1 text-anton-success"
><
Check
size=
{
12
}
/></
button
>
<
button
onClick=
{
()
=>
setRenamingId
(
null
)
}
className=
"p-1 text-anton-muted"
><
X
size=
{
12
}
/></
button
>
</
div
>
)
:
(
<>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
:
c
.
id
})
}
className=
"flex-1 flex items-center gap-2 text-left px-3 py-2 text-sm truncate min-w-0"
>
{
chatStreaming
&&
(
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0"
title=
"Streaming"
/>
)
}
<
span
className=
"truncate"
>
{
c
.
title
}
</
span
>
</
button
>
<
div
className=
"hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0"
>
<
button
onClick=
{
()
=>
{
setRenamingId
(
c
.
id
);
setRenameVal
(
c
.
title
);
}
}
className=
"p-1 rounded hover:bg-anton-border text-anton-muted"
>
<
Edit2
size=
{
11
}
/>
</
button
>
<
button
onClick=
{
()
=>
handleDelete
(
c
.
id
)
}
className=
"p-1 rounded hover:bg-red-500/20 text-anton-danger"
>
<
Trash2
size=
{
11
}
/>
</
button
>
</
div
>
</>
)
}
</
div
>
);
})
}
</>
)
}
)
}
{
state
.
chats
.
map
((
chat
)
=>
{
{
tab
===
"knowledge"
&&
(
const
isActive
=
state
.
activeChatId
===
chat
.
id
;
<>
const
isEditing
=
editingId
===
chat
.
id
;
<
button
const
isDeleting
=
deletingId
===
chat
.
id
;
onClick=
{
()
=>
setShowNewKb
(
!
showNewKb
)
}
className=
"w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
return
(
<
div
key=
{
chat
.
id
}
className=
{
`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${isActive
? "bg-anton-accent/15 border border-anton-accent/30 text-white"
: "hover:bg-anton-card text-anton-muted hover:text-white border border-transparent"
}`
}
onClick=
{
()
=>
{
if
(
!
isEditing
&&
!
isDeleting
)
{
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
:
chat
.
id
});
}
}
}
>
>
<
Plus
size=
{
15
}
/>
New Knowledge Base
<
MessageSquare
size=
{
15
}
className=
{
`shrink-0 ${isActive ? "text-anton-accent" : ""}`
}
/>
</
button
>
{
isEditing
?
(
{
showNewKb
&&
(
<
div
className=
"flex-1 flex items-center gap-1 min-w-0"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
div
className=
"flex gap-1 p-1"
>
<
input
<
input
value=
{
editTitle
}
value=
{
newKbName
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setNewKbName
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
{
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleCreateKb
()
}
if
(
e
.
key
===
"Enter"
)
handleRename
(
chat
.
id
);
placeholder=
"Name…"
if
(
e
.
key
===
"Escape"
)
setEditingId
(
null
);
autoFocus
}
}
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
autoFocus
/>
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 min-w-0"
<
button
onClick=
{
handleCreateKb
}
className=
"px-2 py-1 bg-anton-accent rounded text-white text-xs"
>
Add
</
button
>
/>
</
div
>
<
button
onClick=
{
()
=>
handleRename
(
chat
.
id
)
}
className=
"p-1 text-anton-success hover:bg-anton-success/10 rounded"
>
)
}
<
Check
size=
{
12
}
/>
</
button
>
{
kbs
.
map
((
kb
)
=>
(
<
button
onClick=
{
()
=>
setEditingId
(
null
)
}
className=
"p-1 text-anton-muted hover:bg-anton-card rounded"
>
<
div
key=
{
kb
.
id
}
className=
"rounded-lg border border-anton-border/50 overflow-hidden"
>
<
X
size=
{
12
}
/>
<
button
</
button
>
onClick=
{
()
=>
setExpandedKb
(
expandedKb
===
kb
.
id
?
null
:
kb
.
id
)
}
</
div
>
className=
"w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition"
)
:
isDeleting
?
(
>
<
div
className=
"flex-1 flex items-center gap-1 min-w-0"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
{
expandedKb
===
kb
.
id
?
<
ChevronDown
size=
{
13
}
/>
:
<
ChevronRight
size=
{
13
}
/>
}
<
span
className=
"text-xs text-anton-danger truncate flex-1"
>
Delete?
</
span
>
<
BookOpen
size=
{
13
}
className=
"text-anton-accent shrink-0"
/>
<
button
onClick=
{
()
=>
handleDelete
(
chat
.
id
)
}
className=
"p-1 text-anton-danger hover:bg-anton-danger/10 rounded"
>
<
span
className=
"flex-1 truncate"
>
{
kb
.
name
}
</
span
>
<
Check
size=
{
12
}
/>
<
span
className=
"text-xs text-anton-muted"
>
{
kb
.
document_count
}
docs
</
span
>
</
button
>
</
button
>
<
button
onClick=
{
()
=>
setDeletingId
(
null
)
}
className=
"p-1 text-anton-muted hover:bg-anton-card rounded"
>
<
X
size=
{
12
}
/>
{
expandedKb
===
kb
.
id
&&
(
</
button
>
<
div
className=
"px-3 pb-3 space-y-2 bg-anton-card/50"
>
</
div
>
<
div
className=
"text-xs text-anton-muted space-y-0.5"
>
)
:
(
<
div
>
Chunks:
{
kb
.
chunk_count
}
·
~
{
(
kb
.
estimated_tokens
/
1000
).
toFixed
(
0
)
}
K tokens
</
div
>
<>
</
div
>
<
span
className=
"flex-1 text-xs truncate"
>
{
chat
.
title
}
</
span
>
<
label
className=
{
`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""}`
}
>
<
div
className=
"hidden group-hover:flex items-center gap-0.5 shrink-0"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
Upload
size=
{
12
}
/>
<
button
onClick=
{
()
=>
startEdit
(
chat
)
}
className=
"p-1 text-anton-muted hover:text-white hover:bg-anton-card rounded transition"
>
{
uploading
?
`Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
:
"Upload files (.txt, .pdf, .md, .json, .csv …)"
}
<
Pencil
size=
{
11
}
/>
<
input
</
button
>
type=
"file"
<
button
onClick=
{
()
=>
setDeletingId
(
chat
.
id
)
}
className=
"p-1 text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 rounded transition"
>
className=
"hidden"
<
Trash2
size=
{
11
}
/>
multiple
accept=
".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
onChange=
{
(
e
)
=>
{
if
(
e
.
target
.
files
&&
e
.
target
.
files
.
length
>
0
)
handleUpload
(
kb
.
id
,
Array
.
from
(
e
.
target
.
files
));
e
.
target
.
value
=
""
;
}
}
/>
</
label
>
<
button
onClick=
{
()
=>
handleDeleteKb
(
kb
.
id
)
}
className=
"flex items-center gap-1 text-xs text-anton-danger hover:underline"
>
<
Trash2
size=
{
11
}
/>
Delete KB
</
button
>
</
button
>
</
div
>
</
div
>
)
}
</>
</
div
>
)
}
))
}
</
div
>
</>
);
)
}
}
)
}
</
div
>
</
div
>
{
/* Footer */
}
{
/* Footer */
}
<
div
className=
"p-3 border-t border-anton-border space-y-2"
>
<
div
className=
"p-3 border-t border-anton-border space-y-1"
>
{
state
.
user
?.
role
===
"superadmin"
&&
(
<
button
onClick=
{
()
=>
{
navigate
(
"/"
);
dispatch
({
type
:
"CLOSE_SIDEBAR"
});
}
}
className=
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<
BookOpen
size=
{
14
}
/>
Knowledge Bases
</
button
>
{
isSuperadmin
&&
(
<
button
<
button
onClick=
{
()
=>
navigate
(
"/admin"
)
}
onClick=
{
()
=>
{
navigate
(
"/admin"
);
dispatch
({
type
:
"CLOSE_SIDEBAR"
});
}
}
className=
"w-full flex items-center gap-2
px-3 py-2 rounded-lg text-sm text-anton-muted hover:text-anton-accent
hover:bg-anton-card transition"
className=
"w-full flex items-center gap-2
.5 px-3 py-2 rounded-lg text-xs text-anton-muted hover:text-white
hover:bg-anton-card transition"
>
>
<
Shield
size=
{
15
}
/>
Admin Panel
<
Shield
size=
{
14
}
/>
Admin Panel
</
button
>
</
button
>
)
}
)
}
<
div
className=
"flex items-center justify-between px-2"
>
<
div
className=
"flex items-center justify-between px-
3 py-
2"
>
<
div
>
<
div
className=
"flex items-center gap-2 min-w-0"
>
<
div
className=
"
text-sm font-medium text-white"
>
{
state
.
user
?.
username
}
</
div
>
<
div
className=
"
w-6 h-6 rounded-md bg-anton-card flex items-center justify-center shrink-0"
>
<
div
className=
"text-xs text-anton-muted
"
>
<
span
className=
"text-[10px] font-bold text-anton-accent uppercase
"
>
{
((
state
.
user
?.
tokens_used_this_month
||
0
)
/
1000
).
toFixed
(
0
)
}
K /
{
" "
}
{
(
state
.
user
?.
username
||
"?"
)[
0
]
}
{
((
state
.
user
?.
quota_tokens_monthly
||
0
)
/
1000
).
toFixed
(
0
)
}
K tokens
</
span
>
</
div
>
</
div
>
<
span
className=
"text-xs text-anton-muted truncate"
>
{
state
.
user
?.
username
}
</
span
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
className=
"p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition"
>
<
button
<
LogOut
size=
{
16
}
/>
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
className=
"p-1.5 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition"
title=
"Logout"
>
<
LogOut
size=
{
14
}
/>
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
frontend/src/index.css
View file @
99edb79c
...
@@ -2,137 +2,287 @@
...
@@ -2,137 +2,287 @@
@tailwind
components
;
@tailwind
components
;
@tailwind
utilities
;
@tailwind
utilities
;
/* ── Globals ───────────────────────────────── */
/* ═══════════════════════════════════════════ */
*
{
/* Use dvh for full-height on mobile */
scrollbar-width
:
thin
;
/* ═══════════════════════════════════════════ */
scrollbar-color
:
#2a2a3a
#0a0a0f
;
:root
{
--color-anton-bg
:
#09090f
;
--color-anton-surface
:
#0f0f18
;
--color-anton-card
:
#161622
;
--color-anton-border
:
#1e1e30
;
--color-anton-text
:
#e2e2f0
;
--color-anton-muted
:
#6b6b8a
;
--color-anton-accent
:
#e63946
;
--color-anton-success
:
#2ecc71
;
--color-anton-danger
:
#e74c3c
;
/* safe area insets for notched phones */
--sat
:
env
(
safe-area-inset-top
,
0px
);
--sab
:
env
(
safe-area-inset-bottom
,
0px
);
--sal
:
env
(
safe-area-inset-left
,
0px
);
--sar
:
env
(
safe-area-inset-right
,
0px
);
}
}
*
::-webkit-scrollbar
{
width
:
6px
;
*,
*
::before
,
*
::after
{
box-sizing
:
border-box
;
-webkit-tap-highlight-color
:
transparent
;
}
}
*
::-webkit-scrollbar-track
{
background
:
#0a0a0f
;
html
{
-webkit-text-size-adjust
:
100%
;
text-size-adjust
:
100%
;
}
}
*
::-webkit-scrollbar-thumb
{
background
:
#2a2a3a
;
body
{
border-radius
:
3px
;
margin
:
0
;
padding
:
0
;
background
:
var
(
--color-anton-bg
);
color
:
var
(
--color-anton-text
);
font-family
:
"Inter"
,
-apple-system
,
BlinkMacSystemFont
,
"Segoe UI"
,
sans-serif
;
overflow
:
hidden
;
overscroll-behavior
:
none
;
/* Prevent pull-to-refresh on mobile */
-webkit-overflow-scrolling
:
touch
;
}
/* Fix iOS textarea zoom — font MUST be >= 16px */
textarea
,
input
,
select
{
font-size
:
16px
!important
;
}
@media
(
min-width
:
640px
)
{
textarea
,
input
,
select
{
font-size
:
14px
!important
;
}
}
}
html
,
body
,
#root
{
#root
{
height
:
100
%
;
height
:
100
dvh
;
margin
:
0
;
width
:
100vw
;
overflow
:
hidden
;
overflow
:
hidden
;
}
}
/* ── Markdown prose adjustments ────────────── */
/* ═══════════════════════════════════════════ */
.prose-anton
h1
,
/* Scrollbar styling */
.prose-anton
h2
,
/* ═══════════════════════════════════════════ */
.prose-anton
h3
{
::-webkit-scrollbar
{
color
:
#f97316
;
width
:
5px
;
margin-top
:
1em
;
height
:
5px
;
margin-bottom
:
0.5em
;
font-weight
:
600
;
}
}
.prose-anton
h1
{
font-size
:
1.5rem
;
}
.prose-anton
h2
{
font-size
:
1.25rem
;
}
.prose-anton
h3
{
font-size
:
1.1rem
;
}
.prose-anton
p
{
::-webkit-scrollbar-track
{
margin-bottom
:
0.75em
;
background
:
transparent
;
}
::-webkit-scrollbar-thumb
{
background
:
var
(
--color-anton-border
);
border-radius
:
10px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
var
(
--color-anton-muted
);
}
/* Hide scrollbar on mobile for cleaner look */
@media
(
max-width
:
640px
)
{
::-webkit-scrollbar
{
width
:
2px
;
}
}
/* ═══════════════════════════════════════════ */
/* Animations */
/* ═══════════════════════════════════════════ */
@keyframes
fade-in
{
from
{
opacity
:
0
;
transform
:
translateY
(
6px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
.animate-fade-in
{
animation
:
fade-in
0.25s
ease-out
;
}
.thinking-pulse
{
animation
:
pulse
1.5s
ease-in-out
infinite
;
}
/* ═══════════════════════════════════════════ */
/* Prose (Markdown) styling */
/* ═══════════════════════════════════════════ */
.prose-anton
{
line-height
:
1.7
;
line-height
:
1.7
;
word-break
:
break-word
;
overflow-wrap
:
break-word
;
}
.prose-anton
p
{
margin
:
0.5em
0
;
}
.prose-anton
p
:first-child
{
margin-top
:
0
;
}
.prose-anton
p
:last-child
{
margin-bottom
:
0
;
}
}
.prose-anton
ul
,
.prose-anton
ul
,
.prose-anton
ol
{
.prose-anton
ol
{
margin-left
:
1.
5em
;
padding-left
:
1.2
5em
;
margin
-bottom
:
0.75em
;
margin
:
0.5em
0
;
}
}
.prose-anton
li
{
.prose-anton
li
{
margin
-bottom
:
0.25em
;
margin
:
0.15em
0
;
}
}
.prose-anton
ul
{
list-style-type
:
disc
;
}
.prose-anton
ol
{
list-style-type
:
decimal
;
}
.prose-anton
a
{
.prose-anton
code
:not
(
pre
code
)
{
color
:
#f97316
;
background
:
var
(
--color-anton-border
);
text-decoration
:
underline
;
border-radius
:
4px
;
padding
:
0.15em
0.35em
;
font-family
:
"JetBrains Mono"
,
monospace
;
font-size
:
0.85em
;
color
:
#f0a0a0
;
word-break
:
break-all
;
}
}
.prose-anton
blockquote
{
.prose-anton
blockquote
{
border-left
:
3px
solid
#f97316
;
border-left
:
3px
solid
var
(
--color-anton-accent
);
padding-left
:
1em
;
padding-left
:
0.75em
;
color
:
#8888a0
;
margin
:
0.5em
0
;
margin
:
0.75em
0
;
color
:
var
(
--color-anton-muted
);
}
.prose-anton
a
{
color
:
var
(
--color-anton-accent
);
text-decoration
:
underline
;
text-underline-offset
:
2px
;
}
}
.prose-anton
table
{
.prose-anton
table
{
border-collapse
:
collapse
;
border-collapse
:
collapse
;
margin
:
0.75em
0
;
margin
:
0.75em
0
;
font-size
:
0.85em
;
width
:
100%
;
width
:
100%
;
display
:
block
;
overflow-x
:
auto
;
}
}
.prose-anton
th
,
.prose-anton
th
,
.prose-anton
td
{
.prose-anton
td
{
border
:
1px
solid
#2a2a3a
;
border
:
1px
solid
var
(
--color-anton-border
)
;
padding
:
0.4em
0.
75
em
;
padding
:
0.4em
0.
6
em
;
text-align
:
left
;
text-align
:
left
;
white-space
:
nowrap
;
}
}
.prose-anton
th
{
.prose-anton
th
{
background
:
#1a1a28
;
background
:
var
(
--color-anton-card
)
;
font-weight
:
600
;
font-weight
:
600
;
}
}
.prose-anton
code
:not
(
pre
code
)
{
.prose-anton
h1
,
background
:
#1a1a28
;
.prose-anton
h2
,
padding
:
0.15em
0.4em
;
.prose-anton
h3
,
border-radius
:
4px
;
.prose-anton
h4
{
font-
size
:
0.9em
;
font-
weight
:
600
;
font-family
:
"JetBrains Mono"
,
monospac
e
;
color
:
whit
e
;
color
:
#f97316
;
margin
:
0.75em
0
0.35em
;
}
}
/* ── Thinking block animation ──────────────── */
.prose-anton
h1
{
@keyframes
thinkPulse
{
font-size
:
1.35em
;
0
%,
100
%
{
opacity
:
0.6
;
}
50
%
{
opacity
:
1
;
}
}
}
.thinking-pulse
{
animation
:
thinkPulse
2s
ease-in-out
infinite
;
.prose-anton
h2
{
font-size
:
1.2em
;
}
}
/* ── Slider custom styling ─────────────────── */
.prose-anton
h3
{
font-size
:
1.05em
;
}
.prose-anton
hr
{
border
:
none
;
border-top
:
1px
solid
var
(
--color-anton-border
);
margin
:
1em
0
;
}
/* ═══════════════════════════════════════════ */
/* Range input styling */
/* ═══════════════════════════════════════════ */
input
[
type
=
"range"
]
{
input
[
type
=
"range"
]
{
-webkit-appearance
:
none
;
-webkit-appearance
:
none
;
appearance
:
none
;
background
:
transparent
;
width
:
100%
;
width
:
100%
;
}
input
[
type
=
"range"
]
::-webkit-slider-track
{
height
:
4px
;
height
:
4px
;
border-radius
:
2px
;
border-radius
:
999px
;
background
:
#2a2a3a
;
background
:
var
(
--color-anton-border
);
outline
:
none
;
}
}
input
[
type
=
"range"
]
::-webkit-slider-thumb
{
input
[
type
=
"range"
]
::-webkit-slider-thumb
{
-webkit-appearance
:
none
;
-webkit-appearance
:
none
;
appearance
:
none
;
width
:
18px
;
height
:
16px
;
height
:
18px
;
width
:
16px
;
border-radius
:
50%
;
border-radius
:
50%
;
background
:
#f97316
;
background
:
var
(
--color-anton-accent
);
margin-top
:
-6px
;
cursor
:
pointer
;
cursor
:
pointer
;
border
:
2px
solid
var
(
--color-anton-bg
);
}
}
input
[
type
=
"range"
]
::-moz-range-track
{
height
:
4px
;
border-radius
:
2px
;
background
:
#2a2a3a
;
}
input
[
type
=
"range"
]
::-moz-range-thumb
{
input
[
type
=
"range"
]
::-moz-range-thumb
{
height
:
16
px
;
width
:
18
px
;
width
:
16
px
;
height
:
18
px
;
border-radius
:
50%
;
border-radius
:
50%
;
background
:
#f97316
;
background
:
var
(
--color-anton-accent
);
border
:
none
;
cursor
:
pointer
;
cursor
:
pointer
;
border
:
2px
solid
var
(
--color-anton-bg
);
}
/* ═══════════════════════════════════════════ */
/* Mobile-specific utilities */
/* ═══════════════════════════════════════════ */
@supports
(
height
:
100
dvh
)
{
.h-dvh
{
height
:
100
dvh
;
}
}
@supports
not
(
height
:
100
dvh
)
{
.h-dvh
{
height
:
100vh
;
}
}
/* Ensure touch targets are at least 44px */
@media
(
max-width
:
640px
)
{
button
,
a
,
[
role
=
"button"
]
{
min-height
:
44px
;
min-width
:
44px
;
}
/* Exception for inline/tiny buttons */
.prose-anton
button
,
.text-
\
[
11px
\
]
button
{
min-height
:
auto
;
min-width
:
auto
;
}
}
}
\ No newline at end of file
frontend/src/main.jsx
View file @
99edb79c
import
React
from
"react"
;
import
React
from
"react"
;
import
ReactDOM
from
"react-dom/client"
;
import
ReactDOM
from
"react-dom/client"
;
import
{
BrowserRouter
}
from
"react-router-dom"
;
import
{
BrowserRouter
}
from
"react-router-dom"
;
import
App
from
"./App"
;
import
{
AppProvider
}
from
"./store"
;
import
{
AppProvider
}
from
"./store"
;
import
App
from
"./App"
;
import
"./index.css"
;
import
"./index.css"
;
ReactDOM
.
createRoot
(
document
.
getElementById
(
"root"
)).
render
(
ReactDOM
.
createRoot
(
document
.
getElementById
(
"root"
)).
render
(
...
...
frontend/src/pages/ChatPage.jsx
View file @
99edb79c
import
React
,
{
useEffect
,
useCallback
}
from
"react"
;
import
React
,
{
useEffect
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
listChats
}
from
"../api"
;
import
{
listChats
}
from
"../api"
;
import
Sidebar
from
"../components/Sidebar"
;
import
Sidebar
from
"../components/Sidebar"
;
import
ChatView
from
"../components/ChatView"
;
import
ChatView
from
"../components/ChatView"
;
import
{
Flame
,
Paperclip
,
Layers
,
Zap
}
from
"lucide-react"
;
import
{
Flame
,
MessageSquarePlus
,
Menu
}
from
"lucide-react"
;
export
default
function
ChatPage
()
{
export
default
function
ChatPage
()
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
loadChats
=
useCallback
(
async
()
=>
{
try
{
const
chats
=
await
listChats
(
state
.
token
);
dispatch
({
type
:
"SET_CHATS"
,
chats
});
}
catch
{
/* ignore */
}
},
[
state
.
token
,
dispatch
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
loadChats
();
(
async
()
=>
{
},
[
loadChats
]);
try
{
const
chats
=
await
listChats
(
state
.
token
);
return
(
dispatch
({
type
:
"SET_CHATS"
,
chats
});
<
div
className=
"h-full flex"
>
}
catch
{
}
<
Sidebar
onRefresh=
{
loadChats
}
/>
})();
<
main
className=
"flex-1 flex flex-col min-w-0"
>
},
[
state
.
token
,
dispatch
]);
{
state
.
activeChatId
?
(
<
ChatView
key=
{
state
.
activeChatId
}
chatId=
{
state
.
activeChatId
}
/>
)
:
(
<
EmptyState
/>
)
}
</
main
>
</
div
>
);
}
function
EmptyState
()
{
return
(
return
(
<
div
className=
"flex-1 flex items-center justify-center p-8"
>
<
div
className=
"h-dvh flex overflow-hidden relative"
>
<
div
className=
"text-center animate-fade-in max-w-lg"
>
{
/* Mobile overlay backdrop */
}
<
div
className=
"inline-flex items-center justify-center w-24 h-24 rounded-3xl bg-gradient-to-br from-anton-accent/20 to-transparent border border-anton-accent/20 mb-6"
>
{
state
.
sidebarOpen
&&
(
<
Flame
size=
{
44
}
className=
"text-anton-accent"
/>
<
div
</
div
>
className=
"fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
<
h2
className=
"text-2xl font-bold text-white mb-2"
>
Son of Anton
</
h2
>
onClick=
{
()
=>
dispatch
({
type
:
"CLOSE_SIDEBAR"
})
}
<
p
className=
"text-anton-muted mb-6"
>
/>
Avatar of All Elements of Code. Create a new chat to begin — but bring
)
}
real questions, not that first-result-of-Google garbage.
</
p
>
<
div
className=
"grid grid-cols-1 sm:grid-cols-3 gap-3 text-left"
>
{
/* Sidebar — slides in on mobile, always visible on desktop */
}
<
div
className=
"bg-anton-surface border border-anton-border rounded-xl p-4"
>
<
div
<
div
className=
"flex items-center gap-2 mb-2"
>
className=
{
`
<
div
className=
"w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center"
>
fixed inset-y-0 left-0 z-40 w-72
<
Paperclip
size=
{
16
}
className=
"text-blue-400"
/>
transform transition-transform duration-300 ease-in-out
</
div
>
lg:relative lg:translate-x-0 lg:z-auto
<
span
className=
"text-sm font-medium text-white"
>
File Upload
</
span
>
${state.sidebarOpen ? "translate-x-0" : "-translate-x-full"}
</
div
>
`
}
<
p
className=
"text-xs text-anton-muted leading-relaxed"
>
>
Drop images, videos, PDFs, or code files directly into chat. AI describes and analyzes them.
<
Sidebar
/>
</
p
>
</
div
>
</
div
>
<
div
className=
"bg-anton-surface border border-anton-border rounded-xl p-4"
>
{
/* Main content area */
}
<
div
className=
"flex items-center gap-2 mb-2"
>
<
div
className=
"flex-1 flex flex-col min-w-0"
>
<
div
className=
"w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center"
>
{
/* Mobile top bar */
}
<
Layers
size=
{
16
}
className=
"text-purple-400"
/>
<
div
className=
"flex items-center gap-3 px-4 py-3 border-b border-anton-border bg-anton-surface lg:hidden"
>
</
div
>
<
button
<
span
className=
"text-sm font-medium text-white"
>
Parallel Chats
</
span
>
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
</
div
>
className=
"p-2 -ml-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
<
p
className=
"text-xs text-anton-muted leading-relaxed"
>
>
Run multiple conversations simultaneously. Switch between them while they stream.
<
Menu
size=
{
22
}
/>
</
p
>
</
button
>
<
div
className=
"flex items-center gap-2 flex-1 min-w-0"
>
<
Flame
size=
{
18
}
className=
"text-anton-accent shrink-0"
/>
<
span
className=
"text-sm font-semibold text-white truncate"
>
{
state
.
activeChatId
?
state
.
chats
.
find
((
c
)
=>
c
.
id
===
state
.
activeChatId
)?.
title
||
"Chat"
:
"Son of Anton"
}
</
span
>
</
div
>
</
div
>
</
div
>
<
div
className=
"bg-anton-surface border border-anton-border rounded-xl p-4"
>
{
/* Chat view or empty state */
}
<
div
className=
"flex items-center gap-2 mb-2"
>
{
state
.
activeChatId
?
(
<
div
className=
"w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center"
>
<
ChatView
chatId=
{
state
.
activeChatId
}
/>
<
Zap
size=
{
16
}
className=
"text-green-400"
/>
)
:
(
<
div
className=
"flex-1 flex items-center justify-center p-6"
>
<
div
className=
"text-center max-w-md"
>
<
div
className=
"w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 mb-6"
>
<
Flame
size=
{
40
}
className=
"text-white"
/>
</
div
>
</
div
>
<
span
className=
"text-sm font-medium text-white"
>
Full Code
</
span
>
<
h1
className=
"text-2xl font-bold text-white mb-2"
>
Son of Anton
</
h1
>
<
p
className=
"text-anton-muted text-sm mb-6 leading-relaxed"
>
Avatar of All Elements of Code. Select an existing chat from the sidebar
or create a new one to begin.
</
p
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"OPEN_SIDEBAR"
})
}
className=
"lg:hidden inline-flex items-center gap-2 px-5 py-2.5 bg-anton-accent text-white rounded-xl hover:opacity-90 transition active:scale-95 text-sm font-medium"
>
<
MessageSquarePlus
size=
{
16
}
/>
Open Chats
</
button
>
</
div
>
</
div
>
<
p
className=
"text-xs text-anton-muted leading-relaxed"
>
Production-ready code with syntax highlighting, download buttons, and ZIP export.
</
p
>
</
div
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
);
);
...
...
frontend/src/store.jsx
View file @
99edb79c
import
React
,
{
createContext
,
useContext
,
useReducer
,
useEffect
}
from
"react"
;
import
React
,
{
createContext
,
useContext
,
useReducer
,
useEffect
}
from
"react"
;
import
{
setDispatch
}
from
"./streamManager"
;
const
AppContext
=
createContext
();
const
AppContext
=
createContext
(
null
);
const
initialState
=
{
const
initialState
=
{
token
:
localStorage
.
getItem
(
"token"
)
||
null
,
token
:
localStorage
.
getItem
(
"token"
)
||
null
,
user
:
JSON
.
parse
(
localStorage
.
getItem
(
"user"
)
||
"null"
)
,
user
:
null
,
chats
:
[],
chats
:
[],
activeChatId
:
null
,
activeChatId
:
null
,
sidebarOpen
:
true
,
chatMessages
:
{},
chatMessages
:
{},
activeStreams
:
{},
activeStreams
:
{},
sidebarOpen
:
false
,
// mobile sidebar toggle
};
};
function
reducer
(
state
,
action
)
{
function
reducer
(
state
,
action
)
{
switch
(
action
.
type
)
{
switch
(
action
.
type
)
{
case
"LOGIN"
:
{
localStorage
.
setItem
(
"token"
,
action
.
token
);
localStorage
.
setItem
(
"user"
,
JSON
.
stringify
(
action
.
user
));
return
{
...
state
,
token
:
action
.
token
,
user
:
action
.
user
};
}
case
"SET_TOKEN"
:
case
"SET_TOKEN"
:
if
(
action
.
token
)
localStorage
.
setItem
(
"token"
,
action
.
token
);
localStorage
.
setItem
(
"token"
,
action
.
token
);
else
localStorage
.
removeItem
(
"token"
);
return
{
...
state
,
token
:
action
.
token
};
return
{
...
state
,
token
:
action
.
token
};
case
"SET_USER"
:
case
"SET_USER"
:
localStorage
.
setItem
(
"user"
,
JSON
.
stringify
(
action
.
user
));
return
{
...
state
,
user
:
action
.
user
};
return
{
...
state
,
user
:
action
.
user
};
case
"LOGOUT"
:
case
"LOGOUT"
:
localStorage
.
removeItem
(
"token"
);
localStorage
.
removeItem
(
"token"
);
localStorage
.
removeItem
(
"user"
);
return
{
...
initialState
,
token
:
null
};
return
{
...
initialState
,
token
:
null
,
user
:
null
};
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
{
return
{
...
state
,
...
state
,
chats
:
[
action
.
chat
,
...
state
.
chats
],
chats
:
[
action
.
chat
,
...
state
.
chats
],
activeChatId
:
action
.
chat
.
id
,
activeChatId
:
action
.
chat
.
id
,
sidebarOpen
:
false
,
};
};
case
"UPDATE_CHAT"
:
{
case
"UPDATE_CHAT"
:
{
...
@@ -52,64 +46,52 @@ function reducer(state, action) {
...
@@ -52,64 +46,52 @@ function reducer(state, action) {
return
{
...
state
,
chats
:
updated
};
return
{
...
state
,
chats
:
updated
};
}
}
case
"REMOVE_CHAT"
:
case
"REMOVE_CHAT"
:
{
case
"DELETE_CHAT"
:
{
const
filtered
=
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
action
.
chatId
);
const
chatId
=
action
.
chatId
;
const
filtered
=
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
chatId
);
const
newMessages
=
{
...
state
.
chatMessages
};
const
newMessages
=
{
...
state
.
chatMessages
};
delete
newMessages
[
chatId
];
delete
newMessages
[
action
.
chatId
];
const
newStreams
=
{
...
state
.
activeStreams
};
delete
newStreams
[
chatId
];
return
{
return
{
...
state
,
...
state
,
chats
:
filtered
,
chats
:
filtered
,
chatMessages
:
newMessages
,
chatMessages
:
newMessages
,
activeStreams
:
newStreams
,
activeChatId
:
state
.
activeChatId
===
action
.
chatId
?
null
:
state
.
activeChatId
,
activeChatId
:
state
.
activeChatId
===
chatId
?
filtered
[
0
]?.
id
||
null
:
state
.
activeChatId
,
};
};
}
}
case
"SET_ACTIVE_CHAT"
:
return
{
...
state
,
activeChatId
:
action
.
chatId
};
case
"TOGGLE_SIDEBAR"
:
return
{
...
state
,
sidebarOpen
:
!
state
.
sidebarOpen
};
case
"SET_MESSAGES"
:
case
"SET_MESSAGES"
:
return
{
return
{
...
state
,
...
state
,
chatMessages
:
{
chatMessages
:
{
...
state
.
chatMessages
,
[
action
.
chatId
]:
action
.
messages
},
...
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"
:
if
(
action
.
streaming
)
{
if
(
action
.
streaming
)
{
return
{
return
{
...
state
,
activeStreams
:
{
...
state
.
activeStreams
,
[
action
.
chatId
]:
true
}
};
...
state
,
}
else
{
activeStreams
:
{
...
state
.
activeStreams
,
[
action
.
chatId
]:
true
},
const
s
=
{
...
state
.
activeStreams
};
};
delete
s
[
action
.
chatId
];
return
{
...
state
,
activeStreams
:
s
};
}
}
const
next
=
{
...
state
.
activeStreams
};
delete
next
[
action
.
chatId
];
case
"TOGGLE_SIDEBAR"
:
return
{
...
state
,
activeStreams
:
next
};
return
{
...
state
,
sidebarOpen
:
!
state
.
sidebarOpen
};
}
case
"CLOSE_SIDEBAR"
:
return
{
...
state
,
sidebarOpen
:
false
};
case
"OPEN_SIDEBAR"
:
return
{
...
state
,
sidebarOpen
:
true
};
default
:
default
:
return
state
;
return
state
;
...
@@ -118,11 +100,6 @@ function reducer(state, action) {
...
@@ -118,11 +100,6 @@ function reducer(state, action) {
export
function
AppProvider
({
children
})
{
export
function
AppProvider
({
children
})
{
const
[
state
,
dispatch
]
=
useReducer
(
reducer
,
initialState
);
const
[
state
,
dispatch
]
=
useReducer
(
reducer
,
initialState
);
useEffect
(()
=>
{
setDispatch
(
dispatch
);
},
[
dispatch
]);
return
(
return
(
<
AppContext
.
Provider
value=
{
{
state
,
dispatch
}
}
>
<
AppContext
.
Provider
value=
{
{
state
,
dispatch
}
}
>
{
children
}
{
children
}
...
...
frontend/tailwind.config.js
View file @
99edb79c
/** @type {import('tailwindcss').Config} */
/** @type {import('tailwindcss').Config} */
export
default
{
export
default
{
content
:
[
"./index.html"
,
"./src/**/*.{js,jsx}"
],
content
:
[
"./index.html"
,
"./src/**/*.{js,jsx,ts,tsx}"
],
darkMode
:
"class"
,
theme
:
{
theme
:
{
extend
:
{
extend
:
{
colors
:
{
colors
:
{
anton
:
{
"anton-bg"
:
"#09090f"
,
bg
:
"#0a0a0f"
,
"anton-surface"
:
"#0f0f18"
,
surface
:
"#12121a"
,
"anton-card"
:
"#161622"
,
card
:
"#1a1a28"
,
"anton-border"
:
"#1e1e30"
,
border
:
"#2a2a3a"
,
"anton-text"
:
"#e2e2f0"
,
accent
:
"#f97316"
,
"anton-muted"
:
"#6b6b8a"
,
accentDim
:
"#c2410c"
,
"anton-accent"
:
"#e63946"
,
text
:
"#e4e4ef"
,
"anton-success"
:
"#2ecc71"
,
muted
:
"#8888a0"
,
"anton-danger"
:
"#e74c3c"
,
user
:
"#1e293b"
,
assistant
:
"#15151f"
,
danger
:
"#ef4444"
,
success
:
"#22c55e"
,
},
},
},
fontFamily
:
{
fontFamily
:
{
sans
:
[
'"Inter"'
,
"system-ui"
,
"sans-serif"
],
sans
:
[
'"Inter"'
,
"system-ui"
,
"sans-serif"
],
mono
:
[
'"JetBrains Mono"'
,
'"Fira Code"'
,
"monospace"
],
mono
:
[
'"JetBrains Mono"'
,
"monospace"
],
},
},
animation
:
{
height
:
{
"pulse-slow"
:
"pulse 3s cubic-bezier(0.4,0,0.6,1) infinite"
,
dvh
:
"100dvh"
,
"fade-in"
:
"fadeIn 0.3s ease-out"
,
},
},
keyframes
:
{
minHeight
:
{
fadeIn
:
{
dvh
:
"100dvh"
,
"0%"
:
{
opacity
:
0
,
transform
:
"translateY(8px)"
},
},
"100%"
:
{
opacity
:
1
,
transform
:
"translateY(0)"
},
screens
:
{
}
,
xs
:
"480px"
,
},
},
},
},
},
},
...
...
frontend/vite.config.js
View file @
99edb79c
...
@@ -5,11 +5,20 @@ export default defineConfig({
...
@@ -5,11 +5,20 @@ export default defineConfig({
plugins
:
[
react
()],
plugins
:
[
react
()],
server
:
{
server
:
{
proxy
:
{
proxy
:
{
"/api"
:
"http://localhost:80
00
"
,
"/api"
:
"http://localhost:80"
,
},
},
},
},
build
:
{
build
:
{
outDir
:
"dist"
,
// Content-hash all chunks so browsers fetch new versions
rollupOptions
:
{
output
:
{
entryFileNames
:
"assets/[name]-[hash].js"
,
chunkFileNames
:
"assets/[name]-[hash].js"
,
assetFileNames
:
"assets/[name]-[hash].[ext]"
,
},
},
// Generate a manifest for cache-busting verification
manifest
:
true
,
sourcemap
:
false
,
sourcemap
:
false
,
},
},
});
});
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment