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
Expand all
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
This diff is collapsed.
Click to expand it.
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
This diff is collapsed.
Click to expand it.
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