Commit 25a6e3a4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

ykrftjdgdhmdhdgh ety d jd j

parent d55ca0c4
# === Node ===
frontend/node_modules
frontend/dist
frontend/.vite
# === Python ===
**/__pycache__
**/*.pyc
**/*.pyo
*.egg-info
# === Git ===
.git
.gitignore
# === Dev files ===
.env
.env.local
*.md
create-project.ps1
main.py
**/.DS_Store
# === IDE ===
.idea
.vscode
*.swp
*.swo
...@@ -4,10 +4,23 @@ ...@@ -4,10 +4,23 @@
FROM node:20-alpine AS frontend-build FROM node:20-alpine AS frontend-build
WORKDIR /build/frontend WORKDIR /build/frontend
# Nuke NODE_ENV — CapRover/Docker can inject production
# which prevents devDependencies (vite, tailwind) from installing
ENV NODE_ENV=
COPY frontend/package.json frontend/package-lock.json* ./ COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install --legacy-peer-deps
# Force ALL deps including dev
RUN npm install --legacy-peer-deps --include=dev && \
echo "=== vite check ===" && \
npx vite --version
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build
RUN NODE_ENV=production npx vite build && \
echo "=== dist ===" && \
ls -la dist/
# ============================================ # ============================================
# Stage 2: Python Backend + Serve Frontend # Stage 2: Python Backend + Serve Frontend
...@@ -15,9 +28,9 @@ RUN npm run build ...@@ -15,9 +28,9 @@ RUN npm run build
FROM python:3.11-slim FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
ffmpeg \ ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
...@@ -28,11 +41,12 @@ COPY backend/ ./backend/ ...@@ -28,11 +41,12 @@ COPY backend/ ./backend/
COPY --from=frontend-build /build/frontend/dist ./frontend/dist COPY --from=frontend-build /build/frontend/dist ./frontend/dist
# Warm up the ChromaDB embedding model so first request is fast RUN echo "=== Frontend ===" && ls -la frontend/dist/ && \
echo "=== Assets ===" && ls -la frontend/dist/assets/ || true
COPY warmup.py /tmp/warmup.py COPY warmup.py /tmp/warmup.py
RUN python /tmp/warmup.py && rm /tmp/warmup.py RUN python /tmp/warmup.py && rm /tmp/warmup.py
# Create persistent data directories
RUN mkdir -p /data/chromadb /data/uploads /data/uploads/chat_attachments RUN mkdir -p /data/chromadb /data/uploads /data/uploads/chat_attachments
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
......
#!/usr/bin/env bash
set -euo pipefail
# ═══════════════════════════════════════════════════════════════
# 🔥 Son of Anton — Patch All Modified/New Files
# Run from the son-of-anton root directory
# ═══════════════════════════════════════════════════════════════
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_DIR"
RED='\033[0;31m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
step() { echo -e " ${CYAN}${NC} Writing $1..."; }
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
echo -e "${BOLD} Patching all files...${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
echo ""
# ────────────────────────────────────────
# .dockerignore
# ────────────────────────────────────────
step ".dockerignore"
cat > .dockerignore << 'ENDOFFILE'
# === Node ===
frontend/node_modules
frontend/dist
frontend/.vite
# === Python ===
**/__pycache__
**/*.pyc
**/*.pyo
*.egg-info
# === Git ===
.git
.gitignore
# === Dev files ===
.env
.env.local
*.md
create-project.ps1
main.py
**/.DS_Store
# === IDE ===
.idea
.vscode
*.swp
*.swo
ENDOFFILE
ok ".dockerignore"
# ────────────────────────────────────────
# backend/config.py
# ────────────────────────────────────────
step "backend/config.py"
cat > backend/config.py << 'ENDOFFILE'
"""
Application configuration — reads from environment variables.
"""
import os
import secrets
BEDROCK_API_KEY: str = os.getenv(
"BEDROCK_API_KEY",
os.getenv("AWS_BEARER_TOKEN_BEDROCK", ""),
)
AWS_REGION: str = os.getenv("AWS_REGION", "eu-central-1")
PRIMARY_MODEL: str = os.getenv("PRIMARY_MODEL", "eu.anthropic.claude-opus-4-6-v1")
FAST_MODEL: str = os.getenv("FAST_MODEL", "eu.anthropic.claude-haiku-4-5-20251001-v1:0")
JWT_SECRET: str = os.getenv("JWT_SECRET", secrets.token_hex(32))
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRY_HOURS: int = 72
SUPERADMIN_PASSWORD: str = os.getenv("SUPERADMIN_PASSWORD", "admin123")
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:////data/sonofanton.db")
CHROMADB_PATH: str = os.getenv("CHROMADB_PATH", "/data/chromadb")
UPLOAD_PATH: str = os.getenv("UPLOAD_PATH", "/data/uploads")
ATTACHMENT_PATH: str = os.getenv("ATTACHMENT_PATH", "/data/uploads/chat_attachments")
DEFAULT_QUOTA: int = int(os.getenv("DEFAULT_QUOTA", "2000000"))
MAX_UPLOAD_BYTES: int = int(os.getenv("MAX_UPLOAD_MB", "50")) * 1024 * 1024
MAX_ATTACHMENT_BYTES: int = int(os.getenv("MAX_ATTACHMENT_MB", "25")) * 1024 * 1024
MAX_IMAGE_DIMENSION: int = 1568
MAX_VIDEO_FRAMES: int = 6
BEDROCK_ENDPOINT: str = (
f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com"
)
ENDOFFILE
ok "backend/config.py"
# ────────────────────────────────────────
# backend/models.py
# ────────────────────────────────────────
step "backend/models.py"
cat > backend/models.py << 'ENDOFFILE'
"""
SQLAlchemy ORM models.
"""
from datetime import datetime, timedelta
from uuid import uuid4
from sqlalchemy import (
Column, String, Text, Boolean, BigInteger, Integer, DateTime, ForeignKey,
)
from sqlalchemy.orm import relationship
from backend.database import Base
def new_id() -> str:
return str(uuid4())
def next_month() -> datetime:
now = datetime.utcnow()
if now.month == 12:
return datetime(now.year + 1, 1, 1)
return datetime(now.year, now.month + 1, 1)
class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=new_id)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(120), unique=True, nullable=False)
password_hash = Column(String(200), nullable=False)
role = Column(String(20), default="user")
is_active = Column(Boolean, default=True)
quota_tokens_monthly = Column(BigInteger, default=2_000_000)
tokens_used_this_month = Column(BigInteger, default=0)
quota_reset_date = Column(DateTime, default=next_month)
created_at = Column(DateTime, default=datetime.utcnow)
chats = relationship("Chat", back_populates="user", cascade="all,delete-orphan")
class Chat(Base):
__tablename__ = "chats"
id = Column(String(36), primary_key=True, default=new_id)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
title = Column(String(200), default="New Chat")
model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
knowledge_base_id = Column(String(36), nullable=True)
max_tokens = Column(Integer, default=4096)
reasoning_budget = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="chats")
messages = relationship(
"Message", back_populates="chat",
cascade="all,delete-orphan", order_by="Message.created_at",
)
attachments = relationship(
"ChatAttachment", back_populates="chat",
cascade="all,delete-orphan",
)
class Message(Base):
__tablename__ = "messages"
id = Column(String(36), primary_key=True, default=new_id)
chat_id = Column(String(36), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
role = Column(String(20), nullable=False)
content = Column(Text, default="")
thinking_content = Column(Text, nullable=True)
input_tokens = Column(Integer, default=0)
output_tokens = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
chat = relationship("Chat", back_populates="messages")
attachments = relationship("ChatAttachment", back_populates="message")
class ChatAttachment(Base):
__tablename__ = "chat_attachments"
id = Column(String(36), primary_key=True, default=new_id)
chat_id = Column(String(36), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
message_id = Column(String(36), ForeignKey("messages.id", ondelete="SET NULL"), nullable=True)
filename = Column(String(200), nullable=False)
original_filename = Column(String(200), nullable=False)
mime_type = Column(String(100), nullable=False)
file_type = Column(String(20), nullable=False)
file_size = Column(Integer, default=0)
storage_path = Column(String(500), nullable=False)
text_extract = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
chat = relationship("Chat", back_populates="attachments")
message = relationship("Message", back_populates="attachments")
class KnowledgeBase(Base):
__tablename__ = "knowledge_bases"
id = Column(String(36), primary_key=True, default=new_id)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
name = Column(String(200), nullable=False)
description = Column(Text, default="")
document_count = Column(Integer, default=0)
chunk_count = Column(Integer, default=0)
total_characters = Column(BigInteger, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
class KnowledgeDocument(Base):
__tablename__ = "knowledge_documents"
id = Column(String(36), primary_key=True, default=new_id)
knowledge_base_id = Column(
String(36), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), nullable=False,
)
filename = Column(String(200), nullable=False)
file_size = Column(Integer, default=0)
chunk_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
ENDOFFILE
ok "backend/models.py"
# ────────────────────────────────────────
# backend/main.py
# ────────────────────────────────────────
step "backend/main.py"
cat > backend/main.py << 'ENDOFFILE'
"""
Son of Anton — Main FastAPI Application
"""
import os
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from backend.database import engine, Base
from backend.seed import seed_superadmin
from backend.routes.auth_routes import router as auth_router
from backend.routes.chat_routes import router as chat_router
from backend.routes.admin_routes import router as admin_router
from backend.routes.knowledge_routes import router as knowledge_router
from backend.routes.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router
from backend.services.bedrock_service import close_http_client
def _run_migrations():
"""Add new columns/tables to existing DB if they're missing."""
from sqlalchemy import inspect, text
try:
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
if "chats" in existing_tables:
columns = {c["name"] for c in inspector.get_columns("chats")}
with engine.connect() as conn:
if "max_tokens" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096"))
print(" Added chats.max_tokens column")
if "reasoning_budget" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
print(" Added chats.reasoning_budget column")
conn.commit()
if "chat_attachments" not in existing_tables:
from backend.models import ChatAttachment
ChatAttachment.__table__.create(bind=engine, checkfirst=True)
print(" Created chat_attachments table")
except Exception as e:
print(f" Migration note: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
_run_migrations()
seed_superadmin()
print("Son of Anton is online.")
yield
await close_http_client()
print("Son of Anton shutting down.")
app = FastAPI(
title="Son of Anton",
description="Avatar of All Elements of Code",
version="2.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(chat_router, prefix="/api/chats", tags=["Chats"])
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"])
app.include_router(files_router, prefix="/api/files", tags=["Files"])
app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if (FRONTEND_DIR / "assets").exists():
app.mount(
"/assets",
StaticFiles(directory=str(FRONTEND_DIR / "assets")),
name="static-assets",
)
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_frontend(full_path: str):
if full_path.startswith("api"):
raise HTTPException(status_code=404, detail="Not found")
file_path = FRONTEND_DIR / full_path
if full_path and file_path.is_file():
return FileResponse(str(file_path))
index = FRONTEND_DIR / "index.html"
if index.is_file():
return FileResponse(str(index))
return {"message": "Son of Anton API is running. Frontend not built."}
ENDOFFILE
ok "backend/main.py"
# ────────────────────────────────────────
# backend/services/attachment_service.py
# ────────────────────────────────────────
step "backend/services/attachment_service.py"
cat > backend/services/attachment_service.py << 'ENDOFFILE'
"""
Attachment processing service.
Handles images, videos (frame extraction), PDFs, and text files.
"""
import os
import io
import base64
import shutil
import subprocess
import tempfile
import mimetypes
from pathlib import Path
from uuid import uuid4
from typing import Optional
from backend import config
os.makedirs(config.ATTACHMENT_PATH, exist_ok=True)
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv", ".m4v"}
PDF_EXTENSIONS = {".pdf"}
TEXT_EXTENSIONS = {
".txt", ".md", ".py", ".js", ".ts", ".jsx", ".tsx", ".cs", ".java",
".cpp", ".c", ".h", ".hpp", ".go", ".rs", ".rb", ".php", ".swift",
".kt", ".lua", ".gd", ".html", ".css", ".scss", ".json", ".yaml",
".yml", ".xml", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash",
".sql", ".r", ".dart", ".vue", ".svelte", ".csv", ".log", ".env",
}
IMAGE_MIMES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
VIDEO_MIMES = {"video/mp4", "video/quicktime", "video/x-msvideo", "video/webm"}
def classify_file(filename, mime):
ext = Path(filename).suffix.lower()
if ext in IMAGE_EXTENSIONS or mime in IMAGE_MIMES:
return "image"
if ext in VIDEO_EXTENSIONS or mime in VIDEO_MIMES:
return "video"
if ext in PDF_EXTENSIONS or mime == "application/pdf":
return "document"
return "text"
def get_mime_type(filename, content_type=None):
if content_type and content_type != "application/octet-stream":
return content_type
mime, _ = mimetypes.guess_type(filename)
return mime or "application/octet-stream"
def save_attachment(chat_id, filename, content, content_type=None):
mime = get_mime_type(filename, content_type)
file_type = classify_file(filename, mime)
attachment_id = str(uuid4())
chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
os.makedirs(chat_dir, exist_ok=True)
safe_name = Path(filename).name.replace(" ", "_")
stored_name = f"{attachment_id}_{safe_name}"
storage_path = os.path.join(chat_dir, stored_name)
with open(storage_path, "wb") as f:
f.write(content)
text_extract = None
if file_type == "text":
text_extract = _extract_text_content(storage_path)
elif file_type == "document":
text_extract = _extract_pdf_text(storage_path)
return {
"id": attachment_id,
"filename": stored_name,
"original_filename": filename,
"mime_type": mime,
"file_type": file_type,
"file_size": len(content),
"storage_path": storage_path,
"text_extract": text_extract,
}
def delete_attachment_file(storage_path):
try:
if os.path.exists(storage_path):
os.remove(storage_path)
except Exception:
pass
def delete_chat_attachments(chat_id):
chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
if os.path.isdir(chat_dir):
shutil.rmtree(chat_dir, ignore_errors=True)
def build_claude_content_blocks(attachments):
blocks = []
for att in attachments:
try:
result = _process_single_attachment(att)
if isinstance(result, list):
blocks.extend(result)
elif result:
blocks.append(result)
except Exception as e:
blocks.append({
"type": "text",
"text": f"[Failed to process {att.original_filename}: {str(e)}]",
})
return blocks
def _process_single_attachment(att):
if att.file_type == "image":
return _build_image_block(att)
elif att.file_type == "video":
return _build_video_blocks(att)
elif att.file_type == "document":
return _build_document_block(att)
elif att.file_type == "text":
return _build_text_block(att)
return None
def _build_image_block(att):
data = _read_and_resize_image(att.storage_path, att.mime_type)
mime = att.mime_type if att.mime_type in IMAGE_MIMES else "image/jpeg"
return {
"type": "image",
"source": {"type": "base64", "media_type": mime, "data": data},
}
def _build_video_blocks(att):
frames = _extract_video_frames(att.storage_path)
if not frames:
return [{"type": "text", "text": f"[Video: {att.original_filename} - could not extract frames]"}]
blocks = [{"type": "text", "text": f"[Video: {att.original_filename} - {len(frames)} frames extracted]"}]
for frame_b64 in frames:
blocks.append({
"type": "image",
"source": {"type": "base64", "media_type": "image/jpeg", "data": frame_b64},
})
return blocks
def _build_document_block(att):
if att.mime_type == "application/pdf":
with open(att.storage_path, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")
return {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": data},
}
return _build_text_block(att)
def _build_text_block(att):
text = att.text_extract or _extract_text_content(att.storage_path)
if not text:
text = f"[Could not extract text from {att.original_filename}]"
return {
"type": "text",
"text": f"--- File: {att.original_filename} ---\n{text}\n--- End of {att.original_filename} ---",
}
def _read_and_resize_image(path, mime_type):
try:
from PIL import Image
img = Image.open(path)
if img.mode in ("RGBA", "LA", "P"):
bg = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
bg.paste(img, mask=img.split()[-1] if "A" in img.mode else None)
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
mx = config.MAX_IMAGE_DIMENSION
if img.width > mx or img.height > mx:
ratio = min(mx / img.width, mx / img.height)
img = img.resize((int(img.width * ratio), int(img.height * ratio)), Image.LANCZOS)
buf = io.BytesIO()
fmt = "PNG" if mime_type == "image/png" else "JPEG"
kwargs = {"quality": 85} if fmt == "JPEG" else {}
img.save(buf, format=fmt, **kwargs)
return base64.b64encode(buf.getvalue()).decode("utf-8")
except ImportError:
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception:
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def _extract_video_frames(video_path):
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
return []
frames = []
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", video_path],
capture_output=True, text=True, timeout=30,
)
duration = float(result.stdout.strip() or "0")
if duration <= 0:
return []
max_frames = config.MAX_VIDEO_FRAMES
with tempfile.TemporaryDirectory() as tmpdir:
interval = duration / (max_frames + 1)
for i in range(max_frames):
ts = interval * (i + 1)
out = os.path.join(tmpdir, f"frame_{i}.jpg")
subprocess.run(
["ffmpeg", "-ss", str(ts), "-i", video_path, "-vframes", "1",
"-vf", f"scale='min({config.MAX_IMAGE_DIMENSION},iw)':'min({config.MAX_IMAGE_DIMENSION},ih)':force_original_aspect_ratio=decrease",
"-q:v", "3", out],
capture_output=True, timeout=30,
)
if os.path.exists(out) and os.path.getsize(out) > 0:
with open(out, "rb") as f:
frames.append(base64.b64encode(f.read()).decode("utf-8"))
except Exception:
pass
return frames
def _extract_text_content(path):
try:
with open(path, "r", encoding="utf-8") as f:
return f.read(500_000)
except UnicodeDecodeError:
try:
with open(path, "r", encoding="latin-1") as f:
return f.read(500_000)
except Exception:
return None
except Exception:
return None
def _extract_pdf_text(path):
try:
from PyPDF2 import PdfReader
reader = PdfReader(path)
pages = []
for page in reader.pages[:100]:
text = page.extract_text()
if text:
pages.append(text)
return "\n\n".join(pages) if pages else None
except Exception:
return None
ENDOFFILE
ok "backend/services/attachment_service.py"
# ────────────────────────────────────────
# backend/routes/attachment_routes.py
# ────────────────────────────────────────
step "backend/routes/attachment_routes.py"
cat > backend/routes/attachment_routes.py << 'ENDOFFILE'
"""
Chat attachment upload, serve, and delete routes.
"""
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, Chat, ChatAttachment
from backend.auth import get_current_user, decode_token
from backend.services import attachment_service
from backend.config import MAX_ATTACHMENT_BYTES
router = APIRouter()
def _get_user_flexible(request: Request, db: Session, token_param: Optional[str] = None) -> User:
raw_token = None
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
raw_token = auth_header[7:]
if not raw_token and token_param:
raw_token = token_param
if not raw_token:
raise HTTPException(401, "Authentication required")
payload = decode_token(raw_token)
user = db.query(User).filter(User.id == payload["sub"]).first()
if not user or not user.is_active:
raise HTTPException(401, "User not found or inactive")
return user
@router.post("/chats/{chat_id}/attachments")
async def upload_attachments(
chat_id: str,
files: list[UploadFile] = File(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404, "Chat not found")
results = []
for file in files:
filename = file.filename or "file"
try:
content = await file.read()
if len(content) > MAX_ATTACHMENT_BYTES:
results.append({"error": f"Too large: {filename}"})
continue
meta = attachment_service.save_attachment(
chat_id=chat_id, filename=filename,
content=content, content_type=file.content_type,
)
att = ChatAttachment(
id=meta["id"], chat_id=chat_id,
filename=meta["filename"],
original_filename=meta["original_filename"],
mime_type=meta["mime_type"],
file_type=meta["file_type"],
file_size=meta["file_size"],
storage_path=meta["storage_path"],
text_extract=meta.get("text_extract"),
)
db.add(att)
db.commit()
db.refresh(att)
results.append(_att_dict(att))
except Exception as e:
results.append({"error": f"Failed: {filename}: {str(e)}"})
return {"attachments": results}
@router.get("/attachments/{attachment_id}/file")
def serve_attachment(
attachment_id: str,
request: Request,
token: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
user = _get_user_flexible(request, db, token)
att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
if not att:
raise HTTPException(404, "Attachment not found")
chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
if not chat or (chat.user_id != user.id and user.role != "superadmin"):
raise HTTPException(403, "Access denied")
if not os.path.exists(att.storage_path):
raise HTTPException(404, "File not found on disk")
return FileResponse(att.storage_path, media_type=att.mime_type, filename=att.original_filename)
@router.delete("/attachments/{attachment_id}")
def delete_attachment(
attachment_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
if not att:
raise HTTPException(404)
chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
if not chat or (chat.user_id != user.id and user.role != "superadmin"):
raise HTTPException(403)
attachment_service.delete_attachment_file(att.storage_path)
db.delete(att)
db.commit()
return {"ok": True}
def _att_dict(att):
return {
"id": att.id, "chat_id": att.chat_id, "message_id": att.message_id,
"filename": att.filename, "original_filename": att.original_filename,
"mime_type": att.mime_type, "file_type": att.file_type,
"file_size": att.file_size, "created_at": str(att.created_at),
}
ENDOFFILE
ok "backend/routes/attachment_routes.py"
# ────────────────────────────────────────
# backend/routes/chat_routes.py
# ────────────────────────────────────────
step "backend/routes/chat_routes.py"
cat > backend/routes/chat_routes.py << 'ENDOFFILE'
"""
Chat CRUD and message streaming with multimodal attachment support.
"""
import json
from datetime import datetime
from pydantic import BaseModel
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from backend.database import get_db, SessionLocal
from backend.models import User, Chat, Message, ChatAttachment
from backend.auth import get_current_user
from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service
router = APIRouter()
class CreateChatBody(BaseModel):
title: str = "New Chat"
model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None
max_tokens: int = 4096
reasoning_budget: int = 0
class UpdateChatBody(BaseModel):
title: Optional[str] = None
model: Optional[str] = None
max_tokens: Optional[int] = None
reasoning_budget: Optional[int] = None
knowledge_base_id: Optional[str] = None
class SendMessageBody(BaseModel):
content: str
model: Optional[str] = None
max_tokens: int = 4096
reasoning_budget: int = 0
knowledge_base_id: Optional[str] = None
attachment_ids: list[str] = []
@router.get("")
def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all()
return [_chat_dict(c) for c in chats]
@router.post("")
def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = Chat(
user_id=user.id, title=body.title, model=body.model,
knowledge_base_id=body.knowledge_base_id or None,
max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
)
db.add(chat)
db.commit()
db.refresh(chat)
return _chat_dict(chat)
@router.get("/{chat_id}")
def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404, "Chat not found")
return _chat_dict(chat)
@router.put("/{chat_id}")
def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404)
if body.title is not None:
chat.title = body.title
if body.model is not None:
chat.model = body.model
if body.max_tokens is not None:
chat.max_tokens = body.max_tokens
if body.reasoning_budget is not None:
chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None:
chat.knowledge_base_id = body.knowledge_base_id or None
db.commit()
return _chat_dict(chat)
@router.delete("/{chat_id}")
def delete_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404)
attachment_service.delete_chat_attachments(chat_id)
db.delete(chat)
db.commit()
return {"ok": True}
@router.get("/{chat_id}/messages")
def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404)
msgs = []
for m in chat.messages:
d = _msg_dict(m)
atts = db.query(ChatAttachment).filter(ChatAttachment.message_id == m.id).all()
d["attachments"] = [_att_brief(a) for a in atts]
msgs.append(d)
return msgs
@router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
user_id = user.id
async def generate():
db = SessionLocal()
try:
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user_id).first()
if not chat:
yield _sse({"type": "error", "message": "Chat not found"})
return
db_user = db.query(User).filter(User.id == user_id).first()
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
if now.month == 12:
db_user.quota_reset_date = datetime(now.year + 1, 1, 1)
else:
db_user.quota_reset_date = datetime(now.year, now.month + 1, 1)
db.commit()
if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly:
yield _sse({"type": "error", "message": "Monthly token quota exceeded."})
return
attachments = []
if body.attachment_ids:
attachments = (
db.query(ChatAttachment)
.filter(ChatAttachment.id.in_(body.attachment_ids), ChatAttachment.chat_id == chat_id)
.all()
)
stored_content = body.content
if attachments:
labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
stored_content = "\n".join(notes) + "\n" + body.content
user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg)
db.commit()
db.refresh(user_msg)
for att in attachments:
att.message_id = user_msg.id
if attachments:
db.commit()
kb_id = body.knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
try:
rag_context = rag_service.query(kb_id, body.content, n_results=8)
except Exception:
pass
system_prompt = build_full_prompt(rag_context)
messages = memory_service.build_messages(chat, db)
if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": body.content})
messages[-1]["content"] = content_blocks
model_id = body.model or chat.model
max_tokens = body.max_tokens
thinking_config = None
if body.reasoning_budget > 0:
thinking_config = {"enabled": True, "budget_tokens": body.reasoning_budget}
max_tokens = max_tokens + body.reasoning_budget
full_text = ""
full_thinking = ""
input_tokens = 0
output_tokens = 0
current_block_type = "text"
async for event in bedrock_service.stream_response(
messages=messages, system_prompt=system_prompt,
model_id=model_id, max_tokens=min(max_tokens, 65536),
thinking_config=thinking_config,
):
evt_type = event.get("type", "")
if evt_type == "message_start":
usage = event.get("message", {}).get("usage", {})
input_tokens = usage.get("input_tokens", 0)
elif evt_type == "content_block_start":
blk = event.get("content_block", {})
current_block_type = blk.get("type", "text")
if current_block_type == "thinking":
yield _sse({"type": "thinking_start"})
elif evt_type == "content_block_delta":
delta = event.get("delta", {})
dt = delta.get("type", "")
if dt == "thinking_delta":
t = delta.get("thinking", "")
full_thinking += t
yield _sse({"type": "thinking_delta", "content": t})
elif dt == "text_delta":
t = delta.get("text", "")
full_text += t
yield _sse({"type": "text_delta", "content": t})
elif evt_type == "content_block_stop":
if current_block_type == "thinking":
yield _sse({"type": "thinking_end"})
elif evt_type == "message_delta":
usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0)
assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
input_tokens=input_tokens, output_tokens=output_tokens,
)
db.add(assistant_msg)
db_user.tokens_used_this_month += input_tokens + output_tokens
chat.model = model_id
chat.max_tokens = body.max_tokens
chat.reasoning_budget = body.reasoning_budget
chat.knowledge_base_id = body.knowledge_base_id or None
chat.updated_at = datetime.utcnow()
db.commit()
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
title = await _generate_title(body.content, full_text[:300])
chat.title = title[:120]
db.commit()
yield _sse({"type": "title_update", "title": chat.title})
except Exception:
pass
yield _sse({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
yield _sse({"type": "done", "message_id": assistant_msg.id})
except Exception as exc:
yield _sse({"type": "error", "message": str(exc)})
finally:
db.close()
return StreamingResponse(generate(), media_type="text/event-stream")
async def _generate_title(user_msg, ai_msg):
from backend.config import FAST_MODEL
result = await bedrock_service.invoke_model_simple(
model_id=FAST_MODEL,
prompt=f"Generate a concise title (max 6 words) for this conversation:\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.",
max_tokens=30,
)
return result.strip().strip('"').strip("'")
def _sse(data):
return f"data: {json.dumps(data)}\n\n"
def _chat_dict(c):
return {
"id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id,
"max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at), "updated_at": str(c.updated_at),
}
def _msg_dict(m):
return {
"id": m.id, "role": m.role, "content": m.content,
"thinking_content": m.thinking_content,
"input_tokens": m.input_tokens, "output_tokens": m.output_tokens,
"created_at": str(m.created_at),
}
def _att_brief(a):
return {
"id": a.id, "original_filename": a.original_filename,
"mime_type": a.mime_type, "file_type": a.file_type,
"file_size": a.file_size,
}
ENDOFFILE
ok "backend/routes/chat_routes.py"
# ────────────────────────────────────────
# backend/services/memory_service.py
# ────────────────────────────────────────
step "backend/services/memory_service.py"
cat > backend/services/memory_service.py << 'ENDOFFILE'
"""
Build the messages list for the Bedrock/Anthropic API from chat history.
"""
from sqlalchemy.orm import Session
from backend.models import Chat, Message
MAX_CONTEXT_CHARS = 400_000
MAX_MESSAGES = 80
def build_messages(chat: Chat, db: Session) -> list[dict]:
rows: list[Message] = (
db.query(Message)
.filter(Message.chat_id == chat.id)
.order_by(Message.created_at.desc())
.limit(MAX_MESSAGES)
.all()
)
rows.reverse()
if not rows:
return []
total_chars = sum(len(m.content or "") for m in rows)
idx = 0
while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2:
total_chars -= len(rows[idx].content or "")
idx += 1
trimmed = rows[idx:]
while trimmed and trimmed[0].role != "user":
trimmed = trimmed[1:]
result: list[dict] = []
for m in trimmed:
content = m.content or ""
if not content.strip():
continue
role = m.role
if role not in ("user", "assistant"):
continue
if result and result[-1]["role"] == role:
result[-1]["content"] += "\n" + content
else:
result.append({"role": role, "content": content})
return result
ENDOFFILE
ok "backend/services/memory_service.py"
# ────────────────────────────────────────
# frontend/src/api.js
# ────────────────────────────────────────
step "frontend/src/api.js"
cat > frontend/src/api.js << 'ENDOFFILE'
const BASE = "/api";
function headers(token) {
const h = { "Content-Type": "application/json" };
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
function authHeader(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function request(method, path, token, body) {
const opts = { method, headers: headers(token) };
if (body) opts.body = JSON.stringify(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");
}
return res.json();
}
export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) =>
request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token);
export const listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) =>
updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token);
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", headers: headers(token),
body: JSON.stringify(body), signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || "Stream failed");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
const line = part.trim();
if (line.startsWith("data: ")) {
try { yield JSON.parse(line.slice(6)); } catch { }
}
}
}
if (buffer.trim().startsWith("data: ")) {
try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
}
}
export async function uploadAttachments(token, chatId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json();
}
export function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`;
}
export const deleteAttachment = (token, attachmentId) =>
request("DELETE", `/attachments/${attachmentId}`, token);
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") =>
request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) =>
request("GET", `/knowledge/${kbId}`, token);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocuments(token, kbId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json();
}
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
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);
export async function downloadZip(token, markdown) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown }),
});
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);
}
}
ENDOFFILE
ok "frontend/src/api.js"
# ────────────────────────────────────────
# frontend/src/components/MessageBubble.jsx
# ────────────────────────────────────────
step "frontend/src/components/MessageBubble.jsx"
cat > frontend/src/components/MessageBubble.jsx << 'ENDOFFILE'
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";
import { getAttachmentUrl } from "../api";
import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink,
} from "lucide-react";
const FILE_TYPE_ICONS = {
image: Image, video: Film, document: FileText, text: FileText,
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false);
const [copied, setCopied] = useState(false);
const [expandedImage, setExpandedImage] = useState(null);
function handleCopy() {
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const hasAttachments = attachments && attachments.length > 0;
return (
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!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>
)}
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{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">
<Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</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" />}
</div>
)}
</div>
)}
{hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const url = getAttachmentUrl(att.id);
if (att.file_type === "image") {
return (
<div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }} />
{expandedImage === att.id && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
onClick={() => setExpandedImage(null)}>
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg" />
</div>
)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">
{att.original_filename}
</div>
</div>
);
}
return (
<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2 hover:border-anton-accent transition group">
<Icon size={16} className="shrink-0 text-blue-400" />
<div className="min-w-0">
<div className="text-xs text-white truncate max-w-[160px]">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a>
);
})}
</div>
)}
<div className={`rounded-2xl px-4 py-3 ${
isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(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] || "";
if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null;
if (rawLang.includes(":")) {
const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
}
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />;
},
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>
{!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">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "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>
{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>
)}
</div>
);
});
function _stripPrefixes(text) {
if (!text) return "";
return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim();
}
export default MessageBubble;
ENDOFFILE
ok "frontend/src/components/MessageBubble.jsx"
# ────────────────────────────────────────
# frontend/src/components/ChatView.jsx
# ────────────────────────────────────────
step "frontend/src/components/ChatView.jsx"
cat > frontend/src/components/ChatView.jsx << 'ENDOFFILE'
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, Image, FileText, Film, Loader2 } 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)" },
];
function classifyFile(file) {
const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || "";
if (mime.startsWith("image/") || ["jpg","jpeg","png","gif","webp","bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4","mov","avi","mkv","webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document";
return "text";
}
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const currentChat = state.chats.find((c) => c.id === chatId);
const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId];
const [input, setInput] = useState("");
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([]);
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null);
const inputRef = useRef(null);
const fileRef = useRef(null);
const autoScroll = useRef(true);
const rafRef = useRef(null);
useEffect(() => {
setStreamData(streamManager.getStreamData(chatId));
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
rafRef.current = null;
});
}, []);
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]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]);
async function saveSettings() {
try {
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" });
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
} catch {}
}
function toggleSettings() {
if (showSettings) saveSettings();
setShowSettings(!showSettings);
}
function handleFileSelect(e) {
const files = Array.from(e.target.files || []);
setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
e.target.value = "";
}
function removePending(i) {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
}
async function handleSend() {
const content = input.trim();
if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = [];
if (pendingFiles.length) {
setUploading(true);
try {
const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
uploaded = (res.attachments || []).filter((a) => !a.error);
attIds = uploaded.map((a) => a.id);
} catch (err) { console.error(err); setUploading(false); return; }
setUploading(false);
}
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]);
autoScroll.current = true;
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
}
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handlePaste(e) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return;
e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
}
function handleDrop(e) {
e.preventDefault();
const files = Array.from(e.dataTransfer?.files || []);
if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
}
const streaming = streamData.streaming;
return (
<div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<div className="flex gap-1">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
</div>
)}
</div>
<div className="border-t border-anton-border bg-anton-surface p-4">
{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" /> Settings</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div>
<label className="text-xs text-anton-muted mb-1 block">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 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>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 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>
)}
{pendingFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 animate-fade-in">
{pendingFiles.map((pf, i) => (
<div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden">
{pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-16 h-16 object-cover" />
) : (
<div className="w-16 h-16 flex flex-col items-center justify-center px-1">
<FileText size={20} className="text-anton-muted mb-1" />
<span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
</div>
)}
<button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">{(pf.file.size / 1024).toFixed(0)}KB</div>
</div>
))}
</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>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.dart,.vue,.svelte,.log" onChange={handleFileSelect} />
<div className="flex-1 relative">
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"}
rows={1} style={{ maxHeight: "200px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
</div>
{streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div>
<div 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()} tokens</span>
{reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
{pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span></>}
{messages.some((m) => m.role === "assistant") && (
<button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch {} }}
className="ml-auto hover:text-anton-accent transition">⬇ Download code</button>
)}
</div>
</div>
</div>
);
}
ENDOFFILE
ok "frontend/src/components/ChatView.jsx"
# ────────────────────────────────────────
# frontend/src/streamManager.js
# ────────────────────────────────────────
step "frontend/src/streamManager.js"
cat > frontend/src/streamManager.js << 'ENDOFFILE'
import { streamMessage } from "./api";
const _streams = new Map();
const _listeners = new Map();
let _dispatch = null;
export function setDispatch(dispatch) { _dispatch = dispatch; }
export function getStreamData(chatId) {
const s = _streams.get(chatId);
if (!s) return { streaming: false, text: "", thinking: "", isThinking: false };
return { streaming: true, text: s.text, thinking: s.thinking, isThinking: s.isThinking };
}
export function isStreaming(chatId) { return _streams.has(chatId); }
export function subscribe(chatId, cb) {
if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
_listeners.get(chatId).add(cb);
return () => { const s = _listeners.get(chatId); if (s) { s.delete(cb); if (!s.size) _listeners.delete(chatId); } };
}
function _notify(id) { const s = _listeners.get(id); if (s) s.forEach((cb) => cb()); }
export function abortStream(chatId) {
const s = _streams.get(chatId);
if (s) { s.abortController.abort(); _streams.delete(chatId); _notify(chatId); if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false }); }
}
export function startStream({ token, chatId, body }) {
if (_streams.has(chatId)) return;
const ac = new AbortController();
_streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
_notify(chatId);
(async () => {
const s = _streams.get(chatId);
if (!s) return;
let usage = {}, msgId = "";
try {
for await (const evt of streamMessage(token, chatId, body, ac.signal)) {
if (ac.signal.aborted || !_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;
}
}
if (!ac.signal.aborted && _dispatch) {
_dispatch({ type: "ADD_MESSAGE", chatId, message: {
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(), attachments: [],
}});
}
} catch (err) {
if (!ac.signal.aborted && _dispatch) {
_dispatch({ type: "ADD_MESSAGE", chatId, message: {
id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`,
created_at: new Date().toISOString(), attachments: [],
}});
}
} finally {
_streams.delete(chatId); _notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
}
})();
}
ENDOFFILE
ok "frontend/src/streamManager.js"
# ────────────────────────────────────────
# Done
# ────────────────────────────────────────
echo ""
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════════${NC}"
echo -e "${GREEN}${BOLD} All 12 files written successfully!${NC}"
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════════${NC}"
echo ""
echo -e " Now run: ${CYAN}zsh fix-and-deploy.sh${NC}"
echo ""
\ No newline at end of file
...@@ -23,10 +23,15 @@ DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:////data/sonofanton.db") ...@@ -23,10 +23,15 @@ DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:////data/sonofanton.db")
CHROMADB_PATH: str = os.getenv("CHROMADB_PATH", "/data/chromadb") CHROMADB_PATH: str = os.getenv("CHROMADB_PATH", "/data/chromadb")
UPLOAD_PATH: str = os.getenv("UPLOAD_PATH", "/data/uploads") UPLOAD_PATH: str = os.getenv("UPLOAD_PATH", "/data/uploads")
ATTACHMENT_PATH: str = os.getenv("ATTACHMENT_PATH", "/data/uploads/chat_attachments")
DEFAULT_QUOTA: int = int(os.getenv("DEFAULT_QUOTA", "2000000")) DEFAULT_QUOTA: int = int(os.getenv("DEFAULT_QUOTA", "2000000"))
MAX_UPLOAD_BYTES: int = int(os.getenv("MAX_UPLOAD_MB", "50")) * 1024 * 1024 MAX_UPLOAD_BYTES: int = int(os.getenv("MAX_UPLOAD_MB", "50")) * 1024 * 1024
MAX_ATTACHMENT_BYTES: int = int(os.getenv("MAX_ATTACHMENT_MB", "25")) * 1024 * 1024
MAX_IMAGE_DIMENSION: int = 1568
MAX_VIDEO_FRAMES: int = 6
BEDROCK_ENDPOINT: str = ( BEDROCK_ENDPOINT: str = (
f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com" f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com"
) )
\ No newline at end of file
...@@ -18,37 +18,44 @@ from backend.routes.chat_routes import router as chat_router ...@@ -18,37 +18,44 @@ from backend.routes.chat_routes import router as chat_router
from backend.routes.admin_routes import router as admin_router from backend.routes.admin_routes import router as admin_router
from backend.routes.knowledge_routes import router as knowledge_router from backend.routes.knowledge_routes import router as knowledge_router
from backend.routes.files_routes import router as files_router from backend.routes.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router
from backend.services.bedrock_service import close_http_client from backend.services.bedrock_service import close_http_client
def _run_migrations(): def _run_migrations():
"""Add new columns to existing tables if they're missing (lightweight migration).""" """Add new columns/tables to existing DB if they're missing."""
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
try: try:
inspector = inspect(engine) inspector = inspect(engine)
if "chats" in inspector.get_table_names(): existing_tables = inspector.get_table_names()
if "chats" in existing_tables:
columns = {c["name"] for c in inspector.get_columns("chats")} columns = {c["name"] for c in inspector.get_columns("chats")}
with engine.connect() as conn: with engine.connect() as conn:
if "max_tokens" not in columns: if "max_tokens" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096")) conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096"))
print(" Added chats.max_tokens column") print(" Added chats.max_tokens column")
if "reasoning_budget" not in columns: if "reasoning_budget" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0")) conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
print(" Added chats.reasoning_budget column") print(" Added chats.reasoning_budget column")
conn.commit() conn.commit()
if "chat_attachments" not in existing_tables:
from backend.models import ChatAttachment
ChatAttachment.__table__.create(bind=engine, checkfirst=True)
print(" Created chat_attachments table")
except Exception as e: except Exception as e:
print(f" ⚠️ Migration note: {e}") print(f" Migration note: {e}")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# --- Startup ---
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_run_migrations() _run_migrations()
seed_superadmin() seed_superadmin()
print("🔥 Son of Anton is online.") print("Son of Anton is online.")
yield yield
# --- Shutdown ---
await close_http_client() await close_http_client()
print("Son of Anton shutting down.") print("Son of Anton shutting down.")
...@@ -56,7 +63,7 @@ async def lifespan(app: FastAPI): ...@@ -56,7 +63,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Son of Anton", title="Son of Anton",
description="Avatar of All Elements of Code", description="Avatar of All Elements of Code",
version="1.0.0", version="2.0.0",
lifespan=lifespan, lifespan=lifespan,
) )
...@@ -68,14 +75,13 @@ app.add_middleware( ...@@ -68,14 +75,13 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ── API Routes ────────────────────────────────────
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(chat_router, prefix="/api/chats", tags=["Chats"]) app.include_router(chat_router, prefix="/api/chats", tags=["Chats"])
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"]) app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"])
app.include_router(files_router, prefix="/api/files", tags=["Files"]) app.include_router(files_router, prefix="/api/files", tags=["Files"])
app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
# ── Serve Frontend ────────────────────────────────
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if (FRONTEND_DIR / "assets").exists(): if (FRONTEND_DIR / "assets").exists():
...@@ -96,4 +102,4 @@ async def serve_frontend(full_path: str): ...@@ -96,4 +102,4 @@ async def serve_frontend(full_path: str):
index = FRONTEND_DIR / "index.html" index = FRONTEND_DIR / "index.html"
if index.is_file(): if index.is_file():
return FileResponse(str(index)) return FileResponse(str(index))
return {"message": "Son of Anton API is running. Frontend not built."} return {"message": "Son of Anton API is running. Frontend not built."}
\ No newline at end of file
...@@ -59,6 +59,10 @@ class Chat(Base): ...@@ -59,6 +59,10 @@ class Chat(Base):
"Message", back_populates="chat", "Message", back_populates="chat",
cascade="all,delete-orphan", order_by="Message.created_at", cascade="all,delete-orphan", order_by="Message.created_at",
) )
attachments = relationship(
"ChatAttachment", back_populates="chat",
cascade="all,delete-orphan",
)
class Message(Base): class Message(Base):
...@@ -74,6 +78,26 @@ class Message(Base): ...@@ -74,6 +78,26 @@ class Message(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
chat = relationship("Chat", back_populates="messages") chat = relationship("Chat", back_populates="messages")
attachments = relationship("ChatAttachment", back_populates="message")
class ChatAttachment(Base):
__tablename__ = "chat_attachments"
id = Column(String(36), primary_key=True, default=new_id)
chat_id = Column(String(36), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
message_id = Column(String(36), ForeignKey("messages.id", ondelete="SET NULL"), nullable=True)
filename = Column(String(200), nullable=False)
original_filename = Column(String(200), nullable=False)
mime_type = Column(String(100), nullable=False)
file_type = Column(String(20), nullable=False)
file_size = Column(Integer, default=0)
storage_path = Column(String(500), nullable=False)
text_extract = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
chat = relationship("Chat", back_populates="attachments")
message = relationship("Message", back_populates="attachments")
class KnowledgeBase(Base): class KnowledgeBase(Base):
...@@ -99,4 +123,4 @@ class KnowledgeDocument(Base): ...@@ -99,4 +123,4 @@ class KnowledgeDocument(Base):
filename = Column(String(200), nullable=False) filename = Column(String(200), nullable=False)
file_size = Column(Integer, default=0) file_size = Column(Integer, default=0)
chunk_count = Column(Integer, default=0) chunk_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
\ No newline at end of file
...@@ -3,19 +3,37 @@ Chat attachment upload, serve, and delete routes. ...@@ -3,19 +3,37 @@ Chat attachment upload, serve, and delete routes.
""" """
import os import os
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import User, Chat, ChatAttachment from backend.models import User, Chat, ChatAttachment
from backend.auth import get_current_user from backend.auth import get_current_user, decode_token
from backend.services import attachment_service from backend.services import attachment_service
from backend.config import MAX_ATTACHMENT_BYTES from backend.config import MAX_ATTACHMENT_BYTES
router = APIRouter() router = APIRouter()
def _get_user_flexible(request: Request, db: Session, token_param: Optional[str] = None) -> User:
raw_token = None
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
raw_token = auth_header[7:]
if not raw_token and token_param:
raw_token = token_param
if not raw_token:
raise HTTPException(401, "Authentication required")
payload = decode_token(raw_token)
user = db.query(User).filter(User.id == payload["sub"]).first()
if not user or not user.is_active:
raise HTTPException(401, "User not found or inactive")
return user
@router.post("/chats/{chat_id}/attachments") @router.post("/chats/{chat_id}/attachments")
async def upload_attachments( async def upload_attachments(
chat_id: str, chat_id: str,
...@@ -23,7 +41,6 @@ async def upload_attachments( ...@@ -23,7 +41,6 @@ async def upload_attachments(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Upload one or more files as chat attachments. Returns attachment metadata."""
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat:
raise HTTPException(404, "Chat not found") raise HTTPException(404, "Chat not found")
...@@ -34,21 +51,16 @@ async def upload_attachments( ...@@ -34,21 +51,16 @@ async def upload_attachments(
try: try:
content = await file.read() content = await file.read()
if len(content) > MAX_ATTACHMENT_BYTES: if len(content) > MAX_ATTACHMENT_BYTES:
results.append({ results.append({"error": f"Too large: {filename}"})
"error": f"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB.",
})
continue continue
meta = attachment_service.save_attachment( meta = attachment_service.save_attachment(
chat_id=chat_id, chat_id=chat_id, filename=filename,
filename=filename, content=content, content_type=file.content_type,
content=content,
content_type=file.content_type,
) )
att = ChatAttachment( att = ChatAttachment(
id=meta["id"], id=meta["id"], chat_id=chat_id,
chat_id=chat_id,
filename=meta["filename"], filename=meta["filename"],
original_filename=meta["original_filename"], original_filename=meta["original_filename"],
mime_type=meta["mime_type"], mime_type=meta["mime_type"],
...@@ -60,11 +72,9 @@ async def upload_attachments( ...@@ -60,11 +72,9 @@ async def upload_attachments(
db.add(att) db.add(att)
db.commit() db.commit()
db.refresh(att) db.refresh(att)
results.append(_att_dict(att)) results.append(_att_dict(att))
except Exception as e: except Exception as e:
results.append({"error": f"Failed to upload {filename}: {str(e)}"}) results.append({"error": f"Failed: {filename}: {str(e)}"})
return {"attachments": results} return {"attachments": results}
...@@ -72,26 +82,20 @@ async def upload_attachments( ...@@ -72,26 +82,20 @@ async def upload_attachments(
@router.get("/attachments/{attachment_id}/file") @router.get("/attachments/{attachment_id}/file")
def serve_attachment( def serve_attachment(
attachment_id: str, attachment_id: str,
user: User = Depends(get_current_user), request: Request,
token: Optional[str] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Serve an attachment file. Validates user owns the chat.""" user = _get_user_flexible(request, db, token)
att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first() att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
if not att: if not att:
raise HTTPException(404, "Attachment not found") raise HTTPException(404, "Attachment not found")
chat = db.query(Chat).filter(Chat.id == att.chat_id).first() chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
if not chat or (chat.user_id != user.id and user.role != "superadmin"): if not chat or (chat.user_id != user.id and user.role != "superadmin"):
raise HTTPException(403, "Access denied") raise HTTPException(403, "Access denied")
if not os.path.exists(att.storage_path): if not os.path.exists(att.storage_path):
raise HTTPException(404, "File not found on disk") raise HTTPException(404, "File not found on disk")
return FileResponse(att.storage_path, media_type=att.mime_type, filename=att.original_filename)
return FileResponse(
att.storage_path,
media_type=att.mime_type,
filename=att.original_filename,
)
@router.delete("/attachments/{attachment_id}") @router.delete("/attachments/{attachment_id}")
...@@ -100,30 +104,22 @@ def delete_attachment( ...@@ -100,30 +104,22 @@ def delete_attachment(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Delete a single attachment."""
att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first() att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
if not att: if not att:
raise HTTPException(404) raise HTTPException(404)
chat = db.query(Chat).filter(Chat.id == att.chat_id).first() chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
if not chat or (chat.user_id != user.id and user.role != "superadmin"): if not chat or (chat.user_id != user.id and user.role != "superadmin"):
raise HTTPException(403) raise HTTPException(403)
attachment_service.delete_attachment_file(att.storage_path) attachment_service.delete_attachment_file(att.storage_path)
db.delete(att) db.delete(att)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
def _att_dict(att: ChatAttachment) -> dict: def _att_dict(att):
return { return {
"id": att.id, "id": att.id, "chat_id": att.chat_id, "message_id": att.message_id,
"chat_id": att.chat_id, "filename": att.filename, "original_filename": att.original_filename,
"message_id": att.message_id, "mime_type": att.mime_type, "file_type": att.file_type,
"filename": att.filename, "file_size": att.file_size, "created_at": str(att.created_at),
"original_filename": att.original_filename, }
"mime_type": att.mime_type,
"file_type": att.file_type,
"file_size": att.file_size,
"created_at": str(att.created_at),
}
\ No newline at end of file
""" """
Chat CRUD and message streaming. Chat CRUD and message streaming with multimodal attachment support.
""" """
import json import json
...@@ -12,10 +12,10 @@ from fastapi.responses import StreamingResponse ...@@ -12,10 +12,10 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db, SessionLocal from backend.database import get_db, SessionLocal
from backend.models import User, Chat, Message from backend.models import User, Chat, Message, ChatAttachment
from backend.auth import get_current_user from backend.auth import get_current_user
from backend.system_prompt import build_full_prompt from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service from backend.services import bedrock_service, memory_service, rag_service, attachment_service
router = APIRouter() router = APIRouter()
...@@ -42,30 +42,21 @@ class SendMessageBody(BaseModel): ...@@ -42,30 +42,21 @@ class SendMessageBody(BaseModel):
max_tokens: int = 4096 max_tokens: int = 4096
reasoning_budget: int = 0 reasoning_budget: int = 0
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
attachment_ids: list[str] = []
# ── Chat CRUD ─────────────────────────────────────
@router.get("") @router.get("")
def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)): def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chats = ( chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all()
db.query(Chat)
.filter(Chat.user_id == user.id)
.order_by(Chat.updated_at.desc())
.all()
)
return [_chat_dict(c) for c in chats] return [_chat_dict(c) for c in chats]
@router.post("") @router.post("")
def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)): def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = Chat( chat = Chat(
user_id=user.id, user_id=user.id, title=body.title, model=body.model,
title=body.title,
model=body.model,
knowledge_base_id=body.knowledge_base_id or None, knowledge_base_id=body.knowledge_base_id or None,
max_tokens=body.max_tokens, max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
reasoning_budget=body.reasoning_budget,
) )
db.add(chat) db.add(chat)
db.commit() db.commit()
...@@ -95,7 +86,6 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur ...@@ -95,7 +86,6 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
if body.reasoning_budget is not None: if body.reasoning_budget is not None:
chat.reasoning_budget = body.reasoning_budget chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None: if body.knowledge_base_id is not None:
# Empty string means "clear the KB"
chat.knowledge_base_id = body.knowledge_base_id or None chat.knowledge_base_id = body.knowledge_base_id or None
db.commit() db.commit()
return _chat_dict(chat) return _chat_dict(chat)
...@@ -106,6 +96,7 @@ def delete_chat(chat_id: str, user: User = Depends(get_current_user), db: Sessio ...@@ -106,6 +96,7 @@ def delete_chat(chat_id: str, user: User = Depends(get_current_user), db: Sessio
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat:
raise HTTPException(404) raise HTTPException(404)
attachment_service.delete_chat_attachments(chat_id)
db.delete(chat) db.delete(chat)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
...@@ -116,17 +107,17 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi ...@@ -116,17 +107,17 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat:
raise HTTPException(404) raise HTTPException(404)
return [_msg_dict(m) for m in chat.messages] msgs = []
for m in chat.messages:
d = _msg_dict(m)
atts = db.query(ChatAttachment).filter(ChatAttachment.message_id == m.id).all()
d["attachments"] = [_att_brief(a) for a in atts]
msgs.append(d)
return msgs
# ── Send Message (Streaming) ─────────────────────
@router.post("/{chat_id}/messages") @router.post("/{chat_id}/messages")
async def send_message( async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
chat_id: str,
body: SendMessageBody,
user: User = Depends(get_current_user),
):
user_id = user.id user_id = user.id
async def generate(): async def generate():
...@@ -139,7 +130,6 @@ async def send_message( ...@@ -139,7 +130,6 @@ async def send_message(
db_user = db.query(User).filter(User.id == user_id).first() db_user = db.query(User).filter(User.id == user_id).first()
# Quota check
now = datetime.utcnow() now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date: if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0 db_user.tokens_used_this_month = 0
...@@ -150,15 +140,33 @@ async def send_message( ...@@ -150,15 +140,33 @@ async def send_message(
db.commit() db.commit()
if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly: if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly:
yield _sse({"type": "error", "message": "Monthly token quota exceeded. Contact your admin."}) yield _sse({"type": "error", "message": "Monthly token quota exceeded."})
return return
# Save user message attachments = []
user_msg = Message(chat_id=chat_id, role="user", content=body.content) if body.attachment_ids:
attachments = (
db.query(ChatAttachment)
.filter(ChatAttachment.id.in_(body.attachment_ids), ChatAttachment.chat_id == chat_id)
.all()
)
stored_content = body.content
if attachments:
labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
stored_content = "\n".join(notes) + "\n" + body.content
user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg) db.add(user_msg)
db.commit() db.commit()
db.refresh(user_msg)
for att in attachments:
att.message_id = user_msg.id
if attachments:
db.commit()
# Build context
kb_id = body.knowledge_base_id or chat.knowledge_base_id kb_id = body.knowledge_base_id or chat.knowledge_base_id
rag_context = None rag_context = None
if kb_id: if kb_id:
...@@ -170,14 +178,16 @@ async def send_message( ...@@ -170,14 +178,16 @@ async def send_message(
system_prompt = build_full_prompt(rag_context) system_prompt = build_full_prompt(rag_context)
messages = memory_service.build_messages(chat, db) messages = memory_service.build_messages(chat, db)
if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": body.content})
messages[-1]["content"] = content_blocks
model_id = body.model or chat.model model_id = body.model or chat.model
max_tokens = body.max_tokens max_tokens = body.max_tokens
thinking_config = None thinking_config = None
if body.reasoning_budget > 0: if body.reasoning_budget > 0:
thinking_config = { thinking_config = {"enabled": True, "budget_tokens": body.reasoning_budget}
"enabled": True,
"budget_tokens": body.reasoning_budget,
}
max_tokens = max_tokens + body.reasoning_budget max_tokens = max_tokens + body.reasoning_budget
full_text = "" full_text = ""
...@@ -187,10 +197,8 @@ async def send_message( ...@@ -187,10 +197,8 @@ async def send_message(
current_block_type = "text" current_block_type = "text"
async for event in bedrock_service.stream_response( async for event in bedrock_service.stream_response(
messages=messages, messages=messages, system_prompt=system_prompt,
system_prompt=system_prompt, model_id=model_id, max_tokens=min(max_tokens, 65536),
model_id=model_id,
max_tokens=min(max_tokens, 65536),
thinking_config=thinking_config, thinking_config=thinking_config,
): ):
evt_type = event.get("type", "") evt_type = event.get("type", "")
...@@ -198,13 +206,11 @@ async def send_message( ...@@ -198,13 +206,11 @@ async def send_message(
if evt_type == "message_start": if evt_type == "message_start":
usage = event.get("message", {}).get("usage", {}) usage = event.get("message", {}).get("usage", {})
input_tokens = usage.get("input_tokens", 0) input_tokens = usage.get("input_tokens", 0)
elif evt_type == "content_block_start": elif evt_type == "content_block_start":
blk = event.get("content_block", {}) blk = event.get("content_block", {})
current_block_type = blk.get("type", "text") current_block_type = blk.get("type", "text")
if current_block_type == "thinking": if current_block_type == "thinking":
yield _sse({"type": "thinking_start"}) yield _sse({"type": "thinking_start"})
elif evt_type == "content_block_delta": elif evt_type == "content_block_delta":
delta = event.get("delta", {}) delta = event.get("delta", {})
dt = delta.get("type", "") dt = delta.get("type", "")
...@@ -216,29 +222,20 @@ async def send_message( ...@@ -216,29 +222,20 @@ async def send_message(
t = delta.get("text", "") t = delta.get("text", "")
full_text += t full_text += t
yield _sse({"type": "text_delta", "content": t}) yield _sse({"type": "text_delta", "content": t})
elif evt_type == "content_block_stop": elif evt_type == "content_block_stop":
if current_block_type == "thinking": if current_block_type == "thinking":
yield _sse({"type": "thinking_end"}) yield _sse({"type": "thinking_end"})
elif evt_type == "message_delta": elif evt_type == "message_delta":
usage = event.get("usage", {}) usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0) output_tokens = usage.get("output_tokens", 0)
# Save assistant message
assistant_msg = Message( assistant_msg = Message(
chat_id=chat_id, chat_id=chat_id, role="assistant", content=full_text,
role="assistant",
content=full_text,
thinking_content=full_thinking or None, thinking_content=full_thinking or None,
input_tokens=input_tokens, input_tokens=input_tokens, output_tokens=output_tokens,
output_tokens=output_tokens,
) )
db.add(assistant_msg) db.add(assistant_msg)
db_user.tokens_used_this_month += input_tokens + output_tokens db_user.tokens_used_this_month += input_tokens + output_tokens
# Persist the generation settings used for this message onto the chat
chat.model = model_id chat.model = model_id
chat.max_tokens = body.max_tokens chat.max_tokens = body.max_tokens
chat.reasoning_budget = body.reasoning_budget chat.reasoning_budget = body.reasoning_budget
...@@ -246,7 +243,6 @@ async def send_message( ...@@ -246,7 +243,6 @@ async def send_message(
chat.updated_at = datetime.utcnow() chat.updated_at = datetime.utcnow()
db.commit() db.commit()
# Auto title
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count() msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat": if msg_count <= 2 and chat.title == "New Chat":
try: try:
...@@ -268,7 +264,7 @@ async def send_message( ...@@ -268,7 +264,7 @@ async def send_message(
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
async def _generate_title(user_msg: str, ai_msg: str) -> str: async def _generate_title(user_msg, ai_msg):
from backend.config import FAST_MODEL from backend.config import FAST_MODEL
result = await bedrock_service.invoke_model_simple( result = await bedrock_service.invoke_model_simple(
model_id=FAST_MODEL, model_id=FAST_MODEL,
...@@ -278,30 +274,32 @@ async def _generate_title(user_msg: str, ai_msg: str) -> str: ...@@ -278,30 +274,32 @@ async def _generate_title(user_msg: str, ai_msg: str) -> str:
return result.strip().strip('"').strip("'") return result.strip().strip('"').strip("'")
def _sse(data: dict) -> str: def _sse(data):
return f"data: {json.dumps(data)}\n\n" return f"data: {json.dumps(data)}\n\n"
def _chat_dict(c: Chat) -> dict: def _chat_dict(c):
return { return {
"id": c.id, "id": c.id, "title": c.title, "model": c.model,
"title": c.title,
"model": c.model,
"knowledge_base_id": c.knowledge_base_id, "knowledge_base_id": c.knowledge_base_id,
"max_tokens": c.max_tokens or 4096, "max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0, "reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at), "created_at": str(c.created_at), "updated_at": str(c.updated_at),
"updated_at": str(c.updated_at),
} }
def _msg_dict(m: Message) -> dict: def _msg_dict(m):
return { return {
"id": m.id, "id": m.id, "role": m.role, "content": m.content,
"role": m.role,
"content": m.content,
"thinking_content": m.thinking_content, "thinking_content": m.thinking_content,
"input_tokens": m.input_tokens, "input_tokens": m.input_tokens, "output_tokens": m.output_tokens,
"output_tokens": m.output_tokens,
"created_at": str(m.created_at), "created_at": str(m.created_at),
} }
\ No newline at end of file
def _att_brief(a):
return {
"id": a.id, "original_filename": a.original_filename,
"mime_type": a.mime_type, "file_type": a.file_type,
"file_size": a.file_size,
}
""" """
Attachment processing service. Attachment processing service.
Handles images (resize + base64 for Claude vision), Handles images, videos (frame extraction), PDFs, and text files.
videos (frame extraction via ffmpeg),
PDFs (native document support),
and text files (read content).
""" """
import os import os
...@@ -21,8 +18,6 @@ from backend import config ...@@ -21,8 +18,6 @@ from backend import config
os.makedirs(config.ATTACHMENT_PATH, exist_ok=True) os.makedirs(config.ATTACHMENT_PATH, exist_ok=True)
# ── File type detection ──────────────────────────────
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv", ".m4v"} VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv", ".m4v"}
PDF_EXTENSIONS = {".pdf"} PDF_EXTENSIONS = {".pdf"}
...@@ -32,15 +27,13 @@ TEXT_EXTENSIONS = { ...@@ -32,15 +27,13 @@ TEXT_EXTENSIONS = {
".kt", ".lua", ".gd", ".html", ".css", ".scss", ".json", ".yaml", ".kt", ".lua", ".gd", ".html", ".css", ".scss", ".json", ".yaml",
".yml", ".xml", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash", ".yml", ".xml", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash",
".sql", ".r", ".dart", ".vue", ".svelte", ".csv", ".log", ".env", ".sql", ".r", ".dart", ".vue", ".svelte", ".csv", ".log", ".env",
".gitignore", ".dockerfile", ".makefile",
} }
IMAGE_MIMES = {"image/jpeg", "image/png", "image/gif", "image/webp"} IMAGE_MIMES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
VIDEO_MIMES = {"video/mp4", "video/quicktime", "video/x-msvideo", "video/webm", "video/x-matroska"} VIDEO_MIMES = {"video/mp4", "video/quicktime", "video/x-msvideo", "video/webm"}
def classify_file(filename: str, mime: str) -> str: def classify_file(filename, mime):
"""Classify file into: image, video, document, text"""
ext = Path(filename).suffix.lower() ext = Path(filename).suffix.lower()
if ext in IMAGE_EXTENSIONS or mime in IMAGE_MIMES: if ext in IMAGE_EXTENSIONS or mime in IMAGE_MIMES:
return "image" return "image"
...@@ -51,29 +44,21 @@ def classify_file(filename: str, mime: str) -> str: ...@@ -51,29 +44,21 @@ def classify_file(filename: str, mime: str) -> str:
return "text" return "text"
def get_mime_type(filename: str, content_type: Optional[str] = None) -> str: def get_mime_type(filename, content_type=None):
"""Determine MIME type."""
if content_type and content_type != "application/octet-stream": if content_type and content_type != "application/octet-stream":
return content_type return content_type
mime, _ = mimetypes.guess_type(filename) mime, _ = mimetypes.guess_type(filename)
return mime or "application/octet-stream" return mime or "application/octet-stream"
# ── File storage ───────────────────────────────────── def save_attachment(chat_id, filename, content, content_type=None):
def save_attachment(chat_id: str, filename: str, content: bytes, content_type: Optional[str] = None) -> dict:
"""
Save an uploaded file to disk. Returns metadata dict.
"""
mime = get_mime_type(filename, content_type) mime = get_mime_type(filename, content_type)
file_type = classify_file(filename, mime) file_type = classify_file(filename, mime)
attachment_id = str(uuid4()) attachment_id = str(uuid4())
# Create chat-specific directory
chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id) chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
os.makedirs(chat_dir, exist_ok=True) os.makedirs(chat_dir, exist_ok=True)
# Sanitize filename
safe_name = Path(filename).name.replace(" ", "_") safe_name = Path(filename).name.replace(" ", "_")
stored_name = f"{attachment_id}_{safe_name}" stored_name = f"{attachment_id}_{safe_name}"
storage_path = os.path.join(chat_dir, stored_name) storage_path = os.path.join(chat_dir, stored_name)
...@@ -81,10 +66,9 @@ def save_attachment(chat_id: str, filename: str, content: bytes, content_type: O ...@@ -81,10 +66,9 @@ def save_attachment(chat_id: str, filename: str, content: bytes, content_type: O
with open(storage_path, "wb") as f: with open(storage_path, "wb") as f:
f.write(content) f.write(content)
# Extract text for text-based files
text_extract = None text_extract = None
if file_type == "text": if file_type == "text":
text_extract = _extract_text_content(storage_path, filename) text_extract = _extract_text_content(storage_path)
elif file_type == "document": elif file_type == "document":
text_extract = _extract_pdf_text(storage_path) text_extract = _extract_pdf_text(storage_path)
...@@ -100,8 +84,7 @@ def save_attachment(chat_id: str, filename: str, content: bytes, content_type: O ...@@ -100,8 +84,7 @@ def save_attachment(chat_id: str, filename: str, content: bytes, content_type: O
} }
def delete_attachment_file(storage_path: str): def delete_attachment_file(storage_path):
"""Delete an attachment file from disk."""
try: try:
if os.path.exists(storage_path): if os.path.exists(storage_path):
os.remove(storage_path) os.remove(storage_path)
...@@ -109,28 +92,21 @@ def delete_attachment_file(storage_path: str): ...@@ -109,28 +92,21 @@ def delete_attachment_file(storage_path: str):
pass pass
def delete_chat_attachments(chat_id: str): def delete_chat_attachments(chat_id):
"""Delete all attachment files for a chat."""
chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id) chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
if os.path.isdir(chat_dir): if os.path.isdir(chat_dir):
shutil.rmtree(chat_dir, ignore_errors=True) shutil.rmtree(chat_dir, ignore_errors=True)
# ── Claude content block builders ──────────────────── def build_claude_content_blocks(attachments):
def build_claude_content_blocks(attachments: list) -> list[dict]:
"""
Build Anthropic-compatible content blocks for a list of ChatAttachment objects.
Returns a list of content block dicts ready for the messages API.
"""
blocks = [] blocks = []
for att in attachments: for att in attachments:
try: try:
file_blocks = _process_single_attachment(att) result = _process_single_attachment(att)
if isinstance(file_blocks, list): if isinstance(result, list):
blocks.extend(file_blocks) blocks.extend(result)
elif file_blocks: elif result:
blocks.append(file_blocks) blocks.append(result)
except Exception as e: except Exception as e:
blocks.append({ blocks.append({
"type": "text", "type": "text",
...@@ -139,8 +115,7 @@ def build_claude_content_blocks(attachments: list) -> list[dict]: ...@@ -139,8 +115,7 @@ def build_claude_content_blocks(attachments: list) -> list[dict]:
return blocks return blocks
def _process_single_attachment(att) -> list[dict] | dict | None: def _process_single_attachment(att):
"""Process a single attachment into Claude content blocks."""
if att.file_type == "image": if att.file_type == "image":
return _build_image_block(att) return _build_image_block(att)
elif att.file_type == "video": elif att.file_type == "video":
...@@ -152,70 +127,41 @@ def _process_single_attachment(att) -> list[dict] | dict | None: ...@@ -152,70 +127,41 @@ def _process_single_attachment(att) -> list[dict] | dict | None:
return None return None
def _build_image_block(att) -> dict: def _build_image_block(att):
"""Build an image content block. Resizes if needed."""
data = _read_and_resize_image(att.storage_path, att.mime_type) data = _read_and_resize_image(att.storage_path, att.mime_type)
mime = att.mime_type mime = att.mime_type if att.mime_type in IMAGE_MIMES else "image/jpeg"
# Claude only accepts specific image types
if mime not in IMAGE_MIMES:
mime = "image/jpeg"
return { return {
"type": "image", "type": "image",
"source": { "source": {"type": "base64", "media_type": mime, "data": data},
"type": "base64",
"media_type": mime,
"data": data,
},
} }
def _build_video_blocks(att) -> list[dict]: def _build_video_blocks(att):
"""Extract frames from video and build image content blocks."""
frames = _extract_video_frames(att.storage_path) frames = _extract_video_frames(att.storage_path)
if not frames: if not frames:
return [{ return [{"type": "text", "text": f"[Video: {att.original_filename} - could not extract frames]"}]
"type": "text", blocks = [{"type": "text", "text": f"[Video: {att.original_filename} - {len(frames)} frames extracted]"}]
"text": f"[Video: {att.original_filename} — could not extract frames. ffmpeg may not be available.]",
}]
blocks = [{
"type": "text",
"text": f"[Video: {att.original_filename} — {len(frames)} key frames extracted]",
}]
for frame_b64 in frames: for frame_b64 in frames:
blocks.append({ blocks.append({
"type": "image", "type": "image",
"source": { "source": {"type": "base64", "media_type": "image/jpeg", "data": frame_b64},
"type": "base64",
"media_type": "image/jpeg",
"data": frame_b64,
},
}) })
return blocks return blocks
def _build_document_block(att) -> dict: def _build_document_block(att):
"""Build a document content block for PDFs."""
if att.mime_type == "application/pdf": if att.mime_type == "application/pdf":
with open(att.storage_path, "rb") as f: with open(att.storage_path, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8") data = base64.b64encode(f.read()).decode("utf-8")
return { return {
"type": "document", "type": "document",
"source": { "source": {"type": "base64", "media_type": "application/pdf", "data": data},
"type": "base64",
"media_type": "application/pdf",
"data": data,
},
} }
# Non-PDF documents: fall back to text
return _build_text_block(att) return _build_text_block(att)
def _build_text_block(att) -> dict: def _build_text_block(att):
"""Build a text content block from a text-based file.""" text = att.text_extract or _extract_text_content(att.storage_path)
text = att.text_extract
if not text:
text = _extract_text_content(att.storage_path, att.original_filename)
if not text: if not text:
text = f"[Could not extract text from {att.original_filename}]" text = f"[Could not extract text from {att.original_filename}]"
return { return {
...@@ -224,41 +170,28 @@ def _build_text_block(att) -> dict: ...@@ -224,41 +170,28 @@ def _build_text_block(att) -> dict:
} }
# ── Image processing ───────────────────────────────── def _read_and_resize_image(path, mime_type):
def _read_and_resize_image(path: str, mime_type: str) -> str:
"""Read an image, resize if too large, return base64 string."""
try: try:
from PIL import Image from PIL import Image
img = Image.open(path) img = Image.open(path)
# Convert to RGB if necessary (handles RGBA, palette, etc.)
if img.mode in ("RGBA", "LA", "P"): if img.mode in ("RGBA", "LA", "P"):
background = Image.new("RGB", img.size, (255, 255, 255)) bg = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P": if img.mode == "P":
img = img.convert("RGBA") img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if "A" in img.mode else None) bg.paste(img, mask=img.split()[-1] if "A" in img.mode else None)
img = background img = bg
elif img.mode != "RGB": elif img.mode != "RGB":
img = img.convert("RGB") img = img.convert("RGB")
mx = config.MAX_IMAGE_DIMENSION
# Resize if either dimension exceeds max if img.width > mx or img.height > mx:
max_dim = config.MAX_IMAGE_DIMENSION ratio = min(mx / img.width, mx / img.height)
if img.width > max_dim or img.height > max_dim: img = img.resize((int(img.width * ratio), int(img.height * ratio)), Image.LANCZOS)
ratio = min(max_dim / img.width, max_dim / img.height)
new_size = (int(img.width * ratio), int(img.height * ratio))
img = img.resize(new_size, Image.LANCZOS)
# Encode to JPEG for efficiency
buf = io.BytesIO() buf = io.BytesIO()
fmt = "PNG" if mime_type == "image/png" else "JPEG" fmt = "PNG" if mime_type == "image/png" else "JPEG"
save_kwargs = {"quality": 85} if fmt == "JPEG" else {} kwargs = {"quality": 85} if fmt == "JPEG" else {}
img.save(buf, format=fmt, **save_kwargs) img.save(buf, format=fmt, **kwargs)
return base64.b64encode(buf.getvalue()).decode("utf-8") return base64.b64encode(buf.getvalue()).decode("utf-8")
except ImportError: except ImportError:
# Pillow not installed — send raw
with open(path, "rb") as f: with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8") return base64.b64encode(f.read()).decode("utf-8")
except Exception: except Exception:
...@@ -266,63 +199,43 @@ def _read_and_resize_image(path: str, mime_type: str) -> str: ...@@ -266,63 +199,43 @@ def _read_and_resize_image(path: str, mime_type: str) -> str:
return base64.b64encode(f.read()).decode("utf-8") return base64.b64encode(f.read()).decode("utf-8")
# ── Video frame extraction ─────────────────────────── def _extract_video_frames(video_path):
def _extract_video_frames(video_path: str) -> list[str]:
"""Extract key frames from a video using ffmpeg. Returns list of base64 JPEG strings."""
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"): if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
return [] return []
max_frames = config.MAX_VIDEO_FRAMES
frames = [] frames = []
try: try:
# Get duration
result = subprocess.run( result = subprocess.run(
[ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
"ffprobe", "-v", "error", "-of", "default=noprint_wrappers=1:nokey=1", video_path],
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
video_path,
],
capture_output=True, text=True, timeout=30, capture_output=True, text=True, timeout=30,
) )
duration = float(result.stdout.strip() or "0") duration = float(result.stdout.strip() or "0")
if duration <= 0: if duration <= 0:
return [] return []
max_frames = config.MAX_VIDEO_FRAMES
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
interval = duration / (max_frames + 1) interval = duration / (max_frames + 1)
for i in range(max_frames): for i in range(max_frames):
timestamp = interval * (i + 1) ts = interval * (i + 1)
output = os.path.join(tmpdir, f"frame_{i}.jpg") out = os.path.join(tmpdir, f"frame_{i}.jpg")
subprocess.run( subprocess.run(
[ ["ffmpeg", "-ss", str(ts), "-i", video_path, "-vframes", "1",
"ffmpeg", "-ss", str(timestamp), "-vf", f"scale='min({config.MAX_IMAGE_DIMENSION},iw)':'min({config.MAX_IMAGE_DIMENSION},ih)':force_original_aspect_ratio=decrease",
"-i", video_path, "-q:v", "3", out],
"-vframes", "1",
"-vf", f"scale='min({config.MAX_IMAGE_DIMENSION},iw)':'min({config.MAX_IMAGE_DIMENSION},ih)':force_original_aspect_ratio=decrease",
"-q:v", "3",
output,
],
capture_output=True, timeout=30, capture_output=True, timeout=30,
) )
if os.path.exists(output) and os.path.getsize(output) > 0: if os.path.exists(out) and os.path.getsize(out) > 0:
with open(output, "rb") as f: with open(out, "rb") as f:
frames.append(base64.b64encode(f.read()).decode("utf-8")) frames.append(base64.b64encode(f.read()).decode("utf-8"))
except Exception: except Exception:
pass pass
return frames return frames
# ── Text extraction ────────────────────────────────── def _extract_text_content(path):
def _extract_text_content(path: str, filename: str) -> Optional[str]:
"""Extract text from a text-based file."""
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
return f.read(500_000) # Cap at 500K chars return f.read(500_000)
except UnicodeDecodeError: except UnicodeDecodeError:
try: try:
with open(path, "r", encoding="latin-1") as f: with open(path, "r", encoding="latin-1") as f:
...@@ -333,16 +246,15 @@ def _extract_text_content(path: str, filename: str) -> Optional[str]: ...@@ -333,16 +246,15 @@ def _extract_text_content(path: str, filename: str) -> Optional[str]:
return None return None
def _extract_pdf_text(path: str) -> Optional[str]: def _extract_pdf_text(path):
"""Extract text from a PDF for storage/indexing."""
try: try:
from PyPDF2 import PdfReader from PyPDF2 import PdfReader
reader = PdfReader(path) reader = PdfReader(path)
pages = [] pages = []
for page in reader.pages[:100]: # Cap at 100 pages for page in reader.pages[:100]:
text = page.extract_text() text = page.extract_text()
if text: if text:
pages.append(text) pages.append(text)
return "\n\n".join(pages) if pages else None return "\n\n".join(pages) if pages else None
except Exception: except Exception:
return None return None
\ No newline at end of file
""" """
Build the `messages` list for the Bedrock/Anthropic API from chat history. Build the messages list for the Bedrock/Anthropic API from chat history.
Keeps the most recent messages that fit within a character budget
(rough proxy for tokens — 1 token ≈ 4 chars).
Limits the total number of DB rows loaded to prevent hangs on very long chats.
""" """
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.models import Chat, Message from backend.models import Chat, Message
# ~100 000 tokens budget → ~400 000 characters
MAX_CONTEXT_CHARS = 400_000 MAX_CONTEXT_CHARS = 400_000
# Hard cap: never load more than this many messages from the DB
MAX_MESSAGES = 80 MAX_MESSAGES = 80
def build_messages(chat: Chat, db: Session) -> list[dict]: def build_messages(chat: Chat, db: Session) -> list[dict]:
"""
Return a list of {"role": ..., "content": ...} ready for the API.
Messages are oldest-first (chronological).
Only the most recent MAX_MESSAGES are considered, then trimmed by char budget.
"""
# Fetch the most recent N messages (descending) then reverse to chronological
rows: list[Message] = ( rows: list[Message] = (
db.query(Message) db.query(Message)
.filter(Message.chat_id == chat.id) .filter(Message.chat_id == chat.id)
...@@ -36,7 +22,6 @@ def build_messages(chat: Chat, db: Session) -> list[dict]: ...@@ -36,7 +22,6 @@ def build_messages(chat: Chat, db: Session) -> list[dict]:
if not rows: if not rows:
return [] return []
# --- trim from the oldest to fit character budget ---
total_chars = sum(len(m.content or "") for m in rows) total_chars = sum(len(m.content or "") for m in rows)
idx = 0 idx = 0
while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2: while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2:
...@@ -45,26 +30,20 @@ def build_messages(chat: Chat, db: Session) -> list[dict]: ...@@ -45,26 +30,20 @@ def build_messages(chat: Chat, db: Session) -> list[dict]:
trimmed = rows[idx:] trimmed = rows[idx:]
# Anthropic requires the first message to be role=user.
# If trimming left an assistant message at the front, skip it.
while trimmed and trimmed[0].role != "user": while trimmed and trimmed[0].role != "user":
trimmed = trimmed[1:] trimmed = trimmed[1:]
# Build the final list — collapse consecutive same-role messages
result: list[dict] = [] result: list[dict] = []
for m in trimmed: for m in trimmed:
content = m.content or "" content = m.content or ""
if not content.strip(): if not content.strip():
continue continue
role = m.role role = m.role
if role not in ("user", "assistant"): if role not in ("user", "assistant"):
continue continue
if result and result[-1]["role"] == role: if result and result[-1]["role"] == role:
# Merge into previous
result[-1]["content"] += "\n" + content result[-1]["content"] += "\n" + content
else: else:
result.append({"role": role, "content": content}) result.append({"role": role, "content": content})
return result return result
\ No newline at end of file
#!/usr/bin/env bash
set -uo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_NAME="son-of-anton"
ERRORS=""
WARNINGS=""
banner() {
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
echo -e "${BOLD} $1${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
}
ok() { echo -e " ${GREEN}${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; ERRORS="${ERRORS}\n ${RED}${NC} $1"; }
step() { echo -e " ${CYAN}${NC} $1"; }
warn() { echo -e " ${YELLOW}${NC} $1"; WARNINGS="${WARNINGS}\n ${YELLOW}${NC} $1"; }
info() { echo -e " ${DIM}${NC} $1"; }
has_errors() { [[ -n "$ERRORS" ]]; }
cd "$PROJECT_DIR"
# ══════════════════════════════════════════════════
# Step 0: Check ALL files at once
# ══════════════════════════════════════════════════
banner "Step 0 — Full File Verification (checks everything)"
# ── api.js exports ──
step "Checking api.js exports..."
API_FILE="frontend/src/api.js"
if [[ ! -f "$API_FILE" ]]; then
fail "frontend/src/api.js DOES NOT EXIST"
else
API_MISSING=""
for EXPORT in adminStats adminListUsers adminCreateUser adminUpdateUser adminDeleteUser uploadAttachments getAttachmentUrl deleteAttachment downloadZip streamMessage login register getMe listChats createChat updateChat deleteChat getMessages listKnowledgeBases createKnowledgeBase deleteKnowledgeBase uploadDocuments; do
if ! grep -q "export.*${EXPORT}" "$API_FILE" 2>/dev/null; then
API_MISSING="$API_MISSING $EXPORT"
fi
done
if [[ -n "$API_MISSING" ]]; then
fail "api.js MISSING exports:${API_MISSING}"
else
ok "api.js — all 20 exports present"
fi
fi
# ── Dockerfile ──
step "Checking Dockerfile..."
if [[ ! -f Dockerfile ]]; then
fail "Dockerfile DOES NOT EXIST"
else
DF_ISSUES=""
grep -q "\-\-include=dev" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing --include=dev"
grep -q "npx vite build" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing 'npx vite build'"
grep -q "ENV NODE_ENV=" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing 'ENV NODE_ENV='"
grep -q "ffmpeg" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing ffmpeg install"
grep -q "chat_attachments" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing chat_attachments mkdir"
if [[ -n "$DF_ISSUES" ]]; then
fail "Dockerfile issues:${DF_ISSUES}"
else
ok "Dockerfile — all 5 checks pass"
fi
fi
# ── .dockerignore ──
step "Checking .dockerignore..."
if [[ ! -f .dockerignore ]]; then
fail ".dockerignore DOES NOT EXIST"
else
DI_ISSUES=""
grep -q "node_modules" .dockerignore 2>/dev/null || DI_ISSUES="${DI_ISSUES} missing node_modules"
grep -q "__pycache__" .dockerignore 2>/dev/null || DI_ISSUES="${DI_ISSUES} missing __pycache__"
if [[ -n "$DI_ISSUES" ]]; then
fail ".dockerignore issues:${DI_ISSUES}"
else
ok ".dockerignore — looks good"
fi
fi
# ── Backend files ──
step "Checking backend files..."
BACKEND_MISSING=""
for F in backend/main.py backend/config.py backend/models.py backend/auth.py backend/database.py backend/seed.py backend/system_prompt.py backend/routes/auth_routes.py backend/routes/chat_routes.py backend/routes/admin_routes.py backend/routes/knowledge_routes.py backend/routes/files_routes.py backend/routes/attachment_routes.py backend/services/bedrock_service.py backend/services/memory_service.py backend/services/rag_service.py backend/services/code_extractor.py backend/services/attachment_service.py; do
if [[ ! -f "$F" ]]; then
BACKEND_MISSING="$BACKEND_MISSING $F"
fi
done
if [[ -n "$BACKEND_MISSING" ]]; then
fail "Missing backend files:${BACKEND_MISSING}"
else
ok "All 18 backend files present"
fi
# ── Backend content checks ──
step "Checking backend file contents..."
CONTENT_ISSUES=""
grep -q "attachment_router" backend/main.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} main.py: missing attachment_router import"
grep -q "ChatAttachment" backend/models.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} models.py: missing ChatAttachment model"
grep -q "ATTACHMENT_PATH" backend/config.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} config.py: missing ATTACHMENT_PATH"
grep -q "attachment_ids" backend/routes/chat_routes.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} chat_routes.py: missing attachment_ids"
grep -q "build_claude_content_blocks" backend/services/attachment_service.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} attachment_service.py: missing build_claude_content_blocks"
if [[ -n "$CONTENT_ISSUES" ]]; then
fail "Backend content issues:${CONTENT_ISSUES}"
else
ok "Backend file contents verified"
fi
# ── Frontend component files ──
step "Checking frontend files..."
FRONTEND_MISSING=""
for F in frontend/src/App.jsx frontend/src/main.jsx frontend/src/store.jsx frontend/src/streamManager.js frontend/src/index.css frontend/src/components/ChatView.jsx frontend/src/components/MessageBubble.jsx frontend/src/components/CodeBlock.jsx frontend/src/components/Sidebar.jsx frontend/src/pages/LoginPage.jsx frontend/src/pages/ChatPage.jsx frontend/src/pages/AdminPage.jsx frontend/package.json frontend/vite.config.js frontend/tailwind.config.js frontend/postcss.config.js frontend/index.html; do
if [[ ! -f "$F" ]]; then
FRONTEND_MISSING="$FRONTEND_MISSING $F"
fi
done
if [[ -n "$FRONTEND_MISSING" ]]; then
fail "Missing frontend files:${FRONTEND_MISSING}"
else
ok "All 17 frontend files present"
fi
# ── Frontend content checks ──
step "Checking frontend file contents..."
FE_ISSUES=""
grep -q "uploadAttachments" frontend/src/components/ChatView.jsx 2>/dev/null || FE_ISSUES="${FE_ISSUES} ChatView.jsx: missing uploadAttachments"
grep -q "getAttachmentUrl" frontend/src/components/MessageBubble.jsx 2>/dev/null || FE_ISSUES="${FE_ISSUES} MessageBubble.jsx: missing getAttachmentUrl"
grep -q "Paperclip\|paperclip" frontend/src/components/ChatView.jsx 2>/dev/null || FE_ISSUES="${FE_ISSUES} ChatView.jsx: missing Paperclip (no file attach UI)"
grep -q "Pillow" requirements.txt 2>/dev/null || FE_ISSUES="${FE_ISSUES} requirements.txt: missing Pillow"
if [[ -n "$FE_ISSUES" ]]; then
fail "Frontend/deps content issues:${FE_ISSUES}"
else
ok "Frontend file contents verified"
fi
# ── requirements.txt ──
step "Checking requirements.txt..."
REQ_MISSING=""
for PKG in fastapi uvicorn sqlalchemy pyjwt passlib httpx chromadb PyPDF2 pydantic Pillow; do
if ! grep -qi "$PKG" requirements.txt 2>/dev/null; then
REQ_MISSING="$REQ_MISSING $PKG"
fi
done
if [[ -n "$REQ_MISSING" ]]; then
fail "requirements.txt missing:${REQ_MISSING}"
else
ok "requirements.txt — all 10 packages present"
fi
# ══════════════════════════════════════════════════
# STOP if any errors
# ══════════════════════════════════════════════════
echo ""
if has_errors; then
echo -e "${RED}${BOLD}═══════════════════════════════════════════════════${NC}"
echo -e "${RED}${BOLD} FILES ARE BROKEN — Cannot deploy${NC}"
echo -e "${RED}${BOLD}═══════════════════════════════════════════════════${NC}"
echo ""
echo -e "${BOLD}All issues found:${NC}"
echo -e "$ERRORS"
echo ""
echo -e "${YELLOW}${BOLD}Fix ALL of the above, then run this script again.${NC}"
echo ""
exit 1
fi
echo ""
echo -e " ${GREEN}${BOLD}All file checks passed ✔${NC}"
# ══════════════════════════════════════════════════
# Step 1: Local build test
# ══════════════════════════════════════════════════
banner "Step 1 — Local Build Test"
step "Cleaning frontend/node_modules and dist..."
rm -rf frontend/node_modules frontend/dist 2>/dev/null || true
step "npm install..."
cd frontend
if ! npm install --legacy-peer-deps --include=dev 2>&1 | tail -5; then
echo -e "\n ${RED}${BOLD}npm install failed. Fix above errors.${NC}"
exit 1
fi
ok "Dependencies installed"
step "Building frontend..."
if ! npm run build 2>&1; then
echo -e "\n ${RED}${BOLD}BUILD FAILED. Fix the errors shown above.${NC}"
exit 1
fi
ok "Frontend builds clean"
cd "$PROJECT_DIR"
# ══════════════════════════════════════════════════
# Step 2: Get CapRover credentials
# ══════════════════════════════════════════════════
banner "Step 2 — CapRover Connection"
echo -e " ${BOLD}CapRover URL${NC} (e.g. https://captain.yourdomain.com):"
read -r CAPROVER_URL
CAPROVER_URL="${CAPROVER_URL%/}"
[[ -z "$CAPROVER_URL" ]] && { echo -e " ${RED}URL required${NC}"; exit 1; }
echo -e " ${BOLD}CapRover password:${NC}"
read -rs CAPROVER_PASSWORD
echo ""
[[ -z "$CAPROVER_PASSWORD" ]] && { echo -e " ${RED}Password required${NC}"; exit 1; }
step "Authenticating..."
LOGIN_RESP=$(curl -s -X POST "${CAPROVER_URL}/api/v2/login" \
-H "Content-Type: application/json" \
-H "x-namespace: captain" \
-d "{\"password\": \"${CAPROVER_PASSWORD}\"}" \
--connect-timeout 15 --max-time 30 2>&1) || { echo -e " ${RED}Cannot reach CapRover${NC}"; exit 1; }
TOKEN=$(echo "$LOGIN_RESP" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
print(d['data']['token'] if d.get('status') == 100 else '')
except: pass
" 2>/dev/null)
[[ -z "$TOKEN" ]] && { echo -e " ${RED}Login failed: $(echo "$LOGIN_RESP" | head -c 200)${NC}"; exit 1; }
ok "Authenticated"
# ══════════════════════════════════════════════════
# Step 3: Build tarball from LOCAL files
# ══════════════════════════════════════════════════
banner "Step 3 — Build Deploy Package"
rm -rf frontend/node_modules frontend/dist 2>/dev/null || true
TMPDIR=$(mktemp -d)
TARBALL="${TMPDIR}/deploy.tar"
step "Creating tarball..."
tar cf "$TARBALL" \
--exclude='.git' \
--exclude='node_modules' \
--exclude='dist' \
--exclude='.DS_Store' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.env' \
--exclude='.env.local' \
--exclude='.idea' \
--exclude='.vscode' \
--exclude='main.py' \
--exclude='create-project.ps1' \
--exclude='*.sh' \
.
TARSIZE=$(du -sh "$TARBALL" | cut -f1)
ok "Tarball: ${TARSIZE}"
# Verify inside tarball
step "Verifying tarball contents..."
TAR_ERRORS=""
for F in Dockerfile .dockerignore requirements.txt warmup.py backend/main.py backend/routes/attachment_routes.py backend/services/attachment_service.py frontend/package.json frontend/src/api.js frontend/src/components/ChatView.jsx frontend/src/components/MessageBubble.jsx; do
if ! tar tf "$TARBALL" "./${F}" > /dev/null 2>&1; then
TAR_ERRORS="${TAR_ERRORS} ${F}"
fi
done
if [[ -n "$TAR_ERRORS" ]]; then
echo -e " ${RED}Tarball missing:${TAR_ERRORS}${NC}"
rm -rf "$TMPDIR"
exit 1
fi
# Verify actual content inside tarball
API_COUNT=$(tar xf "$TARBALL" -O ./frontend/src/api.js 2>/dev/null | grep -c "adminStats" || true)
DF_COUNT=$(tar xf "$TARBALL" -O ./Dockerfile 2>/dev/null | grep -c "\-\-include=dev" || true)
if [[ "$API_COUNT" -lt 1 ]]; then
echo -e " ${RED}api.js inside tarball has NO adminStats export!${NC}"
rm -rf "$TMPDIR"
exit 1
fi
if [[ "$DF_COUNT" -lt 1 ]]; then
echo -e " ${RED}Dockerfile inside tarball is OLD (no --include=dev)!${NC}"
rm -rf "$TMPDIR"
exit 1
fi
ok "Tarball contents verified (api.js ✔, Dockerfile ✔)"
# ══════════════════════════════════════════════════
# Step 4: Deploy
# ══════════════════════════════════════════════════
banner "Step 4 — Deploy to CapRover"
step "Uploading & building (5-15 min)..."
info "node:20 → npm install → vite build → python:3.11 → pip install → chromadb warmup"
echo ""
DEPLOY_RESP=$(curl -s -X POST \
"${CAPROVER_URL}/api/v2/user/apps/appData/${APP_NAME}?gitHash=$(date +%s)" \
-H "x-namespace: captain" \
-H "x-captain-auth: ${TOKEN}" \
-F "sourceFile=@${TARBALL}" \
--connect-timeout 30 \
--max-time 1200 \
2>&1)
rm -rf "$TMPDIR"
DEPLOY_STATUS=$(echo "$DEPLOY_RESP" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
if d.get('status') == 100:
print('SUCCESS')
else:
print('FAIL:' + (d.get('description','') or d.get('message','') or str(d)))
except Exception as e:
print('FAIL:' + str(e))
" 2>/dev/null)
echo ""
if [[ "$DEPLOY_STATUS" == "SUCCESS" ]]; then
echo -e " ${GREEN}${BOLD}═══════════════════════════════════════════════════${NC}"
echo -e " ${GREEN}${BOLD} 🔥 DEPLOYMENT SUCCESSFUL!${NC}"
echo -e " ${GREEN}${BOLD}═══════════════════════════════════════════════════${NC}"
echo ""
ok "Son of Anton is live."
echo ""
info "Default login: superadmin / (your SUPERADMIN_PASSWORD env var)"
echo ""
else
ERRMSG="${DEPLOY_STATUS#FAIL:}"
echo -e " ${RED}${BOLD}═══════════════════════════════════════════════════${NC}"
echo -e " ${RED}${BOLD} ✘ DEPLOYMENT FAILED${NC}"
echo -e " ${RED}${BOLD}═══════════════════════════════════════════════════${NC}"
echo ""
echo -e " ${RED}Error: ${ERRMSG}${NC}"
echo ""
step "Fetching build logs..."
sleep 3
BUILD_LOG=$(curl -s -X POST "${CAPROVER_URL}/api/v2/user/apps/appData/${APP_NAME}/buildLogs" \
-H "x-namespace: captain" \
-H "x-captain-auth: ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"appName\": \"${APP_NAME}\"}" \
--connect-timeout 10 --max-time 15 2>&1)
LOGS=$(echo "$BUILD_LOG" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
logs = d.get('data', {}).get('logs', '') or ''
if not logs: logs = json.dumps(d, indent=2)
for line in logs.strip().split('\n')[-60:]:
print(line)
except: print('Could not fetch logs')
" 2>/dev/null)
if [[ -n "$LOGS" ]]; then
echo -e " ${YELLOW}── Build Logs (last 60 lines) ──${NC}"
echo ""
echo "$LOGS" | while IFS= read -r line; do
if echo "$line" | grep -qiE "error|ERR!|failed|FATAL|not exported"; then
echo -e " ${RED}${line}${NC}"
elif echo "$line" | grep -qiE "Step "; then
echo -e " ${CYAN}${line}${NC}"
else
echo -e " ${DIM}${line}${NC}"
fi
done
echo ""
echo -e " ${YELLOW}── End ──${NC}"
fi
exit 1
fi
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -6,19 +6,21 @@ function headers(token) { ...@@ -6,19 +6,21 @@ function headers(token) {
return h; return h;
} }
function authHeader(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function request(method, path, token, body) { async function request(method, path, token, body) {
const opts = { method, headers: headers(token) }; const opts = { method, headers: headers(token) };
if (body) opts.body = JSON.stringify(body); if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts); const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `Request failed: ${res.status}`); throw new Error(err.detail || err.message || "Request failed");
} }
if (res.status === 204) return null;
return res.json(); return res.json();
} }
/* ── Auth ─────────────────────────────────── */
export const login = (username, password) => export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password }); request("POST", "/auth/login", null, { username, password });
...@@ -27,12 +29,9 @@ export const register = (username, email, password) => ...@@ -27,12 +29,9 @@ export const register = (username, email, password) =>
export const getMe = (token) => request("GET", "/auth/me", token); export const getMe = (token) => request("GET", "/auth/me", token);
/* ── Chats ─────────────────────────────────── */ export const listChats = (token) => request("GET", "/chats", token);
export const listChats = (token) =>
request("GET", "/chats", token);
export const createChat = (token, data = {}) => export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) => export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data); request("PUT", `/chats/${chatId}`, token, data);
...@@ -46,88 +45,57 @@ export const deleteChat = (token, chatId) => ...@@ -46,88 +45,57 @@ export const deleteChat = (token, chatId) =>
export const getMessages = (token, chatId) => export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token); request("GET", `/chats/${chatId}/messages`, token);
/* ── Streaming message (accepts AbortSignal) ─ */
export async function* streamMessage(token, chatId, body, signal) { export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", method: "POST", headers: headers(token),
headers: headers(token), body: JSON.stringify(body), signal,
body: JSON.stringify(body),
signal,
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || "Stream failed"); throw new Error(err.detail || "Stream failed");
} }
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n"); const parts = buffer.split("\n\n");
buffer = parts.pop() || ""; buffer = parts.pop() || "";
for (const part of parts) { for (const part of parts) {
const line = part.trim(); const line = part.trim();
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
try { try { yield JSON.parse(line.slice(6)); } catch { }
yield JSON.parse(line.slice(6));
} catch {
/* skip malformed */
}
} }
} }
} }
// flush
if (buffer.trim().startsWith("data: ")) { if (buffer.trim().startsWith("data: ")) {
try { try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
yield JSON.parse(buffer.trim().slice(6));
} catch {
/* skip */
}
} }
} }
/* ── File Uploads (Attachments) ───────────── */
export async function uploadAttachments(token, chatId, files) { export async function uploadAttachments(token, chatId, files) {
const formData = new FormData(); const form = new FormData();
for (const file of files) { for (const file of files) form.append("files", file);
formData.append("files", file);
}
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, { const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST", method: "POST", headers: authHeader(token), body: form,
headers: {
Authorization: `Bearer ${token}`,
// Do NOT set Content-Type — browser sets it with boundary for multipart
},
body: formData,
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed"); throw new Error(err.detail || "Upload failed");
} }
return res.json(); return res.json();
} }
export const listAttachments = (token, chatId) => export function getAttachmentUrl(attachmentId) {
request("GET", `/chats/${chatId}/attachments`, token); return `${BASE}/attachments/${attachmentId}/file`;
export function getAttachmentPreviewUrl(chatId, attachmentId) {
return `${BASE}/chats/${chatId}/attachments/${attachmentId}/preview`;
} }
/* ── Knowledge Bases ───────────────────────── */ export const deleteAttachment = (token, attachmentId) =>
export const listKnowledgeBases = (token) => request("DELETE", `/attachments/${attachmentId}`, token);
request("GET", "/knowledge", token);
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") => export const createKnowledgeBase = (token, name, description = "") =>
request("POST", "/knowledge", token, { name, description }); request("POST", "/knowledge", token, { name, description });
...@@ -139,50 +107,53 @@ export const deleteKnowledgeBase = (token, kbId) => ...@@ -139,50 +107,53 @@ export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token); request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocuments(token, kbId, files) { export async function uploadDocuments(token, kbId, files) {
const formData = new FormData(); const form = new FormData();
for (const file of files) { for (const file of files) form.append("files", file);
formData.append("files", file); const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
} method: "POST", headers: authHeader(token), body: form,
const res = await fetch(`${BASE}/knowledge/${kbId}/documents`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed"); throw new Error(err.detail || "Upload failed");
} }
return res.json(); return res.json();
} }
export const deleteDocument = (token, kbId, docId) => export const uploadDocument = (token, kbId, file) =>
request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token); uploadDocuments(token, kbId, [file]);
export const adminStats = (token) => request("GET", "/admin/stats", token);
/* ── Admin ─────────────────────────────────── */ export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminListUsers = (token) =>
request("GET", "/admin/users", token); export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) => export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data); request("PUT", `/admin/users/${userId}`, token, data);
/* ── Download Zip ──────────────────────────── */ export const adminDeleteUser = (token, userId) =>
export async function downloadZip(token, content) { request("DELETE", `/admin/users/${userId}`, token);
const res = await fetch(`${BASE}/download-zip`, {
method: "POST",
headers: headers(token),
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Download failed"); export const adminListChats = (token) => request("GET", "/admin/chats", token);
const blob = await res.blob(); export async function downloadZip(token, markdown) {
const url = URL.createObjectURL(blob); const res = await fetch(`${BASE}/files/download-zip`, {
const a = document.createElement("a"); method: "POST", headers: headers(token),
a.href = url; body: JSON.stringify({ markdown }),
a.download = "code-files.zip"; });
a.click(); if (!res.ok) throw new Error("Download failed");
URL.revokeObjectURL(url); const ct = res.headers.get("content-type") || "";
} if (ct.includes("application/zip")) {
\ No newline at end of file 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);
}
}
import React, { useEffect, useRef, useState, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { getMessages, downloadZip, uploadAttachments } from "../api"; import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import * as streamManager from "../streamManager"; import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import FileUploadButton from "./FileUploadButton"; import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, Image, FileText, Film, Loader2 } from "lucide-react";
import AttachmentPreview from "./AttachmentPreview";
import {
Send,
Square,
Download,
ChevronDown,
Loader2,
Paperclip,
} from "lucide-react";
export default function ChatView({ chatId }) { const MODELS = [
const { state, dispatch } = useApp(); { id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
const [input, setInput] = useState(""); { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
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 scrollContainerRef = useRef(null); function classifyFile(file) {
const shouldAutoScrollRef = useRef(true); const ext = (file.name || "").split(".").pop().toLowerCase();
const textareaRef = useRef(null); const mime = file.type || "";
if (mime.startsWith("image/") || ["jpg","jpeg","png","gif","webp","bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4","mov","avi","mkv","webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document";
return "text";
}
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const currentChat = state.chats.find((c) => c.id === chatId);
const messages = state.chatMessages[chatId] || []; const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId];
// Per-chat streaming state — subscribe to stream manager const [input, setInput] = useState("");
const [streamData, setStreamData] = useState(() => const [showSettings, setShowSettings] = useState(false);
streamManager.getStreamData(chatId) 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([]);
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null);
const inputRef = useRef(null);
const fileRef = useRef(null);
const autoScroll = useRef(true);
const rafRef = useRef(null);
useEffect(() => { useEffect(() => {
// Initial read
setStreamData(streamManager.getStreamData(chatId)); setStreamData(streamManager.getStreamData(chatId));
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
// Subscribe to updates for THIS chat
const unsub = streamManager.subscribe(chatId, () => {
setStreamData(streamManager.getStreamData(chatId));
});
return unsub;
}, [chatId]); }, [chatId]);
// Load messages on mount function onScroll() {
useEffect(() => { const el = scrollRef.current;
if (!state.chatMessages[chatId]) { if (!el) return;
getMessages(state.token, chatId) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
.then((msgs) => dispatch({ type: "SET_MESSAGES", chatId, messages: msgs })) }
.catch(() => {});
} const scrollBottom = useCallback(() => {
}, [chatId, state.token, dispatch, state.chatMessages]); if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
rafRef.current = null;
});
}, []);
// Auto-scroll
useEffect(() => { useEffect(() => {
if (shouldAutoScrollRef.current && scrollContainerRef.current) { (async () => {
const el = scrollContainerRef.current; try {
el.scrollTop = el.scrollHeight; const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]);
} dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
}, [messages, streamData]); setKbs(kbData);
} catch {}
})();
}, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]);
async function saveSettings() {
try {
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" });
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
} catch {}
}
function handleContainerScroll() { function toggleSettings() {
const el = scrollContainerRef.current; if (showSettings) saveSettings();
if (!el) return; setShowSettings(!showSettings);
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
shouldAutoScrollRef.current = nearBottom;
} }
function scrollToBottom() { function handleFileSelect(e) {
if (scrollContainerRef.current) { const files = Array.from(e.target.files || []);
scrollContainerRef.current.scrollTop = setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
scrollContainerRef.current.scrollHeight; e.target.value = "";
shouldAutoScrollRef.current = true;
}
} }
// THIS chat streaming — only blocks sends for THIS chat, not others function removePending(i) {
const isChatStreaming = !!state.activeStreams[chatId]; setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
}
async function handleSend() { async function handleSend() {
const content = input.trim(); const content = input.trim();
if ((!content && pendingFiles.length === 0) || isChatStreaming) return; if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
const text = content || "Please analyze the attached file(s).";
// Optimistic user message (with attachment previews)
const userMsg = {
id: `tmp-${Date.now()}`,
role: "user",
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;
// Upload files if any let attIds = [], uploaded = [];
let attachmentIds = []; if (pendingFiles.length) {
if (pendingFiles.length > 0) { setUploading(true);
setUploadingFiles(true);
try { try {
const uploaded = await uploadAttachments( const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
state.token, uploaded = (res.attachments || []).filter((a) => !a.error);
chatId, attIds = uploaded.map((a) => a.id);
pendingFiles } catch (err) { console.error(err); setUploading(false); return; }
); setUploading(false);
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: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
dispatch({ setInput("");
type: "UPDATE_CHAT", pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
chat: { setPendingFiles([]);
id: chatId, autoScroll.current = true;
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
},
});
// Start stream — includes attachment_ids
streamManager.startStream({
token: state.token,
chatId,
body: {
content: content || "Please describe and analyze the attached file(s).",
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attachmentIds,
},
});
}
function handleStop() {
streamManager.abortStream(chatId);
}
function handleKeyDown(e) { dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
if (e.key === "Enter" && !e.shiftKey) { streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
e.preventDefault();
handleSend();
}
} }
function handleFilesSelected(files) { function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
setPendingFiles((prev) => [...prev, ...Array.from(files)]);
}
function handleRemovePendingFile(index) {
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
}
function handlePaste(e) { function handlePaste(e) {
const items = e.clipboardData?.items; const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!items) return; if (!imgs.length) 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.preventDefault();
e.stopPropagation(); setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFilesSelected(files);
}
} }
function handleDragOver(e) { function handleDrop(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); const files = Array.from(e.dataTransfer?.files || []);
} if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
async function handleDownloadAll() {
const all = messages
.filter((m) => m.role === "assistant")
.map((m) => m.content)
.join("\n\n---\n\n");
if (all) {
try {
await downloadZip(state.token, all);
} catch {
/* */
}
}
} }
const streaming = streamData.streaming; const streaming = streamData.streaming;
return ( return (
<div <div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
className="flex-1 flex flex-col min-h-0" <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
onDrop={handleDrop} {messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
onDragOver={handleDragOver} {streaming && (streamData.thinking || streamData.text) && (
> <MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
{/* 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"> {streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<h2 className="text-sm font-semibold text-white truncate max-w-[200px]"> <div className="flex gap-1">
{state.chats.find((c) => c.id === chatId)?.title || "New Chat"} <span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
</h2> <span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
{isChatStreaming && ( <span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
<span className="flex items-center gap-1 text-xs text-anton-accent"> </div>
<Loader2 size={12} className="animate-spin" /> <span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
Streaming </div>
</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> </div>
{/* Settings Panel */} <div className="border-t border-anton-border bg-anton-surface p-4">
{showSettings && ( {showSettings && (
<div className="px-4 py-3 border-b border-anton-border bg-anton-surface/80 backdrop-blur-sm animate-fade-in"> <div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
<div className="flex flex-wrap gap-4 items-end"> <div className="flex items-center justify-between">
<label className="flex flex-col gap-1"> <h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
<span className="text-[11px] text-anton-muted uppercase tracking-wider"> <button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
Model </div>
</span> <div>
<select <label className="text-xs text-anton-muted mb-1 block">Model</label>
value={model} <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">
onChange={(e) => setModel(e.target.value)} {MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
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> </select>
</label> </div>
<label className="flex flex-col gap-1"> <div>
<span className="text-[11px] text-anton-muted uppercase tracking-wider"> <div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
Max Tokens <input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</span> </div>
<input <div>
type="number" <div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
value={maxTokens} <input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
onChange={(e) => setMaxTokens(Number(e.target.value))} </div>
min={256} <div>
max={128000} <label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
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" <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">
/>
</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> <option value="">None</option>
{(state.knowledgeBases || []).map((kb) => ( {kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
<option key={kb.id} value={kb.id}>
{kb.name}
</option>
))}
</select> </select>
</label> </div>
</div>
</div>
)}
{/* Messages */}
<div
ref={scrollContainerRef}
onScroll={handleContainerScroll}
className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
{messages.map((m) => (
<MessageBubble key={m.id} message={m} chatId={chatId} />
))}
{/* Streaming overlay — in-progress response */}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble
message={{
id: "streaming",
role: "assistant",
content: streamData.text,
thinking_content: streamData.thinking || null,
}}
chatId={chatId}
isStreaming
isThinking={streamData.isThinking}
/>
)}
{/* 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>
)} )}
</div>
{/* 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 && (
{pendingFiles.length > 0 && ( <div className="mb-3 flex flex-wrap gap-2 animate-fade-in">
<div className="px-4 py-2 border-t border-anton-border bg-anton-surface/50"> {pendingFiles.map((pf, i) => (
<div className="flex flex-wrap gap-2"> <div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden">
{pendingFiles.map((file, idx) => ( {pf.type === "image" && pf.preview ? (
<AttachmentPreview <img src={pf.preview} alt="" className="w-16 h-16 object-cover" />
key={`${file.name}-${idx}`} ) : (
file={file} <div className="w-16 h-16 flex flex-col items-center justify-center px-1">
onRemove={() => handleRemovePendingFile(idx)} <FileText size={20} className="text-anton-muted mb-1" />
isPending <span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
/> </div>
)}
<button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">{(pf.file.size / 1024).toFixed(0)}KB</div>
</div>
))} ))}
</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"> <div className="flex items-end gap-2">
<FileUploadButton onFilesSelected={handleFilesSelected} /> <button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.dart,.vue,.svelte,.log" onChange={handleFileSelect} />
<div className="flex-1 relative"> <div className="flex-1 relative">
<textarea <textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
ref={textareaRef} placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"}
value={input} rows={1} style={{ maxHeight: "200px" }}
onChange={(e) => setInput(e.target.value)} className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onKeyDown={handleKeyDown} onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
onPaste={handlePaste}
placeholder={
pendingFiles.length > 0
? "Add a message about the files, or just send..."
: "Message Son of Anton..."
}
rows={1}
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, 160) + "px";
}}
/>
</div> </div>
{streaming ? ( {streaming ? (
<button <button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
onClick={handleStop}
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={16} />
</button>
) : ( ) : (
<button <button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
onClick={handleSend} 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={ {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
(!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={16} />
</button> </button>
)} )}
</div> </div>
<div className="flex items-center justify-between mt-2"> <div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted">
<span className="text-[10px] text-anton-muted"> <span>{MODELS.find((m) => m.id === model)?.label}</span>
{pendingFiles.length > 0 && ( <span></span><span>{maxTokens.toLocaleString()} tokens</span>
<span className="text-anton-accent"> {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{pendingFiles.length} file{pendingFiles.length > 1 ? "s" : ""}{" "} {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
attached •{" "} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span></>}
</span> {messages.some((m) => m.role === "assistant") && (
)} <button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch {} }}
Shift+Enter for new line • Paste or drag files to attach className="ml-auto hover:text-anton-accent transition">⬇ Download code</button>
</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> </div>
</div> </div>
); );
} }
/** 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
...@@ -2,227 +2,159 @@ import React, { useState } from "react"; ...@@ -2,227 +2,159 @@ import React, { useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock"; import CodeBlock from "./CodeBlock";
import AttachmentPreview from "./AttachmentPreview"; import { getAttachmentUrl } from "../api";
import { import {
User, User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Flame, Image, Film, FileText, ExternalLink,
Copy,
Check,
ChevronDown,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
export default function MessageBubble({ const FILE_TYPE_ICONS = {
message, image: Image, video: Film, document: FileText, text: FileText,
chatId, };
isStreaming = false,
isThinking = false, const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
}) { const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = message.role === "user"; const isUser = role === "user";
const [copied, setCopied] = useState(false);
const [showThinking, setShowThinking] = useState(false); const [showThinking, setShowThinking] = useState(false);
const [copied, setCopied] = useState(false);
const [expandedImage, setExpandedImage] = useState(null);
function handleCopy() { function handleCopy() {
navigator.clipboard.writeText(message.content || ""); navigator.clipboard.writeText(content || "");
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} }
const attachments = message.attachments || []; const hasAttachments = attachments && attachments.length > 0;
return ( return (
<div <div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
className={`flex gap-3 animate-fade-in ${
isUser ? "justify-end" : "justify-start"
}`}
>
{/* Avatar — assistant only */}
{!isUser && ( {!isUser && (
<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"> <div className="shrink-0 mt-1">
<Flame size={14} className="text-anton-accent" /> <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> </div>
)} )}
<div <div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
className={` {thinking_content && (
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"> <div className="mb-2">
<button <button onClick={() => setShowThinking(!showThinking)}
onClick={() => setShowThinking(!showThinking)} className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-anton-accent transition" <Brain size={12} />
> {showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{showThinking ? ( {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
{isThinking ? "Thinking..." : "Thought process"}
</button> </button>
{showThinking && ( {(showThinking || isThinking) && (
<div className="mt-1 pl-3 border-l-2 border-anton-accent/20 text-xs text-anton-muted leading-relaxed whitespace-pre-wrap"> <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">
{message.thinking_content} {thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div> </div>
)} )}
</div> </div>
)} )}
{/* Thinking indicator during streaming */} {hasAttachments && (
{isStreaming && isThinking && !message.thinking_content && ( <div className="mb-2 flex flex-wrap gap-2">
<div className="flex items-center gap-2 text-xs text-anton-accent mb-2"> {attachments.map((att) => {
<div className="flex gap-1"> const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
<span const url = getAttachmentUrl(att.id);
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" if (att.file_type === "image") {
style={{ animationDelay: "0ms" }} return (
/> <div key={att.id} className="relative group">
<span <img src={`${url}?token=${token}`} alt={att.original_filename}
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
style={{ animationDelay: "150ms" }} onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
/> onError={(e) => { e.target.style.display = "none"; }} />
<span {expandedImage === att.id && (
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
style={{ animationDelay: "300ms" }} onClick={() => setExpandedImage(null)}>
/> <img src={`${url}?token=${token}`} alt={att.original_filename}
</div> className="max-w-full max-h-full object-contain rounded-lg" />
Thinking... </div>
)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">
{att.original_filename}
</div>
</div>
);
}
return (
<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2 hover:border-anton-accent transition group">
<Icon size={16} className="shrink-0 text-blue-400" />
<div className="min-w-0">
<div className="text-xs text-white truncate max-w-[160px]">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a>
);
})}
</div> </div>
)} )}
{/* Main content */} <div className={`rounded-2xl px-4 py-3 ${
{message.content && ( isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
<div className="prose prose-invert prose-sm max-w-none break-words"> }`}>
<ReactMarkdown {isUser ? (
remarkPlugins={[remarkGfm]} <div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
components={{ ) : (
<div className="prose-anton text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
code({ node, inline, className, children, ...props }) { code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || ""); const match = /language-(\S+)/.exec(className || "");
const lang = match ? match[1] : ""; const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>;
if (!inline && (match || String(children).includes("\n"))) { let lang = rawLang, filename = null;
// Extract filename from lang if format is "lang:path/to/file.ext" if (rawLang.includes(":")) {
let language = lang; const idx = rawLang.indexOf(":");
let filename = ""; lang = rawLang.slice(0, idx);
if (lang && lang.includes(":")) { filename = rawLang.slice(idx + 1);
const parts = lang.split(":");
language = parts[0];
filename = parts.slice(1).join(":");
}
return (
<CodeBlock
code={String(children).replace(/\n$/, "")}
language={language}
filename={filename}
/>
);
} }
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />;
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 }) { pre({ children }) { return <>{children}</>; },
return ( }}>
<td className="border border-anton-border px-3 py-1.5"> {content || ""}
{children} </ReactMarkdown>
</td> {isStreaming && !isThinking && (
); <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />
},
}}
>
{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
</>
)} )}
</div>
)}
</div>
{!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">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button> </button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}↑ tokens
</span>
)}
</div> </div>
)} )}
</div> </div>
{/* Avatar — user only */}
{isUser && ( {isUser && (
<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"> <div className="shrink-0 mt-1">
<User size={14} className="text-anton-muted" /> <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> </div>
)} )}
</div> </div>
); );
} });
\ No newline at end of file
function _stripPrefixes(text) {
if (!text) return "";
return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim();
}
export default MessageBubble;
/**
* Son of Anton — Background Stream Manager
*
* Runs AI streams outside React component lifecycle.
* Switching chats does NOT abort streams. Multiple chats
* can stream simultaneously. Components subscribe to
* per-chat updates via subscribe().
*/
import { streamMessage } from "./api"; import { streamMessage } from "./api";
// chatId -> { text, thinking, isThinking, abortController }
const _streams = new Map(); const _streams = new Map();
// chatId -> Set<() => void>
const _listeners = new Map(); const _listeners = new Map();
// Store dispatch reference, set once from AppProvider
let _dispatch = null; let _dispatch = null;
export function setDispatch(dispatch) { export function setDispatch(dispatch) { _dispatch = dispatch; }
_dispatch = dispatch;
}
/** Read current stream data for a chat (non-reactive, call from inside subscriber) */
export function getStreamData(chatId) { export function getStreamData(chatId) {
const s = _streams.get(chatId); const s = _streams.get(chatId);
if (!s) return { streaming: false, text: "", thinking: "", isThinking: false }; if (!s) return { streaming: false, text: "", thinking: "", isThinking: false };
return { return { streaming: true, text: s.text, thinking: s.thinking, isThinking: s.isThinking };
streaming: true,
text: s.text,
thinking: s.thinking,
isThinking: s.isThinking,
};
} }
/** Is this chat currently streaming? */ export function isStreaming(chatId) { return _streams.has(chatId); }
export function isStreaming(chatId) {
return _streams.has(chatId);
}
/** Is ANY chat currently streaming? (for UI indicators, NOT for blocking) */ export function subscribe(chatId, cb) {
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()); if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
_listeners.get(chatId).add(callback); _listeners.get(chatId).add(cb);
return () => { return () => { const s = _listeners.get(chatId); if (s) { s.delete(cb); if (!s.size) _listeners.delete(chatId); } };
const set = _listeners.get(chatId);
if (set) {
set.delete(callback);
if (set.size === 0) _listeners.delete(chatId);
}
};
} }
function _notify(chatId) { function _notify(id) { const s = _listeners.get(id); if (s) s.forEach((cb) => cb()); }
const set = _listeners.get(chatId);
if (set) set.forEach((cb) => cb());
}
/** Abort a running stream for a chat */
export function abortStream(chatId) { export function abortStream(chatId) {
const s = _streams.get(chatId); const s = _streams.get(chatId);
if (s) { if (s) { s.abortController.abort(); _streams.delete(chatId); _notify(chatId); if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false }); }
s.abortController.abort();
_streams.delete(chatId);
_notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
}
} }
/**
* Start a background stream for a chat.
* If a stream already exists for this chat, it is aborted first.
*/
export function startStream({ token, chatId, body }) { export function startStream({ token, chatId, body }) {
// Abort existing stream for THIS chat only (other chats keep streaming) if (_streams.has(chatId)) return;
if (_streams.has(chatId)) { const ac = new AbortController();
abortStream(chatId); _streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
}
const abortController = new AbortController();
const streamState = {
text: "",
thinking: "",
isThinking: false,
abortController,
};
_streams.set(chatId, streamState);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true }); if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
_notify(chatId); _notify(chatId);
// Fire and forget — runs independently of React
(async () => { (async () => {
const s = _streams.get(chatId);
if (!s) return;
let usage = {}, msgId = "";
try { try {
const gen = streamMessage(token, chatId, body, abortController.signal); for await (const evt of streamMessage(token, chatId, body, ac.signal)) {
if (ac.signal.aborted || !_streams.has(chatId)) break;
for await (const event of gen) { switch (evt.type) {
const s = _streams.get(chatId); case "thinking_start": s.isThinking = true; _notify(chatId); break;
if (!s) break; // stream was aborted case "thinking_delta": s.thinking += evt.content; _notify(chatId); break;
case "thinking_end": s.isThinking = false; _notify(chatId); break;
if (event.type === "thinking") { case "text_delta": s.text += evt.content; _notify(chatId); break;
s.thinking += event.content || ""; case "usage": usage = { input_tokens: evt.input_tokens, output_tokens: evt.output_tokens }; break;
s.isThinking = true; case "title_update": if (_dispatch) _dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: evt.title } }); break;
} else if (event.type === "text") { case "done": msgId = evt.message_id; break;
s.text += event.content || ""; case "error": s.text += `\n\n**Error:** ${evt.message}`; _notify(chatId); break;
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"}`;
} }
}
_notify(chatId); if (!ac.signal.aborted && _dispatch) {
_dispatch({ type: "ADD_MESSAGE", chatId, message: {
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(), attachments: [],
}});
} }
} catch (err) { } catch (err) {
if (err.name !== "AbortError") { if (!ac.signal.aborted && _dispatch) {
const s = _streams.get(chatId); _dispatch({ type: "ADD_MESSAGE", chatId, message: {
if (s) { id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`,
s.text += `\n\n**Stream error:** ${err.message}`; created_at: new Date().toISOString(), attachments: [],
_notify(chatId); }});
}
} }
} finally { } finally {
_streams.delete(chatId); _streams.delete(chatId); _notify(chatId);
_notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false }); if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
} }
})(); })();
} }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment