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 @@
FROM node:20-alpine AS frontend-build
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* ./
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/ ./
RUN npm run build
RUN NODE_ENV=production npx vite build && \
echo "=== dist ===" && \
ls -la dist/
# ============================================
# Stage 2: Python Backend + Serve Frontend
......@@ -15,9 +28,9 @@ RUN npm run build
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
build-essential \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
......@@ -28,11 +41,12 @@ COPY backend/ ./backend/
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
RUN python /tmp/warmup.py && rm /tmp/warmup.py
# Create persistent data directories
RUN mkdir -p /data/chromadb /data/uploads /data/uploads/chat_attachments
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")
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"
)
\ No newline at end of file
)
......@@ -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.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 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
try:
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")}
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")
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")
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}")
print(f" Migration note: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- Startup ---
Base.metadata.create_all(bind=engine)
_run_migrations()
seed_superadmin()
print("🔥 Son of Anton is online.")
print("Son of Anton is online.")
yield
# --- Shutdown ---
await close_http_client()
print("Son of Anton shutting down.")
......@@ -56,7 +63,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Son of Anton",
description="Avatar of All Elements of Code",
version="1.0.0",
version="2.0.0",
lifespan=lifespan,
)
......@@ -68,14 +75,13 @@ app.add_middleware(
allow_headers=["*"],
)
# ── API Routes ────────────────────────────────────
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"])
# ── Serve Frontend ────────────────────────────────
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if (FRONTEND_DIR / "assets").exists():
......@@ -96,4 +102,4 @@ async def serve_frontend(full_path: str):
index = FRONTEND_DIR / "index.html"
if index.is_file():
return FileResponse(str(index))
return {"message": "Son of Anton API is running. Frontend not built."}
\ No newline at end of file
return {"message": "Son of Anton API is running. Frontend not built."}
......@@ -59,6 +59,10 @@ class Chat(Base):
"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):
......@@ -74,6 +78,26 @@ class Message(Base):
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):
......@@ -99,4 +123,4 @@ class KnowledgeDocument(Base):
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)
\ No newline at end of file
created_at = Column(DateTime, default=datetime.utcnow)
......@@ -3,19 +3,37 @@ Chat attachment upload, serve, and delete routes.
"""
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 sqlalchemy.orm import Session
from backend.database import get_db
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.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,
......@@ -23,7 +41,6 @@ async def upload_attachments(
user: User = Depends(get_current_user),
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()
if not chat:
raise HTTPException(404, "Chat not found")
......@@ -34,21 +51,16 @@ async def upload_attachments(
try:
content = await file.read()
if len(content) > MAX_ATTACHMENT_BYTES:
results.append({
"error": f"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB.",
})
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,
chat_id=chat_id, filename=filename,
content=content, content_type=file.content_type,
)
att = ChatAttachment(
id=meta["id"],
chat_id=chat_id,
id=meta["id"], chat_id=chat_id,
filename=meta["filename"],
original_filename=meta["original_filename"],
mime_type=meta["mime_type"],
......@@ -60,11 +72,9 @@ async def upload_attachments(
db.add(att)
db.commit()
db.refresh(att)
results.append(_att_dict(att))
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}
......@@ -72,26 +82,20 @@ async def upload_attachments(
@router.get("/attachments/{attachment_id}/file")
def serve_attachment(
attachment_id: str,
user: User = Depends(get_current_user),
request: Request,
token: Optional[str] = Query(None),
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()
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,
)
return FileResponse(att.storage_path, media_type=att.mime_type, filename=att.original_filename)
@router.delete("/attachments/{attachment_id}")
......@@ -100,30 +104,22 @@ def delete_attachment(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete a single attachment."""
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: ChatAttachment) -> dict:
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),
}
\ No newline at end of file
"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),
}
"""
Chat CRUD and message streaming.
Chat CRUD and message streaming with multimodal attachment support.
"""
import json
......@@ -12,10 +12,10 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
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.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()
......@@ -42,30 +42,21 @@ class SendMessageBody(BaseModel):
max_tokens: int = 4096
reasoning_budget: int = 0
knowledge_base_id: Optional[str] = None
attachment_ids: list[str] = []
# ── Chat CRUD ─────────────────────────────────────
@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()
)
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,
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,
max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
)
db.add(chat)
db.commit()
......@@ -95,7 +86,6 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
if body.reasoning_budget is not None:
chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None:
# Empty string means "clear the KB"
chat.knowledge_base_id = body.knowledge_base_id or None
db.commit()
return _chat_dict(chat)
......@@ -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()
if not chat:
raise HTTPException(404)
attachment_service.delete_chat_attachments(chat_id)
db.delete(chat)
db.commit()
return {"ok": True}
......@@ -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()
if not chat:
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")
async def send_message(
chat_id: str,
body: SendMessageBody,
user: User = Depends(get_current_user),
):
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
user_id = user.id
async def generate():
......@@ -139,7 +130,6 @@ async def send_message(
db_user = db.query(User).filter(User.id == user_id).first()
# Quota check
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
......@@ -150,15 +140,33 @@ async def send_message(
db.commit()
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
# Save user message
user_msg = Message(chat_id=chat_id, role="user", content=body.content)
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()
# Build context
kb_id = body.knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
......@@ -170,14 +178,16 @@ async def send_message(
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,
}
thinking_config = {"enabled": True, "budget_tokens": body.reasoning_budget}
max_tokens = max_tokens + body.reasoning_budget
full_text = ""
......@@ -187,10 +197,8 @@ async def send_message(
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),
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", "")
......@@ -198,13 +206,11 @@ async def send_message(
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", "")
......@@ -216,29 +222,20 @@ async def send_message(
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)
# Save assistant message
assistant_msg = Message(
chat_id=chat_id,
role="assistant",
content=full_text,
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
input_tokens=input_tokens,
output_tokens=output_tokens,
input_tokens=input_tokens, output_tokens=output_tokens,
)
db.add(assistant_msg)
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.max_tokens = body.max_tokens
chat.reasoning_budget = body.reasoning_budget
......@@ -246,7 +243,6 @@ async def send_message(
chat.updated_at = datetime.utcnow()
db.commit()
# Auto title
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
......@@ -268,7 +264,7 @@ async def send_message(
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
result = await bedrock_service.invoke_model_simple(
model_id=FAST_MODEL,
......@@ -278,30 +274,32 @@ async def _generate_title(user_msg: str, ai_msg: str) -> str:
return result.strip().strip('"').strip("'")
def _sse(data: dict) -> str:
def _sse(data):
return f"data: {json.dumps(data)}\n\n"
def _chat_dict(c: Chat) -> dict:
def _chat_dict(c):
return {
"id": c.id,
"title": c.title,
"model": c.model,
"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),
"created_at": str(c.created_at), "updated_at": str(c.updated_at),
}
def _msg_dict(m: Message) -> dict:
def _msg_dict(m):
return {
"id": m.id,
"role": m.role,
"content": m.content,
"id": m.id, "role": m.role, "content": m.content,
"thinking_content": m.thinking_content,
"input_tokens": m.input_tokens,
"output_tokens": m.output_tokens,
"input_tokens": m.input_tokens, "output_tokens": m.output_tokens,
"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.
Handles images (resize + base64 for Claude vision),
videos (frame extraction via ffmpeg),
PDFs (native document support),
and text files (read content).
Handles images, videos (frame extraction), PDFs, and text files.
"""
import os
......@@ -21,8 +18,6 @@ from backend import config
os.makedirs(config.ATTACHMENT_PATH, exist_ok=True)
# ── File type detection ──────────────────────────────
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv", ".m4v"}
PDF_EXTENSIONS = {".pdf"}
......@@ -32,15 +27,13 @@ TEXT_EXTENSIONS = {
".kt", ".lua", ".gd", ".html", ".css", ".scss", ".json", ".yaml",
".yml", ".xml", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash",
".sql", ".r", ".dart", ".vue", ".svelte", ".csv", ".log", ".env",
".gitignore", ".dockerfile", ".makefile",
}
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:
"""Classify file into: image, video, document, text"""
def classify_file(filename, mime):
ext = Path(filename).suffix.lower()
if ext in IMAGE_EXTENSIONS or mime in IMAGE_MIMES:
return "image"
......@@ -51,29 +44,21 @@ def classify_file(filename: str, mime: str) -> str:
return "text"
def get_mime_type(filename: str, content_type: Optional[str] = None) -> str:
"""Determine MIME type."""
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"
# ── File storage ─────────────────────────────────────
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.
"""
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())
# Create chat-specific directory
chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
os.makedirs(chat_dir, exist_ok=True)
# Sanitize filename
safe_name = Path(filename).name.replace(" ", "_")
stored_name = f"{attachment_id}_{safe_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
with open(storage_path, "wb") as f:
f.write(content)
# Extract text for text-based files
text_extract = None
if file_type == "text":
text_extract = _extract_text_content(storage_path, filename)
text_extract = _extract_text_content(storage_path)
elif file_type == "document":
text_extract = _extract_pdf_text(storage_path)
......@@ -100,8 +84,7 @@ def save_attachment(chat_id: str, filename: str, content: bytes, content_type: O
}
def delete_attachment_file(storage_path: str):
"""Delete an attachment file from disk."""
def delete_attachment_file(storage_path):
try:
if os.path.exists(storage_path):
os.remove(storage_path)
......@@ -109,28 +92,21 @@ def delete_attachment_file(storage_path: str):
pass
def delete_chat_attachments(chat_id: str):
"""Delete all attachment files for a chat."""
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)
# ── Claude content block builders ────────────────────
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.
"""
def build_claude_content_blocks(attachments):
blocks = []
for att in attachments:
try:
file_blocks = _process_single_attachment(att)
if isinstance(file_blocks, list):
blocks.extend(file_blocks)
elif file_blocks:
blocks.append(file_blocks)
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",
......@@ -139,8 +115,7 @@ def build_claude_content_blocks(attachments: list) -> list[dict]:
return blocks
def _process_single_attachment(att) -> list[dict] | dict | None:
"""Process a single attachment into Claude content blocks."""
def _process_single_attachment(att):
if att.file_type == "image":
return _build_image_block(att)
elif att.file_type == "video":
......@@ -152,70 +127,41 @@ def _process_single_attachment(att) -> list[dict] | dict | None:
return None
def _build_image_block(att) -> dict:
"""Build an image content block. Resizes if needed."""
def _build_image_block(att):
data = _read_and_resize_image(att.storage_path, att.mime_type)
mime = att.mime_type
# Claude only accepts specific image types
if mime not in IMAGE_MIMES:
mime = "image/jpeg"
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,
},
"source": {"type": "base64", "media_type": mime, "data": data},
}
def _build_video_blocks(att) -> list[dict]:
"""Extract frames from video and build image content blocks."""
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. ffmpeg may not be available.]",
}]
blocks = [{
"type": "text",
"text": f"[Video: {att.original_filename} — {len(frames)} key frames extracted]",
}]
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,
},
"source": {"type": "base64", "media_type": "image/jpeg", "data": frame_b64},
})
return blocks
def _build_document_block(att) -> dict:
"""Build a document content block for PDFs."""
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,
},
"source": {"type": "base64", "media_type": "application/pdf", "data": data},
}
# Non-PDF documents: fall back to text
return _build_text_block(att)
def _build_text_block(att) -> dict:
"""Build a text content block from a text-based file."""
text = att.text_extract
if not text:
text = _extract_text_content(att.storage_path, att.original_filename)
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 {
......@@ -224,41 +170,28 @@ def _build_text_block(att) -> dict:
}
# ── Image processing ─────────────────────────────────
def _read_and_resize_image(path: str, mime_type: str) -> str:
"""Read an image, resize if too large, return base64 string."""
def _read_and_resize_image(path, mime_type):
try:
from PIL import Image
img = Image.open(path)
# Convert to RGB if necessary (handles RGBA, palette, etc.)
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":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if "A" in img.mode else None)
img = background
bg.paste(img, mask=img.split()[-1] if "A" in img.mode else None)
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
# Resize if either dimension exceeds max
max_dim = config.MAX_IMAGE_DIMENSION
if img.width > max_dim or img.height > max_dim:
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
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"
save_kwargs = {"quality": 85} if fmt == "JPEG" else {}
img.save(buf, format=fmt, **save_kwargs)
kwargs = {"quality": 85} if fmt == "JPEG" else {}
img.save(buf, format=fmt, **kwargs)
return base64.b64encode(buf.getvalue()).decode("utf-8")
except ImportError:
# Pillow not installed — send raw
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception:
......@@ -266,63 +199,43 @@ def _read_and_resize_image(path: str, mime_type: str) -> str:
return base64.b64encode(f.read()).decode("utf-8")
# ── Video frame extraction ───────────────────────────
def _extract_video_frames(video_path: str) -> list[str]:
"""Extract key frames from a video using ffmpeg. Returns list of base64 JPEG strings."""
def _extract_video_frames(video_path):
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
return []
max_frames = config.MAX_VIDEO_FRAMES
frames = []
try:
# Get duration
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
video_path,
],
["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):
timestamp = interval * (i + 1)
output = os.path.join(tmpdir, f"frame_{i}.jpg")
ts = interval * (i + 1)
out = os.path.join(tmpdir, f"frame_{i}.jpg")
subprocess.run(
[
"ffmpeg", "-ss", str(timestamp),
"-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",
output,
],
["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(output) and os.path.getsize(output) > 0:
with open(output, "rb") as f:
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
# ── Text extraction ──────────────────────────────────
def _extract_text_content(path: str, filename: str) -> Optional[str]:
"""Extract text from a text-based file."""
def _extract_text_content(path):
try:
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:
try:
with open(path, "r", encoding="latin-1") as f:
......@@ -333,16 +246,15 @@ def _extract_text_content(path: str, filename: str) -> Optional[str]:
return None
def _extract_pdf_text(path: str) -> Optional[str]:
"""Extract text from a PDF for storage/indexing."""
def _extract_pdf_text(path):
try:
from PyPDF2 import PdfReader
reader = PdfReader(path)
pages = []
for page in reader.pages[:100]: # Cap at 100 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
\ No newline at end of file
return None
"""
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.
Build the messages list for the Bedrock/Anthropic API from chat history.
"""
from sqlalchemy.orm import Session
from backend.models import Chat, Message
# ~100 000 tokens budget → ~400 000 characters
MAX_CONTEXT_CHARS = 400_000
# Hard cap: never load more than this many messages from the DB
MAX_MESSAGES = 80
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] = (
db.query(Message)
.filter(Message.chat_id == chat.id)
......@@ -36,7 +22,6 @@ def build_messages(chat: Chat, db: Session) -> list[dict]:
if not rows:
return []
# --- trim from the oldest to fit character budget ---
total_chars = sum(len(m.content or "") for m in rows)
idx = 0
while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2:
......@@ -45,26 +30,20 @@ def build_messages(chat: Chat, db: Session) -> list[dict]:
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":
trimmed = trimmed[1:]
# Build the final list — collapse consecutive same-role messages
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:
# Merge into previous
result[-1]["content"] += "\n" + content
else:
result.append({"role": role, "content": content})
return result
\ No newline at end of file
return result
#!/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) {
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 || `Request failed: ${res.status}`);
throw new Error(err.detail || err.message || "Request failed");
}
if (res.status === 204) return null;
return res.json();
}
/* ── Auth ─────────────────────────────────── */
export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password });
......@@ -27,12 +29,9 @@ export const register = (username, email, password) =>
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 = {}) =>
request("POST", "/chats", token, data);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data);
......@@ -46,88 +45,57 @@ export const deleteChat = (token, chatId) =>
export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token);
/* ── Streaming message (accepts AbortSignal) ─ */
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,
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 {
/* skip malformed */
}
try { yield JSON.parse(line.slice(6)); } catch { }
}
}
}
// flush
if (buffer.trim().startsWith("data: ")) {
try {
yield JSON.parse(buffer.trim().slice(6));
} catch {
/* skip */
}
try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
}
}
/* ── File Uploads (Attachments) ───────────── */
export async function uploadAttachments(token, chatId, files) {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
// Do NOT set Content-Type — browser sets it with boundary for multipart
},
body: formData,
method: "POST", headers: authHeader(token), body: form,
});
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");
}
return res.json();
}
export const listAttachments = (token, chatId) =>
request("GET", `/chats/${chatId}/attachments`, token);
export function getAttachmentPreviewUrl(chatId, attachmentId) {
return `${BASE}/chats/${chatId}/attachments/${attachmentId}/preview`;
export function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`;
}
/* ── Knowledge Bases ───────────────────────── */
export const listKnowledgeBases = (token) =>
request("GET", "/knowledge", token);
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 });
......@@ -139,50 +107,53 @@ export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocuments(token, kbId, files) {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${BASE}/knowledge/${kbId}/documents`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
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(() => ({ detail: res.statusText }));
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json();
}
export const deleteDocument = (token, kbId, docId) =>
request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export const uploadDocument = (token, kbId, file) =>
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) =>
request("PUT", `/admin/users/${userId}`, token, data);
/* ── Download Zip ──────────────────────────── */
export async function downloadZip(token, content) {
const res = await fetch(`${BASE}/download-zip`, {
method: "POST",
headers: headers(token),
body: JSON.stringify({ content }),
});
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
if (!res.ok) throw new Error("Download failed");
export const adminListChats = (token) => request("GET", "/admin/chats", token);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "code-files.zip";
a.click();
URL.revokeObjectURL(url);
}
\ No newline at end of file
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);
}
}
import React, { useEffect, useRef, useState, useCallback } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import { getMessages, downloadZip, uploadAttachments } from "../api";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import FileUploadButton from "./FileUploadButton";
import AttachmentPreview from "./AttachmentPreview";
import {
Send,
Square,
Download,
ChevronDown,
Loader2,
Paperclip,
} from "lucide-react";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, Image, FileText, Film, Loader2 } from "lucide-react";
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const [input, setInput] = useState("");
const [pendingFiles, setPendingFiles] = useState([]); // File objects waiting to be sent
const [uploadingFiles, setUploadingFiles] = useState(false);
const [model, setModel] = useState(
() =>
state.chats.find((c) => c.id === chatId)?.model ||
"us.anthropic.claude-sonnet-4-20250514"
);
const [maxTokens, setMaxTokens] = useState(
() => state.chats.find((c) => c.id === chatId)?.max_tokens || 16384
);
const [reasoningBudget, setReasoningBudget] = useState(
() => state.chats.find((c) => c.id === chatId)?.reasoning_budget || 10000
);
const [selectedKbId, setSelectedKbId] = useState(
() => state.chats.find((c) => c.id === chatId)?.knowledge_base_id || ""
);
const [showSettings, setShowSettings] = useState(false);
const 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)" },
];
const scrollContainerRef = useRef(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef(null);
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];
// Per-chat streaming state — subscribe to stream manager
const [streamData, setStreamData] = useState(() =>
streamManager.getStreamData(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(() => {
// Initial read
setStreamData(streamManager.getStreamData(chatId));
// Subscribe to updates for THIS chat
const unsub = streamManager.subscribe(chatId, () => {
setStreamData(streamManager.getStreamData(chatId));
});
return unsub;
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]);
// Load messages on mount
useEffect(() => {
if (!state.chatMessages[chatId]) {
getMessages(state.token, chatId)
.then((msgs) => dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }))
.catch(() => {});
}
}, [chatId, state.token, dispatch, state.chatMessages]);
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;
});
}, []);
// Auto-scroll
useEffect(() => {
if (shouldAutoScrollRef.current && scrollContainerRef.current) {
const el = scrollContainerRef.current;
el.scrollTop = el.scrollHeight;
}
}, [messages, streamData]);
(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 handleContainerScroll() {
const el = scrollContainerRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
shouldAutoScrollRef.current = nearBottom;
function toggleSettings() {
if (showSettings) saveSettings();
setShowSettings(!showSettings);
}
function scrollToBottom() {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight;
shouldAutoScrollRef.current = true;
}
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 = "";
}
// THIS chat streaming — only blocks sends for THIS chat, not others
const isChatStreaming = !!state.activeStreams[chatId];
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 === 0) || isChatStreaming) return;
// 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;
if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
const text = content || "Please analyze the attached file(s).";
// Upload files if any
let attachmentIds = [];
if (pendingFiles.length > 0) {
setUploadingFiles(true);
let attIds = [], uploaded = [];
if (pendingFiles.length) {
setUploading(true);
try {
const uploaded = await uploadAttachments(
state.token,
chatId,
pendingFiles
);
attachmentIds = uploaded.map((a) => a.id);
} catch (err) {
// Add error message
dispatch({
type: "ADD_MESSAGE",
chatId,
message: {
id: `err-${Date.now()}`,
role: "assistant",
content: `**Upload failed:** ${err.message}`,
created_at: new Date().toISOString(),
},
});
setUploadingFiles(false);
return;
}
setUploadingFiles(false);
setPendingFiles([]);
} else {
setPendingFiles([]);
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);
}
// Sync settings to store
dispatch({
type: "UPDATE_CHAT",
chat: {
id: chatId,
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);
}
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;
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
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 handleFilesSelected(files) {
setPendingFiles((prev) => [...prev, ...Array.from(files)]);
}
function handleRemovePendingFile(index) {
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
}
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handlePaste(e) {
const items = e.clipboardData?.items;
if (!items) return;
const files = [];
for (const item of items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
e.preventDefault();
handleFilesSelected(files);
}
}
function handleDrop(e) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return;
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFilesSelected(files);
}
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
}
function handleDragOver(e) {
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
}
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 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={handleDragOver}
>
{/* Header Bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-anton-border bg-anton-surface/50 backdrop-blur-sm">
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold text-white truncate max-w-[200px]">
{state.chats.find((c) => c.id === chatId)?.title || "New Chat"}
</h2>
{isChatStreaming && (
<span className="flex items-center gap-1 text-xs text-anton-accent">
<Loader2 size={12} className="animate-spin" />
Streaming
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowSettings(!showSettings)}
className="text-xs text-anton-muted hover:text-white px-2 py-1 rounded hover:bg-anton-card transition"
>
⚙ Settings
</button>
<button
onClick={handleDownloadAll}
className="flex items-center gap-1 text-xs text-anton-muted hover:text-anton-accent px-2 py-1 rounded hover:bg-anton-accent/10 transition"
>
<Download size={12} />
Export
</button>
</div>
<div 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>
{/* Settings Panel */}
{showSettings && (
<div className="px-4 py-3 border-b border-anton-border bg-anton-surface/80 backdrop-blur-sm animate-fade-in">
<div className="flex flex-wrap gap-4 items-end">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-anton-muted uppercase tracking-wider">
Model
</span>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent"
>
<option value="us.anthropic.claude-sonnet-4-20250514">
Claude Sonnet 4
</option>
<option value="us.anthropic.claude-opus-4-20250514">
Claude Opus 4
</option>
<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>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-anton-muted uppercase tracking-wider">
Max Tokens
</span>
<input
type="number"
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value))}
min={256}
max={128000}
className="bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white w-24 focus:outline-none focus:border-anton-accent"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-anton-muted uppercase tracking-wider">
Reasoning Budget
</span>
<input
type="number"
value={reasoningBudget}
onChange={(e) => setReasoningBudget(Number(e.target.value))}
min={1024}
max={64000}
className="bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white w-24 focus:outline-none focus:border-anton-accent"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-anton-muted uppercase tracking-wider">
Knowledge Base
</span>
<select
value={selectedKbId}
onChange={(e) => setSelectedKbId(e.target.value)}
className="bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent"
>
</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>
{(state.knowledgeBases || []).map((kb) => (
<option key={kb.id} value={kb.id}>
{kb.name}
</option>
))}
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
</label>
</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 && (
<div className="px-4 py-2 border-t border-anton-border bg-anton-surface/50">
<div className="flex flex-wrap gap-2">
{pendingFiles.map((file, idx) => (
<AttachmentPreview
key={`${file.name}-${idx}`}
file={file}
onRemove={() => handleRemovePendingFile(idx)}
isPending
/>
{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>
)}
{/* 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">
<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">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
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";
}}
/>
<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={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 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 === 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 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 justify-between mt-2">
<span className="text-[10px] text-anton-muted">
{pendingFiles.length > 0 && (
<span className="text-anton-accent">
{pendingFiles.length} file{pendingFiles.length > 1 ? "s" : ""}{" "}
attached •{" "}
</span>
)}
Shift+Enter for new line • Paste or drag files to attach
</span>
<span className="text-[10px] text-anton-muted">
{Object.keys(state.activeStreams).length > 0 && (
<span className="text-anton-accent">
{Object.keys(state.activeStreams).length} active stream
{Object.keys(state.activeStreams).length > 1 ? "s" : ""}
</span>
)}
</span>
<div 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>
);
}
/** 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";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";
import AttachmentPreview from "./AttachmentPreview";
import { getAttachmentUrl } from "../api";
import {
User,
Flame,
Copy,
Check,
ChevronDown,
ChevronRight,
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink,
} from "lucide-react";
export default function MessageBubble({
message,
chatId,
isStreaming = false,
isThinking = false,
}) {
const isUser = message.role === "user";
const [copied, setCopied] = useState(false);
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(message.content || "");
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const attachments = message.attachments || [];
const hasAttachments = attachments && attachments.length > 0;
return (
<div
className={`flex gap-3 animate-fade-in ${
isUser ? "justify-end" : "justify-start"
}`}
>
{/* Avatar — assistant only */}
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!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">
<Flame size={14} className="text-anton-accent" />
<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%] 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={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{thinking_content && (
<div className="mb-2">
<button
onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-anton-accent transition"
>
{showThinking ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
{isThinking ? "Thinking..." : "Thought process"}
<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 && (
<div className="mt-1 pl-3 border-l-2 border-anton-accent/20 text-xs text-anton-muted leading-relaxed whitespace-pre-wrap">
{message.thinking_content}
{(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>
)}
{/* Thinking indicator during streaming */}
{isStreaming && isThinking && !message.thinking_content && (
<div className="flex items-center gap-2 text-xs text-anton-accent mb-2">
<div className="flex gap-1">
<span
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
Thinking...
{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>
)}
{/* Main content */}
{message.content && (
<div className="prose prose-invert prose-sm max-w-none break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
<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 lang = match ? match[1] : "";
if (!inline && (match || String(children).includes("\n"))) {
// Extract filename from lang if format is "lang:path/to/file.ext"
let language = lang;
let filename = "";
if (lang && lang.includes(":")) {
const parts = lang.split(":");
language = parts[0];
filename = parts.slice(1).join(":");
}
return (
<CodeBlock
code={String(children).replace(/\n$/, "")}
language={language}
filename={filename}
/>
);
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 (
<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>
);
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />;
},
td({ children }) {
return (
<td className="border border-anton-border px-3 py-1.5">
{children}
</td>
);
},
}}
>
{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
</>
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>
{/* Avatar — user only */}
{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">
<User size={14} className="text-anton-muted" />
<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>
);
}
\ 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";
// chatId -> { text, thinking, isThinking, abortController }
const _streams = new Map();
// chatId -> Set<() => void>
const _listeners = new Map();
// Store dispatch reference, set once from AppProvider
let _dispatch = null;
export function setDispatch(dispatch) {
_dispatch = dispatch;
}
export function setDispatch(dispatch) { _dispatch = dispatch; }
/** Read current stream data for a chat (non-reactive, call from inside subscriber) */
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,
};
return { 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 isAnyStreaming() {
return _streams.size > 0;
}
/** Subscribe to stream data changes for a specific chat. Returns unsubscribe fn. */
export function subscribe(chatId, callback) {
export function subscribe(chatId, cb) {
if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
_listeners.get(chatId).add(callback);
return () => {
const set = _listeners.get(chatId);
if (set) {
set.delete(callback);
if (set.size === 0) _listeners.delete(chatId);
}
};
_listeners.get(chatId).add(cb);
return () => { const s = _listeners.get(chatId); if (s) { s.delete(cb); if (!s.size) _listeners.delete(chatId); } };
}
function _notify(chatId) {
const set = _listeners.get(chatId);
if (set) set.forEach((cb) => cb());
}
function _notify(id) { const s = _listeners.get(id); if (s) s.forEach((cb) => cb()); }
/** Abort a running stream for a chat */
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 });
}
if (s) { 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 }) {
// Abort existing stream for THIS chat only (other chats keep streaming)
if (_streams.has(chatId)) {
abortStream(chatId);
}
const abortController = new AbortController();
const streamState = {
text: "",
thinking: "",
isThinking: false,
abortController,
};
_streams.set(chatId, streamState);
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);
// Fire and forget — runs independently of React
(async () => {
const s = _streams.get(chatId);
if (!s) return;
let usage = {}, msgId = "";
try {
const gen = streamMessage(token, chatId, body, abortController.signal);
for await (const event of gen) {
const s = _streams.get(chatId);
if (!s) break; // stream was aborted
if (event.type === "thinking") {
s.thinking += event.content || "";
s.isThinking = true;
} else if (event.type === "text") {
s.text += event.content || "";
s.isThinking = false;
} else if (event.type === "done") {
// Final message — add to store
if (_dispatch && event.message) {
_dispatch({
type: "ADD_MESSAGE",
chatId,
message: event.message,
});
}
// Auto-title
if (_dispatch && event.title) {
_dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, title: event.title },
});
}
} else if (event.type === "error") {
s.text += `\n\n**Error:** ${event.content || "Unknown error"}`;
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;
}
_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) {
if (err.name !== "AbortError") {
const s = _streams.get(chatId);
if (s) {
s.text += `\n\n**Stream error:** ${err.message}`;
_notify(chatId);
}
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);
_streams.delete(chatId); _notify(chatId);
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