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
1a6de5e2
Commit
1a6de5e2
authored
Mar 30, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
try gitlabfix
parent
17ddd732
Changes
8
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
3296 additions
and
2057 deletions
+3296
-2057
FULL_CODEBASE.txt
FULL_CODEBASE.txt
+2866
-1975
chat_routes.py
backend/routes/chat_routes.py
+76
-5
generation_manager.py
backend/services/generation_manager.py
+94
-11
memory_service.py
backend/services/memory_service.py
+52
-1
api.js
frontend/src/api.js
+22
-3
ChatView.jsx
frontend/src/components/ChatView.jsx
+39
-18
CodeBlock.jsx
frontend/src/components/CodeBlock.jsx
+58
-18
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+89
-26
No files found.
FULL_CODEBASE.txt
View file @
1a6de5e2
This diff is collapsed.
Click to expand it.
backend/routes/chat_routes.py
View file @
1a6de5e2
"""
Chat CRUD and message streaming — v4.0.0
Now with linked_repo_id for project-aware conversations
.
Chat CRUD and message streaming — v4.0.0
Enhanced
Project-aware conversations + commit-from-chat support
.
"""
import
json
...
...
@@ -13,9 +13,9 @@ from fastapi.responses import StreamingResponse
from
sqlalchemy.orm
import
Session
from
backend.database
import
get_db
from
backend.models
import
User
,
Chat
,
Message
,
ChatAttachment
,
LinkedRepo
from
backend.auth
import
get_current_user
from
backend.services
import
attachment_service
from
backend.models
import
User
,
Chat
,
Message
,
ChatAttachment
,
LinkedRepo
,
GitLabSettings
from
backend.auth
import
get_current_user
,
require_superadmin
from
backend.services
import
attachment_service
,
gitlab_service
from
backend.services.generation_manager
import
manager
as
gen_manager
router
=
APIRouter
()
...
...
@@ -48,6 +48,12 @@ class SendMessageBody(BaseModel):
attachment_ids
:
list
[
str
]
=
[]
class
CommitFromChatBody
(
BaseModel
):
branch
:
str
commit_message
:
str
files
:
list
[
dict
]
# [{file_path, content, action}]
@
router
.
get
(
""
)
def
list_chats
(
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
)):
chats
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
user_id
==
user
.
id
)
.
order_by
(
Chat
.
updated_at
.
desc
())
.
all
()
...
...
@@ -163,6 +169,71 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
return
StreamingResponse
(
generate
(),
media_type
=
"text/event-stream"
)
@
router
.
post
(
"/{chat_id}/commit"
)
async
def
commit_from_chat
(
chat_id
:
str
,
body
:
CommitFromChatBody
,
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Commit code from chat directly to the linked repository."""
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
chat_id
,
Chat
.
user_id
==
user
.
id
)
.
first
()
if
not
chat
:
raise
HTTPException
(
404
,
"Chat not found"
)
if
not
chat
.
linked_repo_id
:
raise
HTTPException
(
400
,
"No repository linked to this chat"
)
repo
=
db
.
query
(
LinkedRepo
)
.
filter
(
LinkedRepo
.
id
==
chat
.
linked_repo_id
)
.
first
()
if
not
repo
:
raise
HTTPException
(
404
,
"Linked repository not found"
)
settings
=
db
.
query
(
GitLabSettings
)
.
first
()
if
not
settings
or
not
settings
.
is_active
:
raise
HTTPException
(
400
,
"GitLab not configured"
)
# Build commit actions
actions
=
[]
for
f
in
body
.
files
:
actions
.
append
({
"action"
:
f
.
get
(
"action"
,
"update"
),
"file_path"
:
f
[
"file_path"
],
"content"
:
f
[
"content"
],
})
if
not
actions
:
raise
HTTPException
(
400
,
"No files to commit"
)
try
:
result
=
await
gitlab_service
.
commit_files
(
settings
.
gitlab_url
,
settings
.
gitlab_token
,
repo
.
gitlab_project_id
,
body
.
branch
,
body
.
commit_message
,
actions
,
)
# Invalidate repo context cache so next message sees updated code
gen_manager
.
invalidate_repo_cache
(
repo
.
id
)
return
{
"ok"
:
True
,
"commit"
:
result
,
"files_committed"
:
len
(
actions
),
}
except
gitlab_service
.
GitLabError
as
e
:
raise
HTTPException
(
e
.
status_code
,
f
"Commit failed: {e.detail}"
)
@
router
.
post
(
"/{chat_id}/refresh-repo"
)
async
def
refresh_repo_context
(
chat_id
:
str
,
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Force-refresh the cached repo context for this chat."""
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
chat_id
,
Chat
.
user_id
==
user
.
id
)
.
first
()
if
not
chat
or
not
chat
.
linked_repo_id
:
raise
HTTPException
(
400
,
"No repo linked"
)
gen_manager
.
invalidate_repo_cache
(
chat
.
linked_repo_id
)
return
{
"ok"
:
True
}
def
_sse
(
data
):
return
f
"data: {json.dumps(data)}
\n\n
"
...
...
backend/services/generation_manager.py
View file @
1a6de5e2
"""
Background generation manager — v4.0.0
Background generation manager — v4.0.0
Enhanced
Decouples AI generation from the SSE HTTP connection.
Now includes repository context for project-aware conversations
.
Full repository awareness + persistent attachment context
.
"""
import
asyncio
import
json
import
time
from
datetime
import
datetime
from
typing
import
Optional
from
dataclasses
import
dataclass
,
field
...
...
@@ -17,6 +16,13 @@ from backend.system_prompt import build_full_prompt
from
backend.services
import
bedrock_service
,
memory_service
,
rag_service
,
attachment_service
,
gitlab_service
# ═══════════════════════════════════════════════════
# Repo context cache — avoids re-fetching every message
# ═══════════════════════════════════════════════════
_repo_cache
:
dict
[
str
,
tuple
[
float
,
str
]]
=
{}
REPO_CACHE_TTL
=
300
# 5 minutes
@
dataclass
class
GenerationState
:
events
:
list
=
field
(
default_factory
=
list
)
...
...
@@ -78,16 +84,43 @@ class GenerationManager:
break
await
asyncio
.
sleep
(
0.02
)
# ═══════════════════════════════════════════════════
# Build FULL repo context with file contents + cache
# ═══════════════════════════════════════════════════
async
def
_build_repo_context
(
self
,
db
,
chat
)
->
Optional
[
str
]:
"""
Build repository context string if a repo is linked to this chat
."""
"""
Load full repository file contents for project-aware conversations
."""
if
not
chat
.
linked_repo_id
:
return
None
repo
=
db
.
query
(
LinkedRepo
)
.
filter
(
LinkedRepo
.
id
==
chat
.
linked_repo_id
)
.
first
()
if
not
repo
:
return
None
settings
=
db
.
query
(
GitLabSettings
)
.
first
()
if
not
settings
or
not
settings
.
is_active
or
not
settings
.
gitlab_url
or
not
settings
.
gitlab_token
:
return
None
# Check cache
cache_key
=
f
"{repo.id}:{repo.default_branch}"
now
=
time
.
time
()
if
cache_key
in
_repo_cache
:
ts
,
ctx
=
_repo_cache
[
cache_key
]
if
now
-
ts
<
REPO_CACHE_TTL
:
return
ctx
try
:
# Load full file contents (up to 80 files, 300K chars)
result
=
await
gitlab_service
.
load_project_files
(
settings
.
gitlab_url
,
settings
.
gitlab_token
,
repo
.
gitlab_project_id
,
ref
=
repo
.
default_branch
,
)
context
=
self
.
_format_full_repo_context
(
result
,
repo
)
_repo_cache
[
cache_key
]
=
(
now
,
context
)
return
context
except
Exception
as
e
:
# Fallback: try just the tree
try
:
tree
=
await
gitlab_service
.
get_tree
(
settings
.
gitlab_url
,
settings
.
gitlab_token
,
...
...
@@ -95,7 +128,40 @@ class GenerationManager:
)
return
gitlab_service
.
format_tree_for_prompt
(
tree
,
repo
.
name
,
repo
.
default_branch
)
except
Exception
:
return
f
"[Repository: {repo.name} — could not load file tree]"
return
f
"[Repository: {repo.name} — could not load: {str(e)[:100]}]"
def
_format_full_repo_context
(
self
,
result
:
dict
,
repo
)
->
str
:
"""Format loaded project files into a prompt-friendly string."""
lines
=
[
f
"Repository: {repo.name}"
,
f
"Branch: {repo.default_branch}"
,
f
"Path: {repo.path_with_namespace}"
,
f
"Total files in tree: {result.get('total_files_in_tree', '?')}"
,
f
"Files loaded: {result.get('files_loaded', '?')}"
,
f
"Total characters: {result.get('total_characters', '?')}"
,
""
,
"FILE CONTENTS:"
,
"="
*
60
,
]
for
f
in
result
.
get
(
"files"
,
[]):
path
=
f
.
get
(
"path"
,
"?"
)
content
=
f
.
get
(
"content"
,
""
)
lines
.
append
(
f
"
\n
━━━ {path} ━━━"
)
lines
.
append
(
content
)
lines
.
append
(
f
"━━━ end {path} ━━━"
)
return
"
\n
"
.
join
(
lines
)
def
invalidate_repo_cache
(
self
,
repo_id
:
str
):
"""Call when a commit is made to refresh the cache."""
keys_to_remove
=
[
k
for
k
in
_repo_cache
if
k
.
startswith
(
f
"{repo_id}:"
)]
for
k
in
keys_to_remove
:
_repo_cache
.
pop
(
k
,
None
)
# ═══════════════════════════════════════════════════
# Main generation loop
# ═══════════════════════════════════════════════════
async
def
_run
(
self
,
...
...
@@ -118,6 +184,7 @@ class GenerationManager:
db_user
=
db
.
query
(
User
)
.
filter
(
User
.
id
==
user_id
)
.
first
()
# Quota check + reset
now
=
datetime
.
utcnow
()
if
db_user
.
quota_reset_date
and
now
>=
db_user
.
quota_reset_date
:
db_user
.
tokens_used_this_month
=
0
...
...
@@ -131,6 +198,7 @@ class GenerationManager:
state
.
events
.
append
({
"type"
:
"error"
,
"message"
:
"Monthly token quota exceeded."
})
return
# Process current message attachments
attachments
=
[]
if
attachment_ids
:
attachments
=
(
...
...
@@ -155,6 +223,7 @@ class GenerationManager:
if
attachments
:
db
.
commit
()
# RAG context
kb_id
=
knowledge_base_id
or
chat
.
knowledge_base_id
rag_context
=
None
if
kb_id
:
...
...
@@ -163,17 +232,29 @@ class GenerationManager:
except
Exception
:
pass
#
Build repo context for project-aware conversations
#
── FULL REPO CONTEXT (loads all file contents) ──
repo_context
=
await
self
.
_build_repo_context
(
db
,
chat
)
system_prompt
=
build_full_prompt
(
rag_context
,
repo_context
)
# ── PERSISTENT ATTACHMENT CONTEXT (all files in chat) ──
attachment_context
=
memory_service
.
gather_attachment_context
(
chat_id
,
db
)
# Build system prompt with ALL context
system_prompt
=
build_full_prompt
(
rag_context
=
rag_context
,
repo_context
=
repo_context
,
attachment_context
=
attachment_context
,
)
# Build conversation messages
messages
=
memory_service
.
build_messages
(
chat
,
db
)
# Inject current message's multimodal content blocks
if
attachments
and
messages
and
messages
[
-
1
][
"role"
]
==
"user"
:
content_blocks
=
attachment_service
.
build_claude_content_blocks
(
attachments
)
content_blocks
.
append
({
"type"
:
"text"
,
"text"
:
content
})
messages
[
-
1
][
"content"
]
=
content_blocks
# Thinking / reasoning config
effective_max
=
max_tokens
thinking_config
=
None
if
reasoning_budget
>
0
:
...
...
@@ -224,6 +305,7 @@ class GenerationManager:
usage
=
event
.
get
(
"usage"
,
{})
output_tokens
=
usage
.
get
(
"output_tokens"
,
0
)
# Save assistant message
assistant_msg
=
Message
(
chat_id
=
chat_id
,
role
=
"assistant"
,
content
=
full_text
,
thinking_content
=
full_thinking
or
None
,
...
...
@@ -240,6 +322,7 @@ class GenerationManager:
state
.
message_id
=
assistant_msg
.
id
# Auto-generate title for first message
msg_count
=
db
.
query
(
Message
)
.
filter
(
Message
.
chat_id
==
chat_id
)
.
count
()
if
msg_count
<=
2
and
chat
.
title
==
"New Chat"
:
try
:
...
...
backend/services/memory_service.py
View file @
1a6de5e2
"""
Build the messages list for the Bedrock/Anthropic API from chat history.
Also gathers attachment context for persistent file awareness.
"""
from
sqlalchemy.orm
import
Session
from
backend.models
import
Chat
,
Message
from
backend.models
import
Chat
,
Message
,
ChatAttachment
MAX_CONTEXT_CHARS
=
400_000
MAX_MESSAGES
=
80
MAX_ATTACHMENT_CONTEXT_CHARS
=
200_000
def
build_messages
(
chat
:
Chat
,
db
:
Session
)
->
list
[
dict
]:
...
...
@@ -47,3 +49,52 @@ def build_messages(chat: Chat, db: Session) -> list[dict]:
result
.
append
({
"role"
:
role
,
"content"
:
content
})
return
result
def
gather_attachment_context
(
chat_id
:
str
,
db
:
Session
)
->
str
|
None
:
"""
Collect text extracts from ALL attachments in a chat.
This ensures uploaded files remain "visible" to the AI
throughout the entire conversation, not just in the message
where they were uploaded.
"""
attachments
=
(
db
.
query
(
ChatAttachment
)
.
filter
(
ChatAttachment
.
chat_id
==
chat_id
)
.
order_by
(
ChatAttachment
.
created_at
)
.
all
()
)
if
not
attachments
:
return
None
parts
=
[]
total_chars
=
0
for
att
in
attachments
:
if
att
.
file_type
in
(
"image"
,):
# Images: just note they exist (can't include binary in text)
entry
=
f
"[Uploaded Image: {att.original_filename} ({att.file_size} bytes)]"
elif
att
.
file_type
in
(
"video"
,):
entry
=
f
"[Uploaded Video: {att.original_filename} ({att.file_size} bytes)]"
elif
att
.
text_extract
:
# Text files and PDFs: include full content
text
=
att
.
text_extract
remaining
=
MAX_ATTACHMENT_CONTEXT_CHARS
-
total_chars
if
remaining
<=
0
:
parts
.
append
(
f
"[{att.original_filename}: content truncated — context limit reached]"
)
continue
if
len
(
text
)
>
remaining
:
text
=
text
[:
remaining
]
+
"
\n
... [truncated]"
entry
=
(
f
"━━━ {att.original_filename} ({att.file_type}, {att.file_size} bytes) ━━━
\n
"
f
"{text}
\n
"
f
"━━━ end of {att.original_filename} ━━━"
)
else
:
entry
=
f
"[Uploaded {att.file_type}: {att.original_filename} ({att.file_size} bytes) — no text extracted]"
parts
.
append
(
entry
)
total_chars
+=
len
(
entry
)
return
"
\n\n
"
.
join
(
parts
)
if
parts
else
None
\ No newline at end of file
frontend/src/api.js
View file @
1a6de5e2
...
...
@@ -41,6 +41,11 @@ export const renameChat = (token, chatId, title) => updateChat(token, chatId, {
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
refreshRepoContext
=
(
token
,
chatId
)
=>
request
(
"POST"
,
`/chats/
${
chatId
}
/refresh-repo`
,
token
);
// ═══════════ Commit from Chat ═══════════
export
const
commitFromChat
=
(
token
,
chatId
,
data
)
=>
request
(
"POST"
,
`/chats/
${
chatId
}
/commit`
,
token
,
data
);
// ═══════════ Streaming ═══════════
export
async
function
*
streamMessage
(
token
,
chatId
,
body
,
signal
)
{
...
...
@@ -133,10 +138,24 @@ export async function downloadZip(token, markdown, chatTitle) {
}
}
// ═══════════════════════════════════════════════════
// GitLab CE Integration — v4.0.0
// ═══════════════════════════════════════════════════
// ═══════════ Utilities ═══════════
const
CODE_BLOCK_RE
=
/```
(\S
*
?)(?:
:
(\S
+
?))?\s
*
?\n([\s\S]
*
?)
```/g
;
export
function
extractCodeBlocks
(
markdown
)
{
if
(
!
markdown
)
return
[];
const
blocks
=
[];
let
match
;
const
re
=
new
RegExp
(
CODE_BLOCK_RE
.
source
,
"g"
);
while
((
match
=
re
.
exec
(
markdown
))
!==
null
)
{
const
lang
=
(
match
[
1
]
||
"text"
).
toLowerCase
();
const
filename
=
match
[
2
]
||
null
;
const
code
=
(
match
[
3
]
||
""
).
trim
();
if
(
code
)
blocks
.
push
({
language
:
lang
,
filename
,
code
});
}
return
blocks
;
}
// ═══════════ GitLab ═══════════
export
const
gitlabGetSettings
=
(
token
)
=>
request
(
"GET"
,
"/gitlab/settings"
,
token
);
export
const
gitlabUpdateSettings
=
(
token
,
data
)
=>
request
(
"PUT"
,
"/gitlab/settings"
,
token
,
data
);
export
const
gitlabTestConnection
=
(
token
)
=>
request
(
"POST"
,
"/gitlab/test-connection"
,
token
);
...
...
frontend/src/components/ChatView.jsx
View file @
1a6de5e2
import
React
,
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
"react"
;
import
React
,
{
useState
,
useEffect
,
useRef
,
useCallback
,
useMemo
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
getMessages
,
downloadZip
,
listKnowledgeBases
,
updateChat
,
uploadAttachments
,
gitlabListRepos
,
gitlabCommitSingle
}
from
"../api"
;
import
{
getMessages
,
downloadZip
,
listKnowledgeBases
,
updateChat
,
uploadAttachments
,
gitlabListRepos
,
gitlabCommitSingle
,
refreshRepoContext
,
}
from
"../api"
;
import
*
as
streamManager
from
"../streamManager"
;
import
MessageBubble
from
"./MessageBubble"
;
import
{
Send
,
Square
,
Settings2
,
X
,
Brain
,
BookOpen
,
Paperclip
,
FileText
,
Loader2
,
Upload
,
Film
,
Image
as
ImageIcon
,
FileCode
,
GitBranch
,
GitBranch
,
RefreshCw
,
}
from
"lucide-react"
;
const
MODELS
=
[
...
...
@@ -47,6 +51,7 @@ export default function ChatView({ chatId }) {
const
[
pendingFiles
,
setPendingFiles
]
=
useState
([]);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
dragOver
,
setDragOver
]
=
useState
(
false
);
const
[
refreshingRepo
,
setRefreshingRepo
]
=
useState
(
false
);
const
[
streamData
,
setStreamData
]
=
useState
(
streamManager
.
getStreamData
(
chatId
));
const
scrollRef
=
useRef
(
null
);
...
...
@@ -88,20 +93,23 @@ export default function ChatView({ chatId }) {
}
},
[
chatId
]);
function
onScroll
()
{
const
el
=
scrollRef
.
current
;
if
(
el
)
autoScroll
.
current
=
el
.
scrollHeight
-
el
.
scrollTop
-
el
.
clientHeight
<
200
;
}
const
onScroll
=
useCallback
(()
=>
{
const
el
=
scrollRef
.
current
;
if
(
el
)
autoScroll
.
current
=
el
.
scrollHeight
-
el
.
scrollTop
-
el
.
clientHeight
<
200
;
},
[]);
async
function
saveSettings
()
{
const
saveSettings
=
useCallback
(
async
()
=>
{
try
{
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
,
linked_repo_id
:
selectedRepoId
}
});
}
catch
{
}
}
}
,
[
state
.
token
,
chatId
,
model
,
maxTokens
,
reasoningBudget
,
selectedKbId
,
selectedRepoId
,
dispatch
]);
function
toggleSettings
()
{
if
(
showSettings
)
saveSettings
();
setShowSettings
(
!
showSettings
);
}
function
addFiles
(
files
)
{
setPendingFiles
(
prev
=>
[...
prev
,
...
files
.
map
(
f
=>
({
file
:
f
,
type
:
classifyFile
(
f
),
preview
:
classifyFile
(
f
)
===
"image"
?
URL
.
createObjectURL
(
f
)
:
null
}))]);
}
function
removePending
(
i
)
{
setPendingFiles
(
prev
=>
{
if
(
prev
[
i
]?.
preview
)
URL
.
revokeObjectURL
(
prev
[
i
].
preview
);
return
prev
.
filter
((
_
,
j
)
=>
j
!==
i
);
});
}
async
function
handleSend
()
{
const
handleSend
=
useCallback
(
async
()
=>
{
const
content
=
input
.
trim
();
if
((
!
content
&&
!
pendingFiles
.
length
)
||
streamData
.
streaming
)
return
;
const
text
=
content
||
"Please analyze the attached file(s)."
;
...
...
@@ -115,7 +123,7 @@ export default function ChatView({ chatId }) {
setInput
(
""
);
pendingFiles
.
forEach
(
p
=>
{
if
(
p
.
preview
)
URL
.
revokeObjectURL
(
p
.
preview
);
});
setPendingFiles
([]);
autoScroll
.
current
=
true
;
if
(
inputRef
.
current
)
inputRef
.
current
.
style
.
height
=
"auto"
;
streamManager
.
startStream
({
token
:
state
.
token
,
chatId
,
body
:
{
content
:
text
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
attachment_ids
:
attIds
}
});
}
}
,
[
input
,
pendingFiles
,
streamData
.
streaming
,
state
.
token
,
chatId
,
model
,
maxTokens
,
reasoningBudget
,
selectedKbId
,
dispatch
]);
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
));
}
...
...
@@ -124,15 +132,22 @@ export default function ChatView({ chatId }) {
const
streaming
=
streamData
.
streaming
;
const
linkedRepo
=
currentChat
?.
linked_repo
;
async
function
handleCommitFromChat
(
filePath
,
code
,
action
)
{
const
handleCommitFromChat
=
useCallback
(
async
(
filePath
,
code
,
action
)
=>
{
if
(
!
linkedRepo
)
return
;
const
branch
=
linkedRepo
.
default_branch
;
const
msg
=
prompt
(
"Commit message:"
,
`
Update
${
filePath
}
via Son of Anton`
);
const
msg
=
prompt
(
"Commit message:"
,
`
${
action
===
"create"
?
"Create"
:
"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
}
`
);
}
// Refresh repo cache so AI sees updated code
try
{
await
refreshRepoContext
(
state
.
token
,
chatId
);
}
catch
{
}
}
catch
(
e
)
{
alert
(
`❌
${
e
.
message
}
`
);
throw
e
;
}
},
[
linkedRepo
,
state
.
token
,
chatId
]);
async
function
handleRefreshRepo
()
{
setRefreshingRepo
(
true
);
try
{
await
refreshRepoContext
(
state
.
token
,
chatId
);
}
catch
{
}
setRefreshingRepo
(
false
);
}
return
(
...
...
@@ -149,20 +164,25 @@ export default function ChatView({ chatId }) {
<
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
>
<
span
className=
"text-orange-300/40"
>
Full codebase loaded
</
span
>
<
button
onClick=
{
handleRefreshRepo
}
disabled=
{
refreshingRepo
}
className=
"ml-auto text-orange-300/60 hover:text-orange-300 transition"
title=
"Refresh repo context"
>
<
RefreshCw
size=
{
12
}
className=
{
refreshingRepo
?
"animate-spin"
:
""
}
/>
</
button
>
</
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
}
/>)
}
{
messages
.
map
(
m
=>
(
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
token=
{
state
.
token
}
linkedRepo=
{
linkedRepo
}
onCommit=
{
handleCommitFromChat
}
chatId=
{
chatId
}
/>
))
}
{
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
}
/>
)
}
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
<
div
className=
"flex items-center gap-2 px-3 py-3 animate-fade-in"
>
<
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=
"text-anton-muted text-sm"
>
Thinking…
</
span
>
<
span
className=
"text-anton-muted text-sm"
>
{
linkedRepo
?
"Loading codebase & thinking…"
:
"Thinking…"
}
</
span
>
</
div
>
)
}
</
div
>
...
...
@@ -198,11 +218,12 @@ export default function ChatView({ chatId }) {
</
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
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
><
GitBranch
size=
{
12
}
className=
"text-orange-400"
/>
Repository
(AI sees all files)
</
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
>
{
repos
.
map
(
r
=>
<
option
key=
{
r
.
id
}
value=
{
r
.
id
}
>
{
r
.
name
}
(
{
r
.
default_branch
}
)
</
option
>)
}
{
repos
.
map
(
r
=>
<
option
key=
{
r
.
id
}
value=
{
r
.
id
}
>
🔀
{
r
.
name
}
(
{
r
.
default_branch
}
)
</
option
>)
}
</
select
>
<
p
className=
"text-[9px] text-orange-400/60 mt-1"
>
When linked, AI loads the full codebase into context.
</
p
>
</
div
>
)
}
</
div
>
...
...
@@ -214,7 +235,7 @@ export default function ChatView({ chatId }) {
const
Icon
=
TYPE_ICONS
[
pf
.
type
]
||
FileText
;
return
(
<
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"
/>
:
(
{
pf
.
type
===
"image"
&&
pf
.
preview
?
<
img
src=
{
pf
.
preview
}
alt=
""
className=
"w-14 h-14 sm:w-16 sm:h-16 object-cover"
loading=
"lazy"
/>
:
(
<
div
className=
"w-14 h-14 sm:w-16 sm: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`
}
/>
<
span
className=
"text-[7px] text-anton-muted text-center truncate w-full"
>
{
pf
.
file
.
name
.
slice
(
0
,
8
)
}
</
span
>
...
...
frontend/src/components/CodeBlock.jsx
View file @
1a6de5e2
import
React
,
{
useState
}
from
"react"
;
import
React
,
{
useState
,
useCallback
}
from
"react"
;
import
{
Prism
as
SyntaxHighlighter
}
from
"react-syntax-highlighter"
;
import
{
oneDark
}
from
"react-syntax-highlighter/dist/esm/styles/prism"
;
import
{
Copy
,
Check
,
Download
,
GitCommitVertical
}
from
"lucide-react"
;
import
{
Copy
,
Check
,
Download
,
GitCommitVertical
,
Loader2
,
Plus
,
Pencil
}
from
"lucide-react"
;
export
default
function
CodeBlock
({
language
,
filename
,
code
,
linkedRepo
,
onCommit
})
{
export
default
React
.
memo
(
function
CodeBlock
({
language
,
filename
,
code
,
linkedRepo
,
onCommit
})
{
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
[
committing
,
setCommitting
]
=
useState
(
false
);
const
[
commitDone
,
setCommitDone
]
=
useState
(
false
);
const
[
showCommitOptions
,
setShowCommitOptions
]
=
useState
(
false
);
function
handleCopy
()
{
const
handleCopy
=
useCallback
(()
=>
{
navigator
.
clipboard
.
writeText
(
code
);
setCopied
(
true
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
}
}
,
[
code
]);
function
handleDownload
()
{
const
handleDownload
=
useCallback
(()
=>
{
const
blob
=
new
Blob
([
code
],
{
type
:
"text/plain"
});
const
url
=
URL
.
createObjectURL
(
blob
);
const
a
=
document
.
createElement
(
"a"
);
...
...
@@ -20,28 +23,65 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
a
.
download
=
filename
||
`code.
${
language
||
"txt"
}
`
;
a
.
click
();
URL
.
revokeObjectURL
(
url
);
}
}
,
[
code
,
filename
,
language
]);
function
handleCommit
(
)
{
async
function
handleCommit
(
action
)
{
if
(
!
onCommit
||
!
filename
)
return
;
onCommit
(
filename
,
code
,
"update"
);
setCommitting
(
true
);
setShowCommitOptions
(
false
);
try
{
await
onCommit
(
filename
,
code
,
action
);
setCommitDone
(
true
);
setTimeout
(()
=>
setCommitDone
(
false
),
3000
);
}
catch
{
/* error handled in parent */
}
setCommitting
(
false
);
}
const
showGit
=
linkedRepo
&&
filename
;
const
lineCount
=
code
.
split
(
"
\n
"
).
length
;
return
(
<
div
className=
"my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]"
>
{
/* 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 px-3 py-1.5 bg-anton-surface border-b border-anton-border
gap-2
"
>
<
div
className=
"flex items-center gap-2 min-w-0"
>
{
language
&&
<
span
className=
"text-[10px] text-anton-accent font-mono uppercase"
>
{
language
}
</
span
>
}
{
language
&&
<
span
className=
"text-[10px] text-anton-accent font-mono uppercase
shrink-0
"
>
{
language
}
</
span
>
}
{
filename
&&
<
span
className=
"text-[10px] text-anton-muted truncate"
>
{
filename
}
</
span
>
}
</
div
>
<
div
className=
"flex items-center gap-0.5 shrink-0"
>
{
linkedRepo
&&
filename
&&
(
<
button
onClick=
{
handleCommit
}
title=
{
`Commit to ${linkedRepo.name}`
}
className=
"flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition"
>
{
/* Git commit buttons */
}
{
showGit
&&
!
commitDone
&&
(
<
div
className=
"relative"
>
{
committing
?
(
<
span
className=
"flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400"
>
<
Loader2
size=
{
11
}
className=
"animate-spin"
/>
Committing…
</
span
>
)
:
(
<
button
onClick=
{
()
=>
setShowCommitOptions
(
!
showCommitOptions
)
}
className=
"flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition"
title=
{
`Commit to ${linkedRepo.name}`
}
>
<
GitCommitVertical
size=
{
11
}
/>
Commit
</
button
>
)
}
{
showCommitOptions
&&
(
<
div
className=
"absolute right-0 top-full mt-1 z-20 bg-anton-card border border-anton-border rounded-lg shadow-xl p-1.5 min-w-[140px] animate-fade-in"
>
<
button
onClick=
{
()
=>
handleCommit
(
"update"
)
}
className=
"w-full flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-white hover:bg-anton-accent/10 rounded transition"
>
<
Pencil
size=
{
11
}
className=
"text-blue-400"
/>
Update file
</
button
>
<
button
onClick=
{
()
=>
handleCommit
(
"create"
)
}
className=
"w-full flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-white hover:bg-anton-accent/10 rounded transition"
>
<
Plus
size=
{
11
}
className=
"text-green-400"
/>
Create new
</
button
>
</
div
>
)
}
</
div
>
)
}
{
commitDone
&&
(
<
span
className=
"flex items-center gap-1 px-2 py-1 text-[10px] text-green-400"
>
<
Check
size=
{
11
}
/>
Committed!
</
span
>
)
}
<
button
onClick=
{
handleDownload
}
className=
"p-1.5 text-anton-muted hover:text-white transition"
title=
"Download"
>
<
Download
size=
{
12
}
/>
</
button
>
...
...
@@ -56,7 +96,7 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
language=
{
language
||
"text"
}
style=
{
oneDark
}
customStyle=
{
{
margin
:
0
,
padding
:
"12px 16px"
,
fontSize
:
"12px"
,
lineHeight
:
"1.5"
,
background
:
"transparent"
}
}
showLineNumbers=
{
code
.
split
(
"
\n
"
).
length
>
3
}
showLineNumbers=
{
lineCount
>
3
}
lineNumberStyle=
{
{
color
:
"#555"
,
fontSize
:
"10px"
,
paddingRight
:
"12px"
}
}
wrapLongLines
>
...
...
@@ -64,4 +104,4 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
</
SyntaxHighlighter
>
</
div
>
);
}
\ No newline at end of file
});
\ No newline at end of file
frontend/src/components/MessageBubble.jsx
View file @
1a6de5e2
This diff is collapsed.
Click to expand it.
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