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
34d5121e
Commit
34d5121e
authored
Apr 10, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update frontend/src/components/MessageBubble.jsx via Son of Anton
parent
9bdbdc7a
Changes
1
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
60 additions
and
203 deletions
+60
-203
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+60
-203
No files found.
frontend/src/components/MessageBubble.jsx
View file @
34d5121e
import
React
,
{
useState
,
useMemo
,
useCallback
}
from
"react"
;
import
React
,
{
useState
}
from
"react"
;
import
ReactMarkdown
from
"react-markdown"
;
import
ReactMarkdown
from
"react-markdown"
;
import
remarkGfm
from
"remark-gfm"
;
import
remarkGfm
from
"remark-gfm"
;
import
CodeBlock
from
"./CodeBlock"
;
import
CodeBlock
from
"./CodeBlock"
;
import
UIPreview
,
{
buildPreviewHTML
,
isPreviewable
}
from
"./UIPreview"
;
import
{
getAttachmentUrl
}
from
"../api"
;
import
{
getAttachmentUrl
,
extractCodeBlocks
,
commitFromChat
,
exportPptx
,
exportDocx
}
from
"../api"
;
import
{
import
{
User
,
Flame
,
ChevronDown
,
ChevronRight
,
Brain
,
Copy
,
Check
,
User
,
Flame
,
ChevronDown
,
ChevronRight
,
Brain
,
Copy
,
Check
,
Image
,
Film
,
FileText
,
ExternalLink
,
GitCommitVertical
,
Loader2
,
Image
,
Film
,
FileText
,
ExternalLink
,
Presentation
,
FileOutput
,
Eye
,
}
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
,
chatId
})
{
const
MessageBubble
=
React
.
memo
(
function
MessageBubble
({
message
,
isStreaming
,
isThinking
,
token
,
onCommitFile
})
{
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
);
const
[
batchCommitting
,
setBatchCommitting
]
=
useState
(
false
);
const
[
batchDone
,
setBatchDone
]
=
useState
(
false
);
const
[
exportingType
,
setExportingType
]
=
useState
(
""
);
const
[
showUIPreview
,
setShowUIPreview
]
=
useState
(
false
);
const
handleCopy
=
useCallback
(()
=>
{
function
handleCopy
()
{
navigator
.
clipboard
.
writeText
(
content
||
""
);
navigator
.
clipboard
.
writeText
(
content
||
""
);
setCopied
(
true
);
setCopied
(
true
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
},
[
content
]);
const
committableBlocks
=
useMemo
(()
=>
{
if
(
isUser
||
!
content
||
!
linkedRepo
)
return
[];
return
extractCodeBlocks
(
content
).
filter
(
b
=>
b
.
filename
);
},
[
content
,
isUser
,
linkedRepo
]);
const
codeBlocks
=
useMemo
(()
=>
{
if
(
isUser
||
!
content
)
return
[];
return
extractCodeBlocks
(
content
);
},
[
content
,
isUser
]);
const
previewable
=
useMemo
(()
=>
isPreviewable
(
codeBlocks
),
[
codeBlocks
]);
const
previewHTML
=
useMemo
(()
=>
{
if
(
!
previewable
)
return
null
;
return
buildPreviewHTML
(
codeBlocks
);
},
[
previewable
,
codeBlocks
]);
async
function
handleBatchCommit
()
{
if
(
!
committableBlocks
.
length
||
!
linkedRepo
||
!
chatId
)
return
;
const
msg
=
prompt
(
`Commit
${
committableBlocks
.
length
}
file(s) to
${
linkedRepo
.
name
}
/
${
linkedRepo
.
default_branch
}
:`
,
`Update
${
committableBlocks
.
length
}
files via Son of Anton`
);
if
(
!
msg
)
return
;
setBatchCommitting
(
true
);
try
{
await
commitFromChat
(
token
,
chatId
,
{
branch
:
linkedRepo
.
default_branch
,
commit_message
:
msg
,
files
:
committableBlocks
.
map
(
b
=>
({
file_path
:
b
.
filename
,
content
:
b
.
code
,
action
:
"auto"
,
})),
});
setBatchDone
(
true
);
setTimeout
(()
=>
setBatchDone
(
false
),
4000
);
}
catch
(
e
)
{
alert
(
`❌
${
e
.
message
}
`
);
}
setBatchCommitting
(
false
);
}
async
function
handleMsgExport
(
type
)
{
if
(
!
content
)
return
;
setExportingType
(
type
);
try
{
if
(
type
===
"pptx"
)
await
exportPptx
(
token
,
content
,
"response"
);
else
await
exportDocx
(
token
,
content
,
"response"
);
}
catch
(
e
)
{
alert
(
`Export failed:
${
e
.
message
}
`
);
}
setExportingType
(
""
);
}
}
const
hasAttachments
=
attachments
&&
attachments
.
length
>
0
;
const
hasAttachments
=
attachments
&&
attachments
.
length
>
0
;
return
(
return
(
<
div
className=
{
`flex gap-
2 sm:gap-
3 animate-fade-in ${isUser ? "justify-end" : ""}`
}
>
<
div
className=
{
`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`
}
>
{
!
isUser
&&
(
{
!
isUser
&&
(
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"w-
7 h-7 sm:w-8 sm:
h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"
>
<
div
className=
"w-
8
h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"
>
<
Flame
size=
{
1
4
}
className=
"text-white"
/>
<
Flame
size=
{
1
6
}
className=
"text-white"
/>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
<
div
className=
{
`max-w-[8
5%] sm:max-w-[8
0%] ${isUser ? "order-first" : ""}`
}
>
<
div
className=
{
`max-w-[80%] ${isUser ? "order-first" : ""}`
}
>
{
thinking_content
&&
(
{
thinking_content
&&
(
<
div
className=
"mb-2"
>
<
div
className=
"mb-2"
>
<
button
<
button
onClick=
{
()
=>
setShowThinking
(
!
showThinking
)
}
onClick=
{
()
=>
setShowThinking
(
!
showThinking
)
}
className=
"flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
>
className=
"flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
>
<
Brain
size=
{
12
}
/>
<
Brain
size=
{
12
}
/>
{
showThinking
?
<
ChevronDown
size=
{
12
}
/>
:
<
ChevronRight
size=
{
12
}
/>
}
{
showThinking
?
<
ChevronDown
size=
{
12
}
/>
:
<
ChevronRight
size=
{
12
}
/>
}
{
isThinking
?
<
span
className=
"thinking-pulse"
>
Reasoning…
</
span
>
:
<
span
>
View reasoning
</
span
>
}
{
isThinking
?
<
span
className=
"thinking-pulse"
>
Reasoning…
</
span
>
:
<
span
>
View reasoning
</
span
>
}
...
@@ -124,86 +63,67 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -124,86 +63,67 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
if
(
att
.
file_type
===
"image"
)
{
if
(
att
.
file_type
===
"image"
)
{
return
(
return
(
<
div
key=
{
att
.
id
}
className=
"relative group"
>
<
div
key=
{
att
.
id
}
className=
"relative group"
>
<
img
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
src=
{
`${url}?token=${token}`
}
className=
"max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
alt=
{
att
.
original_filename
}
className=
"max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
onClick=
{
()
=>
setExpandedImage
(
expandedImage
===
att
.
id
?
null
:
att
.
id
)
}
onError=
{
(
e
)
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
onError=
{
(
e
)
=>
{
e
.
target
.
style
.
display
=
"none"
;
}
}
/>
loading=
"lazy"
/>
{
expandedImage
===
att
.
id
&&
(
{
expandedImage
===
att
.
id
&&
(
<
div
<
div
className=
"fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
className=
"fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 cursor-pointer"
onClick=
{
()
=>
setExpandedImage
(
null
)
}
>
onClick=
{
()
=>
setExpandedImage
(
null
)
}
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
>
className=
"max-w-full max-h-full object-contain rounded-lg"
/>
<
img
src=
{
`${url}?token=${token}`
}
alt=
{
att
.
original_filename
}
className=
"max-w-full max-h-full object-contain rounded-lg"
/>
</
div
>
</
div
>
)
}
)
}
<
div
className=
"absolute bottom-1 left-1 bg-black/60 text-[
8
px] text-white px-1.5 py-0.5 rounded"
>
<
div
className=
"absolute bottom-1 left-1 bg-black/60 text-[
9
px] text-white px-1.5 py-0.5 rounded"
>
{
att
.
original_filename
}
{
att
.
original_filename
}
</
div
>
</
div
>
</
div
>
</
div
>
);
);
}
}
return
(
return
(
<
a
<
a
key=
{
att
.
id
}
href=
{
`${url}?token=${token}`
}
target=
"_blank"
rel=
"noopener noreferrer"
key=
{
att
.
id
}
className=
"flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2 hover:border-anton-accent transition group"
>
href=
{
`${url}?token=${token}`
}
<
Icon
size=
{
16
}
className=
"shrink-0 text-blue-400"
/>
target=
"_blank"
rel=
"noopener noreferrer"
className=
"flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 hover:border-anton-accent transition group"
>
<
Icon
size=
{
14
}
className=
"shrink-0 text-blue-400"
/>
<
div
className=
"min-w-0"
>
<
div
className=
"min-w-0"
>
<
div
className=
"text-
[11px] text-white truncate max-w-[12
0px]"
>
{
att
.
original_filename
}
</
div
>
<
div
className=
"text-
xs text-white truncate max-w-[16
0px]"
>
{
att
.
original_filename
}
</
div
>
<
div
className=
"text-[
9
px] text-anton-muted"
>
{
(
att
.
file_size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
<
div
className=
"text-[
10
px] text-anton-muted"
>
{
(
att
.
file_size
/
1024
).
toFixed
(
0
)
}
KB
</
div
>
</
div
>
</
div
>
<
ExternalLink
size=
{
1
0
}
className=
"text-anton-muted group-hover:text-anton-accent shrink-0"
/>
<
ExternalLink
size=
{
1
2
}
className=
"text-anton-muted group-hover:text-anton-accent shrink-0"
/>
</
a
>
</
a
>
);
);
})
}
})
}
</
div
>
</
div
>
)
}
)
}
<
div
className=
{
`rounded-2xl px-3 sm:px-4 py-2.5 sm:py-3 ${isUser
<
div
className=
{
`rounded-2xl px-4 py-3 ${
? "bg-anton-accent text-white rounded-br-md"
isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`
}
>
}`
}
>
{
isUser
?
(
{
isUser
?
(
<
div
className=
"text-sm whitespace-pre-wrap"
>
{
_stripPrefixes
(
content
)
}
</
div
>
<
div
className=
"text-sm whitespace-pre-wrap"
>
{
_stripPrefixes
(
content
)
}
</
div
>
)
:
(
)
:
(
<
div
className=
"prose-anton text-sm"
>
<
div
className=
"prose-anton text-sm"
>
<
ReactMarkdown
<
ReactMarkdown
remarkPlugins=
{
[
remarkGfm
]
}
components=
{
{
remarkPlugins=
{
[
remarkGfm
]
}
code
({
node
,
inline
,
className
,
children
,
...
props
})
{
components=
{
{
const
match
=
/language-
(\S
+
)
/
.
exec
(
className
||
""
);
code
({
node
,
inline
,
className
,
children
,
...
props
})
{
const
rawLang
=
match
?.[
1
]
||
""
;
const
match
=
/language-
(\S
+
)
/
.
exec
(
className
||
""
);
if
(
inline
)
return
<
code
className=
{
className
}
{
...
props
}
>
{
children
}
</
code
>;
const
rawLang
=
match
?.[
1
]
||
""
;
let
lang
=
rawLang
,
filename
=
null
;
if
(
inline
)
return
<
code
className=
{
className
}
{
...
props
}
>
{
children
}
</
code
>;
if
(
rawLang
.
includes
(
":"
))
{
let
lang
=
rawLang
,
filename
=
null
;
const
idx
=
rawLang
.
indexOf
(
":"
);
if
(
rawLang
.
includes
(
":"
))
{
lang
=
rawLang
.
slice
(
0
,
idx
);
const
idx
=
rawLang
.
indexOf
(
":"
);
filename
=
rawLang
.
slice
(
idx
+
1
);
lang
=
rawLang
.
slice
(
0
,
idx
);
}
filename
=
rawLang
.
slice
(
idx
+
1
);
return
(
}
<
CodeBlock
return
(
language=
{
lang
}
<
CodeBlock
filename=
{
filename
}
language=
{
lang
}
code=
{
String
(
children
).
replace
(
/
\n
$/
,
""
)
}
filename=
{
filename
}
onCommitFile=
{
onCommitFile
}
code=
{
String
(
children
).
replace
(
/
\n
$/
,
""
)
}
/>
linkedRepo=
{
linkedRepo
}
);
onCommit=
{
onCommit
}
},
/>
pre
({
children
})
{
return
<>
{
children
}
</>;
},
);
}
}
>
},
pre
({
children
})
{
return
<>
{
children
}
</>;
},
}
}
>
{
content
||
""
}
{
content
||
""
}
</
ReactMarkdown
>
</
ReactMarkdown
>
{
isStreaming
&&
!
isThinking
&&
(
{
isStreaming
&&
!
isThinking
&&
(
...
@@ -213,66 +133,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -213,66 +133,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)
}
)
}
</
div
>
</
div
>
{
/* Preview UI Card */
}
{
!
isUser
&&
!
isStreaming
&&
previewable
&&
previewHTML
&&
(
<
button
onClick=
{
()
=>
setShowUIPreview
(
true
)
}
className=
"mt-2 w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-blue-500/30 bg-blue-500/5 hover:bg-blue-500/10 hover:border-blue-500/50 transition group"
>
<
div
className=
"w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0 group-hover:bg-blue-500/30 transition"
>
<
Eye
size=
{
18
}
className=
"text-blue-400"
/>
</
div
>
<
div
className=
"text-left min-w-0"
>
<
p
className=
"text-sm text-white font-medium"
>
Preview UI Design
</
p
>
<
p
className=
"text-[10px] text-blue-400/70"
>
{
codeBlocks
.
length
}
code block
{
codeBlocks
.
length
>
1
?
"s"
:
""
}
• Click to open live preview
</
p
>
</
div
>
<
Eye
size=
{
16
}
className=
"text-blue-400/50 group-hover:text-blue-400 transition ml-auto shrink-0"
/>
</
button
>
)
}
{
!
isUser
&&
!
isStreaming
&&
content
&&
(
{
!
isUser
&&
!
isStreaming
&&
content
&&
(
<
div
className=
"flex items-center gap-
2 sm:gap-3 mt-1.5 px-1 flex-wrap
"
>
<
div
className=
"flex items-center gap-
3 mt-1.5 px-1
"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[1
0
px] text-anton-muted hover:text-white transition"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[1
1
px] text-anton-muted hover:text-white transition"
>
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
"Copied"
:
"Copy"
}
{
copied
?
"Copied"
:
"Copy"
}
</
button
>
</
button
>
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
<
span
className=
"text-[10px] text-anton-muted"
>
<
span
className=
"text-[11px] text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓/
{
output_tokens
?.
toLocaleString
()
}
↑
{
input_tokens
?.
toLocaleString
()
}
↓ /
{
output_tokens
?.
toLocaleString
()
}
↑ tokens
</
span
>
)
}
{
/* Per-message export */
}
<
button
onClick=
{
()
=>
handleMsgExport
(
"pptx"
)
}
disabled=
{
!!
exportingType
}
className=
"flex items-center gap-1 text-[10px] text-anton-muted hover:text-orange-400 transition disabled:opacity-30"
title=
"Export as PPTX"
>
{
exportingType
===
"pptx"
?
<
Loader2
size=
{
10
}
className=
"animate-spin"
/>
:
<
Presentation
size=
{
10
}
/>
}
PPTX
</
button
>
<
button
onClick=
{
()
=>
handleMsgExport
(
"docx"
)
}
disabled=
{
!!
exportingType
}
className=
"flex items-center gap-1 text-[10px] text-anton-muted hover:text-blue-400 transition disabled:opacity-30"
title=
"Export as DOCX"
>
{
exportingType
===
"docx"
?
<
Loader2
size=
{
10
}
className=
"animate-spin"
/>
:
<
FileOutput
size=
{
10
}
/>
}
DOCX
</
button
>
{
committableBlocks
.
length
>
0
&&
!
batchDone
&&
(
<
button
onClick=
{
handleBatchCommit
}
disabled=
{
batchCommitting
}
className=
"ml-auto flex items-center gap-1 text-[10px] text-orange-400 hover:text-orange-300 transition disabled:opacity-50"
>
{
batchCommitting
?
<
Loader2
size=
{
11
}
className=
"animate-spin"
/>
:
<
GitCommitVertical
size=
{
11
}
/>
}
Commit All (
{
committableBlocks
.
length
}
)
</
button
>
)
}
{
batchDone
&&
(
<
span
className=
"ml-auto flex items-center gap-1 text-[10px] text-green-400"
>
<
Check
size=
{
11
}
/>
Committed!
</
span
>
</
span
>
)
}
)
}
</
div
>
</
div
>
...
@@ -281,30 +150,18 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
...
@@ -281,30 +150,18 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{
isUser
&&
(
{
isUser
&&
(
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"w-
7 h-7 sm:w-8 sm:
h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"
>
<
div
className=
"w-
8
h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"
>
<
User
size=
{
1
4
}
className=
"text-anton-muted"
/>
<
User
size=
{
1
6
}
className=
"text-anton-muted"
/>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
{
/* UI Preview Modal */
}
{
showUIPreview
&&
previewHTML
&&
(
<
UIPreview
html=
{
previewHTML
}
title=
"UI Preview"
onClose=
{
()
=>
setShowUIPreview
(
false
)
}
/>
)
}
</
div
>
</
div
>
);
);
});
});
function
_stripPrefixes
(
text
)
{
function
_stripPrefixes
(
text
)
{
if
(
!
text
)
return
""
;
if
(
!
text
)
return
""
;
return
text
return
text
.
replace
(
/^
\[(?:
Image|Video|Document|File
)
:
\s[^\]]
*
\]\n?
/gm
,
""
).
trim
();
.
replace
(
/^
\[(?:
Image|Video|Document|File
)
:
\s[^\]]
*
\]\n?
/gm
,
""
)
.
replace
(
/^
\[
UI DESIGN MODE
\][\s\S]
*
?\n\n
/m
,
""
)
.
trim
();
}
}
export
default
MessageBubble
;
export
default
MessageBubble
;
\ 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