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
e7ac6b83
Commit
e7ac6b83
authored
Mar 19, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
test
parent
bde969b1
Changes
13
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
3242 additions
and
1846 deletions
+3242
-1846
FULL_CODEBASE.txt
FULL_CODEBASE.txt
+1861
-1358
005_attachments.sql
backend/migrations/005_attachments.sql
+18
-0
attachments.py
backend/routes/attachments.py
+157
-0
messages_patch.py
backend/routes/messages_patch.py
+128
-0
file_processor.py
backend/services/file_processor.py
+166
-0
api.js
frontend/src/api.js
+57
-43
AttachmentPreview.jsx
frontend/src/components/AttachmentPreview.jsx
+159
-0
ChatView.jsx
frontend/src/components/ChatView.jsx
+357
-246
FileUploadButton.jsx
frontend/src/components/FileUploadButton.jsx
+82
-0
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+183
-93
ChatPage.jsx
frontend/src/pages/ChatPage.jsx
+6
-0
store.jsx
frontend/src/store.jsx
+15
-25
streamManager.js
frontend/src/streamManager.js
+53
-81
No files found.
FULL_CODEBASE.txt
View file @
e7ac6b83
This source diff could not be displayed because it is too large. You can
view the blob
instead.
backend/migrations/005_attachments.sql
0 → 100644
View file @
e7ac6b83
-- Attachments table for chat file uploads
CREATE
TABLE
IF
NOT
EXISTS
attachments
(
id
UUID
PRIMARY
KEY
DEFAULT
gen_random_uuid
(),
chat_id
UUID
NOT
NULL
REFERENCES
chats
(
id
)
ON
DELETE
CASCADE
,
message_id
UUID
REFERENCES
messages
(
id
)
ON
DELETE
SET
NULL
,
user_id
UUID
NOT
NULL
REFERENCES
users
(
id
)
ON
DELETE
CASCADE
,
filename
TEXT
NOT
NULL
,
original_filename
TEXT
NOT
NULL
,
mime_type
TEXT
NOT
NULL
,
file_size
INTEGER
NOT
NULL
,
media_type
TEXT
NOT
NULL
CHECK
(
media_type
IN
(
'image'
,
'video'
,
'document'
,
'text'
,
'unknown'
)),
storage_path
TEXT
NOT
NULL
,
created_at
TIMESTAMPTZ
DEFAULT
NOW
()
);
CREATE
INDEX
idx_attachments_chat
ON
attachments
(
chat_id
);
CREATE
INDEX
idx_attachments_message
ON
attachments
(
message_id
);
CREATE
INDEX
idx_attachments_user
ON
attachments
(
user_id
);
\ No newline at end of file
backend/routes/attachments.py
0 → 100644
View file @
e7ac6b83
"""
Son of Anton — Attachment Upload Routes
Handles file uploads for chat messages.
"""
import
os
import
uuid
import
shutil
from
pathlib
import
Path
from
datetime
import
datetime
,
timezone
from
fastapi
import
APIRouter
,
Depends
,
UploadFile
,
File
,
HTTPException
,
Form
from
typing
import
List
from
..auth
import
get_current_user
from
..database
import
get_db
from
..services.file_processor
import
classify_media
,
validate_file
router
=
APIRouter
(
prefix
=
"/chats/{chat_id}/attachments"
,
tags
=
[
"attachments"
])
UPLOAD_DIR
=
os
.
getenv
(
"UPLOAD_DIR"
,
"uploads"
)
Path
(
UPLOAD_DIR
)
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
@
router
.
post
(
""
)
async
def
upload_attachments
(
chat_id
:
str
,
files
:
List
[
UploadFile
]
=
File
(
...
),
user
=
Depends
(
get_current_user
),
db
=
Depends
(
get_db
),
):
"""Upload one or more files to a chat. Returns attachment metadata."""
# Verify chat belongs to user
chat
=
await
db
.
fetchrow
(
"SELECT id, user_id FROM chats WHERE id = $1"
,
uuid
.
UUID
(
chat_id
)
)
if
not
chat
:
raise
HTTPException
(
404
,
"Chat not found"
)
if
str
(
chat
[
"user_id"
])
!=
str
(
user
[
"id"
]):
raise
HTTPException
(
403
,
"Not your chat"
)
if
len
(
files
)
>
10
:
raise
HTTPException
(
400
,
"Maximum 10 files per upload"
)
results
=
[]
for
f
in
files
:
# Read file content to get size
content
=
await
f
.
read
()
size
=
len
(
content
)
# Validate
ok
,
error
=
validate_file
(
f
.
filename
or
"file"
,
f
.
content_type
or
""
,
size
)
if
not
ok
:
raise
HTTPException
(
400
,
f
"File '{f.filename}': {error}"
)
media_type
=
classify_media
(
f
.
content_type
or
""
)
# Generate unique storage filename
ext
=
Path
(
f
.
filename
or
"file"
)
.
suffix
or
".bin"
storage_name
=
f
"{uuid.uuid4().hex}{ext}"
chat_dir
=
Path
(
UPLOAD_DIR
)
/
chat_id
chat_dir
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
storage_path
=
f
"{chat_id}/{storage_name}"
full_path
=
chat_dir
/
storage_name
# Write file
full_path
.
write_bytes
(
content
)
# Insert DB record
att
=
await
db
.
fetchrow
(
"""
INSERT INTO attachments (chat_id, user_id, filename, original_filename, mime_type, file_size, media_type, storage_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, filename, original_filename, mime_type, file_size, media_type, created_at
"""
,
uuid
.
UUID
(
chat_id
),
user
[
"id"
],
storage_name
,
f
.
filename
or
"file"
,
f
.
content_type
or
"application/octet-stream"
,
size
,
media_type
,
storage_path
,
)
results
.
append
({
"id"
:
str
(
att
[
"id"
]),
"filename"
:
att
[
"original_filename"
],
"mime_type"
:
att
[
"mime_type"
],
"file_size"
:
att
[
"file_size"
],
"media_type"
:
att
[
"media_type"
],
"created_at"
:
att
[
"created_at"
]
.
isoformat
(),
})
return
results
@
router
.
get
(
""
)
async
def
list_attachments
(
chat_id
:
str
,
user
=
Depends
(
get_current_user
),
db
=
Depends
(
get_db
),
):
"""List all attachments for a chat."""
chat
=
await
db
.
fetchrow
(
"SELECT id, user_id FROM chats WHERE id = $1"
,
uuid
.
UUID
(
chat_id
)
)
if
not
chat
:
raise
HTTPException
(
404
,
"Chat not found"
)
if
str
(
chat
[
"user_id"
])
!=
str
(
user
[
"id"
]):
raise
HTTPException
(
403
,
"Not your chat"
)
rows
=
await
db
.
fetch
(
"""
SELECT id, original_filename as filename, mime_type, file_size, media_type, message_id, created_at
FROM attachments WHERE chat_id = $1 ORDER BY created_at
"""
,
uuid
.
UUID
(
chat_id
),
)
return
[
dict
(
r
)
for
r
in
rows
]
@
router
.
get
(
"/{attachment_id}/preview"
)
async
def
preview_attachment
(
chat_id
:
str
,
attachment_id
:
str
,
user
=
Depends
(
get_current_user
),
db
=
Depends
(
get_db
),
):
"""Return raw file content for preview (images mainly)."""
from
fastapi.responses
import
FileResponse
att
=
await
db
.
fetchrow
(
"""
SELECT a.*, c.user_id as chat_owner
FROM attachments a JOIN chats c ON a.chat_id = c.id
WHERE a.id = $1 AND a.chat_id = $2
"""
,
uuid
.
UUID
(
attachment_id
),
uuid
.
UUID
(
chat_id
),
)
if
not
att
:
raise
HTTPException
(
404
,
"Attachment not found"
)
if
str
(
att
[
"chat_owner"
])
!=
str
(
user
[
"id"
]):
raise
HTTPException
(
403
,
"Not your attachment"
)
full_path
=
Path
(
UPLOAD_DIR
)
/
att
[
"storage_path"
]
if
not
full_path
.
exists
():
raise
HTTPException
(
404
,
"File not found on disk"
)
return
FileResponse
(
path
=
str
(
full_path
),
media_type
=
att
[
"mime_type"
],
filename
=
att
[
"original_filename"
],
)
\ No newline at end of file
backend/routes/messages_patch.py
0 → 100644
View file @
e7ac6b83
"""
SON OF ANTON — INTEGRATION PATCH FOR YOUR EXISTING MESSAGE ROUTE
This is NOT a standalone file. These are the functions and code blocks
you need to ADD to your existing message-sending route (the one that
handles POST /chats/{chat_id}/messages and streams SSE back).
--- STEP 1: Add these imports at the top of your messages route file ---
"""
# Add to your imports:
import
os
from
..services.file_processor
import
build_content_blocks_for_attachments
UPLOAD_DIR
=
os
.
getenv
(
"UPLOAD_DIR"
,
"uploads"
)
"""
--- STEP 2: In your request body model/parsing, add attachment_ids ---
Your existing body probably looks like:
{ content, model, max_tokens, reasoning_budget, knowledge_base_id }
Add:
attachment_ids: list[str] = []
So it becomes:
{ content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids }
"""
"""
--- STEP 3: Where you build the Claude messages array, replace the simple
text content with a content block array when attachments exist ---
BEFORE (you probably have something like):
user_message_content = body.content
# or
messages_for_claude.append({"role": "user", "content": body.content})
AFTER (replace with):
"""
async
def
build_user_content
(
db
,
body_content
:
str
,
attachment_ids
:
list
,
chat_id
:
str
):
"""
Build Claude content blocks for a user message.
If there are attachments, returns a list of content blocks.
If no attachments, returns the plain text string (backward compatible).
"""
if
not
attachment_ids
:
return
[{
"text"
:
body_content
}]
import
uuid
as
uuid_mod
# Fetch attachment records
att_uuids
=
[
uuid_mod
.
UUID
(
aid
)
for
aid
in
attachment_ids
]
attachments
=
await
db
.
fetch
(
"""
SELECT id, filename, original_filename, mime_type, file_size, media_type, storage_path
FROM attachments
WHERE id = ANY($1) AND chat_id = $2
"""
,
att_uuids
,
uuid_mod
.
UUID
(
chat_id
),
)
attachments
=
[
dict
(
a
)
for
a
in
attachments
]
# Build content blocks: text first, then file blocks
content_blocks
=
[]
# Add the text message
if
body_content
.
strip
():
content_blocks
.
append
({
"text"
:
body_content
})
# Add file content blocks
file_blocks
=
build_content_blocks_for_attachments
(
attachments
,
UPLOAD_DIR
)
content_blocks
.
extend
(
file_blocks
)
# If no text was provided, add a default prompt
if
not
body_content
.
strip
():
content_blocks
.
insert
(
0
,
{
"text"
:
"Please describe and analyze the attached file(s)."
})
# Link attachments to the message (do after message is saved)
return
content_blocks
"""
--- STEP 4: In your Claude API call, use the content blocks ---
Instead of:
{"role": "user", "content": "user text here"}
Use:
{"role": "user", "content": content_blocks}
Where content_blocks comes from build_user_content() above.
--- STEP 5: After saving the user message to DB, link attachments ---
"""
async
def
link_attachments_to_message
(
db
,
attachment_ids
:
list
,
message_id
):
"""Call this after inserting the user message into your messages table."""
import
uuid
as
uuid_mod
if
attachment_ids
:
await
db
.
execute
(
"UPDATE attachments SET message_id = $1 WHERE id = ANY($2)"
,
message_id
,
[
uuid_mod
.
UUID
(
aid
)
for
aid
in
attachment_ids
],
)
"""
--- STEP 6: Register the attachments router in your main app file ---
In your main.py or app.py, add:
from .routes.attachments import router as attachments_router
app.include_router(attachments_router, prefix="/api")
--- STEP 7: Also serve uploaded files statically (optional, for image previews) ---
from fastapi.staticfiles import StaticFiles
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
--- DONE. That's it for the backend. ---
"""
\ No newline at end of file
backend/services/file_processor.py
0 → 100644
View file @
e7ac6b83
"""
Son of Anton — File Processor
Handles classification, validation, and Claude content-block generation
for uploaded files (images, videos, documents).
"""
import
base64
import
mimetypes
from
pathlib
import
Path
# Claude Bedrock supported formats
IMAGE_FORMATS
=
{
"image/jpeg"
,
"image/png"
,
"image/gif"
,
"image/webp"
}
VIDEO_FORMATS
=
{
"video/mp4"
,
"video/webm"
,
"video/mov"
,
"video/mpeg"
,
"video/mkv"
,
"video/x-matroska"
,
"video/quicktime"
,
"video/x-flv"
,
"video/x-ms-wmv"
,
"video/3gpp"
}
DOCUMENT_FORMATS
=
{
"application/pdf"
:
"pdf"
,
"text/csv"
:
"csv"
,
"application/msword"
:
"doc"
,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
:
"docx"
,
"application/vnd.ms-excel"
:
"xls"
,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
:
"xlsx"
,
"text/html"
:
"html"
,
"text/plain"
:
"txt"
,
"text/markdown"
:
"md"
,
}
# Max sizes (bytes)
MAX_IMAGE_SIZE
=
20
*
1024
*
1024
# 20MB
MAX_VIDEO_SIZE
=
100
*
1024
*
1024
# 100MB (Claude limit ~25MB for video in message)
MAX_DOCUMENT_SIZE
=
50
*
1024
*
1024
# 50MB
ALLOWED_MIMES
=
IMAGE_FORMATS
|
VIDEO_FORMATS
|
set
(
DOCUMENT_FORMATS
.
keys
())
def
classify_media
(
mime_type
:
str
)
->
str
:
"""Classify a MIME type into a media category."""
if
mime_type
in
IMAGE_FORMATS
:
return
"image"
if
mime_type
in
VIDEO_FORMATS
:
return
"video"
if
mime_type
in
DOCUMENT_FORMATS
:
return
"document"
if
mime_type
and
mime_type
.
startswith
(
"text/"
):
return
"text"
return
"unknown"
def
get_max_size
(
media_type
:
str
)
->
int
:
"""Return max allowed file size in bytes for a media type."""
return
{
"image"
:
MAX_IMAGE_SIZE
,
"video"
:
MAX_VIDEO_SIZE
,
"document"
:
MAX_DOCUMENT_SIZE
,
"text"
:
MAX_DOCUMENT_SIZE
,
}
.
get
(
media_type
,
MAX_DOCUMENT_SIZE
)
def
validate_file
(
filename
:
str
,
content_type
:
str
,
size
:
int
)
->
tuple
[
bool
,
str
]:
"""Validate an uploaded file. Returns (ok, error_message)."""
if
not
content_type
:
guessed
,
_
=
mimetypes
.
guess_type
(
filename
)
content_type
=
guessed
or
"application/octet-stream"
media_type
=
classify_media
(
content_type
)
if
media_type
==
"unknown"
:
return
False
,
f
"Unsupported file type: {content_type}. Supported: images, videos, PDF, Office docs, text files."
max_size
=
get_max_size
(
media_type
)
if
size
>
max_size
:
return
False
,
f
"File too large ({size / 1024 / 1024:.1f}MB). Max for {media_type}: {max_size / 1024 / 1024:.0f}MB."
return
True
,
""
def
mime_to_claude_format
(
mime_type
:
str
,
media_type
:
str
)
->
str
:
"""Convert MIME type to Claude's format string."""
if
media_type
==
"image"
:
return
mime_type
.
split
(
"/"
)[
1
]
# jpeg, png, gif, webp
if
media_type
==
"video"
:
mapping
=
{
"video/mp4"
:
"mp4"
,
"video/webm"
:
"webm"
,
"video/quicktime"
:
"mov"
,
"video/mov"
:
"mov"
,
"video/mpeg"
:
"mpeg"
,
"video/mkv"
:
"mkv"
,
"video/x-matroska"
:
"mkv"
,
"video/x-flv"
:
"flv"
,
"video/x-ms-wmv"
:
"wmv"
,
"video/3gpp"
:
"three_gp"
,
}
return
mapping
.
get
(
mime_type
,
"mp4"
)
if
media_type
==
"document"
:
return
DOCUMENT_FORMATS
.
get
(
mime_type
,
"txt"
)
return
"txt"
def
build_content_block
(
file_path
:
str
,
mime_type
:
str
,
media_type
:
str
,
original_filename
:
str
)
->
dict
:
"""
Build a Claude Converse API content block for a file.
Returns a dict that goes directly into the message content array.
"""
path
=
Path
(
file_path
)
if
not
path
.
exists
():
return
{
"text"
:
f
"[Attachment missing: {original_filename}]"
}
file_bytes
=
path
.
read_bytes
()
fmt
=
mime_to_claude_format
(
mime_type
,
media_type
)
if
media_type
==
"image"
:
return
{
"image"
:
{
"format"
:
fmt
,
"source"
:
{
"bytes"
:
file_bytes
}
}
}
elif
media_type
==
"video"
:
return
{
"video"
:
{
"format"
:
fmt
,
"source"
:
{
"bytes"
:
file_bytes
}
}
}
elif
media_type
==
"document"
:
return
{
"document"
:
{
"format"
:
fmt
,
"name"
:
Path
(
original_filename
)
.
stem
[:
200
],
"source"
:
{
"bytes"
:
file_bytes
}
}
}
elif
media_type
==
"text"
:
try
:
text_content
=
file_bytes
.
decode
(
"utf-8"
,
errors
=
"replace"
)
except
Exception
:
text_content
=
"[Could not decode text file]"
return
{
"text"
:
f
"--- Content of {original_filename} ---
\n
{text_content}
\n
--- End of {original_filename} ---"
}
else
:
return
{
"text"
:
f
"[Unsupported attachment: {original_filename}]"
}
def
build_content_blocks_for_attachments
(
attachments
:
list
,
upload_dir
:
str
)
->
list
[
dict
]:
"""
Given a list of attachment DB records, build all Claude content blocks.
"""
blocks
=
[]
for
att
in
attachments
:
file_path
=
str
(
Path
(
upload_dir
)
/
att
[
"storage_path"
])
block
=
build_content_block
(
file_path
=
file_path
,
mime_type
=
att
[
"mime_type"
],
media_type
=
att
[
"media_type"
],
original_filename
=
att
[
"original_filename"
],
)
blocks
.
append
(
block
)
return
blocks
\ No newline at end of file
frontend/src/api.js
View file @
e7ac6b83
/**
* Son of Anton — API helper functions
*/
const
BASE
=
"/api"
;
function
headers
(
token
)
{
...
...
@@ -16,12 +12,13 @@ async function request(method, path, token, body) {
const
res
=
await
fetch
(
`
${
BASE
}${
path
}
`
,
opts
);
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
throw
new
Error
(
err
.
detail
||
err
.
message
||
"Request failed"
);
throw
new
Error
(
err
.
detail
||
`Request failed:
${
res
.
status
}
`
);
}
if
(
res
.
status
===
204
)
return
null
;
return
res
.
json
();
}
/* ── Auth ───────────────────────────────────
─
*/
/* ── Auth ─────────────────────────────────── */
export
const
login
=
(
username
,
password
)
=>
request
(
"POST"
,
"/auth/login"
,
null
,
{
username
,
password
});
...
...
@@ -97,6 +94,37 @@ export async function* streamMessage(token, chatId, body, signal) {
}
}
/* ── File Uploads (Attachments) ───────────── */
export
async
function
uploadAttachments
(
token
,
chatId
,
files
)
{
const
formData
=
new
FormData
();
for
(
const
file
of
files
)
{
formData
.
append
(
"files"
,
file
);
}
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/attachments`
,
{
method
:
"POST"
,
headers
:
{
Authorization
:
`Bearer
${
token
}
`
,
// Do NOT set Content-Type — browser sets it with boundary for multipart
},
body
:
formData
,
});
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
throw
new
Error
(
err
.
detail
||
"Upload failed"
);
}
return
res
.
json
();
}
export
const
listAttachments
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/attachments`
,
token
);
export
function
getAttachmentPreviewUrl
(
chatId
,
attachmentId
)
{
return
`
${
BASE
}
/chats/
${
chatId
}
/attachments/
${
attachmentId
}
/preview`
;
}
/* ── Knowledge Bases ───────────────────────── */
export
const
listKnowledgeBases
=
(
token
)
=>
request
(
"GET"
,
"/knowledge"
,
token
);
...
...
@@ -111,64 +139,50 @@ export const deleteKnowledgeBase = (token, kbId) =>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
async
function
uploadDocuments
(
token
,
kbId
,
files
)
{
const
form
=
new
FormData
();
const
form
Data
=
new
FormData
();
for
(
const
file
of
files
)
{
form
.
append
(
"files"
,
file
);
form
Data
.
append
(
"files"
,
file
);
}
const
res
=
await
fetch
(
`
${
BASE
}
/knowledge/
${
kbId
}
/upload`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/knowledge/
${
kbId
}
/documents`
,
{
method
:
"POST"
,
headers
:
{
Authorization
:
`Bearer
${
token
}
`
},
body
:
form
,
body
:
form
Data
,
});
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
throw
new
Error
(
err
.
detail
||
"Upload failed"
);
}
return
res
.
json
();
}
// Backward-compat wrapper for single file
export
const
uploadDocument
=
(
token
,
kbId
,
file
)
=>
uploadDocuments
(
token
,
kbId
,
[
file
]);
export
const
deleteDocument
=
(
token
,
kbId
,
docId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
/documents/
${
docId
}
`
,
token
);
/* ── Admin ─────────────────────────────────── */
export
const
adminStats
=
(
token
)
=>
request
(
"GET"
,
"/admin/stats"
,
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
adminDeleteUser
=
(
token
,
userId
)
=>
request
(
"DELETE"
,
`/admin/users/
${
userId
}
`
,
token
);
export
const
adminListChats
=
(
token
)
=>
request
(
"GET"
,
"/admin/chats"
,
token
);
/* ── File helpers ──────────────────────────── */
export
async
function
downloadZip
(
token
,
markdown
)
{
const
res
=
await
fetch
(
`
${
BASE
}
/files/download-zip`
,
{
/* ── Download Zip ──────────────────────────── */
export
async
function
downloadZip
(
token
,
content
)
{
const
res
=
await
fetch
(
`
${
BASE
}
/download-zip`
,
{
method
:
"POST"
,
headers
:
headers
(
token
),
body
:
JSON
.
stringify
({
markdown
}),
body
:
JSON
.
stringify
({
content
}),
});
if
(
!
res
.
ok
)
throw
new
Error
(
"Download failed"
);
const
ct
=
res
.
headers
.
get
(
"content-type"
)
||
""
;
if
(
ct
.
includes
(
"application/zip"
))
{
const
blob
=
await
res
.
blob
();
const
url
=
URL
.
createObjectURL
(
blob
);
const
a
=
document
.
createElement
(
"a"
);
a
.
href
=
url
;
a
.
download
=
"son-of-anton-code.zip"
;
a
.
click
();
URL
.
revokeObjectURL
(
url
);
}
else
{
const
data
=
await
res
.
json
();
if
(
data
.
error
)
throw
new
Error
(
data
.
error
);
}
const
blob
=
await
res
.
blob
();
const
url
=
URL
.
createObjectURL
(
blob
);
const
a
=
document
.
createElement
(
"a"
);
a
.
href
=
url
;
a
.
download
=
"code-files.zip"
;
a
.
click
();
URL
.
revokeObjectURL
(
url
);
}
\ No newline at end of file
frontend/src/components/AttachmentPreview.jsx
0 → 100644
View file @
e7ac6b83
import
React
,
{
useMemo
}
from
"react"
;
import
{
X
,
FileText
,
Image
as
ImageIcon
,
Film
,
File
,
FileSpreadsheet
,
}
from
"lucide-react"
;
/**
* Renders a preview chip for an attached file.
* Used both in the pending-files area (before send) and in message bubbles (after send).
*
* Props:
* file?: File object (for pending uploads — generates preview from File API)
* attachment?: { filename, mime_type, file_size, media_type, preview_url } (for sent messages)
* onRemove?: () => void (if removable)
* isPending?: boolean (styling difference)
*/
export
default
function
AttachmentPreview
({
file
,
attachment
,
onRemove
,
isPending
,
})
{
const
info
=
useMemo
(()
=>
{
if
(
file
)
{
return
{
name
:
file
.
name
,
size
:
file
.
size
,
type
:
file
.
type
,
mediaType
:
classifyMime
(
file
.
type
),
previewUrl
:
file
.
type
.
startsWith
(
"image/"
)
?
URL
.
createObjectURL
(
file
)
:
null
,
};
}
if
(
attachment
)
{
return
{
name
:
attachment
.
filename
||
attachment
.
original_filename
||
"file"
,
size
:
attachment
.
file_size
,
type
:
attachment
.
mime_type
,
mediaType
:
attachment
.
media_type
,
previewUrl
:
attachment
.
preview_url
||
null
,
};
}
return
{
name
:
"unknown"
,
size
:
0
,
type
:
""
,
mediaType
:
"unknown"
,
previewUrl
:
null
};
},
[
file
,
attachment
]);
const
Icon
=
getIcon
(
info
.
mediaType
);
const
sizeStr
=
formatSize
(
info
.
size
);
return
(
<
div
className=
{
`
group relative flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs
${
isPending
? "bg-anton-card border-anton-accent/30 text-anton-text"
: "bg-anton-surface border-anton-border text-anton-muted"
}
transition hover:border-anton-accent/50
`
}
>
{
/* Image thumbnail */
}
{
info
.
previewUrl
&&
info
.
mediaType
===
"image"
?
(
<
img
src=
{
info
.
previewUrl
}
alt=
{
info
.
name
}
className=
"w-8 h-8 rounded object-cover flex-shrink-0"
onLoad=
{
()
=>
{
// Revoke blob URL after load to free memory (only for pending files)
if
(
file
&&
info
.
previewUrl
)
{
// Don't revoke immediately — component might re-render
}
}
}
/>
)
:
(
<
Icon
size=
{
16
}
className=
{
`flex-shrink-0 ${
isPending ? "text-anton-accent" : "text-anton-muted"
}`
}
/>
)
}
<
div
className=
"flex flex-col min-w-0"
>
<
span
className=
"truncate max-w-[140px] font-medium"
>
{
info
.
name
}
</
span
>
<
span
className=
"text-[10px] text-anton-muted"
>
{
sizeStr
}
</
span
>
</
div
>
{
/* Remove button */
}
{
onRemove
&&
(
<
button
onClick=
{
(
e
)
=>
{
e
.
stopPropagation
();
onRemove
();
}
}
className=
"ml-1 p-0.5 rounded-full text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition opacity-0 group-hover:opacity-100"
title=
"Remove"
>
<
X
size=
{
12
}
/>
</
button
>
)
}
{
/* Video badge */
}
{
info
.
mediaType
===
"video"
&&
(
<
span
className=
"absolute -top-1 -right-1 bg-anton-accent text-white text-[9px] font-bold px-1 rounded"
>
VID
</
span
>
)
}
</
div
>
);
}
function
classifyMime
(
mime
)
{
if
(
!
mime
)
return
"unknown"
;
if
(
mime
.
startsWith
(
"image/"
))
return
"image"
;
if
(
mime
.
startsWith
(
"video/"
))
return
"video"
;
if
(
mime
===
"application/pdf"
||
mime
.
includes
(
"word"
)
||
mime
.
includes
(
"document"
)
)
return
"document"
;
if
(
mime
.
includes
(
"excel"
)
||
mime
.
includes
(
"spreadsheet"
)
||
mime
===
"text/csv"
)
return
"spreadsheet"
;
if
(
mime
.
startsWith
(
"text/"
))
return
"text"
;
return
"unknown"
;
}
function
getIcon
(
mediaType
)
{
switch
(
mediaType
)
{
case
"image"
:
return
ImageIcon
;
case
"video"
:
return
Film
;
case
"document"
:
return
FileText
;
case
"spreadsheet"
:
return
FileSpreadsheet
;
case
"text"
:
return
FileText
;
default
:
return
File
;
}
}
function
formatSize
(
bytes
)
{
if
(
!
bytes
||
bytes
===
0
)
return
"0 B"
;
if
(
bytes
<
1024
)
return
`
${
bytes
}
B`
;
if
(
bytes
<
1024
*
1024
)
return
`
${(
bytes
/
1024
).
toFixed
(
1
)}
KB`
;
return
`
${(
bytes
/
(
1024
*
1024
)).
toFixed
(
1
)}
MB`
;
}
\ No newline at end of file
frontend/src/components/ChatView.jsx
View file @
e7ac6b83
import
React
,
{
use
State
,
useEffect
,
useRef
,
useCallback
}
from
"react"
;
import
React
,
{
use
Effect
,
useRef
,
useState
,
useCallback
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
getMessages
,
downloadZip
,
listKnowledgeBases
,
updateChat
,
}
from
"../api"
;
import
{
getMessages
,
downloadZip
,
uploadAttachments
}
from
"../api"
;
import
*
as
streamManager
from
"../streamManager"
;
import
MessageBubble
from
"./MessageBubble"
;
import
FileUploadButton
from
"./FileUploadButton"
;
import
AttachmentPreview
from
"./AttachmentPreview"
;
import
{
Send
,
Square
,
Settings2
,
X
,
Brain
,
BookOpen
,
ChevronDown
,
Send
,
Square
,
Download
,
ChevronDown
,
Loader2
,
Paperclip
,
}
from
"lucide-react"
;
const
MODELS
=
[
{
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
:
"Claude Haiku 4.5 (Fast)"
},
];
export
default
function
ChatView
({
chatId
})
{
const
{
state
,
dispatch
}
=
useApp
();
// ── Persisted settings from the chat object ──
const
currentChat
=
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
);
const
messages
=
state
.
chatMessages
[
chatId
]
||
[];
const
isStreamingGlobal
=
!!
state
.
activeStreams
[
chatId
];
const
[
input
,
setInput
]
=
useState
(
""
);
const
[
pendingFiles
,
setPendingFiles
]
=
useState
([]);
// File objects waiting to be sent
const
[
uploadingFiles
,
setUploadingFiles
]
=
useState
(
false
);
const
[
model
,
setModel
]
=
useState
(
()
=>
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
)?.
model
||
"us.anthropic.claude-sonnet-4-20250514"
);
const
[
maxTokens
,
setMaxTokens
]
=
useState
(
()
=>
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
)?.
max_tokens
||
16384
);
const
[
reasoningBudget
,
setReasoningBudget
]
=
useState
(
()
=>
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
)?.
reasoning_budget
||
10000
);
const
[
selectedKbId
,
setSelectedKbId
]
=
useState
(
()
=>
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
)?.
knowledge_base_id
||
""
);
const
[
showSettings
,
setShowSettings
]
=
useState
(
false
);
const
[
model
,
setModel
]
=
useState
(
currentChat
?.
model
||
MODELS
[
0
].
id
);
const
[
maxTokens
,
setMaxTokens
]
=
useState
(
currentChat
?.
max_tokens
||
4096
);
const
[
reasoningBudget
,
setReasoningBudget
]
=
useState
(
currentChat
?.
reasoning_budget
??
0
);
const
[
selectedKbId
,
setSelectedKbId
]
=
useState
(
currentChat
?.
knowledge_base_id
||
null
);
const
[
kbs
,
setKbs
]
=
useState
([]);
// High-frequency stream data — lives outside the global store to avoid
// re-rendering every component on every token
const
[
streamData
,
setStreamData
]
=
useState
(
streamManager
.
getStreamData
(
chatId
));
const
scrollContainerRef
=
useRef
(
null
);
const
inputRef
=
useRef
(
null
);
const
shouldAutoScrollRef
=
useRef
(
true
);
const
rafRef
=
useRef
(
null
);
const
textareaRef
=
useRef
(
null
);
const
messages
=
state
.
chatMessages
[
chatId
]
||
[];
// Per-chat streaming state — subscribe to stream manager
const
[
streamData
,
setStreamData
]
=
useState
(()
=>
streamManager
.
getStreamData
(
chatId
)
);
// ── Subscribe to background stream data for THIS chat ──
useEffect
(()
=>
{
// Initial read
setStreamData
(
streamManager
.
getStreamData
(
chatId
));
// Subscribe to updates for THIS chat
const
unsub
=
streamManager
.
subscribe
(
chatId
,
()
=>
{
setStreamData
(
streamManager
.
getStreamData
(
chatId
));
});
return
unsub
;
},
[
chatId
]);
// ── Scroll helpers ──
function
handleContainerScroll
()
{
const
el
=
scrollContainerRef
.
current
;
if
(
!
el
)
return
;
const
{
scrollHeight
,
scrollTop
,
clientHeight
}
=
el
;
shouldAutoScrollRef
.
current
=
scrollHeight
-
scrollTop
-
clientHeight
<
200
;
}
const
scrollToBottom
=
useCallback
(()
=>
{
if
(
!
shouldAutoScrollRef
.
current
)
return
;
if
(
rafRef
.
current
)
return
;
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
const
el
=
scrollContainerRef
.
current
;
if
(
el
)
el
.
scrollTop
=
el
.
scrollHeight
;
rafRef
.
current
=
null
;
});
},
[]);
// ── Load messages & KB list on mount ──
// Load messages on mount
useEffect
(()
=>
{
(
async
()
=>
{
try
{
const
[
msgs
,
kbData
]
=
await
Promise
.
all
([
getMessages
(
state
.
token
,
chatId
),
listKnowledgeBases
(
state
.
token
),
]);
dispatch
({
type
:
"SET_MESSAGES"
,
chatId
,
messages
:
msgs
});
setKbs
(
kbData
);
}
catch
{
/* */
}
})();
},
[
chatId
,
state
.
token
,
dispatch
]);
// Scroll when content changes
useEffect
(
scrollToBottom
,
[
messages
,
streamData
.
text
,
streamData
.
thinking
,
scrollToBottom
]);
if
(
!
state
.
chatMessages
[
chatId
])
{
getMessages
(
state
.
token
,
chatId
)
.
then
((
msgs
)
=>
dispatch
({
type
:
"SET_MESSAGES"
,
chatId
,
messages
:
msgs
}))
.
catch
(()
=>
{});
}
},
[
chatId
,
state
.
token
,
dispatch
,
state
.
chatMessages
]);
// Auto-scroll
useEffect
(()
=>
{
inputRef
.
current
?.
focus
();
},
[
chatId
]);
if
(
shouldAutoScrollRef
.
current
&&
scrollContainerRef
.
current
)
{
const
el
=
scrollContainerRef
.
current
;
el
.
scrollTop
=
el
.
scrollHeight
;
}
},
[
messages
,
streamData
]);
// ── Settings persistence ──
async
function
saveSettings
()
{
const
data
=
{
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
||
""
,
};
try
{
await
updateChat
(
state
.
token
,
chatId
,
data
);
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
},
});
}
catch
{
/* */
}
function
handleContainerScroll
()
{
const
el
=
scrollContainerRef
.
current
;
if
(
!
el
)
return
;
const
nearBottom
=
el
.
scrollHeight
-
el
.
scrollTop
-
el
.
clientHeight
<
100
;
shouldAutoScrollRef
.
current
=
nearBottom
;
}
function
toggleSettings
()
{
const
closing
=
showSettings
;
setShowSettings
(
!
showSettings
);
if
(
closing
)
saveSettings
();
function
scrollToBottom
()
{
if
(
scrollContainerRef
.
current
)
{
scrollContainerRef
.
current
.
scrollTop
=
scrollContainerRef
.
current
.
scrollHeight
;
shouldAutoScrollRef
.
current
=
true
;
}
}
// ── Send message ──
function
handleSend
()
{
// THIS chat streaming — only blocks sends for THIS chat, not others
const
isChatStreaming
=
!!
state
.
activeStreams
[
chatId
];
async
function
handleSend
()
{
const
content
=
input
.
trim
();
if
(
!
content
||
isStreamingGlobal
)
return
;
if
(
(
!
content
&&
pendingFiles
.
length
===
0
)
||
isChatStreaming
)
return
;
// Optimistic user message
// Optimistic user message
(with attachment previews)
const
userMsg
=
{
id
:
`tmp-
${
Date
.
now
()}
`
,
role
:
"user"
,
content
,
content
:
content
||
"(attached files)"
,
created_at
:
new
Date
().
toISOString
(),
attachments
:
pendingFiles
.
map
((
f
)
=>
({
id
:
`pending-
${
f
.
name
}
`
,
filename
:
f
.
name
,
mime_type
:
f
.
type
,
file_size
:
f
.
size
,
media_type
:
classifyFile
(
f
),
preview_url
:
f
.
type
.
startsWith
(
"image/"
)
?
URL
.
createObjectURL
(
f
)
:
null
,
})),
};
dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
userMsg
});
setInput
(
""
);
shouldAutoScrollRef
.
current
=
true
;
// Sync settings to store immediately
// Upload files if any
let
attachmentIds
=
[];
if
(
pendingFiles
.
length
>
0
)
{
setUploadingFiles
(
true
);
try
{
const
uploaded
=
await
uploadAttachments
(
state
.
token
,
chatId
,
pendingFiles
);
attachmentIds
=
uploaded
.
map
((
a
)
=>
a
.
id
);
}
catch
(
err
)
{
// Add error message
dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
{
id
:
`err-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
`**Upload failed:**
${
err
.
message
}
`
,
created_at
:
new
Date
().
toISOString
(),
},
});
setUploadingFiles
(
false
);
return
;
}
setUploadingFiles
(
false
);
setPendingFiles
([]);
}
else
{
setPendingFiles
([]);
}
// Sync settings to store
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
...
...
@@ -144,16 +159,17 @@ export default function ChatView({ chatId }) {
},
});
//
Kick off background stream — survives chat switching
//
Start stream — includes attachment_ids
streamManager
.
startStream
({
token
:
state
.
token
,
chatId
,
body
:
{
content
,
content
:
content
||
"Please describe and analyze the attached file(s)."
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
attachment_ids
:
attachmentIds
,
},
});
}
...
...
@@ -169,6 +185,45 @@ export default function ChatView({ chatId }) {
}
}
function
handleFilesSelected
(
files
)
{
setPendingFiles
((
prev
)
=>
[...
prev
,
...
Array
.
from
(
files
)]);
}
function
handleRemovePendingFile
(
index
)
{
setPendingFiles
((
prev
)
=>
prev
.
filter
((
_
,
i
)
=>
i
!==
index
));
}
function
handlePaste
(
e
)
{
const
items
=
e
.
clipboardData
?.
items
;
if
(
!
items
)
return
;
const
files
=
[];
for
(
const
item
of
items
)
{
if
(
item
.
kind
===
"file"
)
{
const
file
=
item
.
getAsFile
();
if
(
file
)
files
.
push
(
file
);
}
}
if
(
files
.
length
>
0
)
{
e
.
preventDefault
();
handleFilesSelected
(
files
);
}
}
function
handleDrop
(
e
)
{
e
.
preventDefault
();
e
.
stopPropagation
();
const
files
=
e
.
dataTransfer
?.
files
;
if
(
files
&&
files
.
length
>
0
)
{
handleFilesSelected
(
files
);
}
}
function
handleDragOver
(
e
)
{
e
.
preventDefault
();
e
.
stopPropagation
();
}
async
function
handleDownloadAll
()
{
const
all
=
messages
.
filter
((
m
)
=>
m
.
role
===
"assistant"
)
...
...
@@ -177,14 +232,119 @@ export default function ChatView({ chatId }) {
if
(
all
)
{
try
{
await
downloadZip
(
state
.
token
,
all
);
}
catch
{
/* */
}
}
catch
{
/* */
}
}
}
const
streaming
=
streamData
.
streaming
;
return
(
<
div
className=
"flex-1 flex flex-col min-h-0"
>
<
div
className=
"flex-1 flex flex-col min-h-0"
onDrop=
{
handleDrop
}
onDragOver=
{
handleDragOver
}
>
{
/* Header Bar */
}
<
div
className=
"flex items-center justify-between px-4 py-2 border-b border-anton-border bg-anton-surface/50 backdrop-blur-sm"
>
<
div
className=
"flex items-center gap-3"
>
<
h2
className=
"text-sm font-semibold text-white truncate max-w-[200px]"
>
{
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
)?.
title
||
"New Chat"
}
</
h2
>
{
isChatStreaming
&&
(
<
span
className=
"flex items-center gap-1 text-xs text-anton-accent"
>
<
Loader2
size=
{
12
}
className=
"animate-spin"
/>
Streaming
</
span
>
)
}
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
button
onClick=
{
()
=>
setShowSettings
(
!
showSettings
)
}
className=
"text-xs text-anton-muted hover:text-white px-2 py-1 rounded hover:bg-anton-card transition"
>
⚙ Settings
</
button
>
<
button
onClick=
{
handleDownloadAll
}
className=
"flex items-center gap-1 text-xs text-anton-muted hover:text-anton-accent px-2 py-1 rounded hover:bg-anton-accent/10 transition"
>
<
Download
size=
{
12
}
/>
Export
</
button
>
</
div
>
</
div
>
{
/* Settings Panel */
}
{
showSettings
&&
(
<
div
className=
"px-4 py-3 border-b border-anton-border bg-anton-surface/80 backdrop-blur-sm animate-fade-in"
>
<
div
className=
"flex flex-wrap gap-4 items-end"
>
<
label
className=
"flex flex-col gap-1"
>
<
span
className=
"text-[11px] text-anton-muted uppercase tracking-wider"
>
Model
</
span
>
<
select
value=
{
model
}
onChange=
{
(
e
)
=>
setModel
(
e
.
target
.
value
)
}
className=
"bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent"
>
<
option
value=
"us.anthropic.claude-sonnet-4-20250514"
>
Claude Sonnet 4
</
option
>
<
option
value=
"us.anthropic.claude-opus-4-20250514"
>
Claude Opus 4
</
option
>
</
select
>
</
label
>
<
label
className=
"flex flex-col gap-1"
>
<
span
className=
"text-[11px] text-anton-muted uppercase tracking-wider"
>
Max Tokens
</
span
>
<
input
type=
"number"
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
min=
{
256
}
max=
{
128000
}
className=
"bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white w-24 focus:outline-none focus:border-anton-accent"
/>
</
label
>
<
label
className=
"flex flex-col gap-1"
>
<
span
className=
"text-[11px] text-anton-muted uppercase tracking-wider"
>
Reasoning Budget
</
span
>
<
input
type=
"number"
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
min=
{
1024
}
max=
{
64000
}
className=
"bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white w-24 focus:outline-none focus:border-anton-accent"
/>
</
label
>
<
label
className=
"flex flex-col gap-1"
>
<
span
className=
"text-[11px] text-anton-muted uppercase tracking-wider"
>
Knowledge Base
</
span
>
<
select
value=
{
selectedKbId
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
)
}
className=
"bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent"
>
<
option
value=
""
>
None
</
option
>
{
(
state
.
knowledgeBases
||
[]).
map
((
kb
)
=>
(
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
</
option
>
))
}
</
select
>
</
label
>
</
div
>
</
div
>
)
}
{
/* Messages */
}
<
div
ref=
{
scrollContainerRef
}
...
...
@@ -192,7 +352,7 @@ export default function ChatView({ chatId }) {
className=
"flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
{
messages
.
map
((
m
)
=>
(
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
/>
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
chatId=
{
chatId
}
/>
))
}
{
/* Streaming overlay — in-progress response */
}
...
...
@@ -204,144 +364,83 @@ export default function ChatView({ chatId }) {
content
:
streamData
.
text
,
thinking_content
:
streamData
.
thinking
||
null
,
}
}
chatId=
{
chatId
}
isStreaming
isThinking=
{
streamData
.
isThinking
}
/>
)
}
{
/* Waiting indicator */
}
{
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
>
{
/* Waiting indicator — streaming started but no content yet */
}
{
streaming
&&
!
streamData
.
thinking
&&
!
streamData
.
text
&&
(
<
div
className=
"flex items-center gap-2 text-anton-muted text-sm animate-pulse-slow pl-2"
>
<
Loader2
size=
{
14
}
className=
"animate-spin text-anton-accent"
/>
<
span
>
Son of Anton is thinking...
</
span
>
</
div
>
)
}
</
div
>
{
/* Input area */
}
<
div
className=
"border-t border-anton-border bg-anton-surface p-4"
>
{
/* Settings panel */
}
{
showSettings
&&
(
<
div
className=
"mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in"
>
<
div
className=
"flex items-center justify-between"
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
>
<
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Generation Settings
</
h3
>
<
button
onClick=
{
toggleSettings
}
className=
"text-anton-muted hover:text-white"
>
<
X
size=
{
14
}
/>
</
button
>
</
div
>
{
/* Model */
}
<
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 text-white text-sm focus:outline-none focus:border-anton-accent"
>
{
MODELS
.
map
((
m
)
=>
(
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>
))
}
</
select
>
</
div
>
{
/* Max Tokens */
}
<
div
>
<
div
className=
"flex justify-between text-xs mb-1"
>
<
span
className=
"text-anton-muted"
>
Max Output 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
className=
"flex justify-between text-[10px] text-anton-muted mt-0.5"
>
<
span
>
256
</
span
>
<
span
>
64K
</
span
>
</
div
>
</
div
>
{
/* Reasoning Budget */
}
<
div
>
<
div
className=
"flex justify-between text-xs mb-1"
>
<
span
className=
"text-anton-muted flex items-center gap-1"
>
<
Brain
size=
{
12
}
className=
"text-purple-400"
/>
Reasoning Budget
</
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
))
}
{
/* Scroll to bottom button */
}
{
!
shouldAutoScrollRef
.
current
&&
(
<
div
className=
"flex justify-center -mt-10 relative z-10"
>
<
button
onClick=
{
scrollToBottom
}
className=
"bg-anton-card border border-anton-border rounded-full p-2 shadow-lg hover:border-anton-accent transition"
>
<
ChevronDown
size=
{
16
}
className=
"text-anton-muted"
/>
</
button
>
</
div
>
)
}
{
/* Pending file previews */
}
{
pendingFiles
.
length
>
0
&&
(
<
div
className=
"px-4 py-2 border-t border-anton-border bg-anton-surface/50"
>
<
div
className=
"flex flex-wrap gap-2"
>
{
pendingFiles
.
map
((
file
,
idx
)
=>
(
<
AttachmentPreview
key=
{
`${file.name}-${idx}`
}
file=
{
file
}
onRemove=
{
()
=>
handleRemovePendingFile
(
idx
)
}
isPending
/>
<
div
className=
"flex justify-between text-[10px] text-anton-muted mt-0.5"
>
<
span
>
Off
</
span
>
<
span
>
32K tokens
</
span
>
</
div
>
</
div
>
{
/* Knowledge Base */
}
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
>
<
BookOpen
size=
{
12
}
/>
Knowledge Base (RAG)
</
label
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<
option
value=
""
>
None
</
option
>
{
kbs
.
map
((
kb
)
=>
(
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs)
</
option
>
))
}
</
select
>
</
div
>
))
}
</
div
>
</
div
>
)
}
{
/* Input Area */
}
<
div
className=
"border-t border-anton-border bg-anton-surface/80 backdrop-blur-sm p-4"
>
{
uploadingFiles
&&
(
<
div
className=
"flex items-center gap-2 text-anton-accent text-xs mb-2 animate-pulse"
>
<
Loader2
size=
{
12
}
className=
"animate-spin"
/>
Uploading files...
</
div
>
)
}
<
div
className=
"flex items-end gap-2"
>
<
button
onClick=
{
toggleSettings
}
className=
{
`p-2.5 rounded-xl transition shrink-0 ${
showSettings
? "bg-anton-accent/20 text-anton-accent"
: "text-anton-muted hover:text-white hover:bg-anton-card"
}`
}
>
<
Settings2
size=
{
18
}
/>
</
button
>
<
FileUploadButton
onFilesSelected=
{
handleFilesSelected
}
/>
<
div
className=
"flex-1 relative"
>
<
textarea
ref=
{
input
Ref
}
ref=
{
textarea
Ref
}
value=
{
input
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
placeholder=
"Ask Son of Anton anything… (Shift+Enter for new line)"
onPaste=
{
handlePaste
}
placeholder=
{
pendingFiles
.
length
>
0
?
"Add a message about the files, or just send..."
:
"Message Son of Anton..."
}
rows=
{
1
}
style=
{
{
maxHeight
:
"200px"
}
}
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 pr-12 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
className=
"w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 pr-12 text-sm text-white placeholder-anton-muted resize-none focus:outline-none focus:border-anton-accent transition max-h-40 overflow-y-auto"
style=
{
{
minHeight
:
"44px"
,
height
:
"auto"
,
}
}
onInput=
{
(
e
)
=>
{
e
.
target
.
style
.
height
=
"auto"
;
e
.
target
.
style
.
height
=
Math
.
min
(
e
.
target
.
scrollHeight
,
200
)
+
"px"
;
e
.
target
.
style
.
height
=
Math
.
min
(
e
.
target
.
scrollHeight
,
160
)
+
"px"
;
}
}
/>
</
div
>
...
...
@@ -349,50 +448,62 @@ export default function ChatView({ chatId }) {
{
streaming
?
(
<
button
onClick=
{
handleStop
}
className=
"p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
className=
"flex items-center justify-center w-10 h-10 rounded-xl bg-anton-danger/20 border border-anton-danger/40 text-anton-danger hover:bg-anton-danger/30 transition"
title=
"Stop generation"
>
<
Square
size=
{
1
8
}
/>
<
Square
size=
{
1
6
}
/>
</
button
>
)
:
(
<
button
onClick=
{
handleSend
}
disabled=
{
!
input
.
trim
()
||
isStreamingGlobal
}
className=
"p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
disabled=
{
(
!
input
.
trim
()
&&
pendingFiles
.
length
===
0
)
||
isChatStreaming
||
uploadingFiles
}
className=
"flex items-center justify-center w-10 h-10 rounded-xl bg-anton-accent text-white hover:bg-anton-accentDim transition disabled:opacity-30 disabled:cursor-not-allowed"
title=
"Send message"
>
<
Send
size=
{
1
8
}
/>
<
Send
size=
{
1
6
}
/>
</
button
>
)
}
</
div
>
{
/* Quick info bar */
}
<
div
className=
"flex items-center gap-3 mt-2 text-[11px] text-anton-muted"
>
<
span
>
{
MODELS
.
find
((
m
)
=>
m
.
id
===
model
)?.
label
}
</
span
>
<
span
>
•
</
span
>
<
span
>
{
maxTokens
.
toLocaleString
()
}
max tokens
</
span
>
{
reasoningBudget
>
0
&&
(
<>
<
span
>
•
</
span
>
<
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
reasoning
<
div
className=
"flex items-center justify-between mt-2"
>
<
span
className=
"text-[10px] text-anton-muted"
>
{
pendingFiles
.
length
>
0
&&
(
<
span
className=
"text-anton-accent"
>
{
pendingFiles
.
length
}
file
{
pendingFiles
.
length
>
1
?
"s"
:
""
}{
" "
}
attached •
{
" "
}
</
span
>
</>
)
}
{
selectedKbId
&&
(
<>
<
span
>
•
</
span
>
<
span
className=
"text-green-400"
>
📚 RAG active
</
span
>
</>
)
}
{
messages
.
some
((
m
)
=>
m
.
role
===
"assistant"
)
&&
(
<
button
onClick=
{
handleDownloadAll
}
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Download all code
</
button
>
)
}
)
}
Shift+Enter for new line • Paste or drag files to attach
</
span
>
<
span
className=
"text-[10px] text-anton-muted"
>
{
Object
.
keys
(
state
.
activeStreams
).
length
>
0
&&
(
<
span
className=
"text-anton-accent"
>
{
Object
.
keys
(
state
.
activeStreams
).
length
}
active stream
{
Object
.
keys
(
state
.
activeStreams
).
length
>
1
?
"s"
:
""
}
</
span
>
)
}
</
span
>
</
div
>
</
div
>
</
div
>
);
}
\ No newline at end of file
}
/** Classify a File object into a media type string */
function
classifyFile
(
file
)
{
if
(
file
.
type
.
startsWith
(
"image/"
))
return
"image"
;
if
(
file
.
type
.
startsWith
(
"video/"
))
return
"video"
;
if
(
file
.
type
===
"application/pdf"
||
file
.
type
.
includes
(
"word"
)
||
file
.
type
.
includes
(
"excel"
)
||
file
.
type
.
includes
(
"spreadsheet"
)
)
return
"document"
;
if
(
file
.
type
.
startsWith
(
"text/"
))
return
"text"
;
return
"unknown"
;
}
\ No newline at end of file
frontend/src/components/FileUploadButton.jsx
0 → 100644
View file @
e7ac6b83
import
React
,
{
useRef
}
from
"react"
;
import
{
Paperclip
}
from
"lucide-react"
;
const
ACCEPT
=
[
// Images
"image/jpeg"
,
"image/png"
,
"image/gif"
,
"image/webp"
,
// Videos
"video/mp4"
,
"video/webm"
,
"video/quicktime"
,
"video/mpeg"
,
"video/x-matroska"
,
// Documents
"application/pdf"
,
"text/csv"
,
"application/msword"
,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
,
"application/vnd.ms-excel"
,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
,
"text/html"
,
"text/plain"
,
"text/markdown"
,
// Also accept by extension for browsers that are bad at MIME
".jpg"
,
".jpeg"
,
".png"
,
".gif"
,
".webp"
,
".mp4"
,
".webm"
,
".mov"
,
".mkv"
,
".pdf"
,
".csv"
,
".doc"
,
".docx"
,
".xls"
,
".xlsx"
,
".html"
,
".txt"
,
".md"
,
].
join
(
","
);
export
default
function
FileUploadButton
({
onFilesSelected
})
{
const
inputRef
=
useRef
(
null
);
function
handleClick
()
{
inputRef
.
current
?.
click
();
}
function
handleChange
(
e
)
{
const
files
=
e
.
target
.
files
;
if
(
files
&&
files
.
length
>
0
)
{
onFilesSelected
(
files
);
}
// Reset so the same file can be re-selected
e
.
target
.
value
=
""
;
}
return
(
<>
<
input
ref=
{
inputRef
}
type=
"file"
multiple
accept=
{
ACCEPT
}
onChange=
{
handleChange
}
className=
"hidden"
/>
<
button
onClick=
{
handleClick
}
className=
"flex items-center justify-center w-10 h-10 rounded-xl border border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent/40 hover:bg-anton-accent/10 transition"
title=
"Attach files (images, videos, documents)"
>
<
Paperclip
size=
{
16
}
/>
</
button
>
</>
);
}
\ No newline at end of file
frontend/src/components/MessageBubble.jsx
View file @
e7ac6b83
...
...
@@ -2,137 +2,227 @@ import React, { useState } from "react";
import
ReactMarkdown
from
"react-markdown"
;
import
remarkGfm
from
"remark-gfm"
;
import
CodeBlock
from
"./CodeBlock"
;
import
{
User
,
Flame
,
ChevronDown
,
ChevronRight
,
Brain
,
Copy
,
Check
}
from
"lucide-react"
;
import
AttachmentPreview
from
"./AttachmentPreview"
;
import
{
User
,
Flame
,
Copy
,
Check
,
ChevronDown
,
ChevronRight
,
}
from
"lucide-react"
;
const
MessageBubble
=
React
.
memo
(
function
MessageBubble
({
message
,
isStreaming
,
isThinking
})
{
const
{
role
,
content
,
thinking_content
,
input_tokens
,
output_tokens
}
=
message
;
const
isUser
=
role
===
"user"
;
const
[
showThinking
,
setShowThinking
]
=
useState
(
false
);
export
default
function
MessageBubble
({
message
,
chatId
,
isStreaming
=
false
,
isThinking
=
false
,
})
{
const
isUser
=
message
.
role
===
"user"
;
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
[
showThinking
,
setShowThinking
]
=
useState
(
false
);
function
handleCopy
()
{
navigator
.
clipboard
.
writeText
(
content
||
""
);
navigator
.
clipboard
.
writeText
(
message
.
content
||
""
);
setCopied
(
true
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
}
const
attachments
=
message
.
attachments
||
[];
return
(
<
div
className=
{
`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`
}
>
{
/* Avatar */
}
<
div
className=
{
`flex gap-3 animate-fade-in ${
isUser ? "justify-end" : "justify-start"
}`
}
>
{
/* Avatar — assistant only */
}
{
!
isUser
&&
(
<
div
className=
"shrink-0 mt-1"
>
<
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=
{
16
}
className=
"text-white"
/>
</
div
>
<
div
className=
"flex-shrink-0 w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent/20 to-transparent border border-anton-accent/20 flex items-center justify-center mt-1"
>
<
Flame
size=
{
14
}
className=
"text-anton-accent"
/>
</
div
>
)
}
<
div
className=
{
`max-w-[80%] ${isUser ? "order-first" : ""}`
}
>
{
/* Thinking block */
}
{
thinking_content
&&
(
<
div
className=
{
`
max-w-[80%] rounded-2xl px-4 py-3 relative group
${
isUser
? "bg-anton-user border border-anton-border/50 text-white"
: "bg-anton-assistant border border-anton-border/30 text-anton-text"
}
${isStreaming ? "border-anton-accent/30" : ""}
`
}
>
{
/* User attachments */
}
{
isUser
&&
attachments
.
length
>
0
&&
(
<
div
className=
"flex flex-wrap gap-1.5 mb-2"
>
{
attachments
.
map
((
att
,
i
)
=>
(
<
AttachmentPreview
key=
{
att
.
id
||
i
}
attachment=
{
att
}
/>
))
}
</
div
>
)
}
{
/* Thinking block (collapsible) */
}
{
message
.
thinking_content
&&
(
<
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 text-[11px] text-anton-muted hover:text-anton-accent transition"
>
<
Brain
size=
{
12
}
/>
{
showThinking
?
<
ChevronDown
size=
{
12
}
/>
:
<
ChevronRight
size=
{
12
}
/>
}
{
isThinking
?
(
<
span
className=
"thinking-pulse"
>
Reasoning…
</
span
>
{
showThinking
?
(
<
ChevronDown
size=
{
12
}
/>
)
:
(
<
span
>
View reasoning
</
span
>
<
ChevronRight
size=
{
12
}
/
>
)
}
{
isThinking
?
"Thinking..."
:
"Thought process"
}
</
button
>
{
(
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"
>
{
thinking_content
}
{
isThinking
&&
<
span
className=
"inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse"
/>
}
{
showThinking
&&
(
<
div
className=
"mt-1 pl-3 border-l-2 border-anton-accent/20 text-xs text-anton-muted leading-relaxed whitespace-pre-wrap"
>
{
message
.
thinking_content
}
</
div
>
)
}
</
div
>
)
}
{
/* Message content */
}
<
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
?
(
<
div
className=
"text-sm whitespace-pre-wrap"
>
{
content
}
</
div
>
)
:
(
<
div
className=
"prose-anton text-sm"
>
<
ReactMarkdown
remarkPlugins=
{
[
remarkGfm
]
}
components=
{
{
code
({
node
,
inline
,
className
,
children
,
...
props
})
{
const
match
=
/language-
(\S
+
)
/
.
exec
(
className
||
""
);
const
rawLang
=
match
?.[
1
]
||
""
;
{
/* Thinking indicator during streaming */
}
{
isStreaming
&&
isThinking
&&
!
message
.
thinking_content
&&
(
<
div
className=
"flex items-center gap-2 text-xs text-anton-accent mb-2"
>
<
div
className=
"flex gap-1"
>
<
span
className=
"w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"0ms"
}
}
/>
<
span
className=
"w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"150ms"
}
}
/>
<
span
className=
"w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"300ms"
}
}
/>
</
div
>
Thinking...
</
div
>
)
}
if
(
inline
)
{
return
(
<
code
className=
{
className
}
{
...
props
}
>
{
children
}
</
code
>
);
}
{
/* Main content */
}
{
message
.
content
&&
(
<
div
className=
"prose prose-invert prose-sm max-w-none break-words"
>
<
ReactMarkdown
remarkPlugins=
{
[
remarkGfm
]
}
components=
{
{
code
({
node
,
inline
,
className
,
children
,
...
props
})
{
const
match
=
/language-
(\S
+
)
/
.
exec
(
className
||
""
);
const
lang
=
match
?
match
[
1
]
:
""
;
// Parse "lang:filename" format
let
lang
=
rawLang
;
let
filename
=
null
;
if
(
rawLang
.
includes
(
":"
))
{
const
idx
=
rawLang
.
indexOf
(
":"
);
lang
=
rawLang
.
slice
(
0
,
idx
);
filename
=
rawLang
.
slice
(
idx
+
1
);
if
(
!
inline
&&
(
match
||
String
(
children
).
includes
(
"
\n
"
)))
{
// Extract filename from lang if format is "lang:path/to/file.ext"
let
language
=
lang
;
let
filename
=
""
;
if
(
lang
&&
lang
.
includes
(
":"
))
{
const
parts
=
lang
.
split
(
":"
);
language
=
parts
[
0
];
filename
=
parts
.
slice
(
1
).
join
(
":"
);
}
const
code
=
String
(
children
).
replace
(
/
\n
$/
,
""
);
return
(
<
CodeBlock
language=
{
lang
}
filename=
{
filename
}
code=
{
code
}
/>
<
CodeBlock
code=
{
String
(
children
).
replace
(
/
\n
$/
,
""
)
}
language=
{
language
}
filename=
{
filename
}
/>
);
},
// Make sure pre doesn't double-wrap
pre
({
children
})
{
return
<>
{
children
}
</>;
},
}
}
>
{
content
||
""
}
</
ReactMarkdown
>
{
isStreaming
&&
!
isThinking
&&
(
<
span
className=
"inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse"
/>
)
}
</
div
>
)
}
</
div
>
}
{
/* Footer */
}
{
!
isUser
&&
!
isStreaming
&&
content
&&
(
<
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"
return
(
<
code
className=
"bg-anton-card px-1.5 py-0.5 rounded text-anton-accent text-[13px] font-mono"
{
...
props
}
>
{
children
}
</
code
>
);
},
// Style links
a
({
children
,
...
props
})
{
return
(
<
a
className=
"text-anton-accent hover:underline"
target=
"_blank"
rel=
"noopener noreferrer"
{
...
props
}
>
{
children
}
</
a
>
);
},
// Style tables
table
({
children
})
{
return
(
<
div
className=
"overflow-x-auto my-2"
>
<
table
className=
"border-collapse border border-anton-border text-xs"
>
{
children
}
</
table
>
</
div
>
);
},
th
({
children
})
{
return
(
<
th
className=
"border border-anton-border bg-anton-card px-3 py-1.5 text-left text-anton-text font-semibold"
>
{
children
}
</
th
>
);
},
td
({
children
})
{
return
(
<
td
className=
"border border-anton-border px-3 py-1.5"
>
{
children
}
</
td
>
);
},
}
}
>
{
copied
?
<
Check
size=
{
11
}
className=
"text-anton-success"
/>
:
<
Copy
size=
{
11
}
/>
}
{
copied
?
"Copied"
:
"Copy"
}
{
message
.
content
}
</
ReactMarkdown
>
</
div
>
)
}
{
/* Streaming cursor */
}
{
isStreaming
&&
!
isThinking
&&
(
<
span
className=
"inline-block w-2 h-4 bg-anton-accent/70 animate-pulse ml-0.5 align-middle"
/>
)
}
{
/* Copy button — assistant messages only */
}
{
!
isUser
&&
message
.
content
&&
!
isStreaming
&&
(
<
div
className=
"absolute -bottom-3 right-2 opacity-0 group-hover:opacity-100 transition"
>
<
button
onClick=
{
handleCopy
}
className=
"flex items-center gap-1 text-[10px] text-anton-muted hover:text-white bg-anton-card border border-anton-border rounded-md px-2 py-0.5 shadow-lg"
>
{
copied
?
(
<>
<
Check
size=
{
10
}
className=
"text-anton-success"
/>
Copied
</>
)
:
(
<>
<
Copy
size=
{
10
}
/>
Copy
</>
)
}
</
button
>
{
(
input_tokens
>
0
||
output_tokens
>
0
)
&&
(
<
span
className=
"text-[11px] text-anton-muted"
>
{
input_tokens
?.
toLocaleString
()
}
↓ /
{
output_tokens
?.
toLocaleString
()
}
↑ tokens
</
span
>
)
}
</
div
>
)
}
</
div
>
{
/*
User avatar
*/
}
{
/*
Avatar — user only
*/
}
{
isUser
&&
(
<
div
className=
"shrink-0 mt-1"
>
<
div
className=
"w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"
>
<
User
size=
{
16
}
className=
"text-anton-muted"
/>
</
div
>
<
div
className=
"flex-shrink-0 w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center mt-1"
>
<
User
size=
{
14
}
className=
"text-anton-muted"
/>
</
div
>
)
}
</
div
>
);
});
export
default
MessageBubble
;
\ No newline at end of file
}
\ No newline at end of file
frontend/src/pages/ChatPage.jsx
View file @
e7ac6b83
...
...
@@ -48,6 +48,12 @@ function EmptyState() {
Avatar of All Elements of Code. Create a new chat to begin — but bring
real questions, not that first-result-of-Google garbage.
</
p
>
<
div
className=
"mt-4 flex items-center justify-center gap-2 text-xs text-anton-muted"
>
<
span
>
📎 Supports images, videos, PDFs, and documents
</
span
>
</
div
>
<
div
className=
"mt-1 flex items-center justify-center gap-2 text-xs text-anton-muted"
>
<
span
>
⚡ Multiple chats can stream in parallel
</
span
>
</
div
>
</
div
>
</
div
>
);
...
...
frontend/src/store.jsx
View file @
e7ac6b83
/**
* Global state via React Context + useReducer.
*
* Holds chat messages and streaming flags so they persist
* across chat switches (background streams keep running).
*/
import
React
,
{
createContext
,
useContext
,
useReducer
,
useEffect
}
from
"react"
;
import
{
setDispatch
}
from
"./streamManager"
;
const
AppContext
=
createContext
();
const
initialState
=
{
token
:
localStorage
.
getItem
(
"
soa_
token"
)
||
null
,
user
:
JSON
.
parse
(
localStorage
.
getItem
(
"soa_user"
)
||
"null"
)
,
token
:
localStorage
.
getItem
(
"token"
)
||
null
,
user
:
null
,
chats
:
[],
activeChatId
:
null
,
sidebarOpen
:
true
,
chatMessages
:
{},
// { [chatId]: Message[] }
activeStreams
:
{},
// { [chatId]: true } — which chats are currently streaming
chatMessages
:
{},
// chatId -> [messages]
activeStreams
:
{},
// chatId -> true (which chats are currently streaming)
};
function
reducer
(
state
,
action
)
{
switch
(
action
.
type
)
{
case
"LOGIN"
:
localStorage
.
setItem
(
"soa_token"
,
action
.
token
);
localStorage
.
setItem
(
"soa_user"
,
JSON
.
stringify
(
action
.
user
));
return
{
...
state
,
token
:
action
.
token
,
user
:
action
.
user
};
case
"SET_TOKEN"
:
if
(
action
.
token
)
localStorage
.
setItem
(
"token"
,
action
.
token
);
else
localStorage
.
removeItem
(
"token"
);
return
{
...
state
,
token
:
action
.
token
};
case
"SET_USER"
:
return
{
...
state
,
user
:
action
.
user
};
case
"LOGOUT"
:
localStorage
.
removeItem
(
"soa_token"
);
localStorage
.
removeItem
(
"soa_user"
);
return
{
...
initialState
,
token
:
null
,
user
:
null
,
chatMessages
:
{},
activeStreams
:
{},
};
localStorage
.
removeItem
(
"token"
);
return
{
...
initialState
,
token
:
null
};
case
"SET_CHATS"
:
return
{
...
state
,
chats
:
action
.
chats
};
...
...
@@ -55,7 +44,7 @@ function reducer(state, action) {
return
{
...
state
,
chats
:
updated
};
}
case
"
REMOV
E_CHAT"
:
{
case
"
DELET
E_CHAT"
:
{
const
filtered
=
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
action
.
chatId
);
const
newMessages
=
{
...
state
.
chatMessages
};
delete
newMessages
[
action
.
chatId
];
...
...
@@ -102,6 +91,7 @@ function reducer(state, action) {
};
// ── Background streaming flags ───────────────
// NOW PER-CHAT — no longer blocks other chats
case
"SET_STREAMING"
:
{
if
(
action
.
streaming
)
{
return
{
...
...
frontend/src/streamManager.js
View file @
e7ac6b83
...
...
@@ -39,6 +39,11 @@ export function isStreaming(chatId) {
return
_streams
.
has
(
chatId
);
}
/** Is ANY chat currently streaming? (for UI indicators, NOT for blocking) */
export
function
isAnyStreaming
()
{
return
_streams
.
size
>
0
;
}
/** Subscribe to stream data changes for a specific chat. Returns unsubscribe fn. */
export
function
subscribe
(
chatId
,
callback
)
{
if
(
!
_listeners
.
has
(
chatId
))
_listeners
.
set
(
chatId
,
new
Set
());
...
...
@@ -70,103 +75,70 @@ export function abortStream(chatId) {
/**
* Start a background stream for a chat.
* Does nothing if that chat is already streaming.
*
* @param {object} opts
* @param {string} opts.token - JWT
* @param {string} opts.chatId - chat UUID
* @param {object} opts.body - SendMessageBody
* If a stream already exists for this chat, it is aborted first.
*/
export
function
startStream
({
token
,
chatId
,
body
})
{
if
(
_streams
.
has
(
chatId
))
return
;
// Abort existing stream for THIS chat only (other chats keep streaming)
if
(
_streams
.
has
(
chatId
))
{
abortStream
(
chatId
);
}
const
a
c
=
new
AbortController
();
_streams
.
set
(
chatId
,
{
const
a
bortController
=
new
AbortController
();
const
streamState
=
{
text
:
""
,
thinking
:
""
,
isThinking
:
false
,
abortController
:
ac
,
});
abortController
,
};
_streams
.
set
(
chatId
,
streamState
);
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
true
});
_notify
(
chatId
);
// Fire
-and-forget async IIFE — runs entirely in the background
// Fire
and forget — runs independently of React
(
async
()
=>
{
const
s
=
_streams
.
get
(
chatId
);
if
(
!
s
)
return
;
let
usage
=
{};
let
msgId
=
""
;
try
{
for
await
(
const
evt
of
streamMessage
(
token
,
chatId
,
body
,
ac
.
signal
))
{
if
(
ac
.
signal
.
aborted
)
break
;
if
(
!
_streams
.
has
(
chatId
))
break
;
switch
(
evt
.
type
)
{
case
"thinking_start"
:
s
.
isThinking
=
true
;
_notify
(
chatId
);
break
;
case
"thinking_delta"
:
s
.
thinking
+=
evt
.
content
;
_notify
(
chatId
);
break
;
case
"thinking_end"
:
s
.
isThinking
=
false
;
_notify
(
chatId
);
break
;
case
"text_delta"
:
s
.
text
+=
evt
.
content
;
_notify
(
chatId
);
break
;
case
"usage"
:
usage
=
{
input_tokens
:
evt
.
input_tokens
,
output_tokens
:
evt
.
output_tokens
,
};
break
;
case
"title_update"
:
if
(
_dispatch
)
_dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
title
:
evt
.
title
},
});
break
;
case
"done"
:
msgId
=
evt
.
message_id
;
break
;
case
"error"
:
s
.
text
+=
`\n\n**Error:**
${
evt
.
message
}
`
;
_notify
(
chatId
);
break
;
const
gen
=
streamMessage
(
token
,
chatId
,
body
,
abortController
.
signal
);
for
await
(
const
event
of
gen
)
{
const
s
=
_streams
.
get
(
chatId
);
if
(
!
s
)
break
;
// stream was aborted
if
(
event
.
type
===
"thinking"
)
{
s
.
thinking
+=
event
.
content
||
""
;
s
.
isThinking
=
true
;
}
else
if
(
event
.
type
===
"text"
)
{
s
.
text
+=
event
.
content
||
""
;
s
.
isThinking
=
false
;
}
else
if
(
event
.
type
===
"done"
)
{
// Final message — add to store
if
(
_dispatch
&&
event
.
message
)
{
_dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
event
.
message
,
});
}
// Auto-title
if
(
_dispatch
&&
event
.
title
)
{
_dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
title
:
event
.
title
},
});
}
}
else
if
(
event
.
type
===
"error"
)
{
s
.
text
+=
`\n\n**Error:**
${
event
.
content
||
"Unknown error"
}
`
;
}
}
// Stream finished normally — persist the final assistant message
if
(
!
ac
.
signal
.
aborted
&&
_dispatch
)
{
const
assistantMsg
=
{
id
:
msgId
||
`gen-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
s
.
text
,
thinking_content
:
s
.
thinking
||
null
,
input_tokens
:
usage
.
input_tokens
||
0
,
output_tokens
:
usage
.
output_tokens
||
0
,
created_at
:
new
Date
().
toISOString
(),
};
_dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
assistantMsg
});
_notify
(
chatId
);
}
}
catch
(
err
)
{
// Only surface errors that aren't deliberate aborts
if
(
!
ac
.
signal
.
aborted
&&
_dispatch
)
{
const
errMsg
=
{
id
:
`err-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
`**Error:**
${
err
.
message
}
`
,
created_at
:
new
Date
().
toISOString
(),
};
_dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
errMsg
});
if
(
err
.
name
!==
"AbortError"
)
{
const
s
=
_streams
.
get
(
chatId
);
if
(
s
)
{
s
.
text
+=
`\n\n**Stream error:**
${
err
.
message
}
`
;
_notify
(
chatId
);
}
}
}
finally
{
_streams
.
delete
(
chatId
);
...
...
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