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
17ddd732
Commit
17ddd732
authored
Mar 29, 2026
by
AGLANPC\aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
ghvntumyutj mtyj mtyjthy nv
parent
f8727239
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
2260 additions
and
3683 deletions
+2260
-3683
FULL_CODEBASE.txt
FULL_CODEBASE.txt
+1975
-2872
gitlab_routes.py
backend/routes/gitlab_routes.py
+5
-11
api.js
frontend/src/api.js
+71
-113
ChatView.jsx
frontend/src/components/ChatView.jsx
+165
-171
CodeBlock.jsx
frontend/src/components/CodeBlock.jsx
+27
-110
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+16
-47
RepoFilePanel.jsx
frontend/src/components/RepoFilePanel.jsx
+0
-358
GitLabPage.jsx
frontend/src/pages/GitLabPage.jsx
+1
-1
No files found.
FULL_CODEBASE.txt
View file @
17ddd732
This source diff could not be displayed because it is too large. You can
view the blob
instead.
backend/routes/gitlab_routes.py
View file @
17ddd732
...
@@ -109,18 +109,12 @@ def update_settings(body: SettingsBody, admin: User = Depends(require_superadmin
...
@@ -109,18 +109,12 @@ def update_settings(body: SettingsBody, admin: User = Depends(require_superadmin
@
router
.
post
(
"/test-connection"
)
@
router
.
post
(
"/test-connection"
)
async
def
test_connection
(
body
:
SettingsBody
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
)):
async
def
test_connection
(
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
)):
url
=
body
.
gitlab_url
.
rstrip
(
"/"
)
if
not
body
.
gitlab_token
or
body
.
gitlab_token
==
"UNCHANGED"
:
s
=
db
.
query
(
GitLabSettings
)
.
first
()
s
=
db
.
query
(
GitLabSettings
)
.
first
()
token
=
s
.
gitlab_token
if
s
else
""
if
not
s
or
not
s
.
gitlab_url
or
not
s
.
gitlab_token
:
else
:
raise
HTTPException
(
400
,
"GitLab URL and token not configured"
)
token
=
body
.
gitlab_token
if
not
url
or
not
token
:
raise
HTTPException
(
400
,
"GitLab URL and token not provided"
)
try
:
try
:
result
=
await
gitlab_service
.
test_connection
(
url
,
token
)
result
=
await
gitlab_service
.
test_connection
(
s
.
gitlab_url
,
s
.
gitlab_
token
)
return
result
return
result
except
gitlab_service
.
GitLabError
as
e
:
except
gitlab_service
.
GitLabError
as
e
:
raise
HTTPException
(
e
.
status_code
,
f
"Connection failed: {e.detail}"
)
raise
HTTPException
(
e
.
status_code
,
f
"Connection failed: {e.detail}"
)
...
...
frontend/src/api.js
View file @
17ddd732
...
@@ -10,49 +10,46 @@ function authHeader(token) {
...
@@ -10,49 +10,46 @@ function authHeader(token) {
return
token
?
{
Authorization
:
`Bearer
${
token
}
`
}
:
{};
return
token
?
{
Authorization
:
`Bearer
${
token
}
`
}
:
{};
}
}
function
extractError
(
err
,
defaultMsg
)
{
let
msg
=
err
.
detail
||
err
.
message
||
defaultMsg
;
if
(
Array
.
isArray
(
msg
))
return
msg
.
map
(
m
=>
m
.
msg
||
JSON
.
stringify
(
m
)).
join
(
", "
);
if
(
typeof
msg
===
"object"
&&
msg
!==
null
)
return
msg
.
message
||
JSON
.
stringify
(
msg
);
return
String
(
msg
);
}
async
function
request
(
method
,
path
,
token
,
body
)
{
async
function
request
(
method
,
path
,
token
,
body
)
{
const
opts
=
{
method
,
headers
:
headers
(
token
)
};
const
opts
=
{
method
,
headers
:
headers
(
token
)
};
if
(
body
)
opts
.
body
=
JSON
.
stringify
(
body
);
if
(
body
)
opts
.
body
=
JSON
.
stringify
(
body
);
const
res
=
await
fetch
(
`
${
BASE
}${
path
}
`
,
opts
);
const
res
=
await
fetch
(
`
${
BASE
}${
path
}
`
,
opts
);
if
(
!
res
.
ok
)
{
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
throw
new
Error
(
e
rr
.
detail
||
err
.
message
||
"Request failed"
);
throw
new
Error
(
e
xtractError
(
err
,
"Request failed"
)
);
}
}
return
res
.
json
();
return
res
.
json
();
}
}
export
const
login
=
(
username
,
password
)
=>
// ═══════════ Auth ═══════════
request
(
"POST"
,
"/auth/login"
,
null
,
{
username
,
password
});
export
const
login
=
(
username
,
password
)
=>
request
(
"POST"
,
"/auth/login"
,
null
,
{
username
,
password
});
export
const
register
=
(
username
,
email
,
password
)
=>
request
(
"POST"
,
"/auth/register"
,
null
,
{
username
,
email
,
password
});
export
const
register
=
(
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
updateChat
=
(
token
,
chatId
,
data
)
=>
request
(
"PUT"
,
`/chats/
${
chatId
}
`
,
token
,
data
);
export
const
renameChat
=
(
token
,
chatId
,
title
)
=>
updateChat
(
token
,
chatId
,
{
title
});
export
const
deleteChat
=
(
token
,
chatId
)
=>
request
(
"DELETE"
,
`/chats/
${
chatId
}
`
,
token
);
export
const
getMessages
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/messages`
,
token
);
export
const
checkGenerating
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/generating`
,
token
);
export
const
updateChat
=
(
token
,
chatId
,
data
)
=>
// ═══════════ Streaming ═══════════
request
(
"PUT"
,
`/chats/
${
chatId
}
`
,
token
,
data
);
export
const
renameChat
=
(
token
,
chatId
,
title
)
=>
updateChat
(
token
,
chatId
,
{
title
});
export
const
deleteChat
=
(
token
,
chatId
)
=>
request
(
"DELETE"
,
`/chats/
${
chatId
}
`
,
token
);
export
const
getMessages
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/messages`
,
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`
,
{
method
:
"POST"
,
headers
:
headers
(
token
),
method
:
"POST"
,
headers
:
headers
(
token
),
body
:
JSON
.
stringify
(
body
),
signal
,
body
:
JSON
.
stringify
(
body
),
signal
,
});
});
if
(
!
res
.
ok
)
{
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
throw
new
Error
(
e
rr
.
detail
||
"Stream failed"
);
throw
new
Error
(
e
xtractError
(
err
,
"Stream failed"
)
);
}
}
const
reader
=
res
.
body
.
getReader
();
const
reader
=
res
.
body
.
getReader
();
const
decoder
=
new
TextDecoder
();
const
decoder
=
new
TextDecoder
();
...
@@ -66,81 +63,57 @@ export async function* streamMessage(token, chatId, body, signal) {
...
@@ -66,81 +63,57 @@ 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
{
/* skip */
}
}
}
}
}
}
}
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
{
/* skip */
}
}
}
}
}
// ═══════════ 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
);
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/attachments`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/attachments`
,
{
method
:
"POST"
,
headers
:
authHeader
(
token
),
body
:
form
,
method
:
"POST"
,
headers
:
authHeader
(
token
),
body
:
form
,
});
});
if
(
!
res
.
ok
)
{
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
extractError
(
err
,
"Upload failed"
));
}
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
err
.
detail
||
"Upload failed"
);
}
return
res
.
json
();
return
res
.
json
();
}
}
export
function
getAttachmentUrl
(
attachmentId
)
{
return
`
${
BASE
}
/attachments/
${
attachmentId
}
/file`
;
}
export
const
deleteAttachment
=
(
token
,
attachmentId
)
=>
request
(
"DELETE"
,
`/attachments/
${
attachmentId
}
`
,
token
);
export
function
getAttachmentUrl
(
attachmentId
)
{
// ═══════════ Knowledge ═══════════
return
`
${
BASE
}
/attachments/
${
attachmentId
}
/file`
;
}
export
const
deleteAttachment
=
(
token
,
attachmentId
)
=>
request
(
"DELETE"
,
`/attachments/
${
attachmentId
}
`
,
token
);
export
const
listKnowledgeBases
=
(
token
)
=>
request
(
"GET"
,
"/knowledge"
,
token
);
export
const
listKnowledgeBases
=
(
token
)
=>
request
(
"GET"
,
"/knowledge"
,
token
);
export
const
createKnowledgeBase
=
(
token
,
name
,
description
=
""
)
=>
request
(
"POST"
,
"/knowledge"
,
token
,
{
name
,
description
});
export
const
createKnowledgeBase
=
(
token
,
name
,
description
=
""
)
=>
export
const
getKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
`
,
token
);
request
(
"POST"
,
"/knowledge"
,
token
,
{
name
,
description
});
export
const
updateKnowledgeBase
=
(
token
,
kbId
,
data
)
=>
request
(
"PUT"
,
`/knowledge/
${
kbId
}
`
,
token
,
data
);
export
const
deleteKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
const
getKnowledgeBase
=
(
token
,
kbId
)
=>
export
const
listKnowledgeDocuments
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
/documents`
,
token
);
request
(
"GET"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
const
deleteKnowledgeDocument
=
(
token
,
kbId
,
docId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
/documents/
${
docId
}
`
,
token
);
export
const
deleteKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
`
,
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
);
const
res
=
await
fetch
(
`
${
BASE
}
/knowledge/
${
kbId
}
/upload`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/knowledge/
${
kbId
}
/upload`
,
{
method
:
"POST"
,
headers
:
authHeader
(
token
),
body
:
form
});
method
:
"POST"
,
headers
:
authHeader
(
token
),
body
:
form
,
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
extractError
(
err
,
"Upload failed"
));
}
});
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
err
.
detail
||
"Upload failed"
);
}
return
res
.
json
();
return
res
.
json
();
}
}
export
const
uploadDocument
=
(
token
,
kbId
,
file
)
=>
uploadDocuments
(
token
,
kbId
,
[
file
]);
export
const
uploadDocument
=
(
token
,
kbId
,
file
)
=>
// ═══════════ Admin ═══════════
uploadDocuments
(
token
,
kbId
,
[
file
]);
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
)
=>
request
(
"POST"
,
"/admin/users"
,
token
,
data
);
export
const
adminCreateUser
=
(
token
,
data
)
=>
export
const
adminUpdateUser
=
(
token
,
userId
,
data
)
=>
request
(
"PUT"
,
`/admin/users/
${
userId
}
`
,
token
,
data
);
request
(
"POST"
,
"/admin/users"
,
token
,
data
);
export
const
adminDeleteUser
=
(
token
,
userId
)
=>
request
(
"DELETE"
,
`/admin/users/
${
userId
}
`
,
token
);
export
const
adminUpdateUser
=
(
token
,
userId
,
data
)
=>
request
(
"PUT"
,
`/admin/users/
${
userId
}
`
,
token
,
data
);
export
const
adminDeleteUser
=
(
token
,
userId
)
=>
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
)
{
// ═══════════ Code Download ═══════════
export
async
function
downloadZip
(
token
,
markdown
,
chatTitle
)
{
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
,
title
:
chatTitle
||
null
}),
body
:
JSON
.
stringify
({
markdown
}),
});
});
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"
)
||
""
;
...
@@ -149,7 +122,9 @@ export async function downloadZip(token, markdown) {
...
@@ -149,7 +122,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
raw
=
(
chatTitle
||
""
).
trim
();
const
safeName
=
raw
&&
raw
!==
"New Chat"
?
raw
.
replace
(
/
[^\w\s
-
]
/g
,
""
).
trim
().
replace
(
/
\s
+/g
,
"-"
).
slice
(
0
,
60
)
||
"code"
:
"code"
;
a
.
download
=
`
${
safeName
}
.zip`
;
a
.
click
();
a
.
click
();
URL
.
revokeObjectURL
(
url
);
URL
.
revokeObjectURL
(
url
);
}
else
{
}
else
{
...
@@ -159,47 +134,30 @@ export async function downloadZip(token, markdown) {
...
@@ -159,47 +134,30 @@ export async function downloadZip(token, markdown) {
}
}
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// GitLab
Repository API — Son of Anton v4.1
.0
// GitLab
CE Integration — v4.0
.0
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
function
gitlabGetTree
(
token
,
repoId
,
ref
=
""
,
path
=
""
)
{
export
const
gitlabGetSettings
=
(
token
)
=>
request
(
"GET"
,
"/gitlab/settings"
,
token
);
const
p
=
new
URLSearchParams
();
export
const
gitlabUpdateSettings
=
(
token
,
data
)
=>
request
(
"PUT"
,
"/gitlab/settings"
,
token
,
data
);
if
(
ref
)
p
.
set
(
"ref"
,
ref
);
export
const
gitlabTestConnection
=
(
token
)
=>
request
(
"POST"
,
"/gitlab/test-connection"
,
token
);
if
(
path
)
p
.
set
(
"path"
,
path
);
export
const
gitlabSearchProjects
=
(
token
,
search
,
owned
)
=>
return
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/tree?
${
p
}
`
,
token
);
request
(
"GET"
,
`/gitlab/projects?search=
${
encodeURIComponent
(
search
||
""
)}
&owned=
${
owned
||
false
}
`
,
token
);
}
export
const
gitlabCreateProject
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/projects"
,
token
,
data
);
export
const
gitlabListRepos
=
(
token
)
=>
request
(
"GET"
,
"/gitlab/repos"
,
token
);
export
function
gitlabGetFile
(
token
,
repoId
,
filePath
,
ref
=
""
)
{
export
const
gitlabLinkRepo
=
(
token
,
gitlabProjectId
)
=>
request
(
"POST"
,
"/gitlab/repos"
,
token
,
{
gitlab_project_id
:
gitlabProjectId
});
const
p
=
new
URLSearchParams
({
path
:
filePath
});
export
const
gitlabUnlinkRepo
=
(
token
,
repoId
)
=>
request
(
"DELETE"
,
`/gitlab/repos/
${
repoId
}
`
,
token
);
if
(
ref
)
p
.
set
(
"ref"
,
ref
);
export
const
gitlabGetTree
=
(
token
,
repoId
,
path
,
ref
)
=>
return
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/file?
${
p
}
`
,
token
);
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/tree?path=
${
encodeURIComponent
(
path
||
""
)}
&ref=
${
encodeURIComponent
(
ref
||
""
)}
`
,
token
);
}
export
const
gitlabGetFile
=
(
token
,
repoId
,
path
,
ref
)
=>
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/file?path=
${
encodeURIComponent
(
path
)}
&ref=
${
encodeURIComponent
(
ref
||
""
)}
`
,
token
);
export
async
function
gitlabFileExists
(
token
,
repoId
,
filePath
,
ref
=
""
)
{
export
const
gitlabGetBranches
=
(
token
,
repoId
)
=>
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
);
try
{
export
const
gitlabCreateBranch
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
,
data
);
await
gitlabGetFile
(
token
,
repoId
,
filePath
,
ref
);
export
const
gitlabCommit
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit`
,
token
,
data
);
return
true
;
export
const
gitlabCommitSingle
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit-single`
,
token
,
data
);
}
catch
{
export
const
gitlabCreateMR
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/merge-request`
,
token
,
data
);
return
false
;
export
const
gitlabAnalyzeProject
=
(
token
,
repoId
,
ref
)
=>
}
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/analyze?ref=
${
encodeURIComponent
(
ref
||
""
)}
`
,
token
);
}
export
const
gitlabListActions
=
(
token
,
status
)
=>
request
(
"GET"
,
`/gitlab/actions?status=
${
status
||
"pending"
}
`
,
token
);
export
const
gitlabCreateAction
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/actions"
,
token
,
data
);
export
function
gitlabCommitSingle
(
token
,
repoId
,
data
)
{
export
const
gitlabApproveAction
=
(
token
,
actionId
)
=>
request
(
"POST"
,
`/gitlab/actions/
${
actionId
}
/approve`
,
token
);
return
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit-single`
,
token
,
data
);
export
const
gitlabRejectAction
=
(
token
,
actionId
)
=>
request
(
"POST"
,
`/gitlab/actions/
${
actionId
}
/reject`
,
token
);
}
\ No newline at end of file
export
function
gitlabCommitMulti
(
token
,
repoId
,
data
)
{
return
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit`
,
token
,
data
);
}
export
function
gitlabGetBranches
(
token
,
repoId
)
{
return
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
);
}
export
function
gitlabCreateBranch
(
token
,
repoId
,
data
)
{
return
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
,
data
);
}
export
function
gitlabListRepos
(
token
)
{
return
request
(
"GET"
,
`/gitlab/repos`
,
token
);
}
\ No newline at end of file
frontend/src/components/ChatView.jsx
View file @
17ddd732
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
,
gitlabListRepos
,
gitlabCommitSingle
}
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
,
Upload
,
Film
,
Image
as
ImageIcon
,
FileCode
,
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
:
"
Opus 4.6
"
},
{
id
:
"eu.anthropic.claude-haiku-4-5-20251001-v1:0"
,
label
:
"
Claude Haiku 4.5 (Fast)
"
},
{
id
:
"eu.anthropic.claude-haiku-4-5-20251001-v1:0"
,
label
:
"
Haiku 4.5
"
},
];
];
function
classifyFile
(
file
)
{
const
TYPE_ICONS
=
{
image
:
ImageIcon
,
video
:
Film
,
document
:
FileText
,
text
:
FileCode
};
const
ext
=
(
file
.
name
||
""
).
split
(
"."
).
pop
().
toLowerCase
();
const
TYPE_COLORS
=
{
image
:
"border-blue-500/40 bg-blue-500/10"
,
video
:
"border-purple-500/40 bg-purple-500/10"
,
document
:
"border-amber-500/40 bg-amber-500/10"
,
text
:
"border-green-500/40 bg-green-500/10"
};
const
mime
=
file
.
type
||
""
;
const
TYPE_ICON_COLORS
=
{
image
:
"text-blue-400"
,
video
:
"text-purple-400"
,
document
:
"text-amber-400"
,
text
:
"text-green-400"
};
function
classifyFile
(
f
)
{
const
ext
=
(
f
.
name
||
""
).
split
(
"."
).
pop
().
toLowerCase
();
const
mime
=
f
.
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"
;
}
}
function
fmtSize
(
b
)
{
if
(
!
b
)
return
"0B"
;
if
(
b
<
1024
)
return
b
+
"B"
;
if
(
b
<
1048576
)
return
(
b
/
1024
).
toFixed
(
0
)
+
"KB"
;
return
(
b
/
1048576
).
toFixed
(
1
)
+
"MB"
;
}
export
default
function
ChatView
({
chatId
})
{
export
default
function
ChatView
({
chatId
})
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
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
isSuperadmin
=
state
.
user
?.
role
===
"superadmin"
;
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
[
dragOver
,
setDragOver
]
=
useState
(
false
);
const
[
streamData
,
setStreamData
]
=
useState
(
streamManager
.
getStreamData
(
chatId
));
const
[
streamData
,
setStreamData
]
=
useState
(
streamManager
.
getStreamData
(
chatId
));
const
scrollRef
=
useRef
(
null
);
const
scrollRef
=
useRef
(
null
);
...
@@ -50,18 +60,9 @@ export default function ChatView({ chatId }) {
...
@@ -50,18 +60,9 @@ export default function ChatView({ chatId }) {
return
streamManager
.
subscribe
(
chatId
,
()
=>
setStreamData
(
streamManager
.
getStreamData
(
chatId
)));
return
streamManager
.
subscribe
(
chatId
,
()
=>
setStreamData
(
streamManager
.
getStreamData
(
chatId
)));
},
[
chatId
]);
},
[
chatId
]);
function
onScroll
()
{
const
el
=
scrollRef
.
current
;
if
(
!
el
)
return
;
autoScroll
.
current
=
el
.
scrollHeight
-
el
.
scrollTop
-
el
.
clientHeight
<
200
;
}
const
scrollBottom
=
useCallback
(()
=>
{
const
scrollBottom
=
useCallback
(()
=>
{
if
(
!
autoScroll
.
current
||
rafRef
.
current
)
return
;
if
(
!
autoScroll
.
current
||
rafRef
.
current
)
return
;
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
scrollRef
.
current
?.
scrollTo
({
top
:
scrollRef
.
current
.
scrollHeight
});
rafRef
.
current
=
null
;
});
if
(
scrollRef
.
current
)
scrollRef
.
current
.
scrollTop
=
scrollRef
.
current
.
scrollHeight
;
rafRef
.
current
=
null
;
});
},
[]);
},
[]);
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -70,201 +71,194 @@ export default function ChatView({ chatId }) {
...
@@ -70,201 +71,194 @@ export default function ChatView({ chatId }) {
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
);
if
(
isSuperadmin
)
{
try
{
setRepos
(
await
gitlabListRepos
(
state
.
token
));
}
catch
{
}
}
}
catch
{
}
}
catch
{
}
})();
})();
},
[
chatId
,
state
.
token
,
dispatch
]);
},
[
chatId
,
state
.
token
,
dispatch
]);
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
]);
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
]);
function
onScroll
()
{
const
el
=
scrollRef
.
current
;
if
(
el
)
autoScroll
.
current
=
el
.
scrollHeight
-
el
.
scrollTop
-
el
.
clientHeight
<
200
;
}
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
,
{
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
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
}
});
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
linked_repo_id
:
selectedRepoId
}
});
}
catch
{
}
}
catch
{
}
}
}
function
toggleSettings
()
{
function
toggleSettings
()
{
if
(
showSettings
)
saveSettings
();
setShowSettings
(
!
showSettings
);
}
if
(
showSettings
)
saveSettings
();
function
addFiles
(
files
)
{
setPendingFiles
(
prev
=>
[...
prev
,
...
files
.
map
(
f
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
}))]);
}
setShowSettings
(
!
showSettings
);
function
removePending
(
i
)
{
setPendingFiles
(
prev
=>
{
if
(
prev
[
i
]?.
preview
)
URL
.
revokeObjectURL
(
prev
[
i
].
preview
);
return
prev
.
filter
((
_
,
j
)
=>
j
!==
i
);
});
}
}
function
handleFileSelect
(
e
)
{
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
}))]);
e
.
target
.
value
=
""
;
}
function
removePending
(
i
)
{
setPendingFiles
((
prev
)
=>
{
if
(
prev
[
i
]?.
preview
)
URL
.
revokeObjectURL
(
prev
[
i
].
preview
);
return
prev
.
filter
((
_
,
j
)
=>
j
!==
i
);
});
}
async
function
handleSend
()
{
async
function
handleSend
()
{
const
content
=
input
.
trim
();
const
content
=
input
.
trim
();
if
((
!
content
&&
!
pendingFiles
.
length
)
||
isStreamingGlobal
)
return
;
if
((
!
content
&&
!
pendingFiles
.
length
)
||
streamData
.
streaming
)
return
;
const
text
=
content
||
"Please analyze the attached file(s)."
;
const
text
=
content
||
"Please analyze the attached file(s)."
;
let
attIds
=
[],
uploaded
=
[];
let
attIds
=
[],
uploaded
=
[];
if
(
pendingFiles
.
length
)
{
if
(
pendingFiles
.
length
)
{
setUploading
(
true
);
setUploading
(
true
);
try
{
try
{
const
res
=
await
uploadAttachments
(
state
.
token
,
chatId
,
pendingFiles
.
map
(
p
=>
p
.
file
));
uploaded
=
(
res
.
attachments
||
[]).
filter
(
a
=>
!
a
.
error
);
attIds
=
uploaded
.
map
(
a
=>
a
.
id
);
}
catch
{
setUploading
(
false
);
return
;
}
const
res
=
await
uploadAttachments
(
state
.
token
,
chatId
,
pendingFiles
.
map
((
p
)
=>
p
.
file
));
uploaded
=
(
res
.
attachments
||
[]).
filter
((
a
)
=>
!
a
.
error
);
attIds
=
uploaded
.
map
((
a
)
=>
a
.
id
);
}
catch
(
err
)
{
console
.
error
(
err
);
setUploading
(
false
);
return
;
}
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
);
});
setPendingFiles
([]);
autoScroll
.
current
=
true
;
pendingFiles
.
forEach
((
p
)
=>
{
if
(
p
.
preview
)
URL
.
revokeObjectURL
(
p
.
preview
);
});
if
(
inputRef
.
current
)
inputRef
.
current
.
style
.
height
=
"auto"
;
setPendingFiles
([]);
autoScroll
.
current
=
true
;
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
}
});
streamManager
.
startStream
({
token
:
state
.
token
,
chatId
,
body
:
{
content
:
text
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
attachment_ids
:
attIds
}
});
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
();
}
}
function
handlePaste
(
e
)
{
const
items
=
Array
.
from
(
e
.
clipboardData
?.
items
||
[]).
filter
(
i
=>
i
.
kind
===
"file"
);
if
(
!
items
.
length
)
return
;
e
.
preventDefault
();
addFiles
(
items
.
map
(
i
=>
i
.
getAsFile
()).
filter
(
Boolean
));
}
function
handleDrop
(
e
)
{
e
.
preventDefault
();
setDragOver
(
false
);
const
files
=
Array
.
from
(
e
.
dataTransfer
?.
files
||
[]);
if
(
files
.
length
)
addFiles
(
files
);
}
function
handlePaste
(
e
)
{
const
streaming
=
streamData
.
streaming
;
const
imgs
=
Array
.
from
(
e
.
clipboardData
?.
items
||
[]).
filter
((
i
)
=>
i
.
type
.
startsWith
(
"image/"
));
const
linkedRepo
=
currentChat
?.
linked_repo
;
if
(
!
imgs
.
length
)
return
;
e
.
preventDefault
();
setPendingFiles
((
prev
)
=>
[...
prev
,
...
imgs
.
map
((
i
)
=>
{
const
f
=
i
.
getAsFile
();
return
{
file
:
f
,
type
:
"image"
,
preview
:
URL
.
createObjectURL
(
f
)
};
})]);
}
function
handleDrop
(
e
)
{
async
function
handleCommitFromChat
(
filePath
,
code
,
action
)
{
e
.
preventDefault
();
if
(
!
linkedRepo
)
return
;
const
files
=
Array
.
from
(
e
.
dataTransfer
?.
files
||
[]);
const
branch
=
linkedRepo
.
default_branch
;
if
(
files
.
length
)
setPendingFiles
((
prev
)
=>
[...
prev
,
...
files
.
map
((
f
)
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
}))]);
const
msg
=
prompt
(
"Commit message:"
,
`Update
${
filePath
}
via Son of Anton`
);
if
(
!
msg
)
return
;
try
{
await
gitlabCommitSingle
(
state
.
token
,
linkedRepo
.
id
,
{
branch
,
file_path
:
filePath
,
content
:
code
,
commit_message
:
msg
,
action
});
alert
(
`✅ Committed to
${
branch
}
`
);
}
catch
(
e
)
{
alert
(
`❌
${
e
.
message
}
`
);
}
}
}
const
streaming
=
streamData
.
streaming
;
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 relative"
onDrop=
{
handleDrop
}
onDragOver=
{
e
=>
{
e
.
preventDefault
();
setDragOver
(
true
);
}
}
onDragLeave=
{
e
=>
{
if
(
!
e
.
currentTarget
.
contains
(
e
.
relatedTarget
))
setDragOver
(
false
);
}
}
>
{
/* Main Chat Column */
}
{
dragOver
&&
(
<
div
className=
"flex-1 flex flex-col min-h-0 min-w-0"
>
<
div
className=
"absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"
>
<
div
ref=
{
scrollRef
}
onScroll=
{
onScroll
}
className=
"flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
<
div
className=
"text-center"
><
Upload
size=
{
36
}
className=
"text-anton-accent mx-auto mb-2 animate-bounce"
/><
p
className=
"text-white font-semibold text-sm"
>
Drop files here
</
p
></
div
>
{
messages
.
map
((
m
)
=>
(
</
div
>
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
/>
)
}
))
}
{
/* Repo banner */
}
{
linkedRepo
&&
(
<
div
className=
"px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs"
>
<
GitBranch
size=
{
12
}
className=
"text-orange-400"
/>
<
span
className=
"text-orange-300 font-medium"
>
{
linkedRepo
.
name
}
</
span
>
<
span
className=
"text-orange-300/60"
>
(
{
linkedRepo
.
default_branch
}
)
</
span
>
<
span
className=
"text-orange-300/40 ml-auto"
>
Project-aware mode
</
span
>
</
div
>
)
}
{
/* Messages */
}
<
div
ref=
{
scrollRef
}
onScroll=
{
onScroll
}
className=
"flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3"
>
{
messages
.
map
(
m
=>
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
onCommit=
{
handleCommitFromChat
}
/>)
}
{
streaming
&&
(
streamData
.
thinking
||
streamData
.
text
)
&&
(
{
streaming
&&
(
streamData
.
thinking
||
streamData
.
text
)
&&
(
<
MessageBubble
<
MessageBubble
message=
{
{
id
:
"streaming"
,
role
:
"assistant"
,
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
attachments
:
[]
}
}
isStreaming
isThinking=
{
streamData
.
isThinking
}
token=
{
state
.
token
}
/>
message=
{
{
id
:
"streaming"
,
role
:
"assistant"
,
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
attachments
:
[]
}
}
isStreaming
isThinking=
{
streamData
.
isThinking
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
/>
)
}
)
}
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
<
div
className=
"flex items-center gap-2 px-4 py-3 animate-fade-in"
>
<
div
className=
"flex items-center gap-2 px-3 py-3 animate-fade-in"
>
<
div
className=
"flex gap-1"
>
<
div
className=
"flex gap-1"
>
{
[
0
,
150
,
300
].
map
(
d
=>
<
span
key=
{
d
}
className=
"w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
d
+
"ms"
}
}
/>)
}
</
div
>
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"0ms"
}
}
/>
<
span
className=
"text-anton-muted text-sm"
>
Thinking…
</
span
>
<
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
>
)
}
)
}
</
div
>
</
div
>
{
/* Input A
rea */
}
{
/* Input a
rea */
}
<
div
className=
"border-t border-anton-border bg-anton-surface p-4
"
>
<
div
className=
"border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom
"
>
{
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-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto
"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex items-center justify-between"
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
><
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Settings
</
h3
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
><
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Settings
</
h3
>
<
button
onClick=
{
toggleSettings
}
className=
"
text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
<
button
onClick=
{
toggleSettings
}
className=
"p-1
text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
<
select
value=
{
model
}
onChange=
{
(
e
)
=>
setModel
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm
focus:outline-none focus:border-anton-accent"
>
<
select
value=
{
model
}
onChange=
{
e
=>
setModel
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white
focus:outline-none focus:border-anton-accent"
>
{
MODELS
.
map
((
m
)
=>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>)
}
{
MODELS
.
map
(
m
=>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>)
}
</
select
>
</
select
>
</
div
>
</
div
>
<
div
>
<
div
>
<
div
className=
"flex justify-between text-xs mb-1
"
><
span
className=
"text-anton-muted"
>
Max Tokens
</
span
><
span
className=
"text-anton-accent font-mono"
>
{
maxTokens
.
toLocaleString
()
}
</
span
></
div
>
<
div
className=
"flex justify-between text-xs mb-1.5
"
><
span
className=
"text-anton-muted"
>
Max Tokens
</
span
><
span
className=
"text-anton-accent font-mono"
>
{
maxTokens
.
toLocaleString
()
}
</
span
></
div
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
/>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
e
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
div
className=
"flex justify-between text-xs mb-1
"
><
span
className=
"text-anton-muted flex items-center gap-1"
><
Brain
size=
{
12
}
className=
"text-purple-400"
/>
Reasoning
</
span
><
span
className=
"text-purple-400 font-mono"
>
{
reasoningBudget
===
0
?
"Off"
:
reasoningBudget
.
toLocaleString
()
}
</
span
></
div
>
<
div
className=
"flex justify-between text-xs mb-1.5
"
><
span
className=
"text-anton-muted flex items-center gap-1"
><
Brain
size=
{
12
}
className=
"text-purple-400"
/>
Reasoning
</
span
><
span
className=
"text-purple-400 font-mono"
>
{
reasoningBudget
===
0
?
"Off"
:
reasoningBudget
.
toLocaleString
()
}
</
span
></
div
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
/>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
e
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
BookOpen
size=
{
12
}
/>
Knowledge Base
</
label
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
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"
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
e
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"
>
<
option
value=
""
>
None
</
option
>
{
kbs
.
map
(
kb
=>
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs)
</
option
>)
}
</
select
>
</
div
>
{
isSuperadmin
&&
repos
.
length
>
0
&&
(
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
GitBranch
size=
{
12
}
className=
"text-orange-400"
/>
Repository
</
label
>
<
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.5 text-white focus:outline-none focus:border-orange-400"
>
<
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
.
default_branch
}
)
</
option
>)
}
</
select
>
</
select
>
</
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-2 flex flex-wrap gap-1.5
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"
>
const
Icon
=
TYPE_ICONS
[
pf
.
type
]
||
FileText
;
{
pf
.
type
===
"image"
&&
pf
.
preview
?
(
return
(
<
img
src=
{
pf
.
preview
}
alt=
""
className=
"w-16 h-16 object-cover"
/
>
<
div
key=
{
i
}
className=
{
`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`
}
>
)
:
(
{
pf
.
type
===
"image"
&&
pf
.
preview
?
<
img
src=
{
pf
.
preview
}
alt=
""
className=
"w-14 h-14 sm:w-16 sm:h-16 object-cover"
/>
:
(
<
div
className=
"w-1
6
h-16 flex flex-col items-center justify-center px-1"
>
<
div
className=
"w-1
4 h-14 sm:w-16 sm:
h-16 flex flex-col items-center justify-center px-1"
>
<
FileText
size=
{
20
}
className=
"text-anton-muted mb-1"
/>
<
Icon
size=
{
16
}
className=
{
`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`
}
/>
<
span
className=
"text-[
9px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
10
)
}
</
span
>
<
span
className=
"text-[
7px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
8
)
}
</
span
>
</
div
>
</
div
>
)
}
)
}
<
button
onClick=
{
()
=>
removePending
(
i
)
}
className=
"absolute -top-
1 -right-1 w-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
)
}
className=
"absolute -top-
0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100
"
><
X
size=
{
10
}
/></
button
>
<
div
className=
"absolute bottom-0 left-0 right-0 bg-black/
60 text-[8px] text-white text-center py-0.5"
>
{
(
pf
.
file
.
size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
<
div
className=
"absolute bottom-0 left-0 right-0 bg-black/
70 text-[7px] text-white text-center py-px"
>
{
fmtSize
(
pf
.
file
.
size
)
}
</
div
>
</
div
>
</
div
>
))
}
);
})
}
</
div
>
</
div
>
)
}
)
}
<
div
className=
"flex items-end gap-2"
>
<
div
className=
"flex items-end gap-1.5"
>
<
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=
{
toggleSettings
}
className=
{
`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`
}
><
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
>
<
button
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
className=
{
`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`
}
title=
"Attach files"
><
Paperclip
size=
{
18
}
/></
button
>
{
linkedRepo
&&
(
<
input
ref=
{
fileRef
}
type=
"file"
multiple
className=
"hidden"
accept=
"image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
onChange=
{
e
=>
{
addFiles
(
Array
.
from
(
e
.
target
.
files
||
[]));
e
.
target
.
value
=
""
;
}
}
/>
<
button
onClick=
{
()
=>
setShowRepoPanel
(
!
showRepoPanel
)
}
title=
{
`${linkedRepo.name} — File Browser`
}
<
div
className=
"flex-1 min-w-0"
>
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"}`
}
>
<
textarea
ref=
{
inputRef
}
value=
{
input
}
onChange=
{
e
=>
setInput
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onPaste=
{
handlePaste
}
<
GitBranch
size=
{
18
}
/>
placeholder=
{
pendingFiles
.
length
?
"Add a message…"
:
linkedRepo
?
`Ask about ${linkedRepo.name}…`
:
"Ask anything…"
}
</
button
>
rows=
{
1
}
style=
{
{
maxHeight
:
"120px"
}
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
)
}
onInput=
{
e
=>
{
e
.
target
.
style
.
height
=
"auto"
;
e
.
target
.
style
.
height
=
Math
.
min
(
e
.
target
.
scrollHeight
,
120
)
+
"px"
;
}
}
/>
<
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
?
(
{
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=
{
()
=>
streamManager
.
abortStream
(
chatId
)
}
className=
"p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center
"
><
Square
size=
{
18
}
/></
button
>
)
:
(
)
:
(
<
button
onClick=
{
handleSend
}
disabled=
{
(
!
input
.
trim
()
&&
!
pendingFiles
.
length
)
||
isStreamingGlobal
||
uploading
}
<
button
onClick=
{
handleSend
}
disabled=
{
(
!
input
.
trim
()
&&
!
pendingFiles
.
length
)
||
uploading
}
className=
"p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30"
>
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
}
/>
}
{
uploading
?
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
:
<
Send
size=
{
18
}
/>
}
</
button
>
</
button
>
)
}
)
}
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3 mt-2 text-[11px] text-anton-muted
"
>
<
div
className=
"flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap
"
>
<
span
>
{
MODELS
.
find
((
m
)
=>
m
.
id
===
model
)?.
label
}
</
span
>
<
span
>
{
MODELS
.
find
(
m
=>
m
.
id
===
model
)?.
label
}
</
span
>
<
span
>
•
</
span
><
span
>
{
maxTokens
.
toLocaleString
()
}
tokens
</
span
>
<
span
>
•
</
span
><
span
>
{
maxTokens
.
toLocaleString
()
}
tok
</
span
>
{
reasoningBudget
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
</
span
></>
}
{
reasoningBudget
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
</
span
></>
}
{
selectedKbId
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
📚 RAG
</
span
></>
}
{
selectedKbId
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
📚 RAG
</
span
></>
}
{
linkedRepo
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
🔗
{
linkedRepo
.
name
}
</
span
></>
}
{
linkedRepo
&&
<><
span
>
•
</
span
><
span
className=
"text-orange-400"
>
🔀
{
linkedRepo
.
name
}
</
span
></>
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
file
{
pendingFiles
.
length
!==
1
?
"s"
:
""
}
</
span
></>
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
</
span
></>
}
{
messages
.
some
((
m
)
=>
m
.
role
===
"assistant"
)
&&
(
{
messages
.
some
(
m
=>
m
.
role
===
"assistant"
)
&&
(
<
button
onClick=
{
async
()
=>
{
const
all
=
messages
.
filter
((
m
)
=>
m
.
role
===
"assistant"
).
map
((
m
)
=>
m
.
content
).
join
(
"
\n\n
---
\n\n
"
);
if
(
all
)
try
{
await
downloadZip
(
state
.
token
,
all
);
}
catch
{
}
}
}
<
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"
>
⬇ Code
</
button
>
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Download code
</
button
>
)
}
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Repo File Panel (slides in from right) */
}
{
showRepoPanel
&&
linkedRepo
&&
(
<
RepoFilePanel
linkedRepo=
{
linkedRepo
}
token=
{
state
.
token
}
onClose=
{
()
=>
setShowRepoPanel
(
false
)
}
/>
)
}
</
div
>
);
);
}
}
\ No newline at end of file
frontend/src/components/CodeBlock.jsx
View file @
17ddd732
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
,
GitCommitVertical
}
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
,
linkedRepo
,
onCommit
})
{
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,119 +22,43 @@ export default function CodeBlock({ language, filename, code, linkedRepo, token
...
@@ -29,119 +22,43 @@ export default function CodeBlock({ language, filename, code, linkedRepo, token
URL
.
revokeObjectURL
(
url
);
URL
.
revokeObjectURL
(
url
);
}
}
function
toggleCommitForm
()
{
function
handleCommit
()
{
if
(
!
showCommitForm
)
{
if
(
!
onCommit
||
!
filename
)
return
;
setCommitMsg
(
`Update
${
filename
||
"file"
}
via Son of Anton`
);
onCommit
(
filename
,
code
,
"update"
);
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 */
}
{
/* Header */
}
<
div
className=
"flex items-center justify-between bg-[#1e1e2e] px-3 py-2 gap-2"
>
<
div
className=
"flex items-center justify-between px-3 py-1.5 bg-anton-surface border-b border-anton-border"
>
<
span
className=
"text-xs text-anton-muted font-mono truncate min-w-0"
>
<
div
className=
"flex items-center gap-2 min-w-0"
>
{
filename
||
language
||
"code"
}
{
language
&&
<
span
className=
"text-[10px] text-anton-accent font-mono uppercase"
>
{
language
}
</
span
>
}
</
span
>
{
filename
&&
<
span
className=
"text-[10px] text-anton-muted truncate"
>
{
filename
}
</
span
>
}
<
div
className=
"flex items-center gap-1 shrink-0"
>
</
div
>
{
canCommit
&&
(
<
div
className=
"flex items-center gap-0.5 shrink-0"
>
<
button
onClick=
{
toggleCommitForm
}
title=
"Commit to repo"
{
linkedRepo
&&
filename
&&
(
className=
{
`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition ${showCommitForm
<
button
onClick=
{
handleCommit
}
title=
{
`Commit to ${linkedRepo.name}`
}
? "bg-green-500/20 text-green-400"
className=
"flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition"
>
: "text-anton-muted hover:text-green-400 hover:bg-green-500/10"
<
GitCommitVertical
size=
{
11
}
/>
Commit
}`
}
>
<
GitBranch
size=
{
12
}
/>
<
span
className=
"hidden sm:inline"
>
Push
</
span
>
</
button
>
)
}
{
filename
&&
(
<
button
onClick=
{
handleDownload
}
title=
"Download file"
className=
"p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition"
>
<
Download
size=
{
13
}
/>
</
button
>
</
button
>
)
}
)
}
<
button
onClick=
{
handleCopy
}
title=
"Copy code"
<
button
onClick=
{
handleDownload
}
className=
"p-1.5 text-anton-muted hover:text-white transition"
title=
"Download"
>
className=
"p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition"
>
<
Download
size=
{
12
}
/>
{
copied
?
<
Check
size=
{
13
}
className=
"text-green-400"
/>
:
<
Copy
size=
{
13
}
/>
}
</
button
>
</
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
>
<
button
onClick=
{
toggleCommitForm
}
className=
"px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition
"
>
<
button
onClick=
{
handleCopy
}
className=
"p-1.5 text-anton-muted hover:text-white transition"
title=
"Copy
"
>
Cancel
{
copied
?
<
Check
size=
{
12
}
className=
"text-green-400"
/>
:
<
Copy
size=
{
12
}
/>
}
</
button
>
</
button
>
{
commitState
===
COMMIT_STATES
.
success
&&
(
<
span
className=
"text-[11px] text-green-400"
>
✓ Pushed to
{
linkedRepo
.
default_branch
}
</
span
>
)
}
</
div
>
</
div
>
{
commitError
&&
(
<
div
className=
"text-[11px] text-red-400 bg-red-500/10 rounded px-2 py-1"
>
{
commitError
}
</
div
>
)
}
</
div
>
</
div
>
)
}
{
/* Code */
}
{
/* 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
:
"12px 16px"
,
fontSize
:
"12px"
,
lineHeight
:
"1.5"
,
background
:
"transparent"
}
}
showLineNumbers
showLineNumbers=
{
code
.
split
(
"
\n
"
).
length
>
3
}
lineNumberStyle=
{
{
minWidth
:
"2.5em"
,
paddingRight
:
"1em"
,
color
:
"#555"
}
}
lineNumberStyle=
{
{
color
:
"#555"
,
fontSize
:
"10px"
,
paddingRight
:
"12px"
}
}
wrapLongLines
>
>
{
code
}
{
code
}
</
SyntaxHighlighter
>
</
SyntaxHighlighter
>
...
...
frontend/src/components/MessageBubble.jsx
View file @
17ddd732
...
@@ -8,22 +8,16 @@ import {
...
@@ -8,22 +8,16 @@ 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
,
linkedRepo
,
onCommit
})
{
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
);
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
[
expandedImage
,
setExpandedImage
]
=
useState
(
null
);
const
[
expandedImage
,
setExpandedImage
]
=
useState
(
null
);
function
handleCopy
()
{
function
handleCopy
()
{
navigator
.
clipboard
.
writeText
(
content
||
""
);
setCopied
(
true
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
}
navigator
.
clipboard
.
writeText
(
content
||
""
);
setCopied
(
true
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
}
const
hasAttachments
=
attachments
&&
attachments
.
length
>
0
;
const
hasAttachments
=
attachments
&&
attachments
.
length
>
0
;
...
@@ -40,16 +34,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -40,16 +34,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<
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"
>
<
button
onClick=
{
()
=>
setShowThinking
(
!
showThinking
)
}
<
button
onClick=
{
()
=>
setShowThinking
(
!
showThinking
)
}
className=
"flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
>
className=
"flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
>
<
Brain
size=
{
12
}
/>
<
Brain
size=
{
12
}
/>
{
showThinking
?
<
ChevronDown
size=
{
12
}
/>
:
<
ChevronRight
size=
{
12
}
/>
}
{
showThinking
?
<
ChevronDown
size=
{
12
}
/>
:
<
ChevronRight
size=
{
12
}
/>
}
{
isThinking
?
<
span
className=
"thinking-pulse"
>
Reasoning…
</
span
>
:
<
span
>
View reasoning
</
span
>
}
{
isThinking
?
<
span
className=
"thinking-pulse"
>
Reasoning…
</
span
>
:
<
span
>
View reasoning
</
span
>
}
</
button
>
</
button
>
{
(
showThinking
||
isThinking
)
&&
(
{
(
showThinking
||
isThinking
)
&&
(
<
div
className=
"bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto"
>
<
div
className=
"bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto"
>
{
thinking_content
}
{
thinking_content
}{
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse"
/>
}
{
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse"
/>
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -57,7 +49,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -57,7 +49,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{
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
=>
{
const
Icon
=
FILE_TYPE_ICONS
[
att
.
file_type
]
||
FileText
;
const
Icon
=
FILE_TYPE_ICONS
[
att
.
file_type
]
||
FileText
;
const
url
=
getAttachmentUrl
(
att
.
id
);
const
url
=
getAttachmentUrl
(
att
.
id
);
if
(
att
.
file_type
===
"image"
)
{
if
(
att
.
file_type
===
"image"
)
{
...
@@ -65,18 +57,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -65,18 +57,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<
div
key=
{
att
.
id
}
className=
"relative group"
>
<
div
key=
{
att
.
id
}
className=
"relative group"
>
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
className=
"max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
className=
"max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
onError=
{
e
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
/>
onError=
{
(
e
)
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
/>
{
expandedImage
===
att
.
id
&&
(
{
expandedImage
===
att
.
id
&&
(
<
div
className=
"fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
<
div
className=
"fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
onClick=
{
()
=>
setExpandedImage
(
null
)
}
>
onClick=
{
()
=>
setExpandedImage
(
null
)
}
>
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
className=
"max-w-full max-h-full object-contain rounded-lg"
/>
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
className=
"max-w-full max-h-full object-contain rounded-lg"
/>
</
div
>
</
div
>
)
}
)
}
<
div
className=
"absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded"
>
<
div
className=
"absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded"
>
{
att
.
original_filename
}
</
div
>
{
att
.
original_filename
}
</
div
>
</
div
>
</
div
>
);
);
}
}
...
@@ -95,8 +82,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -95,8 +82,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
>
)
:
(
)
:
(
...
@@ -107,28 +93,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -107,28 +93,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const
rawLang
=
match
?.[
1
]
||
""
;
const
rawLang
=
match
?.[
1
]
||
""
;
if
(
inline
)
return
<
code
className=
{
className
}
{
...
props
}
>
{
children
}
</
code
>;
if
(
inline
)
return
<
code
className=
{
className
}
{
...
props
}
>
{
children
}
</
code
>;
let
lang
=
rawLang
,
filename
=
null
;
let
lang
=
rawLang
,
filename
=
null
;
if
(
rawLang
.
includes
(
":"
))
{
if
(
rawLang
.
includes
(
":"
))
{
const
idx
=
rawLang
.
indexOf
(
":"
);
lang
=
rawLang
.
slice
(
0
,
idx
);
filename
=
rawLang
.
slice
(
idx
+
1
);
}
const
idx
=
rawLang
.
indexOf
(
":"
);
return
<
CodeBlock
language=
{
lang
}
filename=
{
filename
}
code=
{
String
(
children
).
replace
(
/
\n
$/
,
""
)
}
linkedRepo=
{
linkedRepo
}
onCommit=
{
onCommit
}
/>;
lang
=
rawLang
.
slice
(
0
,
idx
);
filename
=
rawLang
.
slice
(
idx
+
1
);
}
return
(
<
CodeBlock
language=
{
lang
}
filename=
{
filename
}
code=
{
String
(
children
).
replace
(
/
\n
$/
,
""
)
}
linkedRepo=
{
linkedRepo
}
token=
{
token
}
/>
);
},
},
pre
({
children
})
{
return
<>
{
children
}
</>;
},
pre
({
children
})
{
return
<>
{
children
}
</>;
},
}
}
>
}
}
>
{
content
||
""
}
{
content
||
""
}
</
ReactMarkdown
>
</
ReactMarkdown
>
{
isStreaming
&&
!
isThinking
&&
(
{
isStreaming
&&
!
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse"
/>
}
<
span
className=
"inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse"
/>
)
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -136,13 +108,10 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -136,13 +108,10 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{
!
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"
>
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
"Copied"
:
"Copy"
}
{
copied
?
"Copied"
:
"Copy"
}
</
button
>
</
button
>
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
<
span
className=
"text-[11px] text-anton-muted"
>
<
span
className=
"text-[11px] text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓ /
{
output_tokens
?.
toLocaleString
()
}
↑
</
span
>
{
input_tokens
?.
toLocaleString
()
}
↓ /
{
output_tokens
?.
toLocaleString
()
}
↑ tokens
</
span
>
)
}
)
}
</
div
>
</
div
>
)
}
)
}
...
...
frontend/src/components/RepoFilePanel.jsx
deleted
100644 → 0
View file @
f8727239
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
"react"
;
import
{
X
,
FolderOpen
,
FileCode
,
ChevronRight
,
ChevronDown
,
GitBranch
,
RefreshCw
,
Plus
,
Trash2
,
Save
,
Edit3
,
Eye
,
Loader2
,
FileText
,
AlertCircle
,
CheckCircle2
,
FolderClosed
,
}
from
"lucide-react"
;
import
{
gitlabGetTree
,
gitlabGetFile
,
gitlabCommitSingle
,
gitlabCommitMulti
,
gitlabGetBranches
}
from
"../api"
;
const
EXT_LANGS
=
{
py
:
"Python"
,
js
:
"JavaScript"
,
ts
:
"TypeScript"
,
jsx
:
"React"
,
tsx
:
"React TS"
,
cs
:
"C#"
,
java
:
"Java"
,
cpp
:
"C++"
,
c
:
"C"
,
go
:
"Go"
,
rs
:
"Rust"
,
rb
:
"Ruby"
,
php
:
"PHP"
,
swift
:
"Swift"
,
kt
:
"Kotlin"
,
lua
:
"Lua"
,
html
:
"HTML"
,
css
:
"CSS"
,
json
:
"JSON"
,
yaml
:
"YAML"
,
yml
:
"YAML"
,
xml
:
"XML"
,
sql
:
"SQL"
,
sh
:
"Shell"
,
md
:
"Markdown"
,
txt
:
"Text"
,
toml
:
"TOML"
,
dart
:
"Dart"
,
vue
:
"Vue"
,
};
function
getFileExt
(
name
)
{
const
parts
=
name
.
split
(
"."
);
return
parts
.
length
>
1
?
parts
.
pop
().
toLowerCase
()
:
""
;
}
function
buildFileTree
(
items
)
{
const
root
=
{
name
:
"/"
,
type
:
"tree"
,
children
:
{},
path
:
""
};
for
(
const
item
of
items
)
{
const
parts
=
item
.
path
.
split
(
"/"
);
let
node
=
root
;
for
(
let
i
=
0
;
i
<
parts
.
length
;
i
++
)
{
const
part
=
parts
[
i
];
if
(
i
<
parts
.
length
-
1
)
{
if
(
!
node
.
children
[
part
])
{
node
.
children
[
part
]
=
{
name
:
part
,
type
:
"tree"
,
children
:
{},
path
:
parts
.
slice
(
0
,
i
+
1
).
join
(
"/"
)
};
}
node
=
node
.
children
[
part
];
}
else
{
node
.
children
[
part
]
=
{
...
item
,
children
:
item
.
type
===
"tree"
?
{}
:
undefined
};
}
}
}
return
root
;
}
function
TreeNode
({
node
,
depth
,
onSelect
,
selectedPath
})
{
const
[
open
,
setOpen
]
=
useState
(
depth
<
2
);
const
isDir
=
node
.
type
===
"tree"
;
const
isSelected
=
node
.
path
===
selectedPath
;
const
ext
=
!
isDir
?
getFileExt
(
node
.
name
)
:
""
;
const
children
=
isDir
?
Object
.
values
(
node
.
children
).
sort
((
a
,
b
)
=>
{
if
(
a
.
type
===
"tree"
&&
b
.
type
!==
"tree"
)
return
-
1
;
if
(
a
.
type
!==
"tree"
&&
b
.
type
===
"tree"
)
return
1
;
return
a
.
name
.
localeCompare
(
b
.
name
);
})
:
[];
return
(
<
div
>
<
button
onClick=
{
()
=>
{
if
(
isDir
)
setOpen
(
!
open
);
else
onSelect
(
node
.
path
);
}
}
className=
{
`w-full flex items-center gap-1.5 px-2 py-1 text-left text-xs hover:bg-white/5 transition rounded ${isSelected ? "bg-anton-accent/15 text-anton-accent" : "text-anton-text"
}`
}
style=
{
{
paddingLeft
:
`${depth * 14 + 8}px`
}
}
>
{
isDir
?
(
open
?
<
ChevronDown
size=
{
12
}
className=
"text-anton-muted shrink-0"
/>
:
<
ChevronRight
size=
{
12
}
className=
"text-anton-muted shrink-0"
/>
)
:
<
span
className=
"w-3 shrink-0"
/>
}
{
isDir
?
(
open
?
<
FolderOpen
size=
{
13
}
className=
"text-yellow-400 shrink-0"
/>
:
<
FolderClosed
size=
{
13
}
className=
"text-yellow-500/70 shrink-0"
/>
)
:
(
<
FileCode
size=
{
13
}
className=
"text-blue-400 shrink-0"
/>
)
}
<
span
className=
"truncate"
>
{
node
.
name
}
</
span
>
{
ext
&&
<
span
className=
"text-[9px] text-anton-muted ml-auto shrink-0"
>
{
EXT_LANGS
[
ext
]
||
ext
}
</
span
>
}
</
button
>
{
isDir
&&
open
&&
children
.
map
((
child
)
=>
(
<
TreeNode
key=
{
child
.
path
}
node=
{
child
}
depth=
{
depth
+
1
}
onSelect=
{
onSelect
}
selectedPath=
{
selectedPath
}
/>
))
}
</
div
>
);
}
export
default
function
RepoFilePanel
({
linkedRepo
,
token
,
onClose
})
{
const
[
treeData
,
setTreeData
]
=
useState
(
null
);
const
[
branches
,
setBranches
]
=
useState
([]);
const
[
branch
,
setBranch
]
=
useState
(
linkedRepo
?.
default_branch
||
"main"
);
const
[
selectedFile
,
setSelectedFile
]
=
useState
(
null
);
const
[
fileContent
,
setFileContent
]
=
useState
(
""
);
const
[
editContent
,
setEditContent
]
=
useState
(
""
);
const
[
editing
,
setEditing
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
fileLoading
,
setFileLoading
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
commitMsg
,
setCommitMsg
]
=
useState
(
""
);
const
[
status
,
setStatus
]
=
useState
(
null
);
const
[
showCreate
,
setShowCreate
]
=
useState
(
false
);
const
[
newFilePath
,
setNewFilePath
]
=
useState
(
""
);
const
[
newFileContent
,
setNewFileContent
]
=
useState
(
""
);
const
[
showDelete
,
setShowDelete
]
=
useState
(
false
);
const
loadTree
=
useCallback
(
async
()
=>
{
if
(
!
linkedRepo
)
return
;
setLoading
(
true
);
try
{
const
data
=
await
gitlabGetTree
(
token
,
linkedRepo
.
id
,
branch
);
setTreeData
(
buildFileTree
(
data
.
items
||
[]));
}
catch
(
err
)
{
setStatus
({
type
:
"error"
,
text
:
err
.
message
});
}
finally
{
setLoading
(
false
);
}
},
[
linkedRepo
,
token
,
branch
]);
const
loadBranches
=
useCallback
(
async
()
=>
{
if
(
!
linkedRepo
)
return
;
try
{
const
data
=
await
gitlabGetBranches
(
token
,
linkedRepo
.
id
);
setBranches
(
data
);
}
catch
{
}
},
[
linkedRepo
,
token
]);
useEffect
(()
=>
{
loadTree
();
loadBranches
();
},
[
loadTree
,
loadBranches
]);
async
function
handleSelectFile
(
path
)
{
setSelectedFile
(
path
);
setEditing
(
false
);
setFileLoading
(
true
);
setStatus
(
null
);
try
{
const
data
=
await
gitlabGetFile
(
token
,
linkedRepo
.
id
,
path
,
branch
);
setFileContent
(
data
.
content
||
""
);
setEditContent
(
data
.
content
||
""
);
}
catch
(
err
)
{
setFileContent
(
`[Error loading file:
${
err
.
message
}
]`
);
}
finally
{
setFileLoading
(
false
);
}
}
async
function
handleSave
()
{
if
(
!
selectedFile
||
!
linkedRepo
)
return
;
setSaving
(
true
);
setStatus
(
null
);
try
{
await
gitlabCommitSingle
(
token
,
linkedRepo
.
id
,
{
branch
,
file_path
:
selectedFile
,
content
:
editContent
,
commit_message
:
commitMsg
||
`Update
${
selectedFile
}
via Son of Anton`
,
action
:
"update"
,
});
setFileContent
(
editContent
);
setEditing
(
false
);
setCommitMsg
(
""
);
setStatus
({
type
:
"success"
,
text
:
`Committed to
${
branch
}
`
});
setTimeout
(()
=>
setStatus
(
null
),
3000
);
}
catch
(
err
)
{
setStatus
({
type
:
"error"
,
text
:
err
.
message
});
}
finally
{
setSaving
(
false
);
}
}
async
function
handleCreate
()
{
if
(
!
newFilePath
.
trim
()
||
!
linkedRepo
)
return
;
setSaving
(
true
);
setStatus
(
null
);
try
{
await
gitlabCommitSingle
(
token
,
linkedRepo
.
id
,
{
branch
,
file_path
:
newFilePath
.
trim
(),
content
:
newFileContent
||
""
,
commit_message
:
`Create
${
newFilePath
.
trim
()}
via Son of Anton`
,
action
:
"create"
,
});
setShowCreate
(
false
);
setNewFilePath
(
""
);
setNewFileContent
(
""
);
setStatus
({
type
:
"success"
,
text
:
`Created
${
newFilePath
.
trim
()}
`
});
loadTree
();
setTimeout
(()
=>
setStatus
(
null
),
3000
);
}
catch
(
err
)
{
setStatus
({
type
:
"error"
,
text
:
err
.
message
});
}
finally
{
setSaving
(
false
);
}
}
async
function
handleDelete
()
{
if
(
!
selectedFile
||
!
linkedRepo
)
return
;
setSaving
(
true
);
setStatus
(
null
);
try
{
await
gitlabCommitMulti
(
token
,
linkedRepo
.
id
,
{
branch
,
commit_message
:
`Delete
${
selectedFile
}
via Son of Anton`
,
actions
:
[{
action
:
"delete"
,
file_path
:
selectedFile
}],
});
setShowDelete
(
false
);
setSelectedFile
(
null
);
setFileContent
(
""
);
setStatus
({
type
:
"success"
,
text
:
`Deleted
${
selectedFile
}
`
});
loadTree
();
setTimeout
(()
=>
setStatus
(
null
),
3000
);
}
catch
(
err
)
{
setStatus
({
type
:
"error"
,
text
:
err
.
message
});
}
finally
{
setSaving
(
false
);
}
}
if
(
!
linkedRepo
)
return
null
;
return
(
<
div
className=
"w-[420px] shrink-0 border-l border-anton-border bg-anton-surface flex flex-col h-full overflow-hidden"
>
{
/* Header */
}
<
div
className=
"p-3 border-b border-anton-border space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex items-center gap-2 min-w-0"
>
<
GitBranch
size=
{
14
}
className=
"text-green-400 shrink-0"
/>
<
span
className=
"text-sm font-semibold text-white truncate"
>
{
linkedRepo
.
name
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-1 shrink-0"
>
<
button
onClick=
{
loadTree
}
title=
"Refresh"
className=
"p-1.5 rounded hover:bg-white/5 text-anton-muted hover:text-white transition"
>
<
RefreshCw
size=
{
13
}
className=
{
loading
?
"animate-spin"
:
""
}
/>
</
button
>
<
button
onClick=
{
onClose
}
className=
"p-1.5 rounded hover:bg-white/5 text-anton-muted hover:text-white transition"
>
<
X
size=
{
14
}
/>
</
button
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
select
value=
{
branch
}
onChange=
{
(
e
)
=>
setBranch
(
e
.
target
.
value
)
}
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-green-500/50"
>
{
branches
.
map
((
b
)
=>
<
option
key=
{
b
.
name
}
value=
{
b
.
name
}
>
{
b
.
name
}{
b
.
default
?
" ★"
:
""
}
</
option
>)
}
{
!
branches
.
length
&&
<
option
value=
{
branch
}
>
{
branch
}
</
option
>
}
</
select
>
<
button
onClick=
{
()
=>
{
setShowCreate
(
!
showCreate
);
setShowDelete
(
false
);
}
}
title=
"New file"
className=
{
`p-1.5 rounded transition ${showCreate ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-green-400 hover:bg-green-500/10"}`
}
>
<
Plus
size=
{
14
}
/>
</
button
>
</
div
>
{
status
&&
(
<
div
className=
{
`flex items-center gap-1.5 text-[11px] px-2 py-1 rounded ${status.type === "success" ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-400"
}`
}
>
{
status
.
type
===
"success"
?
<
CheckCircle2
size=
{
11
}
/>
:
<
AlertCircle
size=
{
11
}
/>
}
{
status
.
text
}
</
div
>
)
}
</
div
>
{
/* Create New File Form */
}
{
showCreate
&&
(
<
div
className=
"p-3 border-b border-anton-border space-y-2 bg-green-500/5 animate-fade-in"
>
<
div
className=
"text-xs font-medium text-green-400"
>
Create New File
</
div
>
<
input
type=
"text"
value=
{
newFilePath
}
onChange=
{
(
e
)
=>
setNewFilePath
(
e
.
target
.
value
)
}
placeholder=
"path/to/file.ext"
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 font-mono"
/>
<
textarea
value=
{
newFileContent
}
onChange=
{
(
e
)
=>
setNewFileContent
(
e
.
target
.
value
)
}
placeholder=
"File content (optional)..."
rows=
{
4
}
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 font-mono resize-none"
/>
<
div
className=
"flex items-center gap-2"
>
<
button
onClick=
{
handleCreate
}
disabled=
{
!
newFilePath
.
trim
()
||
saving
}
className=
"flex items-center gap-1 px-3 py-1.5 rounded bg-green-600 hover:bg-green-500 text-white text-xs font-medium transition disabled:opacity-50"
>
{
saving
?
<
Loader2
size=
{
12
}
className=
"animate-spin"
/>
:
<
Plus
size=
{
12
}
/>
}
Create
</
button
>
<
button
onClick=
{
()
=>
setShowCreate
(
false
)
}
className=
"px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition"
>
Cancel
</
button
>
</
div
>
</
div
>
)
}
{
/* File Tree */
}
<
div
className=
"flex-1 overflow-y-auto min-h-0"
>
{
loading
&&
!
treeData
?
(
<
div
className=
"flex items-center justify-center py-8 text-anton-muted text-xs"
><
Loader2
size=
{
16
}
className=
"animate-spin mr-2"
/>
Loading tree...
</
div
>
)
:
treeData
?
(
<
div
className=
"py-1"
>
{
Object
.
values
(
treeData
.
children
).
sort
((
a
,
b
)
=>
{
if
(
a
.
type
===
"tree"
&&
b
.
type
!==
"tree"
)
return
-
1
;
if
(
a
.
type
!==
"tree"
&&
b
.
type
===
"tree"
)
return
1
;
return
a
.
name
.
localeCompare
(
b
.
name
);
}).
map
((
node
)
=>
(
<
TreeNode
key=
{
node
.
path
}
node=
{
node
}
depth=
{
0
}
onSelect=
{
handleSelectFile
}
selectedPath=
{
selectedFile
}
/>
))
}
</
div
>
)
:
(
<
div
className=
"text-center py-8 text-anton-muted text-xs"
>
No files loaded
</
div
>
)
}
</
div
>
{
/* File Viewer / Editor */
}
{
selectedFile
&&
(
<
div
className=
"border-t border-anton-border flex flex-col"
style=
{
{
height
:
"45%"
}
}
>
<
div
className=
"flex items-center justify-between px-3 py-2 border-b border-anton-border bg-anton-card"
>
<
div
className=
"flex items-center gap-1.5 min-w-0"
>
<
FileText
size=
{
12
}
className=
"text-blue-400 shrink-0"
/>
<
span
className=
"text-[11px] text-white font-mono truncate"
>
{
selectedFile
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-1 shrink-0"
>
{
!
editing
?
(
<
button
onClick=
{
()
=>
{
setEditing
(
true
);
setEditContent
(
fileContent
);
setCommitMsg
(
`Update ${selectedFile}`
);
}
}
title=
"Edit"
className=
"p-1 rounded text-anton-muted hover:text-yellow-400 hover:bg-yellow-500/10 transition"
>
<
Edit3
size=
{
12
}
/>
</
button
>
)
:
(
<
button
onClick=
{
()
=>
setEditing
(
false
)
}
title=
"View"
className=
"p-1 rounded text-yellow-400 bg-yellow-500/10 transition"
>
<
Eye
size=
{
12
}
/>
</
button
>
)
}
<
button
onClick=
{
()
=>
{
setShowDelete
(
true
);
setShowCreate
(
false
);
}
}
title=
"Delete"
className=
"p-1 rounded text-anton-muted hover:text-red-400 hover:bg-red-500/10 transition"
>
<
Trash2
size=
{
12
}
/>
</
button
>
<
button
onClick=
{
()
=>
setSelectedFile
(
null
)
}
className=
"p-1 rounded text-anton-muted hover:text-white transition"
>
<
X
size=
{
12
}
/>
</
button
>
</
div
>
</
div
>
{
/* Delete Confirmation */
}
{
showDelete
&&
(
<
div
className=
"px-3 py-2 bg-red-500/10 border-b border-red-500/20 animate-fade-in"
>
<
div
className=
"text-[11px] text-red-400 mb-2"
>
Delete
<
strong
>
{
selectedFile
}
</
strong
>
from
{
branch
}
?
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
button
onClick=
{
handleDelete
}
disabled=
{
saving
}
className=
"flex items-center gap-1 px-2.5 py-1 rounded bg-red-600 hover:bg-red-500 text-white text-[11px] font-medium transition disabled:opacity-50"
>
{
saving
?
<
Loader2
size=
{
11
}
className=
"animate-spin"
/>
:
<
Trash2
size=
{
11
}
/>
}
Delete
</
button
>
<
button
onClick=
{
()
=>
setShowDelete
(
false
)
}
className=
"px-2.5 py-1 rounded text-[11px] text-anton-muted hover:text-white transition"
>
Cancel
</
button
>
</
div
>
</
div
>
)
}
{
/* Save bar */
}
{
editing
&&
(
<
div
className=
"px-3 py-2 bg-yellow-500/5 border-b border-anton-border flex items-center gap-2"
>
<
input
type=
"text"
value=
{
commitMsg
}
onChange=
{
(
e
)
=>
setCommitMsg
(
e
.
target
.
value
)
}
placeholder=
"Commit message..."
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-[11px] text-white placeholder-anton-muted focus:outline-none focus:border-green-500/50"
/>
<
button
onClick=
{
handleSave
}
disabled=
{
saving
}
className=
"flex items-center gap-1 px-2.5 py-1 rounded bg-green-600 hover:bg-green-500 text-white text-[11px] font-medium transition disabled:opacity-50"
>
{
saving
?
<
Loader2
size=
{
11
}
className=
"animate-spin"
/>
:
<
Save
size=
{
11
}
/>
}
Save
</
button
>
</
div
>
)
}
{
/* Content */
}
<
div
className=
"flex-1 overflow-auto min-h-0"
>
{
fileLoading
?
(
<
div
className=
"flex items-center justify-center py-8 text-anton-muted text-xs"
><
Loader2
size=
{
14
}
className=
"animate-spin mr-2"
/>
Loading...
</
div
>
)
:
editing
?
(
<
textarea
value=
{
editContent
}
onChange=
{
(
e
)
=>
setEditContent
(
e
.
target
.
value
)
}
className=
"w-full h-full bg-[#1a1a2e] text-green-300 font-mono text-xs p-3 resize-none focus:outline-none"
spellCheck=
{
false
}
/>
)
:
(
<
pre
className=
"text-xs text-anton-text font-mono p-3 whitespace-pre-wrap break-words"
>
{
fileContent
}
</
pre
>
)
}
</
div
>
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/pages/GitLabPage.jsx
View file @
17ddd732
...
@@ -70,7 +70,7 @@ export default function GitLabPage() {
...
@@ -70,7 +70,7 @@ export default function GitLabPage() {
async
function
handleTest
()
{
async
function
handleTest
()
{
setTesting
(
true
);
setTestResult
(
null
);
setTesting
(
true
);
setTestResult
(
null
);
try
{
try
{
const
r
=
await
gitlabTestConnection
(
t
,
{
gitlab_url
:
url
,
gitlab_token
:
token
||
"UNCHANGED"
}
);
const
r
=
await
gitlabTestConnection
(
t
);
setTestResult
({
ok
:
true
,
msg
:
`Connected as
${
r
.
name
}
(@
${
r
.
username
}
)`
});
setTestResult
({
ok
:
true
,
msg
:
`Connected as
${
r
.
name
}
(@
${
r
.
username
}
)`
});
}
catch
(
e
)
{
setTestResult
({
ok
:
false
,
msg
:
e
.
message
});
}
}
catch
(
e
)
{
setTestResult
({
ok
:
false
,
msg
:
e
.
message
});
}
setTesting
(
false
);
setTesting
(
false
);
...
...
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