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
faa34a43
Commit
faa34a43
authored
Mar 29, 2026
by
AGLANPC\aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
im gonna
parent
6b9a6730
Changes
18
Hide whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
1136 additions
and
1908 deletions
+1136
-1908
cleanup_stale_files.sh
cleanup_stale_files.sh
+7
-0
App.jsx
frontend/src/App.jsx
+37
-32
api.js
frontend/src/api.js
+95
-89
ChatView.jsx
frontend/src/components/ChatView.jsx
+231
-127
CodeBlock.jsx
frontend/src/components/CodeBlock.jsx
+22
-116
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+4
-20
Sidebar.jsx
frontend/src/components/Sidebar.jsx
+84
-84
index.css
frontend/src/index.css
+63
-242
main.jsx
frontend/src/main.jsx
+1
-7
AdminPage.jsx
frontend/src/pages/AdminPage.jsx
+69
-218
ChatPage.jsx
frontend/src/pages/ChatPage.jsx
+24
-77
GitLabPage.jsx
frontend/src/pages/GitLabPage.jsx
+227
-306
KnowledgePage.jsx
frontend/src/pages/KnowledgePage.jsx
+153
-370
LoginPage.jsx
frontend/src/pages/LoginPage.jsx
+30
-76
store.jsx
frontend/src/store.jsx
+38
-37
streamManager.js
frontend/src/streamManager.js
+36
-83
tailwind.config.js
frontend/tailwind.config.js
+10
-13
vite.config.js
frontend/vite.config.js
+5
-11
No files found.
cleanup_stale_files.sh
0 → 100644
View file @
faa34a43
#!/bin/bash
# Run from project root
rm
-f
backend/routes/attachment_routes_15.py
rm
-f
backend/routes/attachment_routes_16.py
rm
-f
backend/routes/attachments.py
rm
-f
backend/routes/messages_patch.py
echo
"Stale files removed."
\ No newline at end of file
frontend/src/App.jsx
View file @
faa34a43
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
React
,
{
useEffect
}
from
"react"
;
import
{
Routes
,
Rou
te
}
from
"react-router-dom"
;
import
{
BrowserRouter
,
Routes
,
Route
,
Naviga
te
}
from
"react-router-dom"
;
import
{
useApp
}
from
"./store"
;
import
{
AppProvider
,
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"
;
import
KnowledgePage
from
"./pages/KnowledgePage"
;
import
KnowledgePage
from
"./pages/KnowledgePage"
;
import
GitLabPage
from
"./pages/GitLabPage"
;
import
GitLabPage
from
"./pages/GitLabPage"
;
import
{
Flame
}
from
"lucide-react"
;
export
default
function
App
(
)
{
function
AuthGate
({
children
}
)
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
[
authChecked
,
setAuthChecked
]
=
useState
(
!
state
.
token
);
useEffect
(()
=>
{
streamManager
.
setDispatch
(
dispatch
);
},
[
dispatch
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
state
.
token
)
{
setAuthChecked
(
true
);
return
;
}
if
(
state
.
token
&&
!
state
.
user
)
{
if
(
state
.
user
)
{
setAuthChecked
(
true
);
return
;
}
getMe
(
state
.
token
)
(
async
()
=>
{
.
then
((
user
)
=>
dispatch
({
type
:
"SET_USER"
,
user
}))
try
{
.
catch
(()
=>
dispatch
({
type
:
"LOGOUT"
}));
const
user
=
await
getMe
(
state
.
token
);
}
dispatch
({
type
:
"SET_USER"
,
user
});
}
catch
{
dispatch
({
type
:
"LOGOUT"
});
}
finally
{
setAuthChecked
(
true
);
}
})();
},
[
state
.
token
,
state
.
user
,
dispatch
]);
},
[
state
.
token
,
state
.
user
,
dispatch
]);
if
(
!
authChecked
)
{
if
(
!
state
.
token
)
return
<
LoginPage
/>;
if
(
!
state
.
user
)
{
return
(
return
(
<
div
className=
"h-dvh flex items-center justify-center bg-anton-bg"
>
<
div
className=
"h-screen flex items-center justify-center bg-anton-bg"
>
<
div
className=
"flex flex-col items-center gap-4 animate-fade-in"
>
<
div
className=
"flex items-center gap-3 text-anton-muted"
>
<
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-5 h-5 border-2 border-anton-accent border-t-transparent rounded-full animate-spin"
/>
<
Flame
size=
{
32
}
className=
"text-white animate-pulse"
/>
Loading...
</
div
>
<
p
className=
"text-anton-muted text-sm"
>
Loading...
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
);
);
}
}
return
children
;
}
if
(
!
state
.
token
)
return
<
LoginPage
/>;
function
AppRoutes
()
{
return
(
<
AuthGate
>
<
Routes
>
<
Route
path=
"/"
element=
{
<
ChatPage
/>
}
/>
<
Route
path=
"/chat/:chatId"
element=
{
<
ChatPage
/>
}
/>
<
Route
path=
"/admin"
element=
{
<
AdminPage
/>
}
/>
<
Route
path=
"/knowledge"
element=
{
<
KnowledgePage
/>
}
/>
<
Route
path=
"/gitlab"
element=
{
<
GitLabPage
/>
}
/>
<
Route
path=
"*"
element=
{
<
Navigate
to=
"/"
replace
/>
}
/>
</
Routes
>
</
AuthGate
>
);
}
export
default
function
App
()
{
return
(
return
(
<
Routes
>
<
AppProvider
>
<
Route
path=
"/admin"
element=
{
<
AdminPage
/>
}
/>
<
BrowserRouter
>
<
Route
path=
"/knowledge"
element=
{
<
KnowledgePage
/>
}
/>
<
AppRoutes
/>
<
Route
path=
"/gitlab"
element=
{
<
GitLabPage
/>
}
/>
</
BrowserRouter
>
<
Route
path=
"/*"
element=
{
<
ChatPage
/>
}
/>
</
AppProvider
>
</
Routes
>
);
);
}
}
\ No newline at end of file
frontend/src/api.js
View file @
faa34a43
...
@@ -21,29 +21,27 @@ async function request(method, path, token, body) {
...
@@ -21,29 +21,27 @@ async function request(method, path, token, body) {
return
res
.
json
();
return
res
.
json
();
}
}
// ═══ Auth ═══
export
const
login
=
(
username
,
password
)
=>
export
const
login
=
(
username
,
password
)
=>
request
(
"POST"
,
"/auth/login"
,
null
,
{
username
,
password
});
request
(
"POST"
,
"/auth/login"
,
null
,
{
username
,
password
});
export
const
register
=
(
username
,
email
,
password
)
=>
export
const
register
=
(
username
,
email
,
password
)
=>
request
(
"POST"
,
"/auth/register"
,
null
,
{
username
,
email
,
password
});
request
(
"POST"
,
"/auth/register"
,
null
,
{
username
,
email
,
password
});
export
const
getMe
=
(
token
)
=>
request
(
"GET"
,
"/auth/me"
,
token
);
export
const
getMe
=
(
token
)
=>
request
(
"GET"
,
"/auth/me"
,
token
);
// ═══ Chats ═══
export
const
listChats
=
(
token
)
=>
request
(
"GET"
,
"/chats"
,
token
);
export
const
listChats
=
(
token
)
=>
request
(
"GET"
,
"/chats"
,
token
);
export
const
createChat
=
(
token
,
data
=
{})
=>
request
(
"POST"
,
"/chats"
,
token
,
data
);
export
const
createChat
=
(
token
,
data
=
{})
=>
request
(
"POST"
,
"/chats"
,
token
,
data
);
export
const
getChat
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
`
,
token
);
export
const
updateChat
=
(
token
,
chatId
,
data
)
=>
export
const
updateChat
=
(
token
,
chatId
,
data
)
=>
request
(
"PUT"
,
`/chats/
${
chatId
}
`
,
token
,
data
);
request
(
"PUT"
,
`/chats/
${
chatId
}
`
,
token
,
data
);
export
const
renameChat
=
(
token
,
chatId
,
title
)
=>
export
const
renameChat
=
(
token
,
chatId
,
title
)
=>
updateChat
(
token
,
chatId
,
{
title
});
updateChat
(
token
,
chatId
,
{
title
});
export
const
deleteChat
=
(
token
,
chatId
)
=>
export
const
deleteChat
=
(
token
,
chatId
)
=>
request
(
"DELETE"
,
`/chats/
${
chatId
}
`
,
token
);
request
(
"DELETE"
,
`/chats/
${
chatId
}
`
,
token
);
export
const
getMessages
=
(
token
,
chatId
)
=>
export
const
getMessages
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/messages`
,
token
);
request
(
"GET"
,
`/chats/
${
chatId
}
/messages`
,
token
);
export
const
checkGenerating
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/generating`
,
token
);
export
async
function
*
streamMessage
(
token
,
chatId
,
body
,
signal
)
{
export
async
function
*
streamMessage
(
token
,
chatId
,
body
,
signal
)
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/messages`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/messages`
,
{
...
@@ -66,15 +64,39 @@ export async function* streamMessage(token, chatId, body, signal) {
...
@@ -66,15 +64,39 @@ export async function* streamMessage(token, chatId, body, signal) {
for
(
const
part
of
parts
)
{
for
(
const
part
of
parts
)
{
const
line
=
part
.
trim
();
const
line
=
part
.
trim
();
if
(
line
.
startsWith
(
"data: "
))
{
if
(
line
.
startsWith
(
"data: "
))
{
try
{
yield
JSON
.
parse
(
line
.
slice
(
6
));
}
catch
{
}
try
{
yield
JSON
.
parse
(
line
.
slice
(
6
));
}
catch
{}
}
}
}
}
}
}
if
(
buffer
.
trim
().
startsWith
(
"data: "
))
{
if
(
buffer
.
trim
().
startsWith
(
"data: "
))
{
try
{
yield
JSON
.
parse
(
buffer
.
trim
().
slice
(
6
));
}
catch
{
}
try
{
yield
JSON
.
parse
(
buffer
.
trim
().
slice
(
6
));
}
catch
{}
}
}
}
}
export
async
function
*
reconnectStream
(
token
,
chatId
,
signal
)
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/stream`
,
{
method
:
"GET"
,
headers
:
headers
(
token
),
signal
,
});
if
(
!
res
.
ok
)
return
;
const
reader
=
res
.
body
.
getReader
();
const
decoder
=
new
TextDecoder
();
let
buffer
=
""
;
while
(
true
)
{
const
{
done
,
value
}
=
await
reader
.
read
();
if
(
done
)
break
;
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
});
const
parts
=
buffer
.
split
(
"
\n\n
"
);
buffer
=
parts
.
pop
()
||
""
;
for
(
const
part
of
parts
)
{
const
line
=
part
.
trim
();
if
(
line
.
startsWith
(
"data: "
))
{
try
{
yield
JSON
.
parse
(
line
.
slice
(
6
));
}
catch
{}
}
}
}
}
// ═══ Attachments ═══
export
async
function
uploadAttachments
(
token
,
chatId
,
files
)
{
export
async
function
uploadAttachments
(
token
,
chatId
,
files
)
{
const
form
=
new
FormData
();
const
form
=
new
FormData
();
for
(
const
file
of
files
)
form
.
append
(
"files"
,
file
);
for
(
const
file
of
files
)
form
.
append
(
"files"
,
file
);
...
@@ -87,34 +109,22 @@ export async function uploadAttachments(token, chatId, files) {
...
@@ -87,34 +109,22 @@ export async function uploadAttachments(token, chatId, files) {
}
}
return
res
.
json
();
return
res
.
json
();
}
}
export
function
getAttachmentUrl
(
attachmentId
)
{
export
function
getAttachmentUrl
(
attachmentId
)
{
return
`
${
BASE
}
/attachments/
${
attachmentId
}
/file`
;
return
`
${
BASE
}
/attachments/
${
attachmentId
}
/file`
;
}
}
export
const
deleteAttachment
=
(
token
,
attachmentId
)
=>
export
const
deleteAttachment
=
(
token
,
attachmentId
)
=>
request
(
"DELETE"
,
`/attachments/
${
attachmentId
}
`
,
token
);
request
(
"DELETE"
,
`/attachments/
${
attachmentId
}
`
,
token
);
// ═══ Knowledge Bases ═══
export
const
listKnowledgeBases
=
(
token
)
=>
request
(
"GET"
,
"/knowledge"
,
token
);
export
const
listKnowledgeBases
=
(
token
)
=>
request
(
"GET"
,
"/knowledge"
,
token
);
export
const
createKnowledgeBase
=
(
token
,
name
,
description
=
""
)
=>
export
const
createKnowledgeBase
=
(
token
,
name
,
description
=
""
)
=>
request
(
"POST"
,
"/knowledge"
,
token
,
{
name
,
description
});
request
(
"POST"
,
"/knowledge"
,
token
,
{
name
,
description
});
export
const
getKnowledgeBase
=
(
token
,
kbId
)
=>
export
const
getKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
`
,
token
);
request
(
"GET"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
const
updateKnowledgeBase
=
(
token
,
kbId
,
data
)
=>
request
(
"PUT"
,
`/knowledge/
${
kbId
}
`
,
token
,
data
);
export
const
deleteKnowledgeBase
=
(
token
,
kbId
)
=>
export
const
deleteKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
`
,
token
);
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
const
listKnowledgeDocuments
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
/documents`
,
token
);
export
const
deleteKnowledgeDocument
=
(
token
,
kbId
,
docId
)
=>
export
const
deleteKnowledgeDocument
=
(
token
,
kbId
,
docId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
/documents/
${
docId
}
`
,
token
);
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
/documents/
${
docId
}
`
,
token
);
export
async
function
uploadDocuments
(
token
,
kbId
,
files
)
{
export
async
function
uploadDocuments
(
token
,
kbId
,
files
)
{
const
form
=
new
FormData
();
const
form
=
new
FormData
();
for
(
const
file
of
files
)
form
.
append
(
"files"
,
file
);
for
(
const
file
of
files
)
form
.
append
(
"files"
,
file
);
...
@@ -127,29 +137,25 @@ export async function uploadDocuments(token, kbId, files) {
...
@@ -127,29 +137,25 @@ export async function uploadDocuments(token, kbId, files) {
}
}
return
res
.
json
();
return
res
.
json
();
}
}
export
const
uploadDocument
=
(
token
,
kbId
,
file
)
=>
export
const
uploadDocument
=
(
token
,
kbId
,
file
)
=>
uploadDocuments
(
token
,
kbId
,
[
file
]);
uploadDocuments
(
token
,
kbId
,
[
file
]);
// ═══ Admin ═══
export
const
adminStats
=
(
token
)
=>
request
(
"GET"
,
"/admin/stats"
,
token
);
export
const
adminStats
=
(
token
)
=>
request
(
"GET"
,
"/admin/stats"
,
token
);
export
const
adminListUsers
=
(
token
)
=>
request
(
"GET"
,
"/admin/users"
,
token
);
export
const
adminListUsers
=
(
token
)
=>
request
(
"GET"
,
"/admin/users"
,
token
);
export
const
adminCreateUser
=
(
token
,
data
)
=>
export
const
adminCreateUser
=
(
token
,
data
)
=>
request
(
"POST"
,
"/admin/users"
,
token
,
data
);
request
(
"POST"
,
"/admin/users"
,
token
,
data
);
export
const
adminUpdateUser
=
(
token
,
userId
,
data
)
=>
export
const
adminUpdateUser
=
(
token
,
userId
,
data
)
=>
request
(
"PUT"
,
`/admin/users/
${
userId
}
`
,
token
,
data
);
request
(
"PUT"
,
`/admin/users/
${
userId
}
`
,
token
,
data
);
export
const
adminDeleteUser
=
(
token
,
userId
)
=>
export
const
adminDeleteUser
=
(
token
,
userId
)
=>
request
(
"DELETE"
,
`/admin/users/
${
userId
}
`
,
token
);
request
(
"DELETE"
,
`/admin/users/
${
userId
}
`
,
token
);
export
const
adminListChats
=
(
token
)
=>
request
(
"GET"
,
"/admin/chats"
,
token
);
export
const
adminListChats
=
(
token
)
=>
request
(
"GET"
,
"/admin/chats"
,
token
);
export
async
function
downloadZip
(
token
,
markdown
)
{
// ═══ Files / Code Download ═══
export
async
function
downloadZip
(
token
,
markdown
,
title
)
{
const
res
=
await
fetch
(
`
${
BASE
}
/files/download-zip`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/files/download-zip`
,
{
method
:
"POST"
,
headers
:
headers
(
token
),
method
:
"POST"
,
headers
:
headers
(
token
),
body
:
JSON
.
stringify
({
markdown
}),
body
:
JSON
.
stringify
({
markdown
,
title
}),
});
});
if
(
!
res
.
ok
)
throw
new
Error
(
"Download failed"
);
if
(
!
res
.
ok
)
throw
new
Error
(
"Download failed"
);
const
ct
=
res
.
headers
.
get
(
"content-type"
)
||
""
;
const
ct
=
res
.
headers
.
get
(
"content-type"
)
||
""
;
...
@@ -158,7 +164,9 @@ export async function downloadZip(token, markdown) {
...
@@ -158,7 +164,9 @@ export async function downloadZip(token, markdown) {
const
url
=
URL
.
createObjectURL
(
blob
);
const
url
=
URL
.
createObjectURL
(
blob
);
const
a
=
document
.
createElement
(
"a"
);
const
a
=
document
.
createElement
(
"a"
);
a
.
href
=
url
;
a
.
href
=
url
;
a
.
download
=
"son-of-anton-code.zip"
;
const
disp
=
res
.
headers
.
get
(
"content-disposition"
)
||
""
;
const
match
=
disp
.
match
(
/filename="
?([^
"
]
+
)
"
?
/
);
a
.
download
=
match
?
match
[
1
]
:
"son-of-anton-code.zip"
;
a
.
click
();
a
.
click
();
URL
.
revokeObjectURL
(
url
);
URL
.
revokeObjectURL
(
url
);
}
else
{
}
else
{
...
@@ -167,61 +175,59 @@ export async function downloadZip(token, markdown) {
...
@@ -167,61 +175,59 @@ export async function downloadZip(token, markdown) {
}
}
}
}
// ═══════════════════════════════════════════════════
// ═══ GitLab ═══
// GitLab Repository API — Son of Anton v4.1.0
export
const
getGitLabSettings
=
(
token
)
=>
// ═══════════════════════════════════════════════════
request
(
"GET"
,
"/gitlab/settings"
,
token
);
export
const
updateGitLabSettings
=
(
token
,
data
)
=>
export
const
gitlabGetSettings
=
(
token
)
=>
request
(
"GET"
,
"/gitlab/settings"
,
token
);
request
(
"PUT"
,
"/gitlab/settings"
,
token
,
data
);
export
const
gitlabUpdateSettings
=
(
token
,
data
)
=>
request
(
"PUT"
,
"/gitlab/settings"
,
token
,
data
);
export
const
testGitLabConnection
=
(
token
,
data
)
=>
export
const
gitlabTestConnection
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/test-connection"
,
token
,
data
);
request
(
"POST"
,
"/gitlab/test-connection"
,
token
,
data
);
export
const
gitlabSearchProjects
=
(
token
,
search
,
owned
)
=>
export
const
searchGitLabProjects
=
(
token
,
search
=
""
,
owned
=
false
)
=>
request
(
"GET"
,
`/gitlab/projects?search=
${
encodeURIComponent
(
search
||
""
)}
&owned=
${
owned
||
false
}
`
,
token
);
request
(
"GET"
,
`/gitlab/projects?search=
${
encodeURIComponent
(
search
)}
&owned=
${
owned
}
`
,
token
);
export
const
gitlabCreateProject
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/projects"
,
token
,
data
);
export
const
createGitLabProject
=
(
token
,
data
)
=>
export
const
gitlabLinkRepo
=
(
token
,
gitlabProjectId
)
=>
request
(
"POST"
,
"/gitlab/repos"
,
token
,
{
gitlab_project_id
:
gitlabProjectId
});
request
(
"POST"
,
"/gitlab/projects"
,
token
,
data
);
export
const
gitlabUnlinkRepo
=
(
token
,
repoId
)
=>
request
(
"DELETE"
,
`/gitlab/repos/
${
repoId
}
`
,
token
);
export
const
gitlabListActions
=
(
token
,
status
)
=>
request
(
"GET"
,
`/gitlab/actions?status=
${
status
||
"pending"
}
`
,
token
);
// Linked repos
export
const
gitlabApproveAction
=
(
token
,
actionId
)
=>
request
(
"POST"
,
`/gitlab/actions/
${
actionId
}
/approve`
,
token
);
export
const
listLinkedRepos
=
(
token
)
=>
export
const
gitlabRejectAction
=
(
token
,
actionId
)
=>
request
(
"POST"
,
`/gitlab/actions/
${
actionId
}
/reject`
,
token
);
request
(
"GET"
,
"/gitlab/repos"
,
token
);
export
const
gitlabAnalyzeProject
=
(
token
,
repoId
,
ref
)
=>
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/analyze?ref=
${
encodeURIComponent
(
ref
||
""
)}
`
,
token
);
export
const
linkRepo
=
(
token
,
gitlabProjectId
)
=>
request
(
"POST"
,
"/gitlab/repos"
,
token
,
{
gitlab_project_id
:
gitlabProjectId
});
export
function
gitlabGetTree
(
token
,
repoId
,
ref
=
""
,
path
=
""
)
{
export
const
unlinkRepo
=
(
token
,
repoId
)
=>
const
p
=
new
URLSearchParams
();
request
(
"DELETE"
,
`/gitlab/repos/
${
repoId
}
`
,
token
);
if
(
ref
)
p
.
set
(
"ref"
,
ref
);
if
(
path
)
p
.
set
(
"path"
,
path
);
// Repo operations
return
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/tree?
${
p
}
`
,
token
);
export
const
getRepoTree
=
(
token
,
repoId
,
path
=
""
,
ref
=
null
)
=>
{
}
let
url
=
`/gitlab/repos/
${
repoId
}
/tree?path=
${
encodeURIComponent
(
path
)}
`
;
if
(
ref
)
url
+=
`&ref=
${
encodeURIComponent
(
ref
)}
`
;
export
function
gitlabGetFile
(
token
,
repoId
,
filePath
,
ref
=
""
)
{
return
request
(
"GET"
,
url
,
token
);
const
p
=
new
URLSearchParams
({
path
:
filePath
});
};
if
(
ref
)
p
.
set
(
"ref"
,
ref
);
export
const
getRepoFile
=
(
token
,
repoId
,
path
,
ref
=
null
)
=>
{
return
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/file?
${
p
}
`
,
token
);
let
url
=
`/gitlab/repos/
${
repoId
}
/file?path=
${
encodeURIComponent
(
path
)}
`
;
}
if
(
ref
)
url
+=
`&ref=
${
encodeURIComponent
(
ref
)}
`
;
return
request
(
"GET"
,
url
,
token
);
export
async
function
gitlabFileExists
(
token
,
repoId
,
filePath
,
ref
=
""
)
{
};
try
{
export
const
getRepoBranches
=
(
token
,
repoId
)
=>
await
gitlabGetFile
(
token
,
repoId
,
filePath
,
ref
);
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
);
return
true
;
export
const
createRepoBranch
=
(
token
,
repoId
,
branchName
,
ref
=
"main"
)
=>
}
catch
{
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
,
{
branch_name
:
branchName
,
ref
});
return
false
;
export
const
commitToRepo
=
(
token
,
repoId
,
data
)
=>
}
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit`
,
token
,
data
);
}
export
const
commitSingleFile
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit-single`
,
token
,
data
);
export
function
gitlabCommitSingle
(
token
,
repoId
,
data
)
{
export
const
createMergeRequest
=
(
token
,
repoId
,
data
)
=>
return
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit-single`
,
token
,
data
);
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/merge-request`
,
token
,
data
);
}
export
const
analyzeProject
=
(
token
,
repoId
,
ref
=
null
)
=>
{
let
url
=
`/gitlab/repos/
${
repoId
}
/analyze`
;
export
function
gitlabCommitMulti
(
token
,
repoId
,
data
)
{
if
(
ref
)
url
+=
`?ref=
${
encodeURIComponent
(
ref
)}
`
;
return
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit`
,
token
,
data
);
return
request
(
"GET"
,
url
,
token
);
}
};
export
function
gitlabGetBranches
(
token
,
repoId
)
{
// Pending actions
return
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
);
export
const
listPendingActions
=
(
token
,
status
=
"pending"
)
=>
}
request
(
"GET"
,
`/gitlab/actions?status=
${
status
}
`
,
token
);
export
const
createAction
=
(
token
,
data
)
=>
export
function
gitlabCreateBranch
(
token
,
repoId
,
data
)
{
request
(
"POST"
,
"/gitlab/actions"
,
token
,
data
);
return
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
,
data
);
export
const
approveAction
=
(
token
,
actionId
)
=>
}
request
(
"POST"
,
`/gitlab/actions/
${
actionId
}
/approve`
,
token
);
export
const
rejectAction
=
(
token
,
actionId
)
=>
export
function
gitlabListRepos
(
token
)
{
request
(
"POST"
,
`/gitlab/actions/
${
actionId
}
/reject`
,
token
);
return
request
(
"GET"
,
`/gitlab/repos`
,
token
);
\ No newline at end of file
}
\ No newline at end of file
frontend/src/components/ChatView.jsx
View file @
faa34a43
import
React
,
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
"react"
;
import
React
,
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
getMessages
,
downloadZip
,
listKnowledgeBases
,
updateChat
,
uploadAttachments
}
from
"../api"
;
import
{
getMessages
,
downloadZip
,
listKnowledgeBases
,
updateChat
,
uploadAttachments
,
listLinkedRepos
,
checkGenerating
,
}
from
"../api"
;
import
*
as
streamManager
from
"../streamManager"
;
import
*
as
streamManager
from
"../streamManager"
;
import
MessageBubble
from
"./MessageBubble"
;
import
MessageBubble
from
"./MessageBubble"
;
import
RepoFilePanel
from
"./RepoFilePanel"
;
import
{
import
{
Send
,
Square
,
Settings2
,
X
,
Brain
,
BookOpen
,
Paperclip
,
FileText
,
Loader2
,
GitBranch
}
from
"lucide-react"
;
Send
,
Square
,
Settings2
,
X
,
Brain
,
BookOpen
,
Paperclip
,
FileText
,
Loader2
,
GitBranch
,
}
from
"lucide-react"
;
const
MODELS
=
[
const
MODELS
=
[
{
id
:
"eu.anthropic.claude-opus-4-6-v1"
,
label
:
"Claude Opus 4.6 (Primary)"
},
{
id
:
"eu.anthropic.claude-opus-4-6-v1"
,
label
:
"Claude Opus 4.6 (Primary)"
},
...
@@ -14,8 +19,8 @@ const MODELS = [
...
@@ -14,8 +19,8 @@ const MODELS = [
function
classifyFile
(
file
)
{
function
classifyFile
(
file
)
{
const
ext
=
(
file
.
name
||
""
).
split
(
"."
).
pop
().
toLowerCase
();
const
ext
=
(
file
.
name
||
""
).
split
(
"."
).
pop
().
toLowerCase
();
const
mime
=
file
.
type
||
""
;
const
mime
=
file
.
type
||
""
;
if
(
mime
.
startsWith
(
"image/"
)
||
[
"jpg"
,
"jpeg"
,
"png"
,
"gif"
,
"webp"
,
"bmp"
].
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"
;
}
}
...
@@ -25,16 +30,16 @@ export default function ChatView({ chatId }) {
...
@@ -25,16 +30,16 @@ export default function ChatView({ chatId }) {
const
currentChat
=
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
);
const
currentChat
=
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
);
const
messages
=
state
.
chatMessages
[
chatId
]
||
[];
const
messages
=
state
.
chatMessages
[
chatId
]
||
[];
const
isStreamingGlobal
=
!!
state
.
activeStreams
[
chatId
];
const
isStreamingGlobal
=
!!
state
.
activeStreams
[
chatId
];
const
linkedRepo
=
currentChat
?.
linked_repo
||
null
;
const
[
input
,
setInput
]
=
useState
(
""
);
const
[
input
,
setInput
]
=
useState
(
""
);
const
[
showSettings
,
setShowSettings
]
=
useState
(
false
);
const
[
showSettings
,
setShowSettings
]
=
useState
(
false
);
const
[
showRepoPanel
,
setShowRepoPanel
]
=
useState
(
false
);
const
[
model
,
setModel
]
=
useState
(
currentChat
?.
model
||
MODELS
[
0
].
id
);
const
[
model
,
setModel
]
=
useState
(
currentChat
?.
model
||
MODELS
[
0
].
id
);
const
[
maxTokens
,
setMaxTokens
]
=
useState
(
currentChat
?.
max_tokens
||
4096
);
const
[
maxTokens
,
setMaxTokens
]
=
useState
(
currentChat
?.
max_tokens
||
4096
);
const
[
reasoningBudget
,
setReasoningBudget
]
=
useState
(
currentChat
?.
reasoning_budget
??
0
);
const
[
reasoningBudget
,
setReasoningBudget
]
=
useState
(
currentChat
?.
reasoning_budget
??
0
);
const
[
selectedKbId
,
setSelectedKbId
]
=
useState
(
currentChat
?.
knowledge_base_id
||
null
);
const
[
selectedKbId
,
setSelectedKbId
]
=
useState
(
currentChat
?.
knowledge_base_id
||
null
);
const
[
selectedRepoId
,
setSelectedRepoId
]
=
useState
(
currentChat
?.
linked_repo_id
||
null
);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
repos
,
setRepos
]
=
useState
([]);
const
[
pendingFiles
,
setPendingFiles
]
=
useState
([]);
const
[
pendingFiles
,
setPendingFiles
]
=
useState
([]);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
streamData
,
setStreamData
]
=
useState
(
streamManager
.
getStreamData
(
chatId
));
const
[
streamData
,
setStreamData
]
=
useState
(
streamManager
.
getStreamData
(
chatId
));
...
@@ -50,6 +55,16 @@ export default function ChatView({ chatId }) {
...
@@ -50,6 +55,16 @@ export default function ChatView({ chatId }) {
return
streamManager
.
subscribe
(
chatId
,
()
=>
setStreamData
(
streamManager
.
getStreamData
(
chatId
)));
return
streamManager
.
subscribe
(
chatId
,
()
=>
setStreamData
(
streamManager
.
getStreamData
(
chatId
)));
},
[
chatId
]);
},
[
chatId
]);
useEffect
(()
=>
{
if
(
currentChat
)
{
setModel
(
currentChat
.
model
||
MODELS
[
0
].
id
);
setMaxTokens
(
currentChat
.
max_tokens
||
4096
);
setReasoningBudget
(
currentChat
.
reasoning_budget
??
0
);
setSelectedKbId
(
currentChat
.
knowledge_base_id
||
null
);
setSelectedRepoId
(
currentChat
.
linked_repo_id
||
null
);
}
},
[
chatId
,
currentChat
?.
id
]);
function
onScroll
()
{
function
onScroll
()
{
const
el
=
scrollRef
.
current
;
const
el
=
scrollRef
.
current
;
if
(
!
el
)
return
;
if
(
!
el
)
return
;
...
@@ -67,21 +82,50 @@ export default function ChatView({ chatId }) {
...
@@ -67,21 +82,50 @@ export default function ChatView({ chatId }) {
useEffect
(()
=>
{
useEffect
(()
=>
{
(
async
()
=>
{
(
async
()
=>
{
try
{
try
{
const
[
msgs
,
kbData
]
=
await
Promise
.
all
([
getMessages
(
state
.
token
,
chatId
),
listKnowledgeBases
(
state
.
token
)]);
const
[
msgs
,
kbData
]
=
await
Promise
.
all
([
getMessages
(
state
.
token
,
chatId
),
listKnowledgeBases
(
state
.
token
),
]);
dispatch
({
type
:
"SET_MESSAGES"
,
chatId
,
messages
:
msgs
});
dispatch
({
type
:
"SET_MESSAGES"
,
chatId
,
messages
:
msgs
});
setKbs
(
kbData
);
setKbs
(
kbData
);
}
catch
{
}
// Load linked repos if superadmin
if
(
state
.
user
?.
role
===
"superadmin"
)
{
try
{
const
repoData
=
await
listLinkedRepos
(
state
.
token
);
setRepos
(
repoData
);
dispatch
({
type
:
"SET_LINKED_REPOS"
,
repos
:
repoData
});
}
catch
{}
}
// Check if there's an active generation we should reconnect to
try
{
const
{
active
}
=
await
checkGenerating
(
state
.
token
,
chatId
);
if
(
active
&&
!
streamManager
.
isStreaming
(
chatId
))
{
streamManager
.
reconnectToStream
({
token
:
state
.
token
,
chatId
});
}
}
catch
{}
}
catch
{}
})();
})();
},
[
chatId
,
state
.
token
,
dispatch
]);
},
[
chatId
,
state
.
token
,
dispatch
,
state
.
user
?.
role
]);
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
]);
async
function
saveSettings
()
{
async
function
saveSettings
()
{
try
{
try
{
await
updateChat
(
state
.
token
,
chatId
,
{
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
||
""
});
await
updateChat
(
state
.
token
,
chatId
,
{
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
}
});
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
}
catch
{
}
knowledge_base_id
:
selectedKbId
||
""
,
linked_repo_id
:
selectedRepoId
||
""
,
});
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
linked_repo_id
:
selectedRepoId
,
},
});
}
catch
{}
}
}
function
toggleSettings
()
{
function
toggleSettings
()
{
...
@@ -91,12 +135,21 @@ export default function ChatView({ chatId }) {
...
@@ -91,12 +135,21 @@ export default function ChatView({ chatId }) {
function
handleFileSelect
(
e
)
{
function
handleFileSelect
(
e
)
{
const
files
=
Array
.
from
(
e
.
target
.
files
||
[]);
const
files
=
Array
.
from
(
e
.
target
.
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
,
})),
]);
e
.
target
.
value
=
""
;
e
.
target
.
value
=
""
;
}
}
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
()
{
...
@@ -115,14 +168,29 @@ export default function ChatView({ chatId }) {
...
@@ -115,14 +168,29 @@ 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
;
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
}
});
dispatch
({
streamManager
.
startStream
({
token
:
state
.
token
,
chatId
,
body
:
{
content
:
text
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
attachment_ids
:
attIds
}
});
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
linked_repo_id
:
selectedRepoId
},
});
streamManager
.
startStream
({
token
:
state
.
token
,
chatId
,
body
:
{
content
:
text
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
attachment_ids
:
attIds
,
},
});
}
}
function
handleKeyDown
(
e
)
{
if
(
e
.
key
===
"Enter"
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
function
handleKeyDown
(
e
)
{
if
(
e
.
key
===
"Enter"
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
...
@@ -131,140 +199,176 @@ export default function ChatView({ chatId }) {
...
@@ -131,140 +199,176 @@ export default function ChatView({ chatId }) {
const
imgs
=
Array
.
from
(
e
.
clipboardData
?.
items
||
[]).
filter
((
i
)
=>
i
.
type
.
startsWith
(
"image/"
));
const
imgs
=
Array
.
from
(
e
.
clipboardData
?.
items
||
[]).
filter
((
i
)
=>
i
.
type
.
startsWith
(
"image/"
));
if
(
!
imgs
.
length
)
return
;
if
(
!
imgs
.
length
)
return
;
e
.
preventDefault
();
e
.
preventDefault
();
setPendingFiles
((
prev
)
=>
[...
prev
,
...
imgs
.
map
((
i
)
=>
{
const
f
=
i
.
getAsFile
();
return
{
file
:
f
,
type
:
"image"
,
preview
:
URL
.
createObjectURL
(
f
)
};
})]);
setPendingFiles
((
prev
)
=>
[
...
prev
,
...
imgs
.
map
((
i
)
=>
{
const
f
=
i
.
getAsFile
();
return
{
file
:
f
,
type
:
"image"
,
preview
:
URL
.
createObjectURL
(
f
)
};
}),
]);
}
}
function
handleDrop
(
e
)
{
function
handleDrop
(
e
)
{
e
.
preventDefault
();
e
.
preventDefault
();
const
files
=
Array
.
from
(
e
.
dataTransfer
?.
files
||
[]);
const
files
=
Array
.
from
(
e
.
dataTransfer
?.
files
||
[]);
if
(
files
.
length
)
setPendingFiles
((
prev
)
=>
[...
prev
,
...
files
.
map
((
f
)
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
}))]);
if
(
files
.
length
)
setPendingFiles
((
prev
)
=>
[
...
prev
,
...
files
.
map
((
f
)
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
})),
]);
}
}
const
streaming
=
streamData
.
streaming
;
const
streaming
=
streamData
.
streaming
;
const
selectedRepo
=
repos
.
find
((
r
)
=>
r
.
id
===
selectedRepoId
);
return
(
return
(
<
div
className=
"flex-1 flex min-h-0"
onDrop=
{
handleDrop
}
onDragOver=
{
(
e
)
=>
e
.
preventDefault
()
}
>
<
div
className=
"flex-1 flex flex-col min-h-0"
onDrop=
{
handleDrop
}
onDragOver=
{
(
e
)
=>
e
.
preventDefault
()
}
>
{
/* Main Chat Column */
}
<
div
ref=
{
scrollRef
}
onScroll=
{
onScroll
}
className=
"flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
<
div
className=
"flex-1 flex flex-col min-h-0 min-w-0"
>
{
messages
.
map
((
m
)
=>
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
/>)
}
<
div
ref=
{
scrollRef
}
onScroll=
{
onScroll
}
className=
"flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
{
streaming
&&
(
streamData
.
thinking
||
streamData
.
text
)
&&
(
{
messages
.
map
((
m
)
=>
(
<
MessageBubble
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
/>
message=
{
{
id
:
"streaming"
,
role
:
"assistant"
,
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
attachments
:
[]
}
}
))
}
isStreaming
isThinking=
{
streamData
.
isThinking
}
token=
{
state
.
token
}
{
streaming
&&
(
streamData
.
thinking
||
streamData
.
text
)
&&
(
/>
<
MessageBubble
)
}
message=
{
{
id
:
"streaming"
,
role
:
"assistant"
,
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
attachments
:
[]
}
}
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
isStreaming
isThinking=
{
streamData
.
isThinking
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
<
div
className=
"flex items-center gap-2 px-4 py-3 animate-fade-in"
>
/>
<
div
className=
"flex gap-1"
>
)
}
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"0ms"
}
}
/>
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"150ms"
}
}
/>
<
div
className=
"flex items-center gap-2 px-4 py-3 animate-fade-in"
>
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"300ms"
}
}
/>
<
div
className=
"flex gap-1"
>
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"0ms"
}
}
/>
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"150ms"
}
}
/>
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"300ms"
}
}
/>
</
div
>
<
span
className=
"text-anton-muted text-sm"
>
Son of Anton is thinking…
</
span
>
</
div
>
</
div
>
)
}
<
span
className=
"text-anton-muted text-sm"
>
Son of Anton is thinking…
</
span
>
</
div
>
</
div
>
)
}
</
div
>
{
/* Input Area */
}
<
div
className=
"border-t border-anton-border bg-anton-surface p-4"
>
<
div
className=
"border-t border-anton-border bg-anton-surface p-4"
>
{
showSettings
&&
(
{
showSettings
&&
(
<
div
className=
"mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in"
>
<
div
className=
"mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in"
>
<
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"
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
><
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Settings
</
h3
>
<
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Chat Settings
<
button
onClick=
{
toggleSettings
}
className=
"text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
</
h3
>
</
div
>
<
button
onClick=
{
toggleSettings
}
className=
"text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
<
div
>
</
div
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
<
div
>
<
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"
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
{
MODELS
.
map
((
m
)
=>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>)
}
<
select
value=
{
model
}
onChange=
{
(
e
)
=>
setModel
(
e
.
target
.
value
)
}
</
select
>
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"
>
</
div
>
{
MODELS
.
map
((
m
)
=>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>)
}
<
div
>
</
select
>
<
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
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
/>
<
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
>
<
div
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
className=
"w-full"
/>
<
div
className=
"flex justify-between text-xs mb-1"
><
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
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
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
className=
"w-full"
/>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
BookOpen
size=
{
12
}
/>
Knowledge Base
</
label
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<
option
value=
""
>
None
</
option
>
{
kbs
.
map
((
kb
)
=>
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs)
</
option
>)
}
</
select
>
</
div
>
{
state
.
user
?.
role
===
"superadmin"
&&
repos
.
length
>
0
&&
(
<
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"
><
GitBranch
size=
{
12
}
/>
Linked Repository
</
label
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<
select
value=
{
selectedRepoId
||
""
}
onChange=
{
(
e
)
=>
setSelectedRepoId
(
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"
>
<
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
>)
}
{
repos
.
map
((
r
)
=>
<
option
key=
{
r
.
id
}
value=
{
r
.
id
}
>
{
r
.
name
}
(
{
r
.
path_with_namespace
}
)
</
option
>)
}
</
select
>
</
select
>
{
selectedRepo
&&
(
<
div
className=
"mt-1.5 text-[10px] text-blue-400 flex items-center gap-1"
>
<
GitBranch
size=
{
10
}
/>
<
span
>
{
selectedRepo
.
path_with_namespace
}
</
span
>
<
span
className=
"text-anton-muted"
>
•
{
selectedRepo
.
default_branch
}
</
span
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
)
}
)
}
</
div
>
)
}
{
pendingFiles
.
length
>
0
&&
(
{
pendingFiles
.
length
>
0
&&
(
<
div
className=
"mb-3 flex flex-wrap gap-2 animate-fade-in"
>
<
div
className=
"mb-3 flex flex-wrap gap-2 animate-fade-in"
>
{
pendingFiles
.
map
((
pf
,
i
)
=>
(
{
pendingFiles
.
map
((
pf
,
i
)
=>
(
<
div
key=
{
i
}
className=
"relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden"
>
<
div
key=
{
i
}
className=
"relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden"
>
{
pf
.
type
===
"image"
&&
pf
.
preview
?
(
{
pf
.
type
===
"image"
&&
pf
.
preview
?
(
<
img
src=
{
pf
.
preview
}
alt=
""
className=
"w-16 h-16 object-cover"
/>
<
img
src=
{
pf
.
preview
}
alt=
""
className=
"w-16 h-16 object-cover"
/>
)
:
(
)
:
(
<
div
className=
"w-16 h-16 flex flex-col items-center justify-center px-1"
>
<
div
className=
"w-16 h-16 flex flex-col items-center justify-center px-1"
>
<
FileText
size=
{
20
}
className=
"text-anton-muted mb-1"
/>
<
FileText
size=
{
20
}
className=
"text-anton-muted mb-1"
/>
<
span
className=
"text-[9px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
10
)
}
</
span
>
<
span
className=
"text-[9px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
10
)
}
</
span
>
</
div
>
</
div
>
)
}
)
}
<
button
onClick=
{
()
=>
removePending
(
i
)
}
className=
"absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"
><
X
size=
{
10
}
/></
button
>
<
button
onClick=
{
()
=>
removePending
(
i
)
}
<
div
className=
"absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5"
>
{
(
pf
.
file
.
size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
className=
"absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<
X
size=
{
10
}
/>
</
button
>
<
div
className=
"absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5"
>
{
(
pf
.
file
.
size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
</
div
>
))
}
</
div
>
</
div
>
))
}
)
}
<
div
className=
"flex items-end gap-2"
>
<
button
onClick=
{
toggleSettings
}
className=
{
`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`
}
><
Settings2
size=
{
18
}
/></
button
>
<
button
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
className=
{
`p-2.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
>
{
linkedRepo
&&
(
<
button
onClick=
{
()
=>
setShowRepoPanel
(
!
showRepoPanel
)
}
title=
{
`${linkedRepo.name} — File Browser`
}
className=
{
`p-2.5 rounded-xl transition shrink-0 ${showRepoPanel ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-green-400 hover:bg-anton-card"}`
}
>
<
GitBranch
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,.dart,.vue,.svelte,.log"
onChange=
{
handleFileSelect
}
/>
<
div
className=
"flex-1 relative"
>
<
textarea
ref=
{
inputRef
}
value=
{
input
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onPaste=
{
handlePaste
}
placeholder=
{
pendingFiles
.
length
?
"Add a message or send to analyze files…"
:
"Ask Son of Anton anything…"
}
rows=
{
1
}
style=
{
{
maxHeight
:
"200px"
}
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput=
{
(
e
)
=>
{
e
.
target
.
style
.
height
=
"auto"
;
e
.
target
.
style
.
height
=
Math
.
min
(
e
.
target
.
scrollHeight
,
200
)
+
"px"
;
}
}
/>
</
div
>
{
streaming
?
(
<
button
onClick=
{
()
=>
streamManager
.
abortStream
(
chatId
)
}
className=
"p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
><
Square
size=
{
18
}
/></
button
>
)
:
(
<
button
onClick=
{
handleSend
}
disabled=
{
(
!
input
.
trim
()
&&
!
pendingFiles
.
length
)
||
isStreamingGlobal
||
uploading
}
className=
"p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
>
{
uploading
?
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
:
<
Send
size=
{
18
}
/>
}
</
button
>
)
}
</
div
>
</
div
>
)
}
<
div
className=
"flex items-center gap-3 mt-2 text-[11px] text-anton-muted"
>
<
div
className=
"flex items-end gap-2"
>
<
span
>
{
MODELS
.
find
((
m
)
=>
m
.
id
===
model
)?.
label
}
</
span
>
<
button
onClick=
{
toggleSettings
}
<
span
>
•
</
span
><
span
>
{
maxTokens
.
toLocaleString
()
}
tokens
</
span
>
className=
{
`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"}`
}
>
{
reasoningBudget
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
</
span
></>
}
<
Settings2
size=
{
18
}
/>
{
selectedKbId
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
📚 RAG
</
span
></>
}
</
button
>
{
linkedRepo
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
🔗
{
linkedRepo
.
name
}
</
span
></>
}
<
button
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
file
{
pendingFiles
.
length
!==
1
?
"s"
:
""
}
</
span
></>
}
className=
{
`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"}`
}
{
messages
.
some
((
m
)
=>
m
.
role
===
"assistant"
)
&&
(
title=
"Attach files"
>
<
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
{
}
}
}
<
Paperclip
size=
{
18
}
/>
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Download code
</
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,.dart,.vue,.svelte,.log"
onChange=
{
handleFileSelect
}
/>
<
div
className=
"flex-1 relative"
>
<
textarea
ref=
{
inputRef
}
value=
{
input
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onPaste=
{
handlePaste
}
placeholder=
{
pendingFiles
.
length
?
"Add a message or send to analyze files…"
:
"Ask Son of Anton anything…"
}
rows=
{
1
}
style=
{
{
maxHeight
:
"200px"
}
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput=
{
(
e
)
=>
{
e
.
target
.
style
.
height
=
"auto"
;
e
.
target
.
style
.
height
=
Math
.
min
(
e
.
target
.
scrollHeight
,
200
)
+
"px"
;
}
}
/>
</
div
>
</
div
>
{
streaming
?
(
<
button
onClick=
{
()
=>
streamManager
.
abortStream
(
chatId
)
}
className=
"p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
>
<
Square
size=
{
18
}
/>
</
button
>
)
:
(
<
button
onClick=
{
handleSend
}
disabled=
{
(
!
input
.
trim
()
&&
!
pendingFiles
.
length
)
||
isStreamingGlobal
||
uploading
}
className=
"p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
>
{
uploading
?
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
:
<
Send
size=
{
18
}
/>
}
</
button
>
)
}
</
div
>
</
div
>
</
div
>
{
/* Repo File Panel (slides in from right) */
}
<
div
className=
"flex items-center gap-3 mt-2 text-[11px] text-anton-muted flex-wrap"
>
{
showRepoPanel
&&
linkedRepo
&&
(
<
span
>
{
MODELS
.
find
((
m
)
=>
m
.
id
===
model
)?.
label
}
</
span
>
<
RepoFilePanel
linkedRepo=
{
linkedRepo
}
token=
{
state
.
token
}
onClose=
{
()
=>
setShowRepoPanel
(
false
)
}
/>
<
span
>
•
</
span
><
span
>
{
maxTokens
.
toLocaleString
()
}
tokens
</
span
>
)
}
{
reasoningBudget
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
</
span
></>
}
{
selectedKbId
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
📚 RAG
</
span
></>
}
{
selectedRepoId
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
🔗
{
selectedRepo
?.
name
||
"Repo"
}
</
span
></>
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
file
{
pendingFiles
.
length
!==
1
?
"s"
:
""
}
</
span
></>
}
{
messages
.
some
((
m
)
=>
m
.
role
===
"assistant"
)
&&
(
<
button
onClick=
{
async
()
=>
{
const
all
=
messages
.
filter
((
m
)
=>
m
.
role
===
"assistant"
).
map
((
m
)
=>
m
.
content
).
join
(
"
\n\n
---
\n\n
"
);
if
(
all
)
try
{
await
downloadZip
(
state
.
token
,
all
,
currentChat
?.
title
);
}
catch
{}
}
}
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Download code
</
button
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
);
);
}
}
\ No newline at end of file
frontend/src/components/CodeBlock.jsx
View file @
faa34a43
import
React
,
{
useState
}
from
"react"
;
import
React
,
{
useState
}
from
"react"
;
import
{
Prism
as
SyntaxHighlighter
}
from
"react-syntax-highlighter"
;
import
{
Prism
as
SyntaxHighlighter
}
from
"react-syntax-highlighter"
;
import
{
vscDarkPlus
}
from
"react-syntax-highlighter/dist/esm/styles/prism"
;
import
{
oneDark
}
from
"react-syntax-highlighter/dist/esm/styles/prism"
;
import
{
Copy
,
Check
,
Download
,
GitBranch
,
Loader2
,
CheckCircle2
,
XCircle
}
from
"lucide-react"
;
import
{
Copy
,
Check
,
Download
,
FileCode
}
from
"lucide-react"
;
import
{
gitlabFileExists
,
gitlabCommitSingle
}
from
"../api"
;
const
COMMIT_STATES
=
{
idle
:
"idle"
,
checking
:
"checking"
,
committing
:
"committing"
,
success
:
"success"
,
error
:
"error"
};
export
default
function
CodeBlock
({
language
,
filename
,
code
})
{
export
default
function
CodeBlock
({
language
,
filename
,
code
,
linkedRepo
,
token
})
{
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
[
commitState
,
setCommitState
]
=
useState
(
COMMIT_STATES
.
idle
);
const
[
commitMsg
,
setCommitMsg
]
=
useState
(
""
);
const
[
commitError
,
setCommitError
]
=
useState
(
""
);
const
[
showCommitForm
,
setShowCommitForm
]
=
useState
(
false
);
function
handleCopy
()
{
function
handleCopy
()
{
navigator
.
clipboard
.
writeText
(
code
);
navigator
.
clipboard
.
writeText
(
code
);
...
@@ -29,120 +22,33 @@ export default function CodeBlock({ language, filename, code, linkedRepo, token
...
@@ -29,120 +22,33 @@ export default function CodeBlock({ language, filename, code, linkedRepo, token
URL
.
revokeObjectURL
(
url
);
URL
.
revokeObjectURL
(
url
);
}
}
function
toggleCommitForm
()
{
if
(
!
showCommitForm
)
{
setCommitMsg
(
`Update
${
filename
||
"file"
}
via Son of Anton`
);
setCommitError
(
""
);
setCommitState
(
COMMIT_STATES
.
idle
);
}
setShowCommitForm
(
!
showCommitForm
);
}
async
function
handleCommit
()
{
if
(
!
linkedRepo
||
!
token
||
!
filename
)
return
;
setCommitState
(
COMMIT_STATES
.
checking
);
setCommitError
(
""
);
try
{
const
exists
=
await
gitlabFileExists
(
token
,
linkedRepo
.
id
,
filename
,
linkedRepo
.
default_branch
);
const
action
=
exists
?
"update"
:
"create"
;
setCommitState
(
COMMIT_STATES
.
committing
);
await
gitlabCommitSingle
(
token
,
linkedRepo
.
id
,
{
branch
:
linkedRepo
.
default_branch
,
file_path
:
filename
,
content
:
code
,
commit_message
:
commitMsg
||
`
${
action
===
"create"
?
"Create"
:
"Update"
}
${
filename
}
via Son of Anton`
,
action
,
});
setCommitState
(
COMMIT_STATES
.
success
);
setTimeout
(()
=>
{
setCommitState
(
COMMIT_STATES
.
idle
);
setShowCommitForm
(
false
);
},
2500
);
}
catch
(
err
)
{
setCommitState
(
COMMIT_STATES
.
error
);
setCommitError
(
err
.
message
||
"Commit failed"
);
}
}
const
canCommit
=
linkedRepo
&&
filename
&&
token
;
return
(
return
(
<
div
className=
"rounded-lg overflow-hidden border border-anton-border my-3"
>
<
div
className=
"my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]"
>
{
/* Header */
}
<
div
className=
"flex items-center justify-between px-4 py-2 bg-anton-card border-b border-anton-border"
>
<
div
className=
"flex items-center justify-between bg-[#1e1e2e] px-3 py-2 gap-2"
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-xs text-anton-muted font-mono truncate min-w-0"
>
<
FileCode
size=
{
13
}
className=
"text-anton-accent"
/>
{
filename
||
language
||
"code"
}
{
filename
&&
<
span
className=
"text-xs text-white font-mono"
>
{
filename
}
</
span
>
}
</
span
>
{
language
&&
!
filename
&&
<
span
className=
"text-xs text-anton-muted font-mono"
>
{
language
}
</
span
>
}
<
div
className=
"flex items-center gap-1 shrink-0"
>
</
div
>
{
canCommit
&&
(
<
div
className=
"flex items-center gap-1"
>
<
button
onClick=
{
toggleCommitForm
}
title=
"Commit to repo"
<
button
onClick=
{
handleCopy
}
className=
{
`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition ${showCommitForm
className=
"flex items-center gap-1 px-2 py-1 text-[10px] text-anton-muted hover:text-white rounded transition"
>
? "bg-green-500/20 text-green-400"
{
copied
?
<
Check
size=
{
11
}
className=
"text-green-400"
/>
:
<
Copy
size=
{
11
}
/>
}
: "text-anton-muted hover:text-green-400 hover:bg-green-500/10"
{
copied
?
"Copied"
:
"Copy"
}
}`
}
>
</
button
>
<
GitBranch
size=
{
12
}
/>
<
span
className=
"hidden sm:inline"
>
Push
</
span
>
</
button
>
)
}
{
filename
&&
(
{
filename
&&
(
<
button
onClick=
{
handleDownload
}
title=
"Download file"
<
button
onClick=
{
handleDownload
}
className=
"
p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5
transition"
>
className=
"
flex items-center gap-1 px-2 py-1 text-[10px] text-anton-muted hover:text-white rounded
transition"
>
<
Download
size=
{
1
3
}
/>
<
Download
size=
{
1
1
}
/>
Save
</
button
>
</
button
>
)
}
)
}
<
button
onClick=
{
handleCopy
}
title=
"Copy code"
className=
"p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition"
>
{
copied
?
<
Check
size=
{
13
}
className=
"text-green-400"
/>
:
<
Copy
size=
{
13
}
/>
}
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Commit Form */
}
{
showCommitForm
&&
canCommit
&&
(
<
div
className=
"bg-[#16162a] border-b border-anton-border px-3 py-2.5 space-y-2 animate-fade-in"
>
<
div
className=
"flex items-center gap-2 text-[11px]"
>
<
span
className=
"text-anton-muted"
>
Repo:
</
span
>
<
span
className=
"text-green-400 font-mono"
>
{
linkedRepo
.
name
}
</
span
>
<
span
className=
"text-anton-muted"
>
→
</
span
>
<
span
className=
"text-blue-400 font-mono"
>
{
linkedRepo
.
default_branch
}
</
span
>
</
div
>
<
input
type=
"text"
value=
{
commitMsg
}
onChange=
{
(
e
)
=>
setCommitMsg
(
e
.
target
.
value
)
}
placeholder=
"Commit message..."
className=
"w-full bg-anton-bg border border-anton-border rounded px-2.5 py-1.5 text-xs text-white placeholder-anton-muted focus:outline-none focus:border-green-500/50"
/>
<
div
className=
"flex items-center gap-2"
>
<
button
onClick=
{
handleCommit
}
disabled=
{
commitState
===
COMMIT_STATES
.
checking
||
commitState
===
COMMIT_STATES
.
committing
}
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded bg-green-600 hover:bg-green-500 text-white text-xs font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{
commitState
===
COMMIT_STATES
.
checking
&&
<><
Loader2
size=
{
12
}
className=
"animate-spin"
/>
Checking...
</>
}
{
commitState
===
COMMIT_STATES
.
committing
&&
<><
Loader2
size=
{
12
}
className=
"animate-spin"
/>
Committing...
</>
}
{
commitState
===
COMMIT_STATES
.
success
&&
<><
CheckCircle2
size=
{
12
}
/>
Committed!
</>
}
{
commitState
===
COMMIT_STATES
.
error
&&
<><
XCircle
size=
{
12
}
/>
Retry
</>
}
{
commitState
===
COMMIT_STATES
.
idle
&&
<><
GitBranch
size=
{
12
}
/>
Commit
</>
}
</
button
>
<
button
onClick=
{
toggleCommitForm
}
className=
"px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition"
>
Cancel
</
button
>
{
commitState
===
COMMIT_STATES
.
success
&&
(
<
span
className=
"text-[11px] text-green-400"
>
✓ Pushed to
{
linkedRepo
.
default_branch
}
</
span
>
)
}
</
div
>
{
commitError
&&
(
<
div
className=
"text-[11px] text-red-400 bg-red-500/10 rounded px-2 py-1"
>
{
commitError
}
</
div
>
)
}
</
div
>
)
}
{
/* Code */
}
<
SyntaxHighlighter
<
SyntaxHighlighter
language=
{
language
||
"text"
}
language=
{
language
||
"text"
}
style=
{
vscDarkPlus
}
style=
{
oneDark
}
customStyle=
{
{
margin
:
0
,
padding
:
"1rem"
,
fontSize
:
"0.8rem"
,
background
:
"#1a1a2e"
,
maxHeight
:
"500px"
}
}
customStyle=
{
{
margin
:
0
,
padding
:
"1rem"
,
background
:
"transparent"
,
fontSize
:
"0.8rem"
}
}
showLineNumbers
wrapLongLines
>
lineNumberStyle=
{
{
minWidth
:
"2.5em"
,
paddingRight
:
"1em"
,
color
:
"#555"
}
}
>
{
code
}
{
code
}
</
SyntaxHighlighter
>
</
SyntaxHighlighter
>
</
div
>
</
div
>
...
...
frontend/src/components/MessageBubble.jsx
View file @
faa34a43
...
@@ -8,11 +8,9 @@ import {
...
@@ -8,11 +8,9 @@ import {
Image
,
Film
,
FileText
,
ExternalLink
,
Image
,
Film
,
FileText
,
ExternalLink
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
const
FILE_TYPE_ICONS
=
{
const
FILE_TYPE_ICONS
=
{
image
:
Image
,
video
:
Film
,
document
:
FileText
,
text
:
FileText
};
image
:
Image
,
video
:
Film
,
document
:
FileText
,
text
:
FileText
,
};
const
MessageBubble
=
React
.
memo
(
function
MessageBubble
({
message
,
isStreaming
,
isThinking
,
token
,
linkedRepo
})
{
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"
;
const
[
showThinking
,
setShowThinking
]
=
useState
(
false
);
const
[
showThinking
,
setShowThinking
]
=
useState
(
false
);
...
@@ -36,7 +34,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -36,7 +34,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
<
div
className=
{
`max-w-[80%] ${isUser ? "order-first" : ""}`
}
>
<
div
className=
{
`max-w-[80%] ${isUser ? "order-first" : ""}`
}
>
{
thinking_content
&&
(
{
thinking_content
&&
(
<
div
className=
"mb-2"
>
<
div
className=
"mb-2"
>
...
@@ -54,7 +51,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -54,7 +51,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)
}
)
}
</
div
>
</
div
>
)
}
)
}
{
hasAttachments
&&
(
{
hasAttachments
&&
(
<
div
className=
"mb-2 flex flex-wrap gap-2"
>
<
div
className=
"mb-2 flex flex-wrap gap-2"
>
{
attachments
.
map
((
att
)
=>
{
{
attachments
.
map
((
att
)
=>
{
...
@@ -94,9 +90,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -94,9 +90,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
})
}
})
}
</
div
>
</
div
>
)
}
)
}
<
div
className=
{
`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"}`
}
>
<
div
className=
{
`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-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"
>
{
_stripPrefixes
(
content
)
}
</
div
>
)
:
(
)
:
(
...
@@ -112,15 +106,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -112,15 +106,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
lang
=
rawLang
.
slice
(
0
,
idx
);
lang
=
rawLang
.
slice
(
0
,
idx
);
filename
=
rawLang
.
slice
(
idx
+
1
);
filename
=
rawLang
.
slice
(
idx
+
1
);
}
}
return
(
return
<
CodeBlock
language=
{
lang
}
filename=
{
filename
}
code=
{
String
(
children
).
replace
(
/
\n
$/
,
""
)
}
/>;
<
CodeBlock
language=
{
lang
}
filename=
{
filename
}
code=
{
String
(
children
).
replace
(
/
\n
$/
,
""
)
}
linkedRepo=
{
linkedRepo
}
token=
{
token
}
/>
);
},
},
pre
({
children
})
{
return
<>
{
children
}
</>;
},
pre
({
children
})
{
return
<>
{
children
}
</>;
},
}
}
>
}
}
>
...
@@ -132,7 +118,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -132,7 +118,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
{
!
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.5 px-1"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"
>
...
@@ -147,7 +132,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -147,7 +132,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
{
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-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"
>
...
...
frontend/src/components/Sidebar.jsx
View file @
faa34a43
import
React
,
{
useState
,
useEffect
}
from
"react"
;
import
React
,
{
useState
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
listChats
,
createChat
,
deleteChat
,
renameChat
}
from
"../api"
;
import
{
useApp
}
from
"../store"
;
import
{
createChat
,
deleteChat
,
renameChat
}
from
"../api"
;
import
{
import
{
Plus
,
Trash2
,
MessageSquare
,
Flame
,
LogOut
,
Shield
,
BookOpen
,
Plus
,
MessageSquare
,
Trash2
,
Pencil
,
Check
,
X
,
LogOut
,
Edit3
,
Check
,
X
,
GitBranch
,
Shield
,
BookOpen
,
GitBranch
,
Flame
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
export
default
function
Sidebar
({
activeChatId
,
onSelectChat
,
isOpen
,
onClose
})
{
export
default
function
Sidebar
({
activeChatId
,
onSelectChat
})
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
nav
=
useNavigate
();
const
nav
igate
=
useNavigate
();
const
[
editId
,
setEditId
]
=
useState
(
null
);
const
[
editId
,
setEditId
]
=
useState
(
null
);
const
[
editTitle
,
setEditTitle
]
=
useState
(
""
);
const
[
editTitle
,
setEditTitle
]
=
useState
(
""
);
useEffect
(()
=>
{
async
function
handleNewChat
()
{
(
async
()
=>
{
try
{
const
chats
=
await
listChats
(
state
.
token
);
dispatch
({
type
:
"SET_CHATS"
,
chats
});
}
catch
{
}
})();
},
[
state
.
token
,
dispatch
]);
async
function
handleNew
()
{
try
{
try
{
const
chat
=
await
createChat
(
state
.
token
);
const
chat
=
await
createChat
(
state
.
token
);
dispatch
({
type
:
"ADD_CHAT"
,
chat
});
dispatch
({
type
:
"ADD_CHAT"
,
chat
});
onSelectChat
(
chat
.
id
);
onSelectChat
(
chat
.
id
);
}
catch
{
}
}
catch
{}
}
}
async
function
handleDelete
(
e
,
chatId
)
{
async
function
handleDelete
(
e
,
chatId
)
{
...
@@ -37,88 +28,97 @@ export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose })
...
@@ -37,88 +28,97 @@ export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose })
await
deleteChat
(
state
.
token
,
chatId
);
await
deleteChat
(
state
.
token
,
chatId
);
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
});
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
});
if
(
activeChatId
===
chatId
)
onSelectChat
(
null
);
if
(
activeChatId
===
chatId
)
onSelectChat
(
null
);
}
catch
{
}
}
catch
{}
}
}
async
function
handleRename
(
chatId
)
{
function
startEdit
(
e
,
chat
)
{
if
(
!
editTitle
.
trim
())
{
setEditId
(
null
);
return
;
}
e
.
stopPropagation
();
setEditId
(
chat
.
id
);
setEditTitle
(
chat
.
title
);
}
async
function
saveEdit
(
e
)
{
e
.
stopPropagation
();
if
(
!
editTitle
.
trim
())
return
;
try
{
try
{
await
renameChat
(
state
.
token
,
cha
tId
,
editTitle
.
trim
());
await
renameChat
(
state
.
token
,
edi
tId
,
editTitle
.
trim
());
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
cha
tId
,
title
:
editTitle
.
trim
()
}
});
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
edi
tId
,
title
:
editTitle
.
trim
()
}
});
}
catch
{
}
}
catch
{}
setEditId
(
null
);
setEditId
(
null
);
}
}
const
isSuperadmin
=
state
.
user
?.
role
===
"superadmin"
;
const
isSuperadmin
=
state
.
user
?.
role
===
"superadmin"
;
return
(
return
(
<>
<
div
className=
"w-72 bg-anton-surface border-r border-anton-border flex flex-col h-full shrink-0"
>
{
isOpen
&&
<
div
className=
"fixed inset-0 bg-black/50 z-40 md:hidden"
onClick=
{
onClose
}
/>
}
<
div
className=
"p-4 border-b border-anton-border"
>
<
div
className=
{
`fixed md:static z-50 inset-y-0 left-0 w-72 bg-anton-surface border-r border-anton-border flex flex-col transition-transform duration-200 ${isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}`
}
>
<
div
className=
"flex items-center gap-2 mb-4"
>
{
/* Header */
}
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center"
>
<
div
className=
"p-3 border-b border-anton-border"
>
<
Flame
size=
{
16
}
className=
"text-white"
/>
<
div
className=
"flex items-center gap-2 mb-3"
>
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center"
>
<
Flame
size=
{
16
}
className=
"text-white"
/>
</
div
>
<
div
>
<
h1
className=
"text-sm font-bold text-white"
>
Son of Anton
</
h1
>
<
p
className=
"text-[10px] text-anton-muted"
>
v4.0.0 — The Architect
</
p
>
</
div
>
</
div
>
</
div
>
<
button
onClick=
{
handleNew
}
className=
"w-full flex items-center justify-center gap-1.5 bg-anton-accent text-white rounded-lg py-2 text-sm hover:opacity-80 transition"
>
<
span
className=
"font-bold text-white text-sm"
>
Son of Anton
</
span
>
<
Plus
size=
{
16
}
/>
New Chat
</
button
>
</
div
>
</
div
>
<
button
onClick=
{
handleNewChat
}
className=
"w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition"
>
<
Plus
size=
{
16
}
/>
New Chat
</
button
>
</
div
>
{
/* Chat list */
}
<
div
className=
"flex-1 overflow-y-auto p-2 space-y-0.5"
>
<
div
className=
"flex-1 overflow-y-auto p-2 space-y-0.5"
>
{
state
.
chats
.
map
((
chat
)
=>
(
{
state
.
chats
.
map
((
c
)
=>
(
<
div
key=
{
chat
.
id
}
onClick=
{
()
=>
onSelectChat
(
chat
.
id
)
}
<
div
key=
{
c
.
id
}
onClick=
{
()
=>
{
onSelectChat
(
c
.
id
);
onClose
?.();
}
}
className=
{
`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition text-sm ${
className=
{
`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition text-sm ${activeChatId === c.id ? "bg-anton-accent/15 text-white" : "text-anton-muted hover:bg-anton-card hover:text-white"}`
}
>
activeChatId === chat.id
<
MessageSquare
size=
{
14
}
className=
"shrink-0"
/>
? "bg-anton-accent/15 text-white border border-anton-accent/30"
{
editId
===
c
.
id
?
(
: "text-anton-muted hover:bg-anton-card hover:text-white"
<
div
className=
"flex-1 flex items-center gap-1"
>
}`
}
>
<
input
value=
{
editTitle
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleRename
(
c
.
id
)
}
<
MessageSquare
size=
{
14
}
className=
"shrink-0"
/>
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-1.5 py-0.5 text-xs text-white"
autoFocus
/>
{
editId
===
chat
.
id
?
(
<
button
onClick=
{
()
=>
handleRename
(
c
.
id
)
}
className=
"text-green-400"
><
Check
size=
{
12
}
/></
button
>
<
div
className=
"flex-1 flex items-center gap-1"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
button
onClick=
{
()
=>
setEditId
(
null
)
}
className=
"text-red-400"
><
X
size=
{
12
}
/></
button
>
<
input
value=
{
editTitle
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
autoFocus
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
saveEdit
(
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"
/>
<
button
onClick=
{
saveEdit
}
><
Check
size=
{
12
}
className=
"text-green-400"
/></
button
>
<
button
onClick=
{
()
=>
setEditId
(
null
)
}
><
X
size=
{
12
}
className=
"text-red-400"
/></
button
>
</
div
>
)
:
(
<>
<
span
className=
"flex-1 truncate"
>
{
chat
.
title
}
</
span
>
{
chat
.
linked_repo_id
&&
<
GitBranch
size=
{
11
}
className=
"text-blue-400 shrink-0"
/>
}
<
div
className=
"hidden group-hover:flex items-center gap-0.5"
>
<
button
onClick=
{
(
e
)
=>
startEdit
(
e
,
chat
)
}
className=
"p-0.5 hover:text-anton-accent"
><
Pencil
size=
{
11
}
/></
button
>
<
button
onClick=
{
(
e
)
=>
handleDelete
(
e
,
chat
.
id
)
}
className=
"p-0.5 hover:text-anton-danger"
><
Trash2
size=
{
11
}
/></
button
>
</
div
>
</
div
>
)
:
(
</>
<>
)
}
<
span
className=
"flex-1 truncate text-xs"
>
{
c
.
title
}
</
span
>
</
div
>
<
div
className=
"flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
))
}
{
c
.
linked_repo_id
&&
<
GitBranch
size=
{
11
}
className=
"text-orange-400"
/>
}
</
div
>
<
button
onClick=
{
(
e
)
=>
{
e
.
stopPropagation
();
setEditId
(
c
.
id
);
setEditTitle
(
c
.
title
);
}
}
className=
"p-0.5 hover:text-anton-accent"
><
Edit3
size=
{
11
}
/></
button
>
<
button
onClick=
{
(
e
)
=>
handleDelete
(
e
,
c
.
id
)
}
className=
"p-0.5 hover:text-red-400"
><
Trash2
size=
{
11
}
/></
button
>
</
div
>
</>
)
}
</
div
>
))
}
</
div
>
{
/* Footer */
}
<
div
className=
"p-3 border-t border-anton-border space-y-1"
>
<
div
className=
"p-2 border-t border-anton-border space-y-0.5"
>
{
isSuperadmin
&&
(
{
isSuperadmin
&&
(
<>
<>
<
button
onClick=
{
()
=>
navigate
(
"/gitlab"
)
}
<
button
onClick=
{
()
=>
nav
(
"/gitlab"
)
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-anton-card transition"
>
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition"
>
<
GitBranch
size=
{
14
}
/>
GitLab Center
<
GitBranch
size=
{
14
}
/>
GitLab
</
button
>
</
button
>
<
button
onClick=
{
()
=>
nav
(
"/admin"
)
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition"
>
<
button
onClick=
{
()
=>
navigate
(
"/admin"
)
}
<
Shield
size=
{
14
}
/>
Admin
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition"
>
</
button
>
<
Shield
size=
{
14
}
/>
Admin
</>
</
button
>
)
}
</>
<
button
onClick=
{
()
=>
nav
(
"/knowledge"
)
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition"
>
)
}
<
BookOpen
size=
{
14
}
/>
Knowledge
<
button
onClick=
{
()
=>
navigate
(
"/knowledge"
)
}
</
button
>
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition"
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-red-400 transition"
>
<
BookOpen
size=
{
14
}
/>
Knowledge
<
LogOut
size=
{
14
}
/>
Logout
</
button
>
<
div
className=
"flex items-center justify-between px-3 py-2"
>
<
span
className=
"text-xs text-anton-muted truncate"
>
{
state
.
user
?.
username
}
</
span
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
className=
"text-anton-muted hover:text-anton-danger transition"
>
<
LogOut
size=
{
14
}
/>
</
button
>
</
button
>
<
div
className=
"px-3 py-1 text-[10px] text-anton-muted"
>
{
state
.
user
?.
username
}
•
{
state
.
user
?.
role
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</>
</
div
>
);
);
}
}
\ No newline at end of file
frontend/src/index.css
View file @
faa34a43
...
@@ -2,284 +2,105 @@
...
@@ -2,284 +2,105 @@
@tailwind
components
;
@tailwind
components
;
@tailwind
utilities
;
@tailwind
utilities
;
/* ═══════════════════════════════════════════════════
*
{
ROOT VARIABLES & BASE
scrollbar-width
:
thin
;
═══════════════════════════════════════════════════ */
scrollbar-color
:
#1e2030
transparent
;
: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
;
}
}
/* ═══════════════════════════════════════════════════
*
::-webkit-scrollbar
{
GLOBAL RESETS FOR MOBILE
width
:
6px
;
═══════════════════════════════════════════════════ */
height
:
6px
;
*,
*
::before
,
*
::after
{
-webkit-tap-highlight-color
:
transparent
;
-webkit-touch-callout
:
none
;
}
}
*
::-webkit-scrollbar-track
{
html
{
background
:
transparent
;
overflow
:
hidden
;
height
:
100%
;
height
:
100
dvh
;
}
}
*
::-webkit-scrollbar-thumb
{
body
{
background
:
#1e2030
;
overflow
:
hidden
;
border-radius
:
3px
;
height
:
100%
;
}
height
:
100
dvh
;
*
::-webkit-scrollbar-thumb:hover
{
overscroll-behavior
:
none
;
background
:
#2a2d3e
;
-webkit-overflow-scrolling
:
touch
;
font-family
:
'Inter'
,
system-ui
,
-apple-system
,
sans-serif
;
position
:
fixed
;
width
:
100%
;
top
:
0
;
left
:
0
;
}
}
#root
{
html
,
body
,
#root
{
height
:
100%
;
height
:
100%
;
height
:
100
dvh
;
overflow
:
hidden
;
overflow
:
hidden
;
display
:
flex
;
flex-direction
:
column
;
}
/* ═══════════════════════════════════════════════════
SAFE AREA UTILITIES
═══════════════════════════════════════════════════ */
.safe-top
{
padding-top
:
var
(
--sat
);
}
.safe-bottom
{
padding-bottom
:
max
(
var
(
--sab
),
8px
);
}
.safe-left
{
padding-left
:
var
(
--sal
);
}
.safe-right
{
padding-right
:
var
(
--sar
);
}
/* ═══════════════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════════════ */
::-webkit-scrollbar
{
width
:
4px
;
height
:
4px
;
}
::-webkit-scrollbar-track
{
background
:
transparent
;
}
::-webkit-scrollbar-thumb
{
background
:
rgba
(
255
,
255
,
255
,
0.08
);
border-radius
:
4px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
rgba
(
255
,
255
,
255
,
0.15
);
}
/* ═══════════════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════════ */
@keyframes
fadeIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
6px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
@keyframes
slideInLeft
{
from
{
transform
:
translateX
(
-100%
);
}
to
{
transform
:
translateX
(
0
);
}
}
@keyframes
slideOutLeft
{
from
{
transform
:
translateX
(
0
);
}
to
{
transform
:
translateX
(
-100%
);
}
}
@keyframes
fadeOverlayIn
{
from
{
opacity
:
0
;
}
to
{
opacity
:
1
;
}
}
@keyframes
fadeOverlayOut
{
from
{
opacity
:
1
;
}
to
{
opacity
:
0
;
}
}
}
.animate-fade-in
{
animation
:
fadeIn
0.2s
ease-out
both
;
}
body
{
.animate-slide-in
{
animation
:
slideInLeft
0.25s
cubic-bezier
(
0.16
,
1
,
0.3
,
1
)
both
;
}
-webkit-font-smoothing
:
antialiased
;
.animate-slide-out
{
animation
:
slideOutLeft
0.2s
ease-in
both
;
}
-moz-osx-font-smoothing
:
grayscale
;
.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 */
}
@media
(
min-width
:
640px
)
{
textarea
,
input
,
select
{
font-size
:
14px
!important
;
}
}
textarea
{
-webkit-appearance
:
none
;
appearance
:
none
;
}
select
{
-webkit-appearance
:
none
;
appearance
:
none
;
background-image
:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23666' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E")
;
background-repeat
:
no-repeat
;
background-position
:
right
10px
center
;
padding-right
:
28px
;
}
}
/* ═══════════════════════════════════════════════════
TOUCH-FRIENDLY RANGE SLIDER
═══════════════════════════════════════════════════ */
input
[
type
=
"range"
]
{
input
[
type
=
"range"
]
{
-webkit-appearance
:
none
;
@apply
w-full
h-1.5
bg-anton-border
rounded-lg
appearance-none
cursor-pointer;
appearance
:
none
;
width
:
100%
;
height
:
6px
;
border-radius
:
3px
;
background
:
rgba
(
255
,
255
,
255
,
0.08
);
outline
:
none
;
cursor
:
pointer
;
}
}
input
[
type
=
"range"
]
::-webkit-slider-thumb
{
input
[
type
=
"range"
]
::-webkit-slider-thumb
{
-webkit-appearance
:
none
;
@apply
appearance-none
w-4
h-4
bg-anton-accent
rounded-full
cursor-pointer;
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
);
}
}
input
[
type
=
"range"
]
::-moz-range-thumb
{
input
[
type
=
"range"
]
::-moz-range-thumb
{
width
:
22px
;
@apply
w-4
h-4
bg-anton-accent
rounded-full
cursor-pointer
border-0;
height
:
22px
;
border-radius
:
50%
;
background
:
#e53e3e
;
border
:
2px
solid
#1a1a2e
;
cursor
:
pointer
;
}
}
/* ═══════════════════════════════════════════════════
MARKDOWN PROSE
═══════════════════════════════════════════════════ */
.prose-anton
{
.prose-anton
{
color
:
#e2e2ea
;
@apply
text-anton-text
leading-relaxed;
line-height
:
1.65
;
word-break
:
break-word
;
overflow-wrap
:
anywhere
;
}
}
.prose-anton
p
{
.prose-anton
h1
,
.prose-anton
h2
,
.prose-anton
h3
,
@apply
mb-3;
.prose-anton
h4
,
.prose-anton
h5
,
.prose-anton
h6
{
color
:
#fff
;
font-weight
:
600
;
margin-top
:
1.2em
;
margin-bottom
:
0.5em
;
}
}
.prose-anton
p
:last-child
{
.prose-anton
h1
{
font-size
:
1.4em
;
}
@apply
mb-0;
.prose-anton
h2
{
font-size
:
1.2em
;
}
.prose-anton
h3
{
font-size
:
1.05em
;
}
.prose-anton
p
{
margin-bottom
:
0.75em
;
}
.prose-anton
ul
,
.prose-anton
ol
{
padding-left
:
1.4em
;
margin-bottom
:
0.75em
;
}
}
.prose-anton
h1
,
.prose-anton
h2
,
.prose-anton
h3
,
.prose-anton
h4
{
.prose-anton
li
{
margin-bottom
:
0.25em
;
}
@apply
font-bold
text-white
mt-4
mb-2;
.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
h1
{
@apply
text-xl;
}
.prose-anton
a
{
.prose-anton
h2
{
@apply
text-lg;
}
color
:
#e53e3e
;
.prose-anton
h3
{
@apply
text-base;
}
text-decoration
:
underline
;
.prose-anton
ul
,
.prose-anton
ol
{
text-underline-offset
:
2px
;
@apply
mb-3
pl-6;
}
.prose-anton
ul
{
@apply
list-disc;
}
.prose-anton
ol
{
@apply
list-decimal;
}
.prose-anton
li
{
@apply
mb-1;
}
}
.prose-anton
blockquote
{
.prose-anton
blockquote
{
border-left
:
3px
solid
#e53e3e
;
@apply
border-l-2
border-anton-accent
pl-4
italic
text-anton-muted
my-3;
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
a
{
.prose-anton
hr
{
@apply
text-anton-accent
underline
hover
:
opacity-80
;
border
:
none
;
}
border-top
:
1px
solid
rgba
(
255
,
255
,
255
,
0.08
);
.prose-anton
code
:not
(
pre
code
)
{
margin
:
1.5em
0
;
@apply
bg-anton-card
border
border-anton-border
rounded
px-1.5
py-0.5
text-xs
font-mono
text-white
;
}
}
.prose-anton
table
{
.prose-anton
table
{
width
:
100%
;
@apply
w-full
border-collapse
mb-3;
border-collapse
:
collapse
;
font-size
:
0.85em
;
margin
:
0.75em
0
;
display
:
block
;
overflow-x
:
auto
;
-webkit-overflow-scrolling
:
touch
;
}
}
.prose-anton
th
,
.prose-anton
td
{
.prose-anton
th
,
.prose-anton
td
{
border
:
1px
solid
rgba
(
255
,
255
,
255
,
0.08
);
@apply
border
border-anton-border
px-3
py-1.5
text-sm;
padding
:
0.4em
0.7em
;
text-align
:
left
;
white-space
:
nowrap
;
}
}
.prose-anton
th
{
.prose-anton
th
{
background
:
rgba
(
255
,
255
,
255
,
0.04
);
@apply
bg-anton-card
font-semibold
text-white;
font-weight
:
600
;
}
}
.prose-anton
hr
{
.prose-anton
strong
{
color
:
#fff
;
font-weight
:
600
;
}
@apply
border-anton-border
my-4;
.prose-anton
em
{
font-style
:
italic
;
}
}
.prose-anton
strong
{
/* ═══════════════════════════════════════════════════
@apply
font-semibold
text-white;
THINKING PULSE
}
═══════════════════════════════════════════════════ */
.prose-anton
em
{
@apply
italic;
@keyframes
thinkPulse
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.5
;
}
}
}
.thinking-pulse
{
animation
:
thinkPulse
1.5s
ease-in-out
infinite
;
}
@keyframes
fade-in
{
from
{
opacity
:
0
;
transform
:
translateY
(
4px
);
}
/* ═══════════════════════════════════════════════════
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
MOBILE-SPECIFIC OVERRIDES
}
═══════════════════════════════════════════════════ */
.animate-fade-in
{
animation
:
fade-in
0.2s
ease-out
;
@media
(
max-width
:
639px
)
{
.prose-anton
{
font-size
:
0.9rem
;
line-height
:
1.6
;
}
.prose-anton
h1
{
font-size
:
1.25em
;
}
.prose-anton
h2
{
font-size
:
1.15em
;
}
}
}
/* Prevent body scroll when modal/drawer is open */
.thinking-pulse
{
body
.drawer-open
{
animation
:
pulse
1.5s
ease-in-out
infinite
;
touch-action
:
none
;
}
}
\ No newline at end of file
frontend/src/main.jsx
View file @
faa34a43
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
{
AppProvider
}
from
"./store"
;
import
App
from
"./App"
;
import
App
from
"./App"
;
import
"./index.css"
;
import
"./index.css"
;
ReactDOM
.
createRoot
(
document
.
getElementById
(
"root"
)).
render
(
ReactDOM
.
createRoot
(
document
.
getElementById
(
"root"
)).
render
(
<
React
.
StrictMode
>
<
React
.
StrictMode
>
<
BrowserRouter
>
<
App
/>
<
AppProvider
>
<
App
/>
</
AppProvider
>
</
BrowserRouter
>
</
React
.
StrictMode
>
</
React
.
StrictMode
>
);
);
\ No newline at end of file
frontend/src/pages/AdminPage.jsx
View file @
faa34a43
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
"react"
;
import
React
,
{
useState
,
useEffect
}
from
"react"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
import
{
adminStats
,
adminListUsers
,
adminCreateUser
,
adminUpdateUser
,
adminDeleteUser
}
from
"../api"
;
adminStats
,
import
{
ArrowLeft
,
Shield
,
Users
,
MessageSquare
,
Coins
,
Plus
,
Trash2
,
ToggleLeft
,
ToggleRight
}
from
"lucide-react"
;
adminListUsers
,
adminCreateUser
,
adminUpdateUser
,
adminDeleteUser
,
}
from
"../api"
;
import
{
ArrowLeft
,
Users
,
MessageSquare
,
Database
,
Zap
,
UserPlus
,
Trash2
,
Shield
,
ShieldOff
,
Save
,
X
,
}
from
"lucide-react"
;
export
default
function
AdminPage
()
{
export
default
function
AdminPage
()
{
const
{
state
}
=
useApp
();
const
{
state
}
=
useApp
();
...
@@ -19,27 +10,17 @@ export default function AdminPage() {
...
@@ -19,27 +10,17 @@ export default function AdminPage() {
const
[
stats
,
setStats
]
=
useState
(
null
);
const
[
stats
,
setStats
]
=
useState
(
null
);
const
[
users
,
setUsers
]
=
useState
([]);
const
[
users
,
setUsers
]
=
useState
([]);
const
[
showCreate
,
setShowCreate
]
=
useState
(
false
);
const
[
showCreate
,
setShowCreate
]
=
useState
(
false
);
const
[
editId
,
setEditId
]
=
useState
(
null
);
const
[
newUser
,
setNewUser
]
=
useState
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"user"
,
quota_tokens_monthly
:
2000000
});
const
[
editData
,
setEditData
]
=
useState
({});
const
[
newUser
,
setNewUser
]
=
useState
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"user"
,
quota_tokens_monthly
:
2000000
,
});
const
[
error
,
setError
]
=
useState
(
""
);
const
load
=
useCallback
(
async
()
=>
{
useEffect
(()
=>
{
load
();
},
[]);
async
function
load
()
{
try
{
try
{
const
[
s
,
u
]
=
await
Promise
.
all
([
const
[
s
,
u
]
=
await
Promise
.
all
([
adminStats
(
state
.
token
),
adminListUsers
(
state
.
token
)]);
adminStats
(
state
.
token
),
adminListUsers
(
state
.
token
),
]);
setStats
(
s
);
setStats
(
s
);
setUsers
(
u
);
setUsers
(
u
);
}
catch
(
err
)
{
}
catch
{}
setError
(
err
.
message
);
}
}
},
[
state
.
token
]);
useEffect
(()
=>
{
load
();
},
[
load
]);
async
function
handleCreate
(
e
)
{
async
function
handleCreate
(
e
)
{
e
.
preventDefault
();
e
.
preventDefault
();
...
@@ -48,212 +29,82 @@ export default function AdminPage() {
...
@@ -48,212 +29,82 @@ export default function AdminPage() {
setShowCreate
(
false
);
setShowCreate
(
false
);
setNewUser
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"user"
,
quota_tokens_monthly
:
2000000
});
setNewUser
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"user"
,
quota_tokens_monthly
:
2000000
});
load
();
load
();
}
catch
(
err
)
{
setError
(
err
.
message
);
}
}
catch
{}
}
async
function
handleSaveEdit
(
userId
)
{
try
{
await
adminUpdateUser
(
state
.
token
,
userId
,
editData
);
setEditId
(
null
);
load
();
}
catch
(
err
)
{
setError
(
err
.
message
);
}
}
}
async
function
handleDelete
(
userId
,
username
)
{
async
function
toggleActive
(
u
)
{
if
(
!
confirm
(
`Delete user "
${
username
}
"? This is permanent.`
))
return
;
try
{
try
{
await
admin
DeleteUser
(
state
.
token
,
userId
);
await
admin
UpdateUser
(
state
.
token
,
u
.
id
,
{
is_active
:
!
u
.
is_active
}
);
load
();
load
();
}
catch
(
err
)
{
setError
(
err
.
message
);
}
}
catch
{
}
}
}
function
formatNum
(
n
)
{
async
function
handleDelete
(
u
)
{
if
(
n
>=
1
_000_000
)
return
(
n
/
1
_000_000
).
toFixed
(
1
)
+
"M"
;
if
(
!
confirm
(
`Delete
${
u
.
username
}
?`
))
return
;
if
(
n
>=
1
_000
)
return
(
n
/
1
_000
).
toFixed
(
0
)
+
"K"
;
try
{
await
adminDeleteUser
(
state
.
token
,
u
.
id
);
load
();
}
catch
{}
return
String
(
n
);
}
if
(
state
.
user
?.
role
!==
"superadmin"
)
{
return
(
<
div
className=
"h-full flex items-center justify-center"
>
<
p
className=
"text-anton-danger text-lg"
>
⛔ Access Denied
</
p
>
</
div
>
);
}
}
return
(
return
(
<
div
className=
"h-full overflow-y-auto bg-anton-bg p-6"
>
<
div
className=
"h-screen flex flex-col bg-anton-bg"
>
<
div
className=
"max-w-6xl mx-auto space-y-6 animate-fade-in"
>
<
div
className=
"border-b border-anton-border bg-anton-surface px-6 py-4 flex items-center gap-4"
>
{
/* Header */
}
<
button
onClick=
{
()
=>
navigate
(
"/"
)
}
className=
"text-anton-muted hover:text-white transition"
><
ArrowLeft
size=
{
20
}
/></
button
>
<
div
className=
"flex items-center gap-4"
>
<
Shield
size=
{
20
}
className=
"text-anton-accent"
/>
<
button
onClick=
{
()
=>
navigate
(
"/"
)
}
className=
"p-2 rounded-lg bg-anton-surface border border-anton-border hover:border-anton-accent transition"
>
<
h1
className=
"text-lg font-bold text-white"
>
Admin Dashboard
</
h1
>
<
ArrowLeft
size=
{
20
}
/>
</
div
>
</
button
>
<
div
className=
"flex-1 overflow-y-auto p-6 space-y-6"
>
<
div
>
<
h1
className=
"text-2xl font-bold text-white flex items-center gap-2"
>
<
Shield
size=
{
24
}
className=
"text-anton-accent"
/>
Admin Panel
</
h1
>
<
p
className=
"text-anton-muted text-sm"
>
Manage everything
</
p
>
</
div
>
</
div
>
{
error
&&
(
<
div
className=
"bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3"
>
{
error
}
<
button
onClick=
{
()
=>
setError
(
""
)
}
className=
"ml-2 text-red-300 hover:text-white"
>
✕
</
button
>
</
div
>
)
}
{
/* Stats */
}
{
stats
&&
(
{
stats
&&
(
<
div
className=
"grid grid-cols-2 md:grid-cols-4 gap-4"
>
<
div
className=
"grid grid-cols-2 md:grid-cols-4 gap-4"
>
{
[
{
[
{
label
:
"Users"
,
val
ue
:
stats
.
total_users
,
icon
:
Users
,
color
:
"text-blue-400"
},
{
label
:
"Users"
,
val
:
stats
.
total_users
,
icon
:
Users
},
{
label
:
"Chats"
,
val
ue
:
stats
.
total_chats
,
icon
:
MessageSquare
,
color
:
"text-green-400"
},
{
label
:
"Chats"
,
val
:
stats
.
total_chats
,
icon
:
MessageSquare
},
{
label
:
"Messages"
,
val
ue
:
formatNum
(
stats
.
total_messages
),
icon
:
Zap
,
color
:
"text-anton-accent"
},
{
label
:
"Messages"
,
val
:
stats
.
total_messages
,
icon
:
MessageSquare
},
{
label
:
"Tokens Used"
,
val
ue
:
formatNum
(
stats
.
total_tokens_used
),
icon
:
Database
,
color
:
"text-purple-400"
},
{
label
:
"Tokens Used"
,
val
:
(
stats
.
total_tokens_used
||
0
).
toLocaleString
(),
icon
:
Coins
},
].
map
((
s
)
=>
(
].
map
((
s
)
=>
(
<
div
key=
{
s
.
label
}
className=
"bg-anton-surface border border-anton-border rounded-xl p-4"
>
<
div
key=
{
s
.
label
}
className=
"bg-anton-card border border-anton-border rounded-xl p-4"
>
<
div
className=
"flex items-center gap-2 mb-1"
>
<
div
className=
"flex items-center gap-2 text-anton-muted text-xs mb-1"
><
s
.
icon
size=
{
12
}
/>
{
s
.
label
}
</
div
>
<
s
.
icon
size=
{
16
}
className=
{
s
.
color
}
/>
<
div
className=
"text-xl font-bold text-white"
>
{
s
.
val
}
</
div
>
<
span
className=
"text-anton-muted text-sm"
>
{
s
.
label
}
</
span
>
</
div
>
<
p
className=
"text-2xl font-bold text-white"
>
{
s
.
value
}
</
p
>
</
div
>
</
div
>
))
}
))
}
</
div
>
</
div
>
)
}
)
}
<
div
className=
"flex items-center justify-between"
>
{
/* User Management */
}
<
h2
className=
"text-sm font-semibold text-white"
>
Users
</
h2
>
<
div
className=
"bg-anton-surface border border-anton-border rounded-xl overflow-hidden"
>
<
button
onClick=
{
()
=>
setShowCreate
(
!
showCreate
)
}
className=
"flex items-center gap-1 text-xs text-anton-accent hover:text-white transition"
>
<
div
className=
"px-5 py-4 border-b border-anton-border flex items-center justify-between"
>
<
Plus
size=
{
12
}
/>
Create User
<
h2
className=
"text-lg font-semibold text-white"
>
Users
</
h2
>
</
button
>
<
button
</
div
>
onClick=
{
()
=>
setShowCreate
(
!
showCreate
)
}
{
showCreate
&&
(
className=
"flex items-center gap-1.5 px-3 py-1.5 bg-anton-accent text-white rounded-lg text-sm font-medium hover:opacity-90 transition"
<
form
onSubmit=
{
handleCreate
}
className=
"bg-anton-card border border-anton-border rounded-xl p-4 grid grid-cols-2 gap-3"
>
>
<
input
placeholder=
"Username"
value=
{
newUser
.
username
}
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
username
:
e
.
target
.
value
})
}
required
{
showCreate
?
<
X
size=
{
14
}
/>
:
<
UserPlus
size=
{
14
}
/>
}
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
{
showCreate
?
"Cancel"
:
"New User"
}
<
input
placeholder=
"Email"
value=
{
newUser
.
email
}
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
email
:
e
.
target
.
value
})
}
required
</
button
>
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
</
div
>
<
input
placeholder=
"Password"
type=
"password"
value=
{
newUser
.
password
}
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
password
:
e
.
target
.
value
})
}
required
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
{
showCreate
&&
(
<
select
value=
{
newUser
.
role
}
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
role
:
e
.
target
.
value
})
}
<
form
onSubmit=
{
handleCreate
}
className=
"px-5 py-4 border-b border-anton-border bg-anton-card grid grid-cols-1 md:grid-cols-3 gap-3"
>
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<
input
placeholder=
"Username"
required
value=
{
newUser
.
username
}
<
option
value=
"user"
>
User
</
option
><
option
value=
"admin"
>
Admin
</
option
>
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
username
:
e
.
target
.
value
})
}
</
select
>
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
<
button
type=
"submit"
className=
"col-span-2 bg-anton-accent text-white rounded-lg py-2 text-sm hover:opacity-90 transition"
>
Create
</
button
>
/>
</
form
>
<
input
placeholder=
"Email"
type=
"email"
required
value=
{
newUser
.
email
}
)
}
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
email
:
e
.
target
.
value
})
}
<
div
className=
"space-y-2"
>
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
{
users
.
map
((
u
)
=>
(
/>
<
div
key=
{
u
.
id
}
className=
"bg-anton-card border border-anton-border rounded-xl px-4 py-3 flex items-center justify-between"
>
<
input
placeholder=
"Password"
required
value=
{
newUser
.
password
}
<
div
>
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
password
:
e
.
target
.
value
})
}
<
div
className=
"text-sm text-white font-medium"
>
{
u
.
username
}
<
span
className=
"text-xs text-anton-muted"
>
(
{
u
.
role
}
)
</
span
></
div
>
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
<
div
className=
"text-xs text-anton-muted"
>
{
u
.
email
}
•
{
u
.
chat_count
}
chats •
{
(
u
.
tokens_used_this_month
||
0
).
toLocaleString
()
}
tokens
</
div
>
/>
</
div
>
<
select
value=
{
newUser
.
role
}
<
div
className=
"flex items-center gap-2"
>
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
role
:
e
.
target
.
value
})
}
<
button
onClick=
{
()
=>
toggleActive
(
u
)
}
className=
{
`${u.is_active ? "text-green-400" : "text-red-400"} transition`
}
>
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
{
u
.
is_active
?
<
ToggleRight
size=
{
20
}
/>
:
<
ToggleLeft
size=
{
20
}
/>
}
>
</
button
>
<
option
value=
"user"
>
User
</
option
>
{
u
.
role
!==
"superadmin"
&&
(
<
option
value=
"admin"
>
Admin
</
option
>
<
button
onClick=
{
()
=>
handleDelete
(
u
)
}
className=
"text-anton-muted hover:text-red-400 transition"
><
Trash2
size=
{
14
}
/></
button
>
<
option
value=
"superadmin"
>
Superadmin
</
option
>
)
}
</
select
>
</
div
>
<
input
placeholder=
"Monthly quota"
type=
"number"
value=
{
newUser
.
quota_tokens_monthly
}
</
div
>
onChange=
{
(
e
)
=>
setNewUser
({
...
newUser
,
quota_tokens_monthly
:
Number
(
e
.
target
.
value
)
})
}
))
}
className=
"bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
<
button
type=
"submit"
className=
"bg-anton-success text-white rounded-lg px-3 py-2 text-sm font-medium hover:opacity-90"
>
Create
</
button
>
</
form
>
)
}
<
div
className=
"overflow-x-auto"
>
<
table
className=
"w-full text-sm"
>
<
thead
>
<
tr
className=
"text-left text-anton-muted border-b border-anton-border"
>
<
th
className=
"px-5 py-3"
>
User
</
th
>
<
th
className=
"px-5 py-3"
>
Role
</
th
>
<
th
className=
"px-5 py-3"
>
Quota
</
th
>
<
th
className=
"px-5 py-3"
>
Used
</
th
>
<
th
className=
"px-5 py-3"
>
Chats
</
th
>
<
th
className=
"px-5 py-3"
>
Status
</
th
>
<
th
className=
"px-5 py-3"
>
Actions
</
th
>
</
tr
>
</
thead
>
<
tbody
>
{
users
.
map
((
u
)
=>
(
<
tr
key=
{
u
.
id
}
className=
"border-b border-anton-border/50 hover:bg-anton-card/50 transition"
>
<
td
className=
"px-5 py-3"
>
<
div
className=
"text-white font-medium"
>
{
u
.
username
}
</
div
>
<
div
className=
"text-anton-muted text-xs"
>
{
u
.
email
}
</
div
>
</
td
>
<
td
className=
"px-5 py-3"
>
{
editId
===
u
.
id
?
(
<
select
value=
{
editData
.
role
??
u
.
role
}
onChange=
{
(
e
)
=>
setEditData
({
...
editData
,
role
:
e
.
target
.
value
})
}
className=
"bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs"
>
<
option
value=
"user"
>
user
</
option
>
<
option
value=
"admin"
>
admin
</
option
>
<
option
value=
"superadmin"
>
superadmin
</
option
>
</
select
>
)
:
(
<
span
className=
{
`px-2 py-0.5 rounded-full text-xs font-medium ${
u.role === "superadmin" ? "bg-anton-accent/20 text-anton-accent"
: u.role === "admin" ? "bg-blue-500/20 text-blue-400"
: "bg-anton-border text-anton-muted"
}`
}
>
{
u
.
role
}
</
span
>
)
}
</
td
>
<
td
className=
"px-5 py-3 text-anton-muted"
>
{
editId
===
u
.
id
?
(
<
input
type=
"number"
value=
{
editData
.
quota_tokens_monthly
??
u
.
quota_tokens_monthly
}
onChange=
{
(
e
)
=>
setEditData
({
...
editData
,
quota_tokens_monthly
:
Number
(
e
.
target
.
value
)
})
}
className=
"bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs w-28"
/>
)
:
formatNum
(
u
.
quota_tokens_monthly
)
}
</
td
>
<
td
className=
"px-5 py-3 text-anton-muted"
>
{
formatNum
(
u
.
tokens_used_this_month
)
}
</
td
>
<
td
className=
"px-5 py-3 text-anton-muted"
>
{
u
.
chat_count
}
</
td
>
<
td
className=
"px-5 py-3"
>
<
span
className=
{
`w-2 h-2 inline-block rounded-full mr-1 ${u.is_active ? "bg-anton-success" : "bg-anton-danger"}`
}
/>
<
span
className=
"text-xs text-anton-muted"
>
{
u
.
is_active
?
"Active"
:
"Disabled"
}
</
span
>
</
td
>
<
td
className=
"px-5 py-3"
>
<
div
className=
"flex items-center gap-1"
>
{
editId
===
u
.
id
?
(
<>
<
button
onClick=
{
()
=>
handleSaveEdit
(
u
.
id
)
}
className=
"p-1 rounded hover:bg-anton-success/20 text-anton-success"
><
Save
size=
{
14
}
/></
button
>
<
button
onClick=
{
()
=>
setEditId
(
null
)
}
className=
"p-1 rounded hover:bg-anton-border text-anton-muted"
><
X
size=
{
14
}
/></
button
>
</>
)
:
(
<>
<
button
onClick=
{
()
=>
{
setEditId
(
u
.
id
);
setEditData
({});
}
}
className=
"p-1 rounded hover:bg-anton-accent/20 text-anton-accent text-xs"
>
Edit
</
button
>
<
button
onClick=
{
()
=>
adminUpdateUser
(
state
.
token
,
u
.
id
,
{
is_active
:
!
u
.
is_active
}).
then
(
load
)
}
className=
"p-1 rounded hover:bg-anton-border text-anton-muted"
title=
{
u
.
is_active
?
"Disable"
:
"Enable"
}
>
{
u
.
is_active
?
<
ShieldOff
size=
{
14
}
/>
:
<
Shield
size=
{
14
}
/>
}
</
button
>
{
u
.
role
!==
"superadmin"
&&
(
<
button
onClick=
{
()
=>
handleDelete
(
u
.
id
,
u
.
username
)
}
className=
"p-1 rounded hover:bg-red-500/20 text-anton-danger"
><
Trash2
size=
{
14
}
/></
button
>
)
}
</>
)
}
</
div
>
</
td
>
</
tr
>
))
}
</
tbody
>
</
table
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
frontend/src/pages/ChatPage.jsx
View file @
faa34a43
import
React
,
{
useEffect
}
from
"react"
;
import
React
,
{
useEffect
}
from
"react"
;
import
{
useParams
,
useNavigate
}
from
"react-router-dom"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
listChats
,
createChat
}
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
,
Menu
,
Plus
,
MessageSquare
}
from
"lucide-react"
;
import
{
Flame
}
from
"lucide-react"
;
export
default
function
ChatPage
()
{
export
default
function
ChatPage
()
{
const
{
chatId
}
=
useParams
();
const
navigate
=
useNavigate
();
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
useEffect
(()
=>
{
useEffect
(()
=>
{
(
async
()
=>
{
listChats
(
state
.
token
)
try
{
.
then
((
chats
)
=>
dispatch
({
type
:
"SET_CHATS"
,
chats
}))
const
chats
=
await
listChats
(
state
.
token
);
.
catch
(()
=>
{});
dispatch
({
type
:
"SET_CHATS"
,
chats
});
},
[
state
.
token
,
dispatch
]);
if
(
!
state
.
activeChatId
&&
chats
.
length
>
0
)
{
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
:
chats
[
0
].
id
});
}
}
catch
{
/* ignore */
}
})();
},
[
state
.
token
]);
async
function
handleNewChat
()
{
function
onSelectChat
(
id
)
{
try
{
if
(
id
)
{
const
chat
=
await
createChat
(
state
.
token
);
navigate
(
`/chat/
${
id
}
`
);
dispatch
({
type
:
"ADD_CHAT"
,
chat
});
}
else
{
}
catch
{
/* ignore */
}
navigate
(
"/"
);
}
}
}
return
(
return
(
<
div
className=
"h-full h-dvh flex overflow-hidden bg-anton-bg"
>
<
div
className=
"h-screen flex overflow-hidden bg-anton-bg"
>
{
/* Desktop sidebar */
}
<
Sidebar
activeChatId=
{
chatId
}
onSelectChat=
{
onSelectChat
}
/>
<
div
className=
"hidden sm:flex"
>
<
div
className=
"flex-1 flex flex-col min-h-0"
>
<
Sidebar
/>
{
chatId
?
(
</
div
>
<
ChatView
chatId=
{
chatId
}
/>
{
/* Mobile sidebar overlay */
}
{
state
.
sidebarOpen
&&
(
<>
<
div
className=
"sm:hidden fixed inset-0 z-40 bg-black/60 animate-overlay-in"
onClick=
{
()
=>
dispatch
({
type
:
"SET_SIDEBAR_OPEN"
,
open
:
false
})
}
/>
<
div
className=
"sm:hidden fixed inset-y-0 left-0 z-50 w-[280px] animate-slide-in safe-top safe-bottom"
>
<
Sidebar
mobile
onClose=
{
()
=>
dispatch
({
type
:
"SET_SIDEBAR_OPEN"
,
open
:
false
})
}
/>
</
div
>
</>
)
}
{
/* Main content */
}
<
div
className=
"flex-1 flex flex-col min-w-0"
>
{
/* Mobile header */
}
<
div
className=
"sm:hidden flex items-center gap-2 px-3 py-2.5 border-b border-anton-border bg-anton-surface safe-top"
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
className=
"p-2 -ml-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<
Menu
size=
{
20
}
/>
</
button
>
<
div
className=
"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
onClick=
{
handleNewChat
}
className=
"p-2 -mr-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<
Plus
size=
{
20
}
/>
</
button
>
</
div
>
{
/* Chat or empty state */
}
{
state
.
activeChatId
?
(
<
ChatView
chatId=
{
state
.
activeChatId
}
/>
)
:
(
)
:
(
<
div
className=
"flex-1 flex items-center justify-center
p-6
"
>
<
div
className=
"flex-1 flex items-center justify-center"
>
<
div
className=
"text-center
max-w-sm
"
>
<
div
className=
"text-center"
>
<
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"
>
<
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"
>
<
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"
>
<
p
className=
"text-anton-muted text-sm"
>
Select a chat or create a new one
</
p
>
Avatar of All Elements of Code
</
p
>
<
button
onClick=
{
handleNewChat
}
className=
"inline-flex items-center gap-2 px-5 py-3 bg-anton-accent text-white rounded-xl font-medium hover:opacity-90 transition active:scale-95"
>
<
MessageSquare
size=
{
18
}
/>
Start a conversation
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
...
...
frontend/src/pages/GitLabPage.jsx
View file @
faa34a43
import
React
,
{
useState
,
useEffect
}
from
"react"
;
import
React
,
{
useState
,
useEffect
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useApp
}
from
"../store"
;
import
{
import
{
gitlabGetSettings
,
gitlabUpdateSettings
,
gitlabTestConnection
,
getGitLabSettings
,
updateGitLabSettings
,
testGitLabConnection
,
gitlabSearchProjects
,
gitlabCreateProject
,
gitlabListRepos
,
searchGitLabProjects
,
listLinkedRepos
,
linkRepo
,
unlinkRepo
,
gitlabLinkRepo
,
gitlabUnlinkRepo
,
gitlabGetTree
,
gitlabGetFile
,
gitlabListActions
,
gitlabApproveAction
,
gitlabRejectAction
,
}
from
"../api"
;
}
from
"../api"
;
import
{
import
{
ArrowLeft
,
Settings2
,
Plug
,
Check
,
X
,
Search
,
Plus
,
Link
,
Unlink
,
ArrowLeft
,
GitBranch
,
Settings
,
Search
,
Link
,
Unlink
,
Check
,
FolderTree
,
GitBranch
,
Eye
,
Shield
,
Clock
,
CheckCircle
,
XCircle
,
X
,
Loader2
,
ExternalLink
,
RefreshCw
,
Loader2
,
ExternalLink
,
FileText
,
Folder
,
RefreshCw
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
export
default
function
GitLabPage
()
{
export
default
function
GitLabPage
()
{
const
{
state
}
=
useApp
();
const
{
state
}
=
useApp
();
const
nav
=
useNavigate
();
const
navigate
=
useNavigate
();
const
t
=
state
.
token
;
const
[
tab
,
setTab
]
=
useState
(
"repos"
);
const
[
settings
,
setSettings
]
=
useState
({
gitlab_url
:
""
,
gitlab_token
:
""
});
const
[
tab
,
setTab
]
=
useState
(
"connection"
);
const
[
settingsLoaded
,
setSettingsLoaded
]
=
useState
(
false
);
const
[
settings
,
setSettings
]
=
useState
({
gitlab_url
:
""
,
gitlab_token
:
""
,
is_active
:
false
});
const
[
testResult
,
setTestResult
]
=
useState
(
null
);
const
[
url
,
setUrl
]
=
useState
(
""
);
const
[
testing
,
setTesting
]
=
useState
(
false
);
const
[
token
,
setToken
]
=
useState
(
""
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
testResult
,
setTestResult
]
=
useState
(
null
);
const
[
repos
,
setRepos
]
=
useState
([]);
const
[
testing
,
setTesting
]
=
useState
(
false
);
const
[
projects
,
setProjects
]
=
useState
([]);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
searchQuery
,
setSearchQuery
]
=
useState
(
""
);
const
[
searching
,
setSearching
]
=
useState
(
false
);
const
[
projects
,
setProjects
]
=
useState
([]);
const
[
linking
,
setLinking
]
=
useState
(
null
);
const
[
searchQ
,
setSearchQ
]
=
useState
(
""
);
const
[
searching
,
setSearching
]
=
useState
(
false
);
useEffect
(()
=>
{
const
[
repos
,
setRepos
]
=
useState
([]);
loadSettings
();
const
[
linking
,
setLinking
]
=
useState
(
null
);
loadRepos
();
},
[]);
const
[
actions
,
setActions
]
=
useState
([]);
const
[
actionsTab
,
setActionsTab
]
=
useState
(
"pending"
);
async
function
loadSettings
()
{
const
[
processingAction
,
setProcessingAction
]
=
useState
(
null
);
try
{
const
s
=
await
getGitLabSettings
(
state
.
token
);
const
[
browseRepo
,
setBrowseRepo
]
=
useState
(
null
);
setSettings
({
gitlab_url
:
s
.
gitlab_url
||
""
,
gitlab_token
:
s
.
gitlab_token_set
?
"UNCHANGED"
:
""
});
const
[
tree
,
setTree
]
=
useState
([]);
setSettingsLoaded
(
true
);
const
[
fileContent
,
setFileContent
]
=
useState
(
null
);
}
catch
{}
}
useEffect
(()
=>
{
if
(
state
.
user
?.
role
!==
"superadmin"
)
{
nav
(
"/"
);
return
;
}
async
function
loadRepos
()
{
loadSettings
();
try
{
loadRepos
();
const
r
=
await
listLinkedRepos
(
state
.
token
);
},
[]);
setRepos
(
r
);
}
catch
{}
async
function
loadSettings
()
{
}
try
{
const
s
=
await
gitlabGetSettings
(
t
);
setSettings
(
s
);
setUrl
(
s
.
gitlab_url
||
""
);
}
catch
{
}
}
async
function
handleSaveSettings
()
{
async
function
loadRepos
()
{
setSaving
(
true
);
try
{
setRepos
(
await
gitlabListRepos
(
t
));
}
catch
{
}
try
{
}
await
updateGitLabSettings
(
state
.
token
,
{
async
function
loadActions
()
{
gitlab_url
:
settings
.
gitlab_url
,
try
{
setActions
(
await
gitlabListActions
(
t
,
actionsTab
));
}
catch
{
}
gitlab_token
:
settings
.
gitlab_token
===
"UNCHANGED"
?
"UNCHANGED"
:
settings
.
gitlab_token
,
}
});
useEffect
(()
=>
{
if
(
tab
===
"actions"
)
loadActions
();
},
[
tab
,
actionsTab
]);
setTestResult
({
ok
:
true
,
message
:
"Settings saved!"
});
}
catch
(
err
)
{
async
function
handleSave
()
{
setTestResult
({
ok
:
false
,
message
:
err
.
message
});
setSaving
(
true
);
try
{
await
gitlabUpdateSettings
(
t
,
{
gitlab_url
:
url
,
gitlab_token
:
token
||
"UNCHANGED"
});
await
loadSettings
();
setTestResult
(
null
);
}
catch
(
e
)
{
alert
(
e
.
message
);
}
setSaving
(
false
);
}
async
function
handleTest
()
{
setTesting
(
true
);
setTestResult
(
null
);
try
{
const
r
=
await
gitlabTestConnection
(
t
,
{
gitlab_url
:
url
,
gitlab_token
:
token
||
"UNCHANGED"
});
setTestResult
({
ok
:
true
,
msg
:
`Connected as
${
r
.
name
}
(@
${
r
.
username
}
)`
});
}
catch
(
e
)
{
setTestResult
({
ok
:
false
,
msg
:
e
.
message
});
}
setTesting
(
false
);
}
}
setSaving
(
false
);
async
function
handleSearch
()
{
}
setSearching
(
true
);
try
{
setProjects
(
await
gitlabSearchProjects
(
t
,
searchQ
,
false
));
}
catch
{
}
async
function
handleTest
()
{
setSearching
(
false
);
setTesting
(
true
);
}
setTestResult
(
null
);
try
{
async
function
handleLink
(
projectId
)
{
const
r
=
await
testGitLabConnection
(
state
.
token
,
{
setLinking
(
projectId
);
gitlab_url
:
settings
.
gitlab_url
,
try
{
await
gitlabLinkRepo
(
t
,
projectId
);
await
loadRepos
();
}
catch
(
e
)
{
alert
(
e
.
message
);
}
gitlab_token
:
settings
.
gitlab_token
===
"UNCHANGED"
?
"UNCHANGED"
:
settings
.
gitlab_token
,
setLinking
(
null
);
});
setTestResult
({
ok
:
true
,
message
:
`Connected as
${
r
.
name
}
(@
${
r
.
username
}
)`
});
}
catch
(
err
)
{
setTestResult
({
ok
:
false
,
message
:
err
.
message
});
}
}
setTesting
(
false
);
async
function
handleUnlink
(
repoId
)
{
}
if
(
!
confirm
(
"Unlink this repo?"
))
return
;
try
{
await
gitlabUnlinkRepo
(
t
,
repoId
);
await
loadRepos
();
}
catch
{
}
async
function
handleSearch
()
{
}
setSearching
(
true
);
try
{
async
function
handleBrowse
(
repo
)
{
const
p
=
await
searchGitLabProjects
(
state
.
token
,
searchQuery
,
false
);
setBrowseRepo
(
repo
);
setFileContent
(
null
);
setProjects
(
p
);
try
{
}
catch
{}
const
r
=
await
gitlabGetTree
(
t
,
repo
.
id
,
""
,
null
);
setSearching
(
false
);
setTree
(
r
.
items
||
[]);
}
}
catch
{
setTree
([]);
}
}
async
function
handleLink
(
projectId
)
{
setLinking
(
projectId
);
async
function
handleViewFile
(
path
)
{
try
{
if
(
!
browseRepo
)
return
;
await
linkRepo
(
state
.
token
,
projectId
);
try
{
await
loadRepos
();
const
f
=
await
gitlabGetFile
(
t
,
browseRepo
.
id
,
path
,
null
);
}
catch
{}
setFileContent
(
f
);
setLinking
(
null
);
}
catch
(
e
)
{
setFileContent
({
file_path
:
path
,
content
:
`Error:
${
e
.
message
}
`
});
}
}
}
async
function
handleUnlink
(
repoId
)
{
async
function
handleApprove
(
id
)
{
if
(
!
confirm
(
"Unlink this repository?"
))
return
;
setProcessingAction
(
id
);
try
{
try
{
await
gitlabApproveAction
(
t
,
id
);
await
loadActions
();
}
catch
(
e
)
{
alert
(
e
.
message
);
}
await
unlinkRepo
(
state
.
token
,
repoId
);
setProcessingAction
(
null
);
await
loadRepos
();
}
}
catch
{}
}
async
function
handleReject
(
id
)
{
setProcessingAction
(
id
);
const
linkedProjectIds
=
new
Set
(
repos
.
map
((
r
)
=>
r
.
gitlab_project_id
));
try
{
await
gitlabRejectAction
(
t
,
id
);
await
loadActions
();
}
catch
(
e
)
{
alert
(
e
.
message
);
}
setProcessingAction
(
null
);
return
(
}
<
div
className=
"h-screen flex flex-col bg-anton-bg"
>
<
div
className=
"border-b border-anton-border bg-anton-surface px-6 py-4 flex items-center gap-4"
>
const
linked
=
new
Set
(
repos
.
map
(
r
=>
r
.
gitlab_project_id
));
<
button
onClick=
{
()
=>
navigate
(
"/"
)
}
className=
"text-anton-muted hover:text-white transition"
>
<
ArrowLeft
size=
{
20
}
/>
return
(
</
button
>
<
div
className=
"h-dvh flex flex-col bg-anton-bg text-anton-text"
>
<
GitBranch
size=
{
20
}
className=
"text-anton-accent"
/>
{
/* Header */
}
<
h1
className=
"text-lg font-bold text-white"
>
GitLab Command Center
</
h1
>
<
div
className=
"border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3"
>
</
div
>
<
button
onClick=
{
()
=>
nav
(
"/"
)
}
className=
"text-anton-muted hover:text-white"
><
ArrowLeft
size=
{
20
}
/></
button
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex border-b border-anton-border bg-anton-surface"
>
<
GitBranch
size=
{
20
}
className=
"text-orange-400"
/>
{
[
"repos"
,
"browse"
,
"settings"
].
map
((
t
)
=>
(
<
h1
className=
"text-lg font-bold text-white"
>
GitLab Command Center
</
h1
>
<
button
key=
{
t
}
onClick=
{
()
=>
setTab
(
t
)
}
className=
{
`px-6 py-3 text-sm font-medium transition border-b-2 ${
tab === t ? "border-anton-accent text-white" : "border-transparent text-anton-muted hover:text-white"
}`
}
>
{
t
===
"repos"
?
"Linked Repos"
:
t
===
"browse"
?
"Browse Projects"
:
"Connection"
}
</
button
>
))
}
</
div
>
<
div
className=
"flex-1 overflow-y-auto p-6"
>
{
tab
===
"settings"
&&
(
<
div
className=
"max-w-xl space-y-4"
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
GitLab URL
</
label
>
<
input
value=
{
settings
.
gitlab_url
}
onChange=
{
(
e
)
=>
setSettings
({
...
settings
,
gitlab_url
:
e
.
target
.
value
})
}
placeholder=
"https://gitlab.example.com"
className=
"w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Access Token
</
label
>
<
input
type=
"password"
value=
{
settings
.
gitlab_token
}
onChange=
{
(
e
)
=>
setSettings
({
...
settings
,
gitlab_token
:
e
.
target
.
value
})
}
placeholder=
"glpat-..."
className=
"w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
</
div
>
{
testResult
&&
(
<
div
className=
{
`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${
testResult.ok ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-400"
}`
}
>
{
testResult
.
ok
?
<
Check
size=
{
14
}
/>
:
<
X
size=
{
14
}
/>
}
{
testResult
.
message
}
</
div
>
)
}
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
handleTest
}
disabled=
{
testing
}
className=
"flex items-center gap-2 px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm text-white hover:border-anton-accent transition disabled:opacity-50"
>
{
testing
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
RefreshCw
size=
{
14
}
/>
}
Test Connection
</
button
>
<
button
onClick=
{
handleSaveSettings
}
disabled=
{
saving
}
className=
"flex items-center gap-2 px-4 py-2 bg-anton-accent rounded-lg text-sm text-white hover:opacity-90 transition disabled:opacity-50"
>
{
saving
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Check
size=
{
14
}
/>
}
Save
</
button
>
</
div
>
</
div
>
)
}
{
tab
===
"repos"
&&
(
<
div
className=
"space-y-3"
>
{
repos
.
length
===
0
&&
(
<
div
className=
"text-center py-12 text-anton-muted"
>
<
GitBranch
size=
{
40
}
className=
"mx-auto mb-3 opacity-30"
/>
<
p
>
No linked repositories yet.
</
p
>
<
p
className=
"text-xs mt-1"
>
Go to "Browse Projects" to link one.
</
p
>
</
div
>
)
}
{
repos
.
map
((
r
)
=>
(
<
div
key=
{
r
.
id
}
className=
"bg-anton-card border border-anton-border rounded-xl p-4 flex items-center justify-between"
>
<
div
>
<
div
className=
"text-sm font-medium text-white flex items-center gap-2"
>
<
GitBranch
size=
{
14
}
className=
"text-anton-accent"
/>
{
r
.
name
}
</
div
>
<
div
className=
"text-xs text-anton-muted mt-0.5"
>
{
r
.
path_with_namespace
}
•
{
r
.
default_branch
}
</
div
>
{
r
.
description
&&
<
div
className=
"text-xs text-anton-muted mt-1 line-clamp-1"
>
{
r
.
description
}
</
div
>
}
</
div
>
</
div
>
<
div
className=
{
`ml-auto flex items-center gap-1.5 text-xs ${settings.is_active ? "text-green-400" : "text-red-400"}`
}
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
{
`w-2 h-2 rounded-full ${settings.is_active ? "bg-green-400" : "bg-red-400"}`
}
/>
{
r
.
web_url
&&
(
{
settings
.
is_active
?
"Connected"
:
"Disconnected"
}
<
a
href=
{
r
.
web_url
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"p-2 text-anton-muted hover:text-white transition"
>
<
ExternalLink
size=
{
14
}
/>
</
a
>
)
}
<
button
onClick=
{
()
=>
handleUnlink
(
r
.
id
)
}
className=
"p-2 text-anton-muted hover:text-red-400 transition"
title=
"Unlink"
>
<
Unlink
size=
{
14
}
/>
</
button
>
</
div
>
</
div
>
</
div
>
))
}
</
div
>
)
}
{
tab
===
"browse"
&&
(
<
div
className=
"space-y-4"
>
<
div
className=
"flex gap-2"
>
<
input
value=
{
searchQuery
}
onChange=
{
(
e
)
=>
setSearchQuery
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleSearch
()
}
placeholder=
"Search GitLab projects..."
className=
"flex-1 bg-anton-card border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
<
button
onClick=
{
handleSearch
}
disabled=
{
searching
}
className=
"flex items-center gap-2 px-4 py-2.5 bg-anton-accent rounded-lg text-sm text-white hover:opacity-90 transition disabled:opacity-50"
>
{
searching
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Search
size=
{
14
}
/>
}
Search
</
button
>
</
div
>
</
div
>
{
projects
.
map
((
p
)
=>
{
{
/* Tabs */
}
const
isLinked
=
linkedProjectIds
.
has
(
p
.
id
);
<
div
className=
"border-b border-anton-border bg-anton-surface flex gap-0.5 px-4"
>
return
(
{
[[
"connection"
,
"Connection"
,
Plug
],
[
"repos"
,
"Repositories"
,
FolderTree
],
[
"actions"
,
"Actions"
,
Shield
]].
map
(([
key
,
label
,
Icon
])
=>
(
<
div
key=
{
p
.
id
}
className=
"bg-anton-card border border-anton-border rounded-xl p-4 flex items-center justify-between"
>
<
button
key=
{
key
}
onClick=
{
()
=>
setTab
(
key
)
}
<
div
>
className=
{
`flex items-center gap-1.5 px-4 py-2.5 text-sm border-b-2 transition ${tab === key ? "border-anton-accent text-white" : "border-transparent text-anton-muted hover:text-white"}`
}
>
<
div
className=
"text-sm font-medium text-white"
>
{
p
.
name
}
</
div
>
<
Icon
size=
{
14
}
/>
{
label
}
<
div
className=
"text-xs text-anton-muted mt-0.5"
>
{
p
.
path_with_namespace
}
</
div
>
{
p
.
description
&&
<
div
className=
"text-xs text-anton-muted mt-1 line-clamp-1"
>
{
p
.
description
}
</
div
>
}
</
div
>
{
isLinked
?
(
<
span
className=
"flex items-center gap-1 text-xs text-green-400 px-3 py-1.5 bg-green-500/10 rounded-lg"
>
<
Check
size=
{
12
}
/>
Linked
</
span
>
)
:
(
<
button
onClick=
{
()
=>
handleLink
(
p
.
id
)
}
disabled=
{
linking
===
p
.
id
}
className=
"flex items-center gap-1 text-xs text-white px-3 py-1.5 bg-anton-accent rounded-lg hover:opacity-90 transition disabled:opacity-50"
>
{
linking
===
p
.
id
?
<
Loader2
size=
{
12
}
className=
"animate-spin"
/>
:
<
Link
size=
{
12
}
/>
}
Link
</
button
>
</
button
>
))
}
)
}
</
div
>
</
div
>
);
{
/* Content */
}
})
}
<
div
className=
"flex-1 overflow-y-auto p-4 space-y-4"
>
</
div
>
{
/* ── CONNECTION TAB ── */
}
)
}
{
tab
===
"connection"
&&
(
</
div
>
<
div
className=
"max-w-2xl mx-auto space-y-4"
>
</
div
>
<
div
className=
"bg-anton-card border border-anton-border rounded-xl p-5 space-y-4"
>
);
<
h2
className=
"text-white font-semibold flex items-center gap-2"
><
Settings2
size=
{
16
}
className=
"text-anton-accent"
/>
Connection Settings
</
h2
>
<
div
>
<
label
className=
"text-xs text-anton-muted block mb-1"
>
GitLab URL
</
label
>
<
input
value=
{
url
}
onChange=
{
e
=>
setUrl
(
e
.
target
.
value
)
}
placeholder=
"https://gitlab.example.com"
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"
/>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted block mb-1"
>
Personal Access Token
</
label
>
<
input
type=
"password"
value=
{
token
}
onChange=
{
e
=>
setToken
(
e
.
target
.
value
)
}
placeholder=
{
settings
.
gitlab_token_set
?
"••••••• (saved)"
:
"glpat-..."
}
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"
/>
<
p
className=
"text-[10px] text-anton-muted mt-1"
>
Needs api, read_repository, write_repository scopes
</
p
>
</
div
>
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
handleSave
}
disabled=
{
saving
}
className=
"px-4 py-2 bg-anton-accent text-white rounded-lg hover:opacity-80 transition disabled:opacity-50 flex items-center gap-1.5"
>
{
saving
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Check
size=
{
14
}
/>
}
Save
</
button
>
<
button
onClick=
{
handleTest
}
disabled=
{
testing
}
className=
"px-4 py-2 bg-anton-card border border-anton-border text-white rounded-lg hover:border-anton-accent transition disabled:opacity-50 flex items-center gap-1.5"
>
{
testing
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Plug
size=
{
14
}
/>
}
Test
</
button
>
</
div
>
{
testResult
&&
(
<
div
className=
{
`p-3 rounded-lg text-sm ${testResult.ok ? "bg-green-500/10 text-green-400 border border-green-500/30" : "bg-red-500/10 text-red-400 border border-red-500/30"}`
}
>
{
testResult
.
ok
?
<
CheckCircle
size=
{
14
}
className=
"inline mr-1"
/>
:
<
XCircle
size=
{
14
}
className=
"inline mr-1"
/>
}
{
testResult
.
msg
}
</
div
>
)
}
</
div
>
</
div
>
)
}
{
/* ── REPOS TAB ── */
}
{
tab
===
"repos"
&&
(
<
div
className=
"max-w-4xl mx-auto space-y-4"
>
{
/* Search & Link */
}
<
div
className=
"bg-anton-card border border-anton-border rounded-xl p-4 space-y-3"
>
<
h2
className=
"text-white font-semibold flex items-center gap-2"
><
Search
size=
{
16
}
className=
"text-anton-accent"
/>
Find
&
Link Projects
</
h2
>
<
div
className=
"flex gap-2"
>
<
input
value=
{
searchQ
}
onChange=
{
e
=>
setSearchQ
(
e
.
target
.
value
)
}
onKeyDown=
{
e
=>
e
.
key
===
"Enter"
&&
handleSearch
()
}
placeholder=
"Search GitLab projects..."
className=
"flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
<
button
onClick=
{
handleSearch
}
disabled=
{
searching
}
className=
"px-4 py-2 bg-anton-accent text-white rounded-lg text-sm hover:opacity-80 disabled:opacity-50"
>
{
searching
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
"Search"
}
</
button
>
</
div
>
{
projects
.
length
>
0
&&
(
<
div
className=
"max-h-60 overflow-y-auto space-y-1"
>
{
projects
.
map
(
p
=>
(
<
div
key=
{
p
.
id
}
className=
"flex items-center justify-between bg-anton-bg rounded-lg px-3 py-2"
>
<
div
className=
"min-w-0"
>
<
div
className=
"text-sm text-white truncate"
>
{
p
.
path_with_namespace
}
</
div
>
<
div
className=
"text-[10px] text-anton-muted truncate"
>
{
p
.
description
||
"No description"
}
</
div
>
</
div
>
{
linked
.
has
(
p
.
id
)
?
(
<
span
className=
"text-xs text-green-400 shrink-0 ml-2"
>
✓ Linked
</
span
>
)
:
(
<
button
onClick=
{
()
=>
handleLink
(
p
.
id
)
}
disabled=
{
linking
===
p
.
id
}
className=
"text-xs bg-anton-accent/20 text-anton-accent px-2.5 py-1 rounded hover:bg-anton-accent/30 shrink-0 ml-2"
>
{
linking
===
p
.
id
?
<
Loader2
size=
{
12
}
className=
"animate-spin"
/>
:
<><
Link
size=
{
12
}
className=
"inline mr-1"
/>
Link
</>
}
</
button
>
)
}
</
div
>
))
}
</
div
>
)
}
</
div
>
{
/* Linked Repos */
}
<
div
className=
"bg-anton-card border border-anton-border rounded-xl p-4 space-y-3"
>
<
div
className=
"flex items-center justify-between"
>
<
h2
className=
"text-white font-semibold flex items-center gap-2"
><
FolderTree
size=
{
16
}
className=
"text-green-400"
/>
Linked Repositories (
{
repos
.
length
}
)
</
h2
>
<
button
onClick=
{
loadRepos
}
className=
"text-anton-muted hover:text-white"
><
RefreshCw
size=
{
14
}
/></
button
>
</
div
>
{
repos
.
length
===
0
&&
<
p
className=
"text-anton-muted text-sm"
>
No repos linked yet. Search above.
</
p
>
}
{
repos
.
map
(
r
=>
(
<
div
key=
{
r
.
id
}
className=
"bg-anton-bg rounded-xl p-3 space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
div
>
<
div
className=
"text-sm text-white font-medium"
>
{
r
.
name
}
</
div
>
<
div
className=
"text-[10px] text-anton-muted"
>
{
r
.
path_with_namespace
}
•
{
r
.
default_branch
}
</
div
>
</
div
>
<
div
className=
"flex gap-1.5"
>
{
r
.
web_url
&&
<
a
href=
{
r
.
web_url
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"p-1.5 text-anton-muted hover:text-white"
><
ExternalLink
size=
{
14
}
/></
a
>
}
<
button
onClick=
{
()
=>
handleBrowse
(
r
)
}
className=
"p-1.5 text-anton-muted hover:text-green-400"
><
Eye
size=
{
14
}
/></
button
>
<
button
onClick=
{
()
=>
handleUnlink
(
r
.
id
)
}
className=
"p-1.5 text-anton-muted hover:text-red-400"
><
Unlink
size=
{
14
}
/></
button
>
</
div
>
</
div
>
</
div
>
))
}
</
div
>
{
/* File Browser */
}
{
browseRepo
&&
(
<
div
className=
"bg-anton-card border border-anton-border rounded-xl p-4 space-y-3"
>
<
div
className=
"flex items-center justify-between"
>
<
h2
className=
"text-white font-semibold text-sm"
>
📂
{
browseRepo
.
name
}
/
{
browseRepo
.
default_branch
}
</
h2
>
<
button
onClick=
{
()
=>
{
setBrowseRepo
(
null
);
setFileContent
(
null
);
}
}
className=
"text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
</
div
>
<
div
className=
"grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[60vh]"
>
<
div
className=
"overflow-y-auto border border-anton-border rounded-lg p-2 space-y-0.5 max-h-[55vh]"
>
{
tree
.
map
(
item
=>
(
<
button
key=
{
item
.
path
}
onClick=
{
()
=>
item
.
type
===
"blob"
&&
handleViewFile
(
item
.
path
)
}
className=
{
`w-full text-left px-2 py-1 rounded text-xs flex items-center gap-1.5 ${item.type === "blob" ? "hover:bg-anton-accent/10 text-white cursor-pointer" : "text-anton-muted cursor-default"}`
}
>
{
item
.
type
===
"tree"
?
<
Folder
size=
{
12
}
className=
"text-blue-400 shrink-0"
/>
:
<
FileText
size=
{
12
}
className=
"text-anton-muted shrink-0"
/>
}
<
span
className=
"truncate"
>
{
item
.
path
}
</
span
>
</
button
>
))
}
</
div
>
<
div
className=
"overflow-y-auto border border-anton-border rounded-lg max-h-[55vh]"
>
{
fileContent
?
(
<
div
>
<
div
className=
"sticky top-0 bg-anton-surface px-3 py-1.5 border-b border-anton-border text-xs text-anton-muted"
>
{
fileContent
.
file_path
}
</
div
>
<
pre
className=
"p-3 text-[11px] text-white whitespace-pre-wrap font-mono leading-relaxed"
>
{
fileContent
.
content
}
</
pre
>
</
div
>
)
:
(
<
div
className=
"flex items-center justify-center h-full text-anton-muted text-sm p-4"
>
Click a file to view
</
div
>
)
}
</
div
>
</
div
>
</
div
>
)
}
</
div
>
)
}
{
/* ── ACTIONS TAB ── */
}
{
tab
===
"actions"
&&
(
<
div
className=
"max-w-3xl mx-auto space-y-4"
>
<
div
className=
"flex gap-2 mb-2"
>
{
[
"pending"
,
"approved"
,
"rejected"
].
map
(
s
=>
(
<
button
key=
{
s
}
onClick=
{
()
=>
setActionsTab
(
s
)
}
className=
{
`px-3 py-1.5 rounded-lg text-xs capitalize ${actionsTab === s ? "bg-anton-accent text-white" : "bg-anton-card text-anton-muted border border-anton-border hover:text-white"}`
}
>
{
s
}
</
button
>
))
}
<
button
onClick=
{
loadActions
}
className=
"ml-auto text-anton-muted hover:text-white"
><
RefreshCw
size=
{
14
}
/></
button
>
</
div
>
{
actions
.
length
===
0
&&
<
p
className=
"text-anton-muted text-sm text-center py-8"
>
No
{
actionsTab
}
actions
</
p
>
}
{
actions
.
map
(
a
=>
(
<
div
key=
{
a
.
id
}
className=
"bg-anton-card border border-anton-border rounded-xl p-4 space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
div
>
<
span
className=
"text-xs bg-anton-accent/20 text-anton-accent px-2 py-0.5 rounded mr-2"
>
{
a
.
action_type
}
</
span
>
<
span
className=
"text-sm text-white"
>
{
a
.
title
||
"Untitled"
}
</
span
>
</
div
>
<
span
className=
"text-[10px] text-anton-muted"
>
{
a
.
repo_name
}
</
span
>
</
div
>
<
pre
className=
"text-[10px] text-anton-muted bg-anton-bg rounded p-2 max-h-32 overflow-y-auto"
>
{
a
.
payload
}
</
pre
>
{
a
.
status
===
"pending"
&&
(
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
()
=>
handleApprove
(
a
.
id
)
}
disabled=
{
processingAction
===
a
.
id
}
className=
"px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-500 disabled:opacity-50 flex items-center gap-1"
>
{
processingAction
===
a
.
id
?
<
Loader2
size=
{
12
}
className=
"animate-spin"
/>
:
<
CheckCircle
size=
{
12
}
/>
}
Approve
</
button
>
<
button
onClick=
{
()
=>
handleReject
(
a
.
id
)
}
disabled=
{
processingAction
===
a
.
id
}
className=
"px-3 py-1.5 bg-red-600 text-white rounded text-xs hover:bg-red-500 disabled:opacity-50 flex items-center gap-1"
>
<
XCircle
size=
{
12
}
/>
Reject
</
button
>
</
div
>
)
}
{
a
.
result_message
&&
<
p
className=
"text-[10px] text-anton-muted"
>
{
a
.
result_message
}
</
p
>
}
</
div
>
))
}
</
div
>
)
}
</
div
>
</
div
>
);
}
}
\ No newline at end of file
frontend/src/pages/KnowledgePage.jsx
View file @
faa34a43
import
React
,
{
useState
,
useEffect
,
useRef
}
from
"react"
;
import
React
,
{
useState
,
useEffect
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useApp
}
from
"../store"
;
import
{
import
{
listKnowledgeBases
,
listKnowledgeBases
,
createKnowledgeBase
,
getKnowledgeBase
,
createKnowledgeBase
,
deleteKnowledgeBase
,
uploadDocuments
,
deleteKnowledgeDocument
,
getKnowledgeBase
,
updateKnowledgeBase
,
deleteKnowledgeBase
,
deleteKnowledgeDocument
,
uploadDocuments
,
}
from
"../api"
;
}
from
"../api"
;
import
{
import
{
BookOpen
,
Plus
,
Trash2
,
FileText
,
Upload
,
ArrowLeft
,
Edit3
,
ArrowLeft
,
BookOpen
,
Plus
,
Trash2
,
Upload
,
FileText
,
Loader2
,
X
,
Check
,
X
,
ChevronRight
,
Database
,
Hash
,
Type
,
Calendar
,
AlertTriangle
,
Loader2
,
Search
,
Flame
,
RefreshCw
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
function
fmtSize
(
b
)
{
if
(
!
b
)
return
"0 B"
;
if
(
b
<
1024
)
return
b
+
" B"
;
if
(
b
<
1048576
)
return
(
b
/
1024
).
toFixed
(
1
)
+
" KB"
;
return
(
b
/
1048576
).
toFixed
(
1
)
+
" MB"
;
}
function
fmtDate
(
s
)
{
if
(
!
s
)
return
""
;
try
{
return
new
Date
(
s
).
toLocaleDateString
(
undefined
,
{
year
:
"numeric"
,
month
:
"short"
,
day
:
"numeric"
,
hour
:
"2-digit"
,
minute
:
"2-digit"
});
}
catch
{
return
s
;
}
}
export
default
function
KnowledgePage
()
{
export
default
function
KnowledgePage
()
{
const
{
state
}
=
useApp
();
const
{
state
}
=
useApp
();
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
token
=
state
.
token
;
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
selectedKb
,
setSelectedKb
]
=
useState
(
null
);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
kbDetail
,
setKbDetail
]
=
useState
(
null
);
const
[
selectedKb
,
setSelectedKb
]
=
useState
(
null
);
const
[
newName
,
setNewName
]
=
useState
(
""
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
newDesc
,
setNewDesc
]
=
useState
(
""
);
const
[
error
,
setError
]
=
useState
(
""
);
const
[
showCreate
,
setShowCreate
]
=
useState
(
false
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
// Create KB state
const
[
showCreate
,
setShowCreate
]
=
useState
(
false
);
useEffect
(()
=>
{
loadKbs
();
},
[]);
const
[
newName
,
setNewName
]
=
useState
(
""
);
const
[
newDesc
,
setNewDesc
]
=
useState
(
""
);
async
function
loadKbs
()
{
const
[
creating
,
setCreating
]
=
useState
(
false
);
try
{
const
data
=
await
listKnowledgeBases
(
state
.
token
);
// Edit KB state
setKbs
(
data
);
const
[
editing
,
setEditing
]
=
useState
(
false
);
}
catch
{}
const
[
editName
,
setEditName
]
=
useState
(
""
);
}
const
[
editDesc
,
setEditDesc
]
=
useState
(
""
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
// Upload state
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
uploadResult
,
setUploadResult
]
=
useState
(
null
);
const
fileRef
=
useRef
(
null
);
// Delete doc state
const
[
deletingDocId
,
setDeletingDocId
]
=
useState
(
null
);
const
[
confirmDeleteKb
,
setConfirmDeleteKb
]
=
useState
(
false
);
// Search
const
[
docSearch
,
setDocSearch
]
=
useState
(
""
);
useEffect
(()
=>
{
loadKbs
();
},
[]);
async
function
loadKbs
()
{
setLoading
(
true
);
setError
(
""
);
try
{
const
data
=
await
listKnowledgeBases
(
token
);
setKbs
(
data
);
}
catch
(
e
)
{
setError
(
e
.
message
);
}
finally
{
setLoading
(
false
);
}
}
async
function
loadKbDetail
(
kbId
)
{
setError
(
""
);
try
{
const
data
=
await
getKnowledgeBase
(
token
,
kbId
);
setSelectedKb
(
data
);
}
catch
(
e
)
{
setError
(
e
.
message
);
}
}
async
function
handleCreate
()
{
if
(
!
newName
.
trim
())
return
;
setCreating
(
true
);
setError
(
""
);
try
{
await
createKnowledgeBase
(
token
,
newName
.
trim
(),
newDesc
.
trim
());
setNewName
(
""
);
setNewDesc
(
""
);
setShowCreate
(
false
);
await
loadKbs
();
}
catch
(
e
)
{
setError
(
e
.
message
);
}
finally
{
setCreating
(
false
);
}
}
async
function
handleDeleteKb
()
{
if
(
!
selectedKb
)
return
;
setError
(
""
);
try
{
await
deleteKnowledgeBase
(
token
,
selectedKb
.
id
);
setSelectedKb
(
null
);
setConfirmDeleteKb
(
false
);
await
loadKbs
();
}
catch
(
e
)
{
setError
(
e
.
message
);
}
}
async
function
handleSaveEdit
()
{
if
(
!
selectedKb
)
return
;
setSaving
(
true
);
setError
(
""
);
try
{
await
updateKnowledgeBase
(
token
,
selectedKb
.
id
,
{
name
:
editName
.
trim
()
||
selectedKb
.
name
,
description
:
editDesc
.
trim
(),
});
setEditing
(
false
);
await
loadKbDetail
(
selectedKb
.
id
);
await
loadKbs
();
}
catch
(
e
)
{
setError
(
e
.
message
);
}
finally
{
setSaving
(
false
);
}
}
async
function
handleUpload
(
e
)
{
const
files
=
Array
.
from
(
e
.
target
.
files
||
[]);
if
(
!
files
.
length
||
!
selectedKb
)
return
;
e
.
target
.
value
=
""
;
setUploading
(
true
);
setUploadResult
(
null
);
setError
(
""
);
try
{
const
result
=
await
uploadDocuments
(
token
,
selectedKb
.
id
,
files
);
setUploadResult
(
result
);
await
loadKbDetail
(
selectedKb
.
id
);
await
loadKbs
();
}
catch
(
err
)
{
setError
(
err
.
message
);
}
finally
{
setUploading
(
false
);
}
}
async
function
handleDeleteDoc
(
docId
)
{
if
(
!
selectedKb
)
return
;
setDeletingDocId
(
docId
);
setError
(
""
);
try
{
await
deleteKnowledgeDocument
(
token
,
selectedKb
.
id
,
docId
);
await
loadKbDetail
(
selectedKb
.
id
);
await
loadKbs
();
}
catch
(
e
)
{
setError
(
e
.
message
);
}
finally
{
setDeletingDocId
(
null
);
}
}
function
openKb
(
kb
)
{
setSelectedKb
(
null
);
setEditing
(
false
);
setUploadResult
(
null
);
setDocSearch
(
""
);
setConfirmDeleteKb
(
false
);
loadKbDetail
(
kb
.
id
);
}
function
goBack
()
{
setSelectedKb
(
null
);
setEditing
(
false
);
setUploadResult
(
null
);
setDocSearch
(
""
);
setConfirmDeleteKb
(
false
);
}
const
filteredDocs
=
(
selectedKb
?.
documents
||
[]).
filter
((
d
)
=>
d
.
filename
.
toLowerCase
().
includes
(
docSearch
.
toLowerCase
())
);
// ─── KB Detail View ───
if
(
selectedKb
)
{
return
(
<
div
className=
"h-dvh flex flex-col bg-anton-bg text-anton-text"
>
{
/* Header */
}
<
div
className=
"border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3"
>
<
button
onClick=
{
goBack
}
className=
"text-anton-muted hover:text-white transition"
><
ArrowLeft
size=
{
20
}
/></
button
>
<
div
className=
"flex-1 min-w-0"
>
{
editing
?
(
<
div
className=
"flex items-center gap-2"
>
<
input
value=
{
editName
}
onChange=
{
(
e
)
=>
setEditName
(
e
.
target
.
value
)
}
className=
"bg-anton-card border border-anton-border rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-anton-accent flex-1"
placeholder=
"Name"
/>
<
button
onClick=
{
handleSaveEdit
}
disabled=
{
saving
}
className=
"p-1.5 rounded-lg bg-anton-accent text-white hover:opacity-80 transition disabled:opacity-50"
>
{
saving
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Check
size=
{
14
}
/>
}
</
button
>
<
button
onClick=
{
()
=>
setEditing
(
false
)
}
className=
"p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
><
X
size=
{
14
}
/></
button
>
</
div
>
)
:
(
<
div
className=
"flex items-center gap-2"
>
<
h1
className=
"text-lg font-bold text-white truncate"
>
{
selectedKb
.
name
}
</
h1
>
<
button
onClick=
{
()
=>
{
setEditName
(
selectedKb
.
name
);
setEditDesc
(
selectedKb
.
description
||
""
);
setEditing
(
true
);
}
}
className=
"text-anton-muted hover:text-anton-accent transition"
><
Edit3
size=
{
14
}
/></
button
>
</
div
>
)
}
{
!
editing
&&
selectedKb
.
description
&&
<
p
className=
"text-xs text-anton-muted truncate mt-0.5"
>
{
selectedKb
.
description
}
</
p
>
}
</
div
>
<
button
onClick=
{
()
=>
navigate
(
"/"
)
}
className=
"text-anton-muted hover:text-white transition text-sm"
>
← Chat
</
button
>
</
div
>
{
/* Edit description row */
}
{
editing
&&
(
<
div
className=
"px-4 py-2 border-b border-anton-border bg-anton-surface"
>
<
textarea
value=
{
editDesc
}
onChange=
{
(
e
)
=>
setEditDesc
(
e
.
target
.
value
)
}
placeholder=
"Description (optional)"
rows=
{
2
}
className=
"w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-anton-accent"
/>
</
div
>
)
}
{
error
&&
<
div
className=
"px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-red-400 text-sm flex items-center gap-2"
><
AlertTriangle
size=
{
14
}
/>
{
error
}
</
div
>
}
{
/* Stats bar */
}
<
div
className=
"px-4 py-2 border-b border-anton-border bg-anton-surface/50 flex items-center gap-4 text-xs text-anton-muted flex-wrap"
>
<
span
className=
"flex items-center gap-1"
><
FileText
size=
{
12
}
/>
{
selectedKb
.
document_count
}
docs
</
span
>
<
span
className=
"flex items-center gap-1"
><
Hash
size=
{
12
}
/>
{
selectedKb
.
chunk_count
}
chunks
</
span
>
<
span
className=
"flex items-center gap-1"
><
Type
size=
{
12
}
/>
{
(
selectedKb
.
estimated_tokens
||
0
).
toLocaleString
()
}
est. tokens
</
span
>
<
span
className=
"flex items-center gap-1"
><
Calendar
size=
{
12
}
/>
{
fmtDate
(
selectedKb
.
created_at
)
}
</
span
>
</
div
>
{
/* Actions bar */
}
<
div
className=
"px-4 py-2 border-b border-anton-border bg-anton-surface/30 flex items-center gap-2 flex-wrap"
>
<
button
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
disabled=
{
uploading
}
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-anton-accent text-white text-sm hover:opacity-80 transition disabled:opacity-50"
>
{
uploading
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Upload
size=
{
14
}
/>
}
{
uploading
?
"Uploading…"
:
"Upload Files"
}
</
button
>
<
input
ref=
{
fileRef
}
type=
"file"
multiple
className=
"hidden"
accept=
".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,.doc,.docx"
onChange=
{
handleUpload
}
/>
<
button
onClick=
{
()
=>
loadKbDetail
(
selectedKb
.
id
)
}
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-anton-border text-anton-muted text-sm hover:text-white hover:bg-anton-card transition"
><
RefreshCw
size=
{
14
}
/>
Refresh
</
button
>
<
div
className=
"flex-1"
/>
{
confirmDeleteKb
?
(
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-red-400 text-xs"
>
Delete entire KB?
</
span
>
<
button
onClick=
{
handleDeleteKb
}
className=
"px-3 py-1.5 rounded-lg bg-red-600 text-white text-sm hover:opacity-80 transition"
>
Yes, Delete
</
button
>
<
button
onClick=
{
()
=>
setConfirmDeleteKb
(
false
)
}
className=
"px-3 py-1.5 rounded-lg border border-anton-border text-anton-muted text-sm hover:text-white transition"
>
Cancel
</
button
>
</
div
>
)
:
(
<
button
onClick=
{
()
=>
setConfirmDeleteKb
(
true
)
}
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-red-500/30 text-red-400 text-sm hover:bg-red-500/10 transition"
><
Trash2
size=
{
14
}
/>
Delete KB
</
button
>
)
}
</
div
>
{
/* Upload result */
}
{
uploadResult
&&
(
<
div
className=
"px-4 py-2 border-b border-anton-border bg-green-500/5"
>
<
p
className=
"text-xs text-green-400 font-semibold mb-1"
>
Upload complete:
{
uploadResult
.
total_chunks_added
}
chunks from
{
uploadResult
.
total_files
}
file(s)
</
p
>
{
uploadResult
.
files
?.
map
((
f
,
i
)
=>
(
<
p
key=
{
i
}
className=
{
`text-xs ${f.error ? "text-red-400" : "text-anton-muted"}`
}
>
{
f
.
filename
}
:
{
f
.
error
?
`Error: ${f.error}`
:
`${f.chunks_added} chunks, ~${f.estimated_tokens?.toLocaleString()} tokens`
}
</
p
>
))
}
</
div
>
)
}
{
/* Search */
}
{
(
selectedKb
.
documents
||
[]).
length
>
3
&&
(
<
div
className=
"px-4 py-2 border-b border-anton-border"
>
<
div
className=
"relative"
>
<
Search
size=
{
14
}
className=
"absolute left-3 top-1/2 -translate-y-1/2 text-anton-muted"
/>
<
input
value=
{
docSearch
}
onChange=
{
(
e
)
=>
setDocSearch
(
e
.
target
.
value
)
}
placeholder=
"Search documents…"
className=
"w-full bg-anton-card border border-anton-border rounded-lg pl-9 pr-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
</
div
>
</
div
>
)
}
{
/* Document list */
}
async
function
loadDetail
(
kbId
)
{
<
div
className=
"flex-1 overflow-y-auto px-4 py-3"
>
try
{
{
filteredDocs
.
length
===
0
?
(
const
data
=
await
getKnowledgeBase
(
state
.
token
,
kbId
);
<
div
className=
"text-center py-16"
>
setKbDetail
(
data
);
<
Database
size=
{
40
}
className=
"mx-auto text-anton-muted mb-3 opacity-50"
/>
}
catch
{}
<
p
className=
"text-anton-muted text-sm"
>
{
docSearch
?
"No documents match your search."
:
"No documents yet. Upload some files!"
}
</
p
>
}
</
div
>
)
:
(
async
function
handleCreate
(
e
)
{
<
div
className=
"space-y-1.5"
>
e
.
preventDefault
();
{
filteredDocs
.
map
((
doc
)
=>
(
if
(
!
newName
.
trim
())
return
;
<
div
key=
{
doc
.
id
}
className=
"flex items-center gap-3 px-3 py-2.5 rounded-lg border border-anton-border bg-anton-card hover:border-anton-accent/30 transition group"
>
try
{
<
FileText
size=
{
16
}
className=
"text-anton-accent shrink-0"
/>
await
createKnowledgeBase
(
state
.
token
,
newName
.
trim
(),
newDesc
.
trim
());
<
div
className=
"flex-1 min-w-0"
>
setNewName
(
""
);
<
p
className=
"text-sm text-white truncate font-medium"
>
{
doc
.
filename
}
</
p
>
setNewDesc
(
""
);
<
p
className=
"text-[11px] text-anton-muted"
>
{
fmtSize
(
doc
.
file_size
)
}
·
{
doc
.
chunk_count
}
chunks ·
{
fmtDate
(
doc
.
created_at
)
}
</
p
>
setShowCreate
(
false
);
</
div
>
loadKbs
();
<
button
}
catch
{}
onClick=
{
()
=>
handleDeleteDoc
(
doc
.
id
)
}
}
disabled=
{
deletingDocId
===
doc
.
id
}
className=
"p-1.5 rounded-lg text-anton-muted hover:text-red-400 hover:bg-red-500/10 transition opacity-0 group-hover:opacity-100 disabled:opacity-50"
async
function
handleDeleteKb
(
kbId
)
{
title=
"Remove document and its vector chunks"
if
(
!
confirm
(
"Delete this knowledge base and all its documents?"
))
return
;
>
try
{
{
deletingDocId
===
doc
.
id
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Trash2
size=
{
14
}
/>
}
await
deleteKnowledgeBase
(
state
.
token
,
kbId
);
</
button
>
if
(
selectedKb
===
kbId
)
{
setSelectedKb
(
null
);
setKbDetail
(
null
);
}
</
div
>
loadKbs
();
))
}
}
catch
{}
</
div
>
}
)
}
</
div
>
async
function
handleUpload
(
e
)
{
if
(
!
selectedKb
)
return
;
const
files
=
Array
.
from
(
e
.
target
.
files
||
[]);
if
(
!
files
.
length
)
return
;
setUploading
(
true
);
try
{
await
uploadDocuments
(
state
.
token
,
selectedKb
,
files
);
loadDetail
(
selectedKb
);
loadKbs
();
}
catch
{}
setUploading
(
false
);
e
.
target
.
value
=
""
;
}
async
function
handleDeleteDoc
(
docId
)
{
if
(
!
selectedKb
||
!
confirm
(
"Delete this document?"
))
return
;
try
{
await
deleteKnowledgeDocument
(
state
.
token
,
selectedKb
,
docId
);
loadDetail
(
selectedKb
);
loadKbs
();
}
catch
{}
}
function
selectKb
(
kbId
)
{
setSelectedKb
(
kbId
);
loadDetail
(
kbId
);
}
return
(
<
div
className=
"h-screen flex flex-col bg-anton-bg"
>
<
div
className=
"border-b border-anton-border bg-anton-surface px-6 py-4 flex items-center gap-4"
>
<
button
onClick=
{
()
=>
navigate
(
"/"
)
}
className=
"text-anton-muted hover:text-white transition"
><
ArrowLeft
size=
{
20
}
/></
button
>
<
BookOpen
size=
{
20
}
className=
"text-anton-accent"
/>
<
h1
className=
"text-lg font-bold text-white"
>
Knowledge Bases
</
h1
>
</
div
>
<
div
className=
"flex-1 flex overflow-hidden"
>
<
div
className=
"w-80 border-r border-anton-border overflow-y-auto p-4 space-y-2"
>
<
button
onClick=
{
()
=>
setShowCreate
(
!
showCreate
)
}
className=
"w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition"
>
<
Plus
size=
{
16
}
/>
New Knowledge Base
</
button
>
{
showCreate
&&
(
<
form
onSubmit=
{
handleCreate
}
className=
"bg-anton-card border border-anton-border rounded-xl p-3 space-y-2"
>
<
input
placeholder=
"Name"
value=
{
newName
}
onChange=
{
(
e
)
=>
setNewName
(
e
.
target
.
value
)
}
required
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"
/>
<
input
placeholder=
"Description (optional)"
value=
{
newDesc
}
onChange=
{
(
e
)
=>
setNewDesc
(
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"
/>
<
button
type=
"submit"
className=
"w-full bg-anton-accent text-white rounded-lg py-2 text-xs hover:opacity-90 transition"
>
Create
</
button
>
</
form
>
)
}
{
kbs
.
map
((
kb
)
=>
(
<
div
key=
{
kb
.
id
}
onClick=
{
()
=>
selectKb
(
kb
.
id
)
}
className=
{
`p-3 rounded-xl cursor-pointer transition ${
selectedKb === kb.id ? "bg-anton-accent/15 border border-anton-accent/30" : "bg-anton-card border border-anton-border hover:border-anton-accent/50"
}`
}
>
<
div
className=
"text-sm text-white font-medium truncate"
>
{
kb
.
name
}
</
div
>
<
div
className=
"text-xs text-anton-muted mt-0.5"
>
{
kb
.
document_count
}
docs •
{
kb
.
chunk_count
}
chunks
</
div
>
</
div
>
</
div
>
);
))
}
}
</
div
>
<
div
className=
"flex-1 overflow-y-auto p-6"
>
// ─── KB List View ───
{
kbDetail
?
(
return
(
<
div
className=
"space-y-4"
>
<
div
className=
"h-dvh flex flex-col bg-anton-bg text-anton-text"
>
<
div
className=
"flex items-center justify-between"
>
{
/* Header */
}
<
div
>
<
div
className=
"border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3"
>
<
h2
className=
"text-lg font-bold text-white"
>
{
kbDetail
.
name
}
</
h2
>
<
button
onClick=
{
()
=>
navigate
(
"/"
)
}
className=
"text-anton-muted hover:text-white transition"
><
ArrowLeft
size=
{
20
}
/></
button
>
{
kbDetail
.
description
&&
<
p
className=
"text-sm text-anton-muted mt-1"
>
{
kbDetail
.
description
}
</
p
>
}
<
div
className=
"w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center"
>
<
div
className=
"text-xs text-anton-muted mt-2"
>
<
Flame
size=
{
18
}
className=
"text-white"
/>
{
kbDetail
.
document_count
}
documents •
{
kbDetail
.
chunk_count
}
chunks • ~
{
kbDetail
.
estimated_tokens
?.
toLocaleString
()
}
tokens
</
div
>
</
div
>
</
div
>
<
div
className=
"flex-1"
>
<
div
className=
"flex gap-2"
>
<
h1
className=
"text-lg font-bold text-white"
>
Knowledge Bases
</
h1
>
<
label
className=
"flex items-center gap-2 px-3 py-2 bg-anton-accent rounded-lg text-sm text-white cursor-pointer hover:opacity-90 transition"
>
<
p
className=
"text-xs text-anton-muted"
>
Manage your RAG document collections
</
p
>
{
uploading
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Upload
size=
{
14
}
/>
}
Upload
<
input
type=
"file"
multiple
className=
"hidden"
onChange=
{
handleUpload
}
accept=
".txt,.md,.pdf,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.go,.rs,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv"
/>
</
label
>
<
button
onClick=
{
()
=>
handleDeleteKb
(
kbDetail
.
id
)
}
className=
"px-3 py-2 bg-anton-danger rounded-lg text-sm text-white hover:opacity-90 transition"
>
<
Trash2
size=
{
14
}
/>
</
button
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
setShowCreate
(
!
showCreate
)
}
className=
{
`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${showCreate ? "bg-anton-card text-anton-muted" : "bg-anton-accent text-white hover:opacity-80"}`
}
>
</
div
>
{
showCreate
?
<
X
size=
{
14
}
/>
:
<
Plus
size=
{
14
}
/>
}
<
div
className=
"space-y-2"
>
{
showCreate
?
"Cancel"
:
"New KB"
}
{
(
kbDetail
.
documents
||
[]).
map
((
doc
)
=>
(
</
button
>
<
div
key=
{
doc
.
id
}
className=
"bg-anton-card border border-anton-border rounded-xl px-4 py-3 flex items-center justify-between"
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
<
FileText
size=
{
16
}
className=
"text-anton-muted"
/>
{
error
&&
<
div
className=
"px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-red-400 text-sm flex items-center gap-2"
><
AlertTriangle
size=
{
14
}
/>
{
error
}
</
div
>
}
<
div
>
<
div
className=
"text-sm text-white"
>
{
doc
.
filename
}
</
div
>
{
/* Create form */
}
<
div
className=
"text-xs text-anton-muted"
>
{
doc
.
chunk_count
}
chunks •
{
(
doc
.
file_size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
{
showCreate
&&
(
</
div
>
<
div
className=
"px-4 py-3 border-b border-anton-border bg-anton-surface/50 space-y-2 animate-fade-in"
>
<
input
value=
{
newName
}
onChange=
{
(
e
)
=>
setNewName
(
e
.
target
.
value
)
}
placeholder=
"Knowledge base name"
className=
"w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleCreate
()
}
/>
<
textarea
value=
{
newDesc
}
onChange=
{
(
e
)
=>
setNewDesc
(
e
.
target
.
value
)
}
placeholder=
"Description (optional)"
rows=
{
2
}
className=
"w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-anton-accent"
/>
<
button
onClick=
{
handleCreate
}
disabled=
{
!
newName
.
trim
()
||
creating
}
className=
"flex items-center gap-1.5 px-4 py-2 rounded-lg bg-anton-accent text-white text-sm hover:opacity-80 transition disabled:opacity-50"
>
{
creating
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Plus
size=
{
14
}
/>
}
Create
</
button
>
</
div
>
)
}
{
/* KB list */
}
<
div
className=
"flex-1 overflow-y-auto px-4 py-3"
>
{
loading
?
(
<
div
className=
"flex items-center justify-center py-20"
><
Loader2
size=
{
24
}
className=
"text-anton-accent animate-spin"
/></
div
>
)
:
kbs
.
length
===
0
?
(
<
div
className=
"text-center py-20"
>
<
BookOpen
size=
{
48
}
className=
"mx-auto text-anton-muted mb-4 opacity-50"
/>
<
p
className=
"text-anton-muted text-sm"
>
No knowledge bases yet.
</
p
>
<
p
className=
"text-anton-muted text-xs mt-1"
>
Create one to start uploading documents for RAG.
</
p
>
</
div
>
)
:
(
<
div
className=
"space-y-2"
>
{
kbs
.
map
((
kb
)
=>
(
<
button
key=
{
kb
.
id
}
onClick=
{
()
=>
openKb
(
kb
)
}
className=
"w-full text-left flex items-center gap-3 px-4 py-3 rounded-xl border border-anton-border bg-anton-card hover:border-anton-accent/40 hover:bg-anton-card/80 transition group"
>
<
div
className=
"w-10 h-10 rounded-lg bg-anton-accent/10 flex items-center justify-center shrink-0"
>
<
BookOpen
size=
{
18
}
className=
"text-anton-accent"
/>
</
div
>
<
div
className=
"flex-1 min-w-0"
>
<
p
className=
"text-sm text-white font-semibold truncate"
>
{
kb
.
name
}
</
p
>
{
kb
.
description
&&
<
p
className=
"text-xs text-anton-muted truncate"
>
{
kb
.
description
}
</
p
>
}
<
p
className=
"text-[11px] text-anton-muted mt-0.5"
>
{
kb
.
document_count
}
docs ·
{
kb
.
chunk_count
}
chunks · ~
{
(
kb
.
estimated_tokens
||
0
).
toLocaleString
()
}
tokens
</
p
>
</
div
>
<
ChevronRight
size=
{
16
}
className=
"text-anton-muted group-hover:text-anton-accent transition shrink-0"
/>
</
button
>
))
}
</
div
>
</
div
>
)
}
<
button
onClick=
{
()
=>
handleDeleteDoc
(
doc
.
id
)
}
className=
"text-anton-muted hover:text-red-400 transition"
><
Trash2
size=
{
14
}
/></
button
>
</
div
>
))
}
</
div
>
</
div
>
)
:
(
<
div
className=
"flex items-center justify-center h-full text-anton-muted text-sm"
>
Select a knowledge base to manage
</
div
>
</
div
>
)
}
</
div
>
</
div
>
);
</
div
>
</
div
>
);
}
}
\ No newline at end of file
frontend/src/pages/LoginPage.jsx
View file @
faa34a43
import
React
,
{
useState
}
from
"react"
;
import
React
,
{
useState
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
login
,
register
}
from
"../api"
;
import
{
login
,
register
}
from
"../api"
;
import
{
Flame
,
Eye
,
EyeOff
,
Loader2
}
from
"lucide-react"
;
import
{
Flame
,
LogIn
,
UserPlus
}
from
"lucide-react"
;
export
default
function
LoginPage
()
{
export
default
function
LoginPage
()
{
const
{
dispatch
}
=
useApp
();
const
{
dispatch
}
=
useApp
();
...
@@ -9,7 +9,6 @@ export default function LoginPage() {
...
@@ -9,7 +9,6 @@ export default function LoginPage() {
const
[
username
,
setUsername
]
=
useState
(
""
);
const
[
username
,
setUsername
]
=
useState
(
""
);
const
[
email
,
setEmail
]
=
useState
(
""
);
const
[
email
,
setEmail
]
=
useState
(
""
);
const
[
password
,
setPassword
]
=
useState
(
""
);
const
[
password
,
setPassword
]
=
useState
(
""
);
const
[
showPw
,
setShowPw
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
""
);
const
[
error
,
setError
]
=
useState
(
""
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
...
@@ -21,100 +20,55 @@ export default function LoginPage() {
...
@@ -21,100 +20,55 @@ export default function LoginPage() {
const
res
=
isRegister
const
res
=
isRegister
?
await
register
(
username
,
email
,
password
)
?
await
register
(
username
,
email
,
password
)
:
await
login
(
username
,
password
);
:
await
login
(
username
,
password
);
dispatch
({
type
:
"
LOGIN
"
,
token
:
res
.
token
,
user
:
res
.
user
});
dispatch
({
type
:
"
SET_AUTH
"
,
token
:
res
.
token
,
user
:
res
.
user
});
}
catch
(
err
)
{
}
catch
(
err
)
{
setError
(
err
.
message
||
"Authentication failed"
);
setError
(
err
.
message
);
}
finally
{
setLoading
(
false
);
}
}
setLoading
(
false
);
}
}
return
(
return
(
<
div
className=
"h-full h-dvh flex items-center justify-center bg-anton-bg px-4 safe-top safe-bottom"
>
<
div
className=
"h-screen flex items-center justify-center bg-anton-bg"
>
<
div
className=
"w-full max-w-sm"
>
<
div
className=
"w-full max-w-sm mx-4"
>
{
/* Logo */
}
<
div
className=
"text-center mb-8"
>
<
div
className=
"text-center mb-8"
>
<
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"
>
<
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"
>
<
Flame
size=
{
32
}
className=
"text-white"
/>
<
Flame
size=
{
32
}
className=
"text-white"
/>
</
div
>
</
div
>
<
h1
className=
"text-2xl font-bold text-white"
>
Son of Anton
</
h1
>
<
h1
className=
"text-2xl font-bold text-white"
>
Son of Anton
</
h1
>
<
p
className=
"text-anton-muted text-sm mt-1"
>
Avatar of All Elements of Code
</
p
>
<
p
className=
"text-anton-muted text-sm mt-1"
>
Avatar of All Elements of Code
</
p
>
</
div
>
</
div
>
<
form
onSubmit=
{
handleSubmit
}
className=
"bg-anton-surface border border-anton-border rounded-2xl p-6 space-y-4"
>
{
/* Form */
}
{
error
&&
<
div
className=
"bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 text-red-400 text-xs"
>
{
error
}
</
div
>
}
<
form
onSubmit=
{
handleSubmit
}
className=
"space-y-4"
>
<
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1.5 block"
>
Username
</
label
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Username
</
label
>
<
input
<
input
type=
"text"
value=
{
username
}
onChange=
{
(
e
)
=>
setUsername
(
e
.
target
.
value
)
}
required
autoFocus
type=
"text"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent transition"
/>
value=
{
username
}
onChange=
{
(
e
)
=>
setUsername
(
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=
"Enter username"
required
autoComplete=
"username"
autoCapitalize=
"off"
/>
</
div
>
</
div
>
{
isRegister
&&
(
{
isRegister
&&
(
<
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1.5 block"
>
Email
</
label
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Email
</
label
>
<
input
<
input
type=
"email"
value=
{
email
}
onChange=
{
(
e
)
=>
setEmail
(
e
.
target
.
value
)
}
required
type=
"email"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent transition"
/>
value=
{
email
}
onChange=
{
(
e
)
=>
setEmail
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white focus:outline-none focus:border-anton-accent transition"
placeholder=
"your@email.com"
required
autoComplete=
"email"
/>
</
div
>
</
div
>
)
}
)
}
<
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1.5 block"
>
Password
</
label
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Password
</
label
>
<
div
className=
"relative"
>
<
input
type=
"password"
value=
{
password
}
onChange=
{
(
e
)
=>
setPassword
(
e
.
target
.
value
)
}
required
<
input
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent transition"
/>
type=
{
showPw
?
"text"
:
"password"
}
value=
{
password
}
onChange=
{
(
e
)
=>
setPassword
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 pr-12 text-white focus:outline-none focus:border-anton-accent transition"
placeholder=
"••••••••"
required
autoComplete=
{
isRegister
?
"new-password"
:
"current-password"
}
/>
<
button
type=
"button"
onClick=
{
()
=>
setShowPw
(
!
showPw
)
}
className=
"absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white transition p-1"
>
{
showPw
?
<
EyeOff
size=
{
18
}
/>
:
<
Eye
size=
{
18
}
/>
}
</
button
>
</
div
>
</
div
>
</
div
>
<
button
type=
"submit"
disabled=
{
loading
}
{
error
&&
(
className=
"w-full flex items-center justify-center gap-2 bg-anton-accent text-white rounded-xl py-2.5 text-sm font-medium hover:opacity-90 transition disabled:opacity-50"
>
<
div
className=
"bg-anton-danger/10 border border-anton-danger/30 text-anton-danger text-sm rounded-lg px-3 py-2.5"
>
{
loading
?
(
{
error
}
<
div
className=
"w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
/>
</
div
>
)
:
isRegister
?
(
)
}
<><
UserPlus
size=
{
16
}
/>
Register
</>
)
:
(
<
button
<><
LogIn
size=
{
16
}
/>
Login
</>
type=
"submit"
)
}
disabled=
{
loading
}
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
&&
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
}
{
isRegister
?
"Create Account"
:
"Sign In"
}
</
button
>
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
{
setIsRegister
(
!
isRegister
);
setError
(
""
);
}
}
<
button
className=
"w-full text-xs text-anton-muted hover:text-white transition text-center"
>
type=
"button"
{
isRegister
?
"Already have an account? Login"
:
"Need an account? Register"
}
onClick=
{
()
=>
{
setIsRegister
(
!
isRegister
);
setError
(
""
);
}
}
className=
"w-full text-center text-sm text-anton-muted hover:text-white transition py-2"
>
{
isRegister
?
"Already have an account? Sign in"
:
"Need an account? Register"
}
</
button
>
</
button
>
</
form
>
</
form
>
</
div
>
</
div
>
...
...
frontend/src/store.jsx
View file @
faa34a43
import
React
,
{
createContext
,
useContext
,
useReducer
,
useCallback
}
from
"react"
;
import
React
,
{
createContext
,
useContext
,
useReducer
,
useEffect
}
from
"react"
;
import
{
setDispatch
}
from
"./streamManager"
;
const
AppContext
=
createContext
(
null
);
const
AppContext
=
createContext
(
null
);
...
@@ -9,31 +10,24 @@ const initialState = {
...
@@ -9,31 +10,24 @@ const initialState = {
activeChatId
:
null
,
activeChatId
:
null
,
chatMessages
:
{},
chatMessages
:
{},
activeStreams
:
{},
activeStreams
:
{},
sidebarOpen
:
false
,
linkedRepos
:
[]
,
};
};
function
reducer
(
state
,
action
)
{
function
reducer
(
state
,
action
)
{
switch
(
action
.
type
)
{
switch
(
action
.
type
)
{
case
"LOGIN"
:
case
"SET_AUTH"
:
localStorage
.
setItem
(
"token"
,
action
.
token
);
return
{
...
state
,
token
:
action
.
token
,
user
:
action
.
user
};
return
{
...
state
,
token
:
action
.
token
,
user
:
action
.
user
};
case
"LOGOUT"
:
case
"LOGOUT"
:
localStorage
.
removeItem
(
"token"
);
localStorage
.
removeItem
(
"token"
);
return
{
...
initialState
,
token
:
null
};
return
{
...
initialState
,
token
:
null
};
case
"SET_USER"
:
case
"SET_USER"
:
return
{
...
state
,
user
:
action
.
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"
:
case
"SET_ACTIVE_CHAT"
:
return
{
...
state
,
activeChatId
:
action
.
chatId
,
sidebarOpen
:
false
};
return
{
...
state
,
activeChatId
:
action
.
chatId
};
case
"ADD_CHAT"
:
case
"ADD_CHAT"
:
return
{
return
{
...
state
,
chats
:
[
action
.
chat
,
...
state
.
chats
]
};
...
state
,
chats
:
[
action
.
chat
,
...
state
.
chats
],
activeChatId
:
action
.
chat
.
id
,
sidebarOpen
:
false
,
};
case
"UPDATE_CHAT"
:
case
"UPDATE_CHAT"
:
return
{
return
{
...
state
,
...
state
,
...
@@ -41,49 +35,49 @@ function reducer(state, action) {
...
@@ -41,49 +35,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
:
remaining
,
chats
:
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
action
.
chatId
)
,
activeChatId
:
activeChatId
:
state
.
activeChatId
===
action
.
chatId
state
.
activeChatId
===
action
.
chatId
?
null
:
state
.
activeChatId
,
?
remaining
[
0
]?.
id
||
null
chatMessages
:
(()
=>
{
:
state
.
activeChatId
,
const
m
=
{
...
state
.
chatMessages
};
delete
m
[
action
.
chatId
];
return
m
;
})(),
};
};
}
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
]:
[...
prev
,
action
.
message
],
[
action
.
chatId
]:
[
...(
state
.
chatMessages
[
action
.
chatId
]
||
[]).
filter
(
(
m
)
=>
!
m
.
id
.
startsWith
(
"tmp-"
)
&&
!
m
.
id
.
startsWith
(
"err-"
)
&&
!
m
.
id
.
startsWith
(
"gen-"
)
),
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
(
:
(()
=>
{
Object
.
entries
(
state
.
activeStreams
).
filter
(([
k
])
=>
k
!==
action
.
chatId
)
const
s
=
{
...
state
.
activeStreams
};
),
delete
s
[
action
.
chatId
];
return
s
;
})(),
};
};
case
"SET_LINKED_REPOS"
:
case
"SET_SIDEBAR_OPEN"
:
return
{
...
state
,
linkedRepos
:
action
.
repos
};
return
{
...
state
,
sidebarOpen
:
action
.
open
};
case
"TOGGLE_SIDEBAR"
:
return
{
...
state
,
sidebarOpen
:
!
state
.
sidebarOpen
};
default
:
default
:
return
state
;
return
state
;
}
}
...
@@ -91,6 +85,15 @@ function reducer(state, action) {
...
@@ -91,6 +85,15 @@ 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
]);
useEffect
(()
=>
{
if
(
state
.
token
)
localStorage
.
setItem
(
"token"
,
state
.
token
);
},
[
state
.
token
]);
return
(
return
(
<
AppContext
.
Provider
value=
{
{
state
,
dispatch
}
}
>
<
AppContext
.
Provider
value=
{
{
state
,
dispatch
}
}
>
{
children
}
{
children
}
...
@@ -99,7 +102,5 @@ export function AppProvider({ children }) {
...
@@ -99,7 +102,5 @@ export function AppProvider({ children }) {
}
}
export
function
useApp
()
{
export
function
useApp
()
{
const
ctx
=
useContext
(
AppContext
);
return
useContext
(
AppContext
);
if
(
!
ctx
)
throw
new
Error
(
"useApp must be inside AppProvider"
);
return
ctx
;
}
}
\ No newline at end of file
frontend/src/streamManager.js
View file @
faa34a43
import
{
streamMessage
}
from
"./api"
;
import
{
streamMessage
,
reconnectStream
}
from
"./api"
;
const
_streams
=
new
Map
();
const
_streams
=
new
Map
();
const
_listeners
=
new
Map
();
const
_listeners
=
new
Map
();
let
_dispatch
=
null
;
let
_dispatch
=
null
;
export
function
setDispatch
(
dispatch
)
{
export
function
setDispatch
(
dispatch
)
{
_dispatch
=
dispatch
;
}
_dispatch
=
dispatch
;
}
export
function
getStreamData
(
chatId
)
{
export
function
getStreamData
(
chatId
)
{
const
s
=
_streams
.
get
(
chatId
);
const
s
=
_streams
.
get
(
chatId
);
...
@@ -14,9 +12,7 @@ export function getStreamData(chatId) {
...
@@ -14,9 +12,7 @@ export function getStreamData(chatId) {
return
{
streaming
:
true
,
text
:
s
.
text
,
thinking
:
s
.
thinking
,
isThinking
:
s
.
isThinking
};
return
{
streaming
:
true
,
text
:
s
.
text
,
thinking
:
s
.
thinking
,
isThinking
:
s
.
isThinking
};
}
}
export
function
isStreaming
(
chatId
)
{
export
function
isStreaming
(
chatId
)
{
return
_streams
.
has
(
chatId
);
}
return
_streams
.
has
(
chatId
);
}
export
function
subscribe
(
chatId
,
cb
)
{
export
function
subscribe
(
chatId
,
cb
)
{
if
(
!
_listeners
.
has
(
chatId
))
_listeners
.
set
(
chatId
,
new
Set
());
if
(
!
_listeners
.
has
(
chatId
))
_listeners
.
set
(
chatId
,
new
Set
());
...
@@ -42,24 +38,32 @@ export function abortStream(chatId) {
...
@@ -42,24 +38,32 @@ export function abortStream(chatId) {
}
}
}
}
export
function
startStream
({
token
,
chatId
,
body
})
{
function
_processEvents
(
chatId
,
eventIterator
,
ac
)
{
if
(
_streams
.
has
(
chatId
))
return
;
const
s
=
_streams
.
get
(
chatId
);
const
ac
=
new
AbortController
();
if
(
!
s
)
return
;
_streams
.
set
(
chatId
,
{
text
:
""
,
thinking
:
""
,
isThinking
:
false
,
abortController
:
ac
});
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
true
});
_notify
(
chatId
);
(
async
()
=>
{
(
async
()
=>
{
const
s
=
_streams
.
get
(
chatId
);
let
usage
=
{},
msgId
=
""
;
if
(
!
s
)
return
;
let
usage
=
{};
let
msgId
=
""
;
try
{
try
{
for
await
(
const
evt
of
streamMessage
(
token
,
chatId
,
body
,
ac
.
signal
)
)
{
for
await
(
const
evt
of
eventIterator
)
{
if
(
ac
.
signal
.
aborted
||
!
_streams
.
has
(
chatId
))
break
;
if
(
ac
.
signal
.
aborted
||
!
_streams
.
has
(
chatId
))
break
;
_handleEvent
(
chatId
,
s
,
evt
,
(
u
)
=>
{
usage
=
u
;
},
(
id
)
=>
{
msgId
=
id
;
});
switch
(
evt
.
type
)
{
case
"thinking_start"
:
s
.
isThinking
=
true
;
_notify
(
chatId
);
break
;
case
"thinking_delta"
:
s
.
thinking
+=
evt
.
content
;
_notify
(
chatId
);
break
;
case
"thinking_end"
:
s
.
isThinking
=
false
;
_notify
(
chatId
);
break
;
case
"text_delta"
:
s
.
text
+=
evt
.
content
;
_notify
(
chatId
);
break
;
case
"usage"
:
usage
=
{
input_tokens
:
evt
.
input_tokens
,
output_tokens
:
evt
.
output_tokens
};
break
;
case
"title_update"
:
if
(
_dispatch
)
_dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
title
:
evt
.
title
}
});
break
;
case
"done"
:
msgId
=
evt
.
message_id
;
break
;
case
"error"
:
s
.
text
+=
`\n\n**Error:**
${
evt
.
message
}
`
;
_notify
(
chatId
);
break
;
}
}
}
if
(
!
ac
.
signal
.
aborted
&&
_dispatch
)
{
if
(
!
ac
.
signal
.
aborted
&&
_dispatch
&&
(
s
.
text
||
s
.
thinking
)
)
{
_dispatch
({
_dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
{
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
{
id
:
msgId
||
`gen-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
s
.
text
,
id
:
msgId
||
`gen-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
s
.
text
,
...
@@ -85,75 +89,24 @@ export function startStream({ token, chatId, body }) {
...
@@ -85,75 +89,24 @@ export function startStream({ token, chatId, body }) {
})();
})();
}
}
/**
export
function
startStream
({
token
,
chatId
,
body
})
{
* Reconnect to an ongoing background generation via GET /stream endpoint.
*/
export
function
reconnectStream
({
token
,
chatId
})
{
if
(
_streams
.
has
(
chatId
))
return
;
if
(
_streams
.
has
(
chatId
))
return
;
const
ac
=
new
AbortController
();
const
ac
=
new
AbortController
();
_streams
.
set
(
chatId
,
{
text
:
""
,
thinking
:
""
,
isThinking
:
false
,
abortController
:
ac
});
_streams
.
set
(
chatId
,
{
text
:
""
,
thinking
:
""
,
isThinking
:
false
,
abortController
:
ac
});
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
true
});
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
true
});
_notify
(
chatId
);
_notify
(
chatId
);
(
async
()
=>
{
const
iter
=
streamMessage
(
token
,
chatId
,
body
,
ac
.
signal
);
const
s
=
_streams
.
get
(
chatId
);
_processEvents
(
chatId
,
iter
,
ac
);
if
(
!
s
)
return
;
let
usage
=
{};
let
msgId
=
""
;
try
{
const
res
=
await
fetch
(
`/api/chats/
${
chatId
}
/stream`
,
{
headers
:
{
Authorization
:
`Bearer
${
token
}
`
},
signal
:
ac
.
signal
,
});
if
(
!
res
.
ok
)
throw
new
Error
(
"Reconnect failed"
);
const
reader
=
res
.
body
.
getReader
();
const
decoder
=
new
TextDecoder
();
let
buffer
=
""
;
while
(
true
)
{
const
{
done
,
value
}
=
await
reader
.
read
();
if
(
done
)
break
;
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
});
const
parts
=
buffer
.
split
(
"
\n\n
"
);
buffer
=
parts
.
pop
()
||
""
;
for
(
const
part
of
parts
)
{
const
line
=
part
.
trim
();
if
(
line
.
startsWith
(
"data: "
))
{
try
{
const
evt
=
JSON
.
parse
(
line
.
slice
(
6
));
if
(
ac
.
signal
.
aborted
||
!
_streams
.
has
(
chatId
))
break
;
_handleEvent
(
chatId
,
s
,
evt
,
(
u
)
=>
{
usage
=
u
;
},
(
id
)
=>
{
msgId
=
id
;
});
}
catch
{
/* skip */
}
}
}
}
if
(
!
ac
.
signal
.
aborted
&&
s
.
text
&&
_dispatch
)
{
_dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
{
id
:
msgId
||
`gen-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
s
.
text
,
thinking_content
:
s
.
thinking
||
null
,
input_tokens
:
usage
.
input_tokens
||
0
,
output_tokens
:
usage
.
output_tokens
||
0
,
created_at
:
new
Date
().
toISOString
(),
attachments
:
[],
}
});
}
}
catch
{
/* reconnect failed, generation may be done */
}
finally
{
_streams
.
delete
(
chatId
);
_notify
(
chatId
);
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
false
});
}
})();
}
}
function
_handleEvent
(
chatId
,
s
,
evt
,
setUsage
,
setMsgId
)
{
export
function
reconnectToStream
({
token
,
chatId
})
{
switch
(
evt
.
type
)
{
if
(
_streams
.
has
(
chatId
))
return
;
case
"thinking_start"
:
s
.
isThinking
=
true
;
_notify
(
chatId
);
break
;
const
ac
=
new
AbortController
();
case
"thinking_delta"
:
s
.
thinking
+=
evt
.
content
;
_notify
(
chatId
);
break
;
_streams
.
set
(
chatId
,
{
text
:
""
,
thinking
:
""
,
isThinking
:
false
,
abortController
:
ac
});
case
"thinking_end"
:
s
.
isThinking
=
false
;
_notify
(
chatId
);
break
;
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
true
});
case
"text_delta"
:
s
.
text
+=
evt
.
content
;
_notify
(
chatId
);
break
;
_notify
(
chatId
);
case
"usage"
:
setUsage
({
input_tokens
:
evt
.
input_tokens
,
output_tokens
:
evt
.
output_tokens
});
break
;
case
"title_update"
:
if
(
_dispatch
)
_dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
title
:
evt
.
title
}
});
break
;
const
iter
=
reconnectStream
(
token
,
chatId
,
ac
.
signal
);
case
"done"
:
setMsgId
(
evt
.
message_id
);
break
;
_processEvents
(
chatId
,
iter
,
ac
);
case
"error"
:
s
.
text
+=
`\n\n**Error:**
${
evt
.
message
}
`
;
_notify
(
chatId
);
break
;
}
}
}
\ No newline at end of file
frontend/tailwind.config.js
View file @
faa34a43
/** @type {import('tailwindcss').Config} */
/** @type {import('tailwindcss').Config} */
export
default
{
export
default
{
content
:
[
"./index.html"
,
"./src/**/*.{js,
ts,jsx,t
sx}"
],
content
:
[
"./index.html"
,
"./src/**/*.{js,
j
sx}"
],
theme
:
{
theme
:
{
extend
:
{
extend
:
{
colors
:
{
colors
:
{
"anton-bg"
:
"#09090f"
,
"anton-bg"
:
"#09090f"
,
"anton-surface"
:
"#0f
0f1a
"
,
"anton-surface"
:
"#0f
1017
"
,
"anton-card"
:
"#161
62a
"
,
"anton-card"
:
"#161
822
"
,
"anton-border"
:
"#1e
1e3a
"
,
"anton-border"
:
"#1e
2030
"
,
"anton-text"
:
"#e
2e2ea
"
,
"anton-text"
:
"#e
4e4e7
"
,
"anton-muted"
:
"#
6b6b8
a"
,
"anton-muted"
:
"#
71717
a"
,
"anton-accent"
:
"#e53e3e"
,
"anton-accent"
:
"#e53e3e"
,
"anton-
success"
:
"#48bb78
"
,
"anton-
danger"
:
"#dc2626
"
,
"anton-
danger"
:
"#e53e3
e"
,
"anton-
success"
:
"#22c55
e"
,
},
},
fontFamily
:
{
fontFamily
:
{
sans
:
[
"Inter"
,
"system-ui"
,
"-apple-system"
,
"sans-serif"
],
sans
:
[
"Inter"
,
"system-ui"
,
"sans-serif"
],
mono
:
[
"JetBrains Mono"
,
"Fira Code"
,
"monospace"
],
mono
:
[
"JetBrains Mono"
,
"monospace"
],
},
screens
:
{
"xs"
:
"400px"
,
},
},
},
},
},
},
...
...
frontend/vite.config.js
View file @
faa34a43
...
@@ -5,20 +5,14 @@ export default defineConfig({
...
@@ -5,20 +5,14 @@ export default defineConfig({
plugins
:
[
react
()],
plugins
:
[
react
()],
server
:
{
server
:
{
proxy
:
{
proxy
:
{
"/api"
:
"http://localhost:80"
,
"/api"
:
{
target
:
"http://localhost:80"
,
changeOrigin
:
true
,
},
},
},
},
},
build
:
{
build
:
{
// Content-hash all chunks so browsers fetch new versions
outDir
:
"dist"
,
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