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
02876ff2
Commit
02876ff2
authored
Mar 29, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
dgdfg jdfjgyjt
parent
2dd589eb
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
3671 additions
and
2254 deletions
+3671
-2254
FULL_CODEBASE.txt
FULL_CODEBASE.txt
+2872
-1975
api.js
frontend/src/api.js
+113
-71
ChatView.jsx
frontend/src/components/ChatView.jsx
+171
-165
CodeBlock.jsx
frontend/src/components/CodeBlock.jsx
+110
-27
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+47
-16
RepoFilePanel.jsx
frontend/src/components/RepoFilePanel.jsx
+358
-0
No files found.
FULL_CODEBASE.txt
View file @
02876ff2
This source diff could not be displayed because it is too large. You can
view the blob
instead.
frontend/src/api.js
View file @
02876ff2
...
@@ -10,46 +10,49 @@ function authHeader(token) {
...
@@ -10,46 +10,49 @@ 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
xtractError
(
err
,
"Request failed"
)
);
throw
new
Error
(
e
rr
.
detail
||
err
.
message
||
"Request failed"
);
}
}
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
)
=>
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
);
// ═══════════ Streaming ═══════════
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
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
),
body
:
JSON
.
stringify
(
body
),
signal
,
method
:
"POST"
,
headers
:
headers
(
token
),
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
xtractError
(
err
,
"Stream failed"
)
);
throw
new
Error
(
e
rr
.
detail
||
"Stream failed"
);
}
}
const
reader
=
res
.
body
.
getReader
();
const
reader
=
res
.
body
.
getReader
();
const
decoder
=
new
TextDecoder
();
const
decoder
=
new
TextDecoder
();
...
@@ -63,57 +66,81 @@ export async function* streamMessage(token, chatId, body, signal) {
...
@@ -63,57 +66,81 @@ 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
{
/* skip */
}
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
{
/* skip */
}
try
{
yield
JSON
.
parse
(
buffer
.
trim
().
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
);
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
)
{
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
function
getAttachmentUrl
(
attachmentId
)
{
return
`
${
BASE
}
/attachments/
${
attachmentId
}
/file`
;
}
export
const
deleteAttachment
=
(
token
,
attachmentId
)
=>
request
(
"DELETE"
,
`/attachments/
${
attachmentId
}
`
,
token
);
// ═══════════ Knowledge ═══════════
export
function
getAttachmentUrl
(
attachmentId
)
{
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
getKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
const
createKnowledgeBase
=
(
token
,
name
,
description
=
""
)
=>
export
const
updateKnowledgeBase
=
(
token
,
kbId
,
data
)
=>
request
(
"PUT"
,
`/knowledge/
${
kbId
}
`
,
token
,
data
);
request
(
"POST"
,
"/knowledge"
,
token
,
{
name
,
description
});
export
const
deleteKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
const
listKnowledgeDocuments
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
/documents`
,
token
);
export
const
getKnowledgeBase
=
(
token
,
kbId
)
=>
export
const
deleteKnowledgeDocument
=
(
token
,
kbId
,
docId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
/documents/
${
docId
}
`
,
token
);
request
(
"GET"
,
`/knowledge/
${
kbId
}
`
,
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`
,
{
method
:
"POST"
,
headers
:
authHeader
(
token
),
body
:
form
});
const
res
=
await
fetch
(
`
${
BASE
}
/knowledge/
${
kbId
}
/upload`
,
{
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
extractError
(
err
,
"Upload failed"
));
}
method
:
"POST"
,
headers
:
authHeader
(
token
),
body
:
form
,
});
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
]);
// ═══════════ Admin ═══════════
export
const
uploadDocument
=
(
token
,
kbId
,
file
)
=>
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
adminUpdateUser
=
(
token
,
userId
,
data
)
=>
request
(
"PUT"
,
`/admin/users/
${
userId
}
`
,
token
,
data
);
export
const
adminCreateUser
=
(
token
,
data
)
=>
export
const
adminDeleteUser
=
(
token
,
userId
)
=>
request
(
"DELETE"
,
`/admin/users/
${
userId
}
`
,
token
);
request
(
"POST"
,
"/admin/users"
,
token
,
data
);
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
);
// ═══════════ Code Download ═══════════
export
async
function
downloadZip
(
token
,
markdown
)
{
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
),
body
:
JSON
.
stringify
({
markdown
,
title
:
chatTitle
||
null
}),
method
:
"POST"
,
headers
:
headers
(
token
),
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"
)
||
""
;
...
@@ -122,9 +149,7 @@ export async function downloadZip(token, markdown, chatTitle) {
...
@@ -122,9 +149,7 @@ export async function downloadZip(token, markdown, chatTitle) {
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
;
const
raw
=
(
chatTitle
||
""
).
trim
();
a
.
download
=
"son-of-anton-code.zip"
;
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
{
...
@@ -134,30 +159,47 @@ export async function downloadZip(token, markdown, chatTitle) {
...
@@ -134,30 +159,47 @@ export async function downloadZip(token, markdown, chatTitle) {
}
}
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// GitLab
CE Integration — v4.0
.0
// GitLab
Repository API — Son of Anton v4.1
.0
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
const
gitlabGetSettings
=
(
token
)
=>
request
(
"GET"
,
"/gitlab/settings"
,
token
);
export
function
gitlabGetTree
(
token
,
repoId
,
ref
=
""
,
path
=
""
)
{
export
const
gitlabUpdateSettings
=
(
token
,
data
)
=>
request
(
"PUT"
,
"/gitlab/settings"
,
token
,
data
);
const
p
=
new
URLSearchParams
();
export
const
gitlabTestConnection
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/test-connection"
,
token
,
data
);
if
(
ref
)
p
.
set
(
"ref"
,
ref
);
export
const
gitlabSearchProjects
=
(
token
,
search
,
owned
)
=>
if
(
path
)
p
.
set
(
"path"
,
path
);
request
(
"GET"
,
`/gitlab/projects?search=
${
encodeURIComponent
(
search
||
""
)}
&owned=
${
owned
||
false
}
`
,
token
);
return
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/tree?
${
p
}
`
,
token
);
export
const
gitlabCreateProject
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/projects"
,
token
,
data
);
}
export
const
gitlabListRepos
=
(
token
)
=>
request
(
"GET"
,
"/gitlab/repos"
,
token
);
export
const
gitlabLinkRepo
=
(
token
,
gitlabProjectId
)
=>
request
(
"POST"
,
"/gitlab/repos"
,
token
,
{
gitlab_project_id
:
gitlabProjectId
});
export
function
gitlabGetFile
(
token
,
repoId
,
filePath
,
ref
=
""
)
{
export
const
gitlabUnlinkRepo
=
(
token
,
repoId
)
=>
request
(
"DELETE"
,
`/gitlab/repos/
${
repoId
}
`
,
token
);
const
p
=
new
URLSearchParams
({
path
:
filePath
});
export
const
gitlabGetTree
=
(
token
,
repoId
,
path
,
ref
)
=>
if
(
ref
)
p
.
set
(
"ref"
,
ref
);
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/tree?path=
${
encodeURIComponent
(
path
||
""
)}
&ref=
${
encodeURIComponent
(
ref
||
""
)}
`
,
token
);
return
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/file?
${
p
}
`
,
token
);
export
const
gitlabGetFile
=
(
token
,
repoId
,
path
,
ref
)
=>
}
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/file?path=
${
encodeURIComponent
(
path
)}
&ref=
${
encodeURIComponent
(
ref
||
""
)}
`
,
token
);
export
const
gitlabGetBranches
=
(
token
,
repoId
)
=>
request
(
"GET"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
);
export
async
function
gitlabFileExists
(
token
,
repoId
,
filePath
,
ref
=
""
)
{
export
const
gitlabCreateBranch
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/branches`
,
token
,
data
);
try
{
export
const
gitlabCommit
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit`
,
token
,
data
);
await
gitlabGetFile
(
token
,
repoId
,
filePath
,
ref
);
export
const
gitlabCommitSingle
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit-single`
,
token
,
data
);
return
true
;
export
const
gitlabCreateMR
=
(
token
,
repoId
,
data
)
=>
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/merge-request`
,
token
,
data
);
}
catch
{
export
const
gitlabAnalyzeProject
=
(
token
,
repoId
,
ref
)
=>
return
false
;
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
const
gitlabApproveAction
=
(
token
,
actionId
)
=>
request
(
"POST"
,
`/gitlab/actions/
${
actionId
}
/approve`
,
token
);
export
function
gitlabCommitSingle
(
token
,
repoId
,
data
)
{
export
const
gitlabRejectAction
=
(
token
,
actionId
)
=>
request
(
"POST"
,
`/gitlab/actions/
${
actionId
}
/reject`
,
token
);
return
request
(
"POST"
,
`/gitlab/repos/
${
repoId
}
/commit-single`
,
token
,
data
);
\ 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 @
02876ff2
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
,
gitlabListRepos
,
gitlabCommitSingle
}
from
"../api"
;
import
{
getMessages
,
downloadZip
,
listKnowledgeBases
,
updateChat
,
uploadAttachments
}
from
"../api"
;
import
*
as
streamManager
from
"../streamManager"
;
import
*
as
streamManager
from
"../streamManager"
;
import
MessageBubble
from
"./MessageBubble"
;
import
MessageBubble
from
"./MessageBubble"
;
import
{
import
RepoFilePanel
from
"./RepoFilePanel"
;
Send
,
Square
,
Settings2
,
X
,
Brain
,
BookOpen
,
Paperclip
,
import
{
Send
,
Square
,
Settings2
,
X
,
Brain
,
BookOpen
,
Paperclip
,
FileText
,
Loader2
,
GitBranch
}
from
"lucide-react"
;
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
:
"
Opus 4.6
"
},
{
id
:
"eu.anthropic.claude-opus-4-6-v1"
,
label
:
"
Claude Opus 4.6 (Primary)
"
},
{
id
:
"eu.anthropic.claude-haiku-4-5-20251001-v1:0"
,
label
:
"
Haiku 4.5
"
},
{
id
:
"eu.anthropic.claude-haiku-4-5-20251001-v1:0"
,
label
:
"
Claude Haiku 4.5 (Fast)
"
},
];
];
const
TYPE_ICONS
=
{
image
:
ImageIcon
,
video
:
Film
,
document
:
FileText
,
text
:
FileCode
};
function
classifyFile
(
file
)
{
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
ext
=
(
file
.
name
||
""
).
split
(
"."
).
pop
().
toLowerCase
();
const
TYPE_ICON_COLORS
=
{
image
:
"text-blue-400"
,
video
:
"text-purple-400"
,
document
:
"text-amber-400"
,
text
:
"text-green-400"
};
const
mime
=
file
.
type
||
""
;
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
isSuperadmin
=
state
.
user
?.
role
===
"superadmin"
;
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
[
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
);
...
@@ -60,9 +50,18 @@ export default function ChatView({ chatId }) {
...
@@ -60,9 +50,18 @@ 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
(()
=>
{
scrollRef
.
current
?.
scrollTo
({
top
:
scrollRef
.
current
.
scrollHeight
});
rafRef
.
current
=
null
;
});
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
if
(
scrollRef
.
current
)
scrollRef
.
current
.
scrollTop
=
scrollRef
.
current
.
scrollHeight
;
rafRef
.
current
=
null
;
});
},
[]);
},
[]);
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -71,194 +70,201 @@ export default function ChatView({ chatId }) {
...
@@ -71,194 +70,201 @@ 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
||
""
,
linked_repo_id
:
selectedRepoId
||
""
});
await
updateChat
(
state
.
token
,
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
}
});
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
}
});
}
catch
{
}
}
catch
{
}
}
}
function
toggleSettings
()
{
if
(
showSettings
)
saveSettings
();
setShowSettings
(
!
showSettings
);
}
function
toggleSettings
()
{
function
addFiles
(
files
)
{
setPendingFiles
(
prev
=>
[...
prev
,
...
files
.
map
(
f
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
}))]);
}
if
(
showSettings
)
saveSettings
();
function
removePending
(
i
)
{
setPendingFiles
(
prev
=>
{
if
(
prev
[
i
]?.
preview
)
URL
.
revokeObjectURL
(
prev
[
i
].
preview
);
return
prev
.
filter
((
_
,
j
)
=>
j
!==
i
);
});
}
setShowSettings
(
!
showSettings
);
}
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
)
||
streamData
.
streaming
)
return
;
if
((
!
content
&&
!
pendingFiles
.
length
)
||
isStreamingGlobal
)
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
{
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
;
}
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
(
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
(
""
);
pendingFiles
.
forEach
(
p
=>
{
if
(
p
.
preview
)
URL
.
revokeObjectURL
(
p
.
preview
);
});
setPendingFiles
([]);
autoScroll
.
current
=
true
;
setInput
(
""
);
if
(
inputRef
.
current
)
inputRef
.
current
.
style
.
height
=
"auto"
;
pendingFiles
.
forEach
((
p
)
=>
{
if
(
p
.
preview
)
URL
.
revokeObjectURL
(
p
.
preview
);
});
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
);
}
const
streaming
=
streamData
.
streaming
;
function
handlePaste
(
e
)
{
const
linkedRepo
=
currentChat
?.
linked_repo
;
const
imgs
=
Array
.
from
(
e
.
clipboardData
?.
items
||
[]).
filter
((
i
)
=>
i
.
type
.
startsWith
(
"image/"
));
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
)
};
})]);
}
async
function
handleCommitFromChat
(
filePath
,
code
,
action
)
{
function
handleDrop
(
e
)
{
if
(
!
linkedRepo
)
return
;
e
.
preventDefault
();
const
branch
=
linkedRepo
.
default_branch
;
const
files
=
Array
.
from
(
e
.
dataTransfer
?.
files
||
[]);
const
msg
=
prompt
(
"Commit message:"
,
`Update
${
filePath
}
via Son of Anton`
);
if
(
files
.
length
)
setPendingFiles
((
prev
)
=>
[...
prev
,
...
files
.
map
((
f
)
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
}))]);
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
}
`
);
}
}
}
return
(
const
streaming
=
streamData
.
streaming
;
<
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
);
}
}
>
{
dragOver
&&
(
<
div
className=
"absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"
>
<
div
className=
"text-center"
><
Upload
size=
{
36
}
className=
"text-anton-accent mx-auto mb-2 animate-bounce"
/><
p
className=
"text-white font-semibold text-sm"
>
Drop files here
</
p
></
div
>
</
div
>
)
}
{
/* Repo banner */
}
return
(
{
linkedRepo
&&
(
<
div
className=
"flex-1 flex min-h-0"
onDrop=
{
handleDrop
}
onDragOver=
{
(
e
)
=>
e
.
preventDefault
()
}
>
<
div
className=
"px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs"
>
{
/* Main Chat Column */
}
<
GitBranch
size=
{
12
}
className=
"text-orange-400"
/>
<
div
className=
"flex-1 flex flex-col min-h-0 min-w-0"
>
<
span
className=
"text-orange-300 font-medium"
>
{
linkedRepo
.
name
}
</
span
>
<
div
ref=
{
scrollRef
}
onScroll=
{
onScroll
}
className=
"flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
<
span
className=
"text-orange-300/60"
>
(
{
linkedRepo
.
default_branch
}
)
</
span
>
{
messages
.
map
((
m
)
=>
(
<
span
className=
"text-orange-300/40 ml-auto"
>
Project-aware mode
</
span
>
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
/>
))
}
{
streaming
&&
(
streamData
.
thinking
||
streamData
.
text
)
&&
(
<
MessageBubble
message=
{
{
id
:
"streaming"
,
role
:
"assistant"
,
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
attachments
:
[]
}
}
isStreaming
isThinking=
{
streamData
.
isThinking
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
/>
)
}
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
<
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"
}
}
/>
<
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
>
)
}
{
/* Messages */
}
{
/* Input Area */
}
<
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"
>
<
div
className=
"border-t border-anton-border bg-anton-surface p-4"
>
{
messages
.
map
(
m
=>
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
onCommit=
{
handleCommitFromChat
}
/>)
}
{
showSettings
&&
(
{
streaming
&&
(
streamData
.
thinking
||
streamData
.
text
)
&&
(
<
div
className=
"mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in"
>
<
MessageBubble
message=
{
{
id
:
"streaming"
,
role
:
"assistant"
,
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
attachments
:
[]
}
}
isStreaming
isThinking=
{
streamData
.
isThinking
}
token=
{
state
.
token
}
/>
<
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
>
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
<
button
onClick=
{
toggleSettings
}
className=
"text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
<
div
className=
"flex items-center gap-2 px-3 py-3 animate-fade-in"
>
</
div
>
<
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
>
<
div
>
<
span
className=
"text-anton-muted text-sm"
>
Thinking…
</
span
>
<
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"
>
)
}
{
MODELS
.
map
((
m
)
=>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>)
}
</
div
>
</
select
>
</
div
>
{
/* Input area */
}
<
div
>
<
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"
>
<
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
>
{
showSettings
&&
(
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
/>
<
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
>
<
div
className=
"flex items-center justify-between"
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
><
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Settings
</
h3
>
<
button
onClick=
{
toggleSettings
}
className=
"p-1 text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
<
select
value=
{
model
}
onChange=
{
e
=>
setModel
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"
>
{
MODELS
.
map
(
m
=>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>)
}
</
select
>
</
div
>
<
div
>
<
div
className=
"flex justify-between text-xs mb-1.5"
><
span
className=
"text-anton-muted"
>
Max Tokens
</
span
><
span
className=
"text-anton-accent font-mono"
>
{
maxTokens
.
toLocaleString
()
}
</
span
></
div
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
e
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
<
div
>
<
div
className=
"flex justify-between text-xs mb-1.5"
><
span
className=
"text-anton-muted flex items-center gap-1"
><
Brain
size=
{
12
}
className=
"text-purple-400"
/>
Reasoning
</
span
><
span
className=
"text-purple-400 font-mono"
>
{
reasoningBudget
===
0
?
"Off"
:
reasoningBudget
.
toLocaleString
()
}
</
span
></
div
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
e
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
BookOpen
size=
{
12
}
/>
Knowledge Base
</
label
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
e
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.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
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
GitBranch
size=
{
12
}
className=
"text-orange-400"
/>
Repository
</
label
>
<
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
>
<
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"
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
/>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
BookOpen
size=
{
12
}
/>
Knowledge Base
</
label
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<
option
value=
""
>
None
</
option
>
<
option
value=
""
>
None
</
option
>
{
repos
.
map
(
r
=>
<
option
key=
{
r
.
id
}
value=
{
r
.
id
}
>
{
r
.
name
}
(
{
r
.
default_branch
}
)
</
option
>)
}
{
kbs
.
map
((
kb
)
=>
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs
)
</
option
>)
}
</
select
>
</
select
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
)
}
)
}
{
pendingFiles
.
length
>
0
&&
(
{
pendingFiles
.
length
>
0
&&
(
<
div
className=
"mb-2 flex flex-wrap gap-1.5
animate-fade-in"
>
<
div
className=
"mb-3 flex flex-wrap gap-2
animate-fade-in"
>
{
pendingFiles
.
map
((
pf
,
i
)
=>
{
{
pendingFiles
.
map
((
pf
,
i
)
=>
(
const
Icon
=
TYPE_ICONS
[
pf
.
type
]
||
FileText
;
<
div
key=
{
i
}
className=
"relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden"
>
return
(
{
pf
.
type
===
"image"
&&
pf
.
preview
?
(
<
div
key=
{
i
}
className=
{
`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`
}
>
<
img
src=
{
pf
.
preview
}
alt=
""
className=
"w-16 h-16 object-cover"
/
>
{
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
4 h-14 sm:w-16 sm:
h-16 flex flex-col items-center justify-center px-1"
>
<
div
className=
"w-1
6
h-16 flex flex-col items-center justify-center px-1"
>
<
Icon
size=
{
16
}
className=
{
`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`
}
/>
<
FileText
size=
{
20
}
className=
"text-anton-muted mb-1"
/>
<
span
className=
"text-[
7px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
8
)
}
</
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-
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
>
<
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
>
<
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
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
>
)
}
)
}
<
div
className=
"flex items-end gap-1.5"
>
<
div
className=
"flex items-end gap-2"
>
<
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=
{
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 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
>
<
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
>
<
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
=
""
;
}
}
/>
{
linkedRepo
&&
(
<
div
className=
"flex-1 min-w-0"
>
<
button
onClick=
{
()
=>
setShowRepoPanel
(
!
showRepoPanel
)
}
title=
{
`${linkedRepo.name} — File Browser`
}
<
textarea
ref=
{
inputRef
}
value=
{
input
}
onChange=
{
e
=>
setInput
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onPaste=
{
handlePaste
}
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"}`
}
>
placeholder=
{
pendingFiles
.
length
?
"Add a message…"
:
linkedRepo
?
`Ask about ${linkedRepo.name}…`
:
"Ask anything…"
}
<
GitBranch
size=
{
18
}
/>
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"
</
button
>
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
>
{
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
>
{
streaming
?
(
<
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
)
||
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"
>
{
uploading
?
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
:
<
Send
size=
{
18
}
/>
}
</
button
>
)
}
</
div
>
<
div
className=
"flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap"
>
<
div
className=
"flex items-center gap-3 mt-2 text-[11px] text-anton-muted"
>
<
span
>
{
MODELS
.
find
(
m
=>
m
.
id
===
model
)?.
label
}
</
span
>
<
span
>
{
MODELS
.
find
((
m
)
=>
m
.
id
===
model
)?.
label
}
</
span
>
<
span
>
•
</
span
><
span
>
{
maxTokens
.
toLocaleString
()
}
tok
</
span
>
<
span
>
•
</
span
><
span
>
{
maxTokens
.
toLocaleString
()
}
tokens
</
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-orange-400"
>
🔀
{
linkedRepo
.
name
}
</
span
></>
}
{
linkedRepo
&&
<><
span
>
•
</
span
><
span
className=
"text-green-400"
>
🔗
{
linkedRepo
.
name
}
</
span
></>
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
</
span
></>
}
{
pendingFiles
.
length
>
0
&&
<><
span
>
•
</
span
><
span
className=
"text-blue-400"
>
📎
{
pendingFiles
.
length
}
file
{
pendingFiles
.
length
!==
1
?
"s"
:
""
}
</
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
,
currentChat
?.
title
);
}
catch
{
}
}
}
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Code
</
button
>
<
button
onClick=
{
async
()
=>
{
const
all
=
messages
.
filter
((
m
)
=>
m
.
role
===
"assistant"
).
map
((
m
)
=>
m
.
content
).
join
(
"
\n\n
---
\n\n
"
);
if
(
all
)
try
{
await
downloadZip
(
state
.
token
,
all
);
}
catch
{
}
}
}
)
}
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Download code
</
button
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Repo File Panel (slides in from right) */
}
{
showRepoPanel
&&
linkedRepo
&&
(
<
RepoFilePanel
linkedRepo=
{
linkedRepo
}
token=
{
state
.
token
}
onClose=
{
()
=>
setShowRepoPanel
(
false
)
}
/>
)
}
</
div
>
</
div
>
);
);
}
}
\ No newline at end of file
frontend/src/components/CodeBlock.jsx
View file @
02876ff2
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
{
oneDark
}
from
"react-syntax-highlighter/dist/esm/styles/prism"
;
import
{
vscDarkPlus
}
from
"react-syntax-highlighter/dist/esm/styles/prism"
;
import
{
Copy
,
Check
,
Download
,
GitCommitVertical
}
from
"lucide-react"
;
import
{
Copy
,
Check
,
Download
,
GitBranch
,
Loader2
,
CheckCircle2
,
XCircle
}
from
"lucide-react"
;
import
{
gitlabFileExists
,
gitlabCommitSingle
}
from
"../api"
;
export
default
function
CodeBlock
({
language
,
filename
,
code
,
linkedRepo
,
onCommit
})
{
const
COMMIT_STATES
=
{
idle
:
"idle"
,
checking
:
"checking"
,
committing
:
"committing"
,
success
:
"success"
,
error
:
"error"
};
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
);
...
@@ -22,43 +29,119 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
...
@@ -22,43 +29,119 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
URL
.
revokeObjectURL
(
url
);
URL
.
revokeObjectURL
(
url
);
}
}
function
handleCommit
()
{
function
toggleCommitForm
()
{
if
(
!
onCommit
||
!
filename
)
return
;
if
(
!
showCommitForm
)
{
onCommit
(
filename
,
code
,
"update"
);
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=
"
my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]
"
>
<
div
className=
"
rounded-lg overflow-hidden border border-anton-border my-3
"
>
{
/* Header */
}
{
/* Header */
}
<
div
className=
"flex items-center justify-between px-3 py-1.5 bg-anton-surface 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 min-w-0"
>
<
span
className=
"text-xs text-anton-muted font-mono truncate min-w-0"
>
{
language
&&
<
span
className=
"text-[10px] text-anton-accent font-mono uppercase"
>
{
language
}
</
span
>
}
{
filename
||
language
||
"code"
}
{
filename
&&
<
span
className=
"text-[10px] text-anton-muted truncate"
>
{
filename
}
</
span
>
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-1 shrink-0"
>
<
div
className=
"flex items-center gap-0.5 shrink-0"
>
{
canCommit
&&
(
{
linkedRepo
&&
filename
&&
(
<
button
onClick=
{
toggleCommitForm
}
title=
"Commit to repo"
<
button
onClick=
{
handleCommit
}
title=
{
`Commit to ${linkedRepo.name}`
}
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-orange-400 hover:bg-orange-400/10 rounded transition"
>
? "bg-green-500/20 text-green-400"
<
GitCommitVertical
size=
{
11
}
/>
Commit
: "text-anton-muted hover:text-green-400 hover:bg-green-500/10"
}`
}
>
<
GitBranch
size=
{
12
}
/>
<
span
className=
"hidden sm:inline"
>
Push
</
span
>
</
button
>
</
button
>
)
}
)
}
<
button
onClick=
{
handleDownload
}
className=
"p-1.5 text-anton-muted hover:text-white transition"
title=
"Download"
>
{
filename
&&
(
<
Download
size=
{
12
}
/>
<
button
onClick=
{
handleDownload
}
title=
"Download file"
</
button
>
className=
"p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition"
>
<
button
onClick=
{
handleCopy
}
className=
"p-1.5 text-anton-muted hover:text-white transition"
title=
"Copy"
>
<
Download
size=
{
13
}
/>
{
copied
?
<
Check
size=
{
12
}
className=
"text-green-400"
/>
:
<
Copy
size=
{
12
}
/>
}
</
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
>
</
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 */
}
{
/* Code */
}
<
SyntaxHighlighter
<
SyntaxHighlighter
language=
{
language
||
"text"
}
language=
{
language
||
"text"
}
style=
{
oneDark
}
style=
{
vscDarkPlus
}
customStyle=
{
{
margin
:
0
,
padding
:
"12px 16px"
,
fontSize
:
"12px"
,
lineHeight
:
"1.5"
,
background
:
"transparent"
}
}
customStyle=
{
{
margin
:
0
,
padding
:
"1rem"
,
fontSize
:
"0.8rem"
,
background
:
"#1a1a2e"
,
maxHeight
:
"500px"
}
}
showLineNumbers=
{
code
.
split
(
"
\n
"
).
length
>
3
}
showLineNumbers
lineNumberStyle=
{
{
color
:
"#555"
,
fontSize
:
"10px"
,
paddingRight
:
"12px"
}
}
lineNumberStyle=
{
{
minWidth
:
"2.5em"
,
paddingRight
:
"1em"
,
color
:
"#555"
}
}
wrapLongLines
>
>
{
code
}
{
code
}
</
SyntaxHighlighter
>
</
SyntaxHighlighter
>
...
...
frontend/src/components/MessageBubble.jsx
View file @
02876ff2
...
@@ -8,16 +8,22 @@ import {
...
@@ -8,16 +8,22 @@ import {
Image
,
Film
,
FileText
,
ExternalLink
,
Image
,
Film
,
FileText
,
ExternalLink
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
const
FILE_TYPE_ICONS
=
{
image
:
Image
,
video
:
Film
,
document
:
FileText
,
text
:
FileText
};
const
FILE_TYPE_ICONS
=
{
image
:
Image
,
video
:
Film
,
document
:
FileText
,
text
:
FileText
,
};
const
MessageBubble
=
React
.
memo
(
function
MessageBubble
({
message
,
isStreaming
,
isThinking
,
token
,
linkedRepo
,
onCommit
})
{
const
MessageBubble
=
React
.
memo
(
function
MessageBubble
({
message
,
isStreaming
,
isThinking
,
token
,
linkedRepo
})
{
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
()
{
navigator
.
clipboard
.
writeText
(
content
||
""
);
setCopied
(
true
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
}
function
handleCopy
()
{
navigator
.
clipboard
.
writeText
(
content
||
""
);
setCopied
(
true
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
}
const
hasAttachments
=
attachments
&&
attachments
.
length
>
0
;
const
hasAttachments
=
attachments
&&
attachments
.
length
>
0
;
...
@@ -34,14 +40,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -34,14 +40,16 @@ 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
)
}
className=
"flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
>
<
button
onClick=
{
()
=>
setShowThinking
(
!
showThinking
)
}
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
}{
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse"
/>
}
{
thinking_content
}
{
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse"
/>
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -49,7 +57,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -49,7 +57,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"
)
{
...
@@ -57,13 +65,18 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -57,13 +65,18 @@ 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
)
}
onError=
{
e
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
/>
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
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"
onClick=
{
()
=>
setExpandedImage
(
null
)
}
>
<
div
className=
"fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
className=
"max-w-full max-h-full object-contain rounded-lg"
/>
onClick=
{
()
=>
setExpandedImage
(
null
)
}
>
<
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"
>
{
att
.
original_filename
}
</
div
>
<
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
>
</
div
>
</
div
>
);
);
}
}
...
@@ -82,7 +95,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -82,7 +95,8 @@ 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
>
)
:
(
)
:
(
...
@@ -93,14 +107,28 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -93,14 +107,28 @@ 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
(
":"
))
{
const
idx
=
rawLang
.
indexOf
(
":"
);
lang
=
rawLang
.
slice
(
0
,
idx
);
filename
=
rawLang
.
slice
(
idx
+
1
);
}
if
(
rawLang
.
includes
(
":"
))
{
return
<
CodeBlock
language=
{
lang
}
filename=
{
filename
}
code=
{
String
(
children
).
replace
(
/
\n
$/
,
""
)
}
linkedRepo=
{
linkedRepo
}
onCommit=
{
onCommit
}
/>;
const
idx
=
rawLang
.
indexOf
(
":"
);
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
&&
<
span
className=
"inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse"
/>
}
{
isStreaming
&&
!
isThinking
&&
(
<
span
className=
"inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse"
/>
)
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -108,10 +136,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -108,10 +136,13 @@ 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
?
"Copied"
:
"Copy"
}
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
"Copied"
:
"Copy"
}
</
button
>
</
button
>
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
<
span
className=
"text-[11px] text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓ /
{
output_tokens
?.
toLocaleString
()
}
↑
</
span
>
<
span
className=
"text-[11px] text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓ /
{
output_tokens
?.
toLocaleString
()
}
↑ tokens
</
span
>
)
}
)
}
</
div
>
</
div
>
)
}
)
}
...
...
frontend/src/components/RepoFilePanel.jsx
0 → 100644
View file @
02876ff2
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
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