Commit 8bb1768b authored by Mahmoud Aglan's avatar Mahmoud Aglan

gooooo

parent f90d5b94
{
"permissions": {
"allow": [
"Bash(npx vite:*)",
"Bash(node -e ':*)",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('backend/services/generation_manager.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('backend/routes/auth_routes.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")"
]
}
}
#!/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
"""
Chat attachment upload, serve, and delete routes.
"""
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
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()
@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),
):
"""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")
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"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB.",
})
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 to upload {filename}: {str(e)}"})
return {"attachments": results}
@router.get("/attachments/{attachment_id}/file")
def serve_attachment(
attachment_id: str,
token: Optional[str] = Query(None),
user: Optional[User] = Depends(_optional_current_user),
db: Session = Depends(get_db),
):
"""
Serve an attachment file.
Supports both Bearer header auth and ?token= query param
(needed for <img> tags that can't send headers).
"""
# Try query param auth if header auth didn't work
if user is None and token:
try:
payload = decode_token(token)
user = db.query(User).filter(User.id == payload["sub"]).first()
except Exception:
pass
if user is None:
raise HTTPException(401, "Authentication required")
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),
):
"""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 _optional_current_user(
db: Session = Depends(get_db),
):
"""
A dependency that tries to get current user but returns None on failure.
This allows the endpoint to also accept ?token= query param.
"""
# This is a placeholder — the actual auth is handled in the route
# by checking both header and query param
return None
def _att_dict(att: ChatAttachment) -> dict:
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
"""
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_from_request(request: Request, db: Session, token_param: Optional[str] = None) -> User:
"""
Resolve user from either:
1. Authorization: Bearer <token> header
2. ?token=<token> query parameter (for img/video tags)
"""
raw_token = None
# Try header first
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
raw_token = auth_header[7:]
# Fall back to query param
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),
):
"""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")
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"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB.",
})
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 to upload {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),
):
"""
Serve an attachment file.
Supports both Bearer header auth and ?token= query param
(needed for <img> tags that can't send headers).
"""
user = _get_user_from_request(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),
):
"""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:
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
"""
Son of Anton — Attachment Upload Routes
Handles file uploads for chat messages.
"""
import os
import uuid
import shutil
from pathlib import Path
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form
from typing import List
from ..auth import get_current_user
from ..database import get_db
from ..services.file_processor import classify_media, validate_file
router = APIRouter(prefix="/chats/{chat_id}/attachments", tags=["attachments"])
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
@router.post("")
async def upload_attachments(
chat_id: str,
files: List[UploadFile] = File(...),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""Upload one or more files to a chat. Returns attachment metadata."""
# Verify chat belongs to user
chat = await db.fetchrow(
"SELECT id, user_id FROM chats WHERE id = $1", uuid.UUID(chat_id)
)
if not chat:
raise HTTPException(404, "Chat not found")
if str(chat["user_id"]) != str(user["id"]):
raise HTTPException(403, "Not your chat")
if len(files) > 10:
raise HTTPException(400, "Maximum 10 files per upload")
results = []
for f in files:
# Read file content to get size
content = await f.read()
size = len(content)
# Validate
ok, error = validate_file(f.filename or "file", f.content_type or "", size)
if not ok:
raise HTTPException(400, f"File '{f.filename}': {error}")
media_type = classify_media(f.content_type or "")
# Generate unique storage filename
ext = Path(f.filename or "file").suffix or ".bin"
storage_name = f"{uuid.uuid4().hex}{ext}"
chat_dir = Path(UPLOAD_DIR) / chat_id
chat_dir.mkdir(parents=True, exist_ok=True)
storage_path = f"{chat_id}/{storage_name}"
full_path = chat_dir / storage_name
# Write file
full_path.write_bytes(content)
# Insert DB record
att = await db.fetchrow(
"""
INSERT INTO attachments (chat_id, user_id, filename, original_filename, mime_type, file_size, media_type, storage_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, filename, original_filename, mime_type, file_size, media_type, created_at
""",
uuid.UUID(chat_id),
user["id"],
storage_name,
f.filename or "file",
f.content_type or "application/octet-stream",
size,
media_type,
storage_path,
)
results.append({
"id": str(att["id"]),
"filename": att["original_filename"],
"mime_type": att["mime_type"],
"file_size": att["file_size"],
"media_type": att["media_type"],
"created_at": att["created_at"].isoformat(),
})
return results
@router.get("")
async def list_attachments(
chat_id: str,
user=Depends(get_current_user),
db=Depends(get_db),
):
"""List all attachments for a chat."""
chat = await db.fetchrow(
"SELECT id, user_id FROM chats WHERE id = $1", uuid.UUID(chat_id)
)
if not chat:
raise HTTPException(404, "Chat not found")
if str(chat["user_id"]) != str(user["id"]):
raise HTTPException(403, "Not your chat")
rows = await db.fetch(
"""
SELECT id, original_filename as filename, mime_type, file_size, media_type, message_id, created_at
FROM attachments WHERE chat_id = $1 ORDER BY created_at
""",
uuid.UUID(chat_id),
)
return [dict(r) for r in rows]
@router.get("/{attachment_id}/preview")
async def preview_attachment(
chat_id: str,
attachment_id: str,
user=Depends(get_current_user),
db=Depends(get_db),
):
"""Return raw file content for preview (images mainly)."""
from fastapi.responses import FileResponse
att = await db.fetchrow(
"""
SELECT a.*, c.user_id as chat_owner
FROM attachments a JOIN chats c ON a.chat_id = c.id
WHERE a.id = $1 AND a.chat_id = $2
""",
uuid.UUID(attachment_id),
uuid.UUID(chat_id),
)
if not att:
raise HTTPException(404, "Attachment not found")
if str(att["chat_owner"]) != str(user["id"]):
raise HTTPException(403, "Not your attachment")
full_path = Path(UPLOAD_DIR) / att["storage_path"]
if not full_path.exists():
raise HTTPException(404, "File not found on disk")
return FileResponse(
path=str(full_path),
media_type=att["mime_type"],
filename=att["original_filename"],
)
\ No newline at end of file
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
Authentication routes: register, login, profile — with permissions and registration toggle. Authentication routes: register, login, profile — with permissions and registration toggle.
""" """
from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
...@@ -84,6 +86,11 @@ def login(body: LoginBody, db: Session = Depends(get_db)): ...@@ -84,6 +86,11 @@ def login(body: LoginBody, db: Session = Depends(get_db)):
@router.get("/me") @router.get("/me")
def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)): def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
now = datetime.utcnow()
if user.quota_reset_date and now >= user.quota_reset_date:
user.tokens_used_this_month = 0
user.quota_reset_date = datetime(now.year + 1, 1, 1) if now.month == 12 else datetime(now.year, now.month + 1, 1)
db.commit()
perms = get_user_permissions(user.id, db) perms = get_user_permissions(user.id, db)
return _user_dict(user, perms) return _user_dict(user, perms)
......
...@@ -352,7 +352,7 @@ async def commit_from_chat( ...@@ -352,7 +352,7 @@ async def commit_from_chat(
except gitlab_service.GitLabError as e: except gitlab_service.GitLabError as e:
# Check if it's a "already exists" type error (file was committed despite error) # Check if it's a "already exists" type error (file was committed despite error)
error_detail = str(e.detail).lower() error_detail = str(e.detail).lower()
if "a]ready exists" in error_detail or "already been taken" in error_detail: if "already exists" in error_detail or "already been taken" in error_detail:
# Files might have been committed — retry with update action # Files might have been committed — retry with update action
for a in actions: for a in actions:
if a["action"] == "create": if a["action"] == "create":
......
"""
SON OF ANTON — INTEGRATION PATCH FOR YOUR EXISTING MESSAGE ROUTE
This is NOT a standalone file. These are the functions and code blocks
you need to ADD to your existing message-sending route (the one that
handles POST /chats/{chat_id}/messages and streams SSE back).
--- STEP 1: Add these imports at the top of your messages route file ---
"""
# Add to your imports:
import os
from ..services.file_processor import build_content_blocks_for_attachments
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
"""
--- STEP 2: In your request body model/parsing, add attachment_ids ---
Your existing body probably looks like:
{ content, model, max_tokens, reasoning_budget, knowledge_base_id }
Add:
attachment_ids: list[str] = []
So it becomes:
{ content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids }
"""
"""
--- STEP 3: Where you build the Claude messages array, replace the simple
text content with a content block array when attachments exist ---
BEFORE (you probably have something like):
user_message_content = body.content
# or
messages_for_claude.append({"role": "user", "content": body.content})
AFTER (replace with):
"""
async def build_user_content(db, body_content: str, attachment_ids: list, chat_id: str):
"""
Build Claude content blocks for a user message.
If there are attachments, returns a list of content blocks.
If no attachments, returns the plain text string (backward compatible).
"""
if not attachment_ids:
return [{"text": body_content}]
import uuid as uuid_mod
# Fetch attachment records
att_uuids = [uuid_mod.UUID(aid) for aid in attachment_ids]
attachments = await db.fetch(
"""
SELECT id, filename, original_filename, mime_type, file_size, media_type, storage_path
FROM attachments
WHERE id = ANY($1) AND chat_id = $2
""",
att_uuids,
uuid_mod.UUID(chat_id),
)
attachments = [dict(a) for a in attachments]
# Build content blocks: text first, then file blocks
content_blocks = []
# Add the text message
if body_content.strip():
content_blocks.append({"text": body_content})
# Add file content blocks
file_blocks = build_content_blocks_for_attachments(attachments, UPLOAD_DIR)
content_blocks.extend(file_blocks)
# If no text was provided, add a default prompt
if not body_content.strip():
content_blocks.insert(0, {"text": "Please describe and analyze the attached file(s)."})
# Link attachments to the message (do after message is saved)
return content_blocks
"""
--- STEP 4: In your Claude API call, use the content blocks ---
Instead of:
{"role": "user", "content": "user text here"}
Use:
{"role": "user", "content": content_blocks}
Where content_blocks comes from build_user_content() above.
--- STEP 5: After saving the user message to DB, link attachments ---
"""
async def link_attachments_to_message(db, attachment_ids: list, message_id):
"""Call this after inserting the user message into your messages table."""
import uuid as uuid_mod
if attachment_ids:
await db.execute(
"UPDATE attachments SET message_id = $1 WHERE id = ANY($2)",
message_id,
[uuid_mod.UUID(aid) for aid in attachment_ids],
)
"""
--- STEP 6: Register the attachments router in your main app file ---
In your main.py or app.py, add:
from .routes.attachments import router as attachments_router
app.include_router(attachments_router, prefix="/api")
--- STEP 7: Also serve uploaded files statically (optional, for image previews) ---
from fastapi.staticfiles import StaticFiles
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
--- DONE. That's it for the backend. ---
"""
\ No newline at end of file
"""
Son of Anton — File Processor
Handles classification, validation, and Claude content-block generation
for uploaded files (images, videos, documents).
"""
import base64
import mimetypes
from pathlib import Path
# Claude Bedrock supported formats
IMAGE_FORMATS = {"image/jpeg", "image/png", "image/gif", "image/webp"}
VIDEO_FORMATS = {"video/mp4", "video/webm", "video/mov", "video/mpeg", "video/mkv",
"video/x-matroska", "video/quicktime", "video/x-flv",
"video/x-ms-wmv", "video/3gpp"}
DOCUMENT_FORMATS = {
"application/pdf": "pdf",
"text/csv": "csv",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"text/html": "html",
"text/plain": "txt",
"text/markdown": "md",
}
# Max sizes (bytes)
MAX_IMAGE_SIZE = 20 * 1024 * 1024 # 20MB
MAX_VIDEO_SIZE = 100 * 1024 * 1024 # 100MB (Claude limit ~25MB for video in message)
MAX_DOCUMENT_SIZE = 50 * 1024 * 1024 # 50MB
ALLOWED_MIMES = IMAGE_FORMATS | VIDEO_FORMATS | set(DOCUMENT_FORMATS.keys())
def classify_media(mime_type: str) -> str:
"""Classify a MIME type into a media category."""
if mime_type in IMAGE_FORMATS:
return "image"
if mime_type in VIDEO_FORMATS:
return "video"
if mime_type in DOCUMENT_FORMATS:
return "document"
if mime_type and mime_type.startswith("text/"):
return "text"
return "unknown"
def get_max_size(media_type: str) -> int:
"""Return max allowed file size in bytes for a media type."""
return {
"image": MAX_IMAGE_SIZE,
"video": MAX_VIDEO_SIZE,
"document": MAX_DOCUMENT_SIZE,
"text": MAX_DOCUMENT_SIZE,
}.get(media_type, MAX_DOCUMENT_SIZE)
def validate_file(filename: str, content_type: str, size: int) -> tuple[bool, str]:
"""Validate an uploaded file. Returns (ok, error_message)."""
if not content_type:
guessed, _ = mimetypes.guess_type(filename)
content_type = guessed or "application/octet-stream"
media_type = classify_media(content_type)
if media_type == "unknown":
return False, f"Unsupported file type: {content_type}. Supported: images, videos, PDF, Office docs, text files."
max_size = get_max_size(media_type)
if size > max_size:
return False, f"File too large ({size / 1024 / 1024:.1f}MB). Max for {media_type}: {max_size / 1024 / 1024:.0f}MB."
return True, ""
def mime_to_claude_format(mime_type: str, media_type: str) -> str:
"""Convert MIME type to Claude's format string."""
if media_type == "image":
return mime_type.split("/")[1] # jpeg, png, gif, webp
if media_type == "video":
mapping = {
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"video/mov": "mov",
"video/mpeg": "mpeg",
"video/mkv": "mkv",
"video/x-matroska": "mkv",
"video/x-flv": "flv",
"video/x-ms-wmv": "wmv",
"video/3gpp": "three_gp",
}
return mapping.get(mime_type, "mp4")
if media_type == "document":
return DOCUMENT_FORMATS.get(mime_type, "txt")
return "txt"
def build_content_block(file_path: str, mime_type: str, media_type: str, original_filename: str) -> dict:
"""
Build a Claude Converse API content block for a file.
Returns a dict that goes directly into the message content array.
"""
path = Path(file_path)
if not path.exists():
return {"text": f"[Attachment missing: {original_filename}]"}
file_bytes = path.read_bytes()
fmt = mime_to_claude_format(mime_type, media_type)
if media_type == "image":
return {
"image": {
"format": fmt,
"source": {
"bytes": file_bytes
}
}
}
elif media_type == "video":
return {
"video": {
"format": fmt,
"source": {
"bytes": file_bytes
}
}
}
elif media_type == "document":
return {
"document": {
"format": fmt,
"name": Path(original_filename).stem[:200],
"source": {
"bytes": file_bytes
}
}
}
elif media_type == "text":
try:
text_content = file_bytes.decode("utf-8", errors="replace")
except Exception:
text_content = "[Could not decode text file]"
return {
"text": f"--- Content of {original_filename} ---\n{text_content}\n--- End of {original_filename} ---"
}
else:
return {"text": f"[Unsupported attachment: {original_filename}]"}
def build_content_blocks_for_attachments(attachments: list, upload_dir: str) -> list[dict]:
"""
Given a list of attachment DB records, build all Claude content blocks.
"""
blocks = []
for att in attachments:
file_path = str(Path(upload_dir) / att["storage_path"])
block = build_content_block(
file_path=file_path,
mime_type=att["mime_type"],
media_type=att["media_type"],
original_filename=att["original_filename"],
)
blocks.append(block)
return blocks
\ No newline at end of file
...@@ -15,7 +15,9 @@ from backend.services import bedrock_service, memory_service, rag_service, attac ...@@ -15,7 +15,9 @@ from backend.services import bedrock_service, memory_service, rag_service, attac
_tree_cache: dict[str, tuple[float, list[dict]]] = {} _tree_cache: dict[str, tuple[float, list[dict]]] = {}
TREE_CACHE_TTL = 600 TREE_CACHE_TTL = 600
TREE_CACHE_MAX = 50
_chat_file_history: dict[str, set[str]] = {} _chat_file_history: dict[str, set[str]] = {}
CHAT_HISTORY_MAX = 200
def _get_tree_cache(repo_id, branch): def _get_tree_cache(repo_id, branch):
...@@ -28,7 +30,14 @@ def _get_tree_cache(repo_id, branch): ...@@ -28,7 +30,14 @@ def _get_tree_cache(repo_id, branch):
def _set_tree_cache(repo_id, branch, tree): def _set_tree_cache(repo_id, branch, tree):
_tree_cache[f"{repo_id}:{branch}"] = (time.time(), tree) now = time.time()
expired = [k for k, (ts, _) in _tree_cache.items() if now - ts >= TREE_CACHE_TTL]
for k in expired:
del _tree_cache[k]
if len(_tree_cache) >= TREE_CACHE_MAX:
oldest = min(_tree_cache, key=lambda k: _tree_cache[k][0])
del _tree_cache[oldest]
_tree_cache[f"{repo_id}:{branch}"] = (now, tree)
@dataclass @dataclass
...@@ -109,6 +118,8 @@ class GenerationManager: ...@@ -109,6 +118,8 @@ class GenerationManager:
lines.append(f"\n━━━ {f['path']} ━━━"); lines.append(f["content"]); lines.append(f"━━━ end ━━━") lines.append(f"\n━━━ {f['path']} ━━━"); lines.append(f["content"]); lines.append(f"━━━ end ━━━")
return "\n".join(lines) return "\n".join(lines)
# ── Orchestrator ──────────────────────────────────────────────
async def _run(self, state, chat_id, user_id, content, model_id, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False): async def _run(self, state, chat_id, user_id, content, model_id, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False):
db = SessionLocal() db = SessionLocal()
try: try:
...@@ -116,52 +127,13 @@ class GenerationManager: ...@@ -116,52 +127,13 @@ class GenerationManager:
if not chat: if not chat:
state.events.append({"type": "error", "message": "Chat not found"}); return state.events.append({"type": "error", "message": "Chat not found"}); return
db_user = db.query(User).filter(User.id == user_id).first() 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: if not self._check_quota(db, db_user):
db_user.tokens_used_this_month = 0
db_user.quota_reset_date = datetime(now.year + 1, 1, 1) if now.month == 12 else datetime(now.year, now.month + 1, 1)
db.commit()
if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly:
state.events.append({"type": "error", "message": "Monthly quota exceeded."}); return state.events.append({"type": "error", "message": "Monthly quota exceeded."}); return
attachments = [] attachments = self._store_user_message(db, chat_id, content, attachment_ids)
if attachment_ids: system_prompt = await self._gather_context(state, db, chat, content, knowledge_base_id, web_search)
attachments = db.query(ChatAttachment).filter(ChatAttachment.id.in_(attachment_ids), ChatAttachment.chat_id == chat_id).all() messages = self._prepare_messages(db, chat, content, attachments)
stored_content = 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" + 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 = knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
try: rag_context = rag_service.query(kb_id, content, n_results=8)
except Exception: pass
repo_context = await self._build_repo_context(db, chat, content)
attachment_context = memory_service.gather_attachment_context(chat_id, db)
# Web search
web_context = None
if web_search:
try:
from backend.services.web_search_service import search_web
state.events.append({"type": "status", "message": "Searching the web..."})
web_context = await search_web(content, num_results=8, fetch_pages=3)
except Exception as e:
web_context = f"[Web search failed: {str(e)[:100]}]"
system_prompt = build_full_prompt(rag_context=rag_context, repo_context=repo_context, attachment_context=attachment_context, web_search_context=web_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": content})
messages[-1]["content"] = content_blocks
effective_max = max_tokens effective_max = max_tokens
thinking_config = None thinking_config = None
...@@ -169,54 +141,169 @@ class GenerationManager: ...@@ -169,54 +141,169 @@ class GenerationManager:
thinking_config = {"enabled": True, "budget_tokens": reasoning_budget} thinking_config = {"enabled": True, "budget_tokens": reasoning_budget}
effective_max = max_tokens + reasoning_budget effective_max = max_tokens + reasoning_budget
full_text = ""; full_thinking = ""; input_tokens = 0; output_tokens = 0; current_block_type = "text" full_text, full_thinking, input_tokens, output_tokens = await self._stream_response(
state, messages, system_prompt, model_id, effective_max, thinking_config,
async for event in bedrock_service.stream_response(messages=messages, system_prompt=system_prompt, model_id=model_id, max_tokens=min(effective_max, 65536), thinking_config=thinking_config): )
if state.done.is_set(): break
evt_type = event.get("type", "") await self._save_response(
if evt_type == "message_start": db, state, chat, db_user, chat_id, content,
usage = event.get("message", {}).get("usage", {}); input_tokens = usage.get("input_tokens", 0) full_text, full_thinking, input_tokens, output_tokens,
elif evt_type == "content_block_start": model_id, max_tokens, reasoning_budget, knowledge_base_id,
current_block_type = event.get("content_block", {}).get("type", "text") )
if current_block_type == "thinking": state.events.append({"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; state.events.append({"type": "thinking_delta", "content": t})
elif dt == "text_delta":
t = delta.get("text", ""); full_text += t; state.events.append({"type": "text_delta", "content": t})
elif evt_type == "content_block_stop":
if current_block_type == "thinking": state.events.append({"type": "thinking_end"})
elif evt_type == "message_delta":
output_tokens = event.get("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 = max_tokens; chat.reasoning_budget = reasoning_budget
chat.knowledge_base_id = knowledge_base_id or None; chat.updated_at = datetime.utcnow()
db.commit()
state.message_id = assistant_msg.id
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
title = await self._generate_title(content, full_text[:300])
chat.title = title[:120]; db.commit()
state.events.append({"type": "title_update", "title": chat.title})
except Exception: pass
state.events.append({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
state.events.append({"type": "done", "message_id": assistant_msg.id})
except Exception as exc: except Exception as exc:
state.events.append({"type": "error", "message": str(exc)}); state.error = str(exc) state.events.append({"type": "error", "message": str(exc)}); state.error = str(exc)
finally: finally:
state.done.set(); db.close() state.done.set(); db.close()
await asyncio.sleep(120); self._active.pop(chat_id, None) await asyncio.sleep(120); self._active.pop(chat_id, None)
if len(_chat_file_history) > CHAT_HISTORY_MAX:
inactive = [k for k in _chat_file_history if k not in self._active]
for k in inactive[:len(_chat_file_history) - CHAT_HISTORY_MAX]:
del _chat_file_history[k]
# ── Quota ──────────────────────────────────────────────────
def _check_quota(self, db, db_user):
"""Reset quota if period elapsed, return True if under limit."""
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
db_user.quota_reset_date = datetime(now.year + 1, 1, 1) if now.month == 12 else datetime(now.year, now.month + 1, 1)
db.commit()
return db_user.tokens_used_this_month < db_user.quota_tokens_monthly
# ── User message ───────────────────────────────────────────
def _store_user_message(self, db, chat_id, content, attachment_ids):
"""Create the user Message row, link attachments, return attachment list."""
attachments = []
if attachment_ids:
attachments = db.query(ChatAttachment).filter(
ChatAttachment.id.in_(attachment_ids), ChatAttachment.chat_id == chat_id,
).all()
stored_content = 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" + 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()
return attachments
# ── Context gathering ──────────────────────────────────────
async def _gather_context(self, state, db, chat, content, knowledge_base_id, web_search):
"""Collect RAG, repo, attachment, and web contexts; return assembled system prompt."""
kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
try: rag_context = rag_service.query(kb_id, content, n_results=8)
except Exception: pass
repo_context = await self._build_repo_context(db, chat, content)
attachment_context = memory_service.gather_attachment_context(chat.id, db)
web_context = None
if web_search:
try:
from backend.services.web_search_service import search_web
state.events.append({"type": "status", "message": "Searching the web..."})
web_context = await search_web(content, num_results=8, fetch_pages=3)
except Exception as e:
web_context = f"[Web search failed: {str(e)[:100]}]"
return build_full_prompt(
rag_context=rag_context, repo_context=repo_context,
attachment_context=attachment_context, web_search_context=web_context,
)
# ── Message preparation ────────────────────────────────────
def _prepare_messages(self, db, chat, content, attachments):
"""Build the message list for Bedrock, injecting attachment content blocks."""
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": content})
messages[-1]["content"] = content_blocks
return messages
# ── Streaming ──────────────────────────────────────────────
async def _stream_response(self, state, messages, system_prompt, model_id, max_tokens, thinking_config):
"""Stream from Bedrock, emit events, return (text, thinking, in_tokens, out_tokens)."""
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,
):
if state.done.is_set(): break
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":
current_block_type = event.get("content_block", {}).get("type", "text")
if current_block_type == "thinking":
state.events.append({"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
state.events.append({"type": "thinking_delta", "content": t})
elif dt == "text_delta":
t = delta.get("text", ""); full_text += t
state.events.append({"type": "text_delta", "content": t})
elif evt_type == "content_block_stop":
if current_block_type == "thinking":
state.events.append({"type": "thinking_end"})
elif evt_type == "message_delta":
output_tokens = event.get("usage", {}).get("output_tokens", 0)
return full_text, full_thinking, input_tokens, output_tokens
# ── Persist response ───────────────────────────────────────
async def _save_response(self, db, state, chat, db_user, chat_id, content, full_text, full_thinking, input_tokens, output_tokens, model_id, max_tokens, reasoning_budget, knowledge_base_id):
"""Save assistant message, update quota/chat metadata, auto-generate title."""
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 = max_tokens; chat.reasoning_budget = reasoning_budget
chat.knowledge_base_id = knowledge_base_id or None; chat.updated_at = datetime.utcnow()
db.commit()
state.message_id = assistant_msg.id
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
title = await self._generate_title(content, full_text[:300])
chat.title = title[:120]; db.commit()
state.events.append({"type": "title_update", "title": chat.title})
except Exception: pass
state.events.append({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
state.events.append({"type": "done", "message_id": assistant_msg.id})
# ── Title generation ───────────────────────────────────────
async def _generate_title(self, user_msg, ai_msg): async def _generate_title(self, user_msg, ai_msg):
from backend.config import FAST_MODEL 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):\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.", max_tokens=30) result = await bedrock_service.invoke_model_simple(
model_id=FAST_MODEL,
prompt=f"Generate a concise title (max 6 words):\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.",
max_tokens=30,
)
return result.strip().strip('"').strip("'") return result.strip().strip('"').strip("'")
......
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();
}
// ═══════════════════════════════════════════
// Auth
// ═══════════════════════════════════════════
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 getRegistrationStatus = () => request("GET", "/auth/registration-status", null);
// ═══════════════════════════════════════════
// Chats
// ═══════════════════════════════════════════
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 const checkGenerating = (token, chatId) =>
request("GET", `/chats/${chatId}/generating`, 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 { }
}
}
// ═══════════════════════════════════════════
// Attachments
// ═══════════════════════════════════════════
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);
// ═══════════════════════════════════════════
// Knowledge Bases
// ═══════════════════════════════════════════
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 updateKnowledgeBase = (token, kbId, data) =>
request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export const listKnowledgeDocuments = (token, kbId) =>
request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) =>
request("DELETE", `/knowledge/${kbId}/documents/${docId}`, 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]);
// ═══════════════════════════════════════════
// GitLab — User-facing (for chat integration)
// ═══════════════════════════════════════════
export const listLinkedRepos = (token) =>
request("GET", "/chats/available-repos", token);
export const getRepoBranches = (token, repoId) =>
request("GET", `/chats/repos/${repoId}/branches`, token);
export const commitFromChat = (token, chatId, data) =>
request("POST", `/chats/${chatId}/commit`, token, data);
export const refreshRepoContext = (token, chatId) =>
request("POST", `/chats/${chatId}/refresh-repo`, token);
// ═══════════════════════════════════════════
// GitLab — Admin (for GitLab Command Center)
// Canonical names + gitlab-prefixed aliases
// used by GitLabPage.jsx
// ═══════════════════════════════════════════
export const getGitlabSettings = (token) => request("GET", "/gitlab/settings", token);
export const gitlabGetSettings = getGitlabSettings;
export const updateGitlabSettings = (token, data) => request("PUT", "/gitlab/settings", token, data);
export const gitlabUpdateSettings = updateGitlabSettings;
export const testGitlabConnection = (token) => request("POST", "/gitlab/test-connection", token);
export const gitlabTestConnection = testGitlabConnection;
export const searchGitlabProjects = (token, search, owned) =>
request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token);
export const gitlabSearchProjects = searchGitlabProjects;
export const createGitlabProject = (token, data) => request("POST", "/gitlab/projects", token, data);
export const gitlabCreateProject = createGitlabProject;
export const adminListRepos = (token) => request("GET", "/gitlab/repos", token);
export const gitlabListRepos = adminListRepos;
export const linkRepo = (token, gitlabProjectId) => request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId });
export const gitlabLinkRepo = linkRepo;
export const unlinkRepo = (token, repoId) => request("DELETE", `/gitlab/repos/${repoId}`, token);
export const gitlabUnlinkRepo = unlinkRepo;
export const analyzeRepo = (token, repoId) => request("POST", `/gitlab/repos/${repoId}/analyze`, token);
export const gitlabAnalyzeRepo = analyzeRepo;
export const getRepoMap = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/map`, token);
export const getRepoTree = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path || "")}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetTree = getRepoTree;
export const getRepoFile = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetFile = getRepoFile;
export const adminGetBranches = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/branches`, token);
export const adminCommitFiles = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
export const adminCommitSingle = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
export const createBranch = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
export const createMergeRequest = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
export const listActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token);
export const gitlabListActions = listActions;
export const createAction = (token, data) => request("POST", "/gitlab/actions", token, data);
export const approveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token);
export const gitlabApproveAction = approveAction;
export const rejectAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/reject`, token);
export const gitlabRejectAction = rejectAction;
// ═══════════════════════════════════════════
// Export (PPTX / DOCX)
// ═══════════════════════════════════════════
export async function exportPptx(token, markdown, title) {
const res = await fetch(`${BASE}/export/pptx`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown, title }),
});
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = url;
a.download = `${(title || "presentation").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "presentation"}.pptx`;
a.click(); URL.revokeObjectURL(url);
}
export async function exportDocx(token, markdown, title) {
const res = await fetch(`${BASE}/export/docx`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown, title }),
});
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = url;
a.download = `${(title || "document").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "document"}.docx`;
a.click(); URL.revokeObjectURL(url);
}
// ═══════════════════════════════════════════
// Admin — Core
// ═══════════════════════════════════════════
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);
// ═══════════════════════════════════════════
// Admin — App Settings
// ═══════════════════════════════════════════
export const adminGetAppSettings = (token) => request("GET", "/admin/app-settings", token);
export const adminUpdateAppSettings = (token, data) => request("PUT", "/admin/app-settings", token, data);
// ═══════════════════════════════════════════
// Admin — Models
// ═══════════════════════════════════════════
export const adminGetModels = (token) => request("GET", "/admin/models", token);
// ═══════════════════════════════════════════
// Admin — Permission Defaults
// BOTH short AND long names exported
// ═══════════════════════════════════════════
export const adminGetPermDefaults = (token) => request("GET", "/admin/permissions/defaults", token);
export const adminGetPermissionDefaults = adminGetPermDefaults;
export const adminUpdatePermDefaults = (token, data) => request("PUT", "/admin/permissions/defaults", token, data);
export const adminUpdatePermissionDefaults = adminUpdatePermDefaults;
export const adminApplyPermDefaults = (token) => request("POST", "/admin/permissions/apply-defaults", token);
export const adminApplyDefaults = adminApplyPermDefaults;
// ═══════════════════════════════════════════
// Admin — User Permissions
// BOTH short AND long names exported
// ═══════════════════════════════════════════
export const adminGetUserPerms = (token, userId) => request("GET", `/admin/users/${userId}/permissions`, token);
export const adminGetUserPermissions = adminGetUserPerms;
export const adminUpdateUserPerms = (token, userId, data) => request("PUT", `/admin/users/${userId}/permissions`, token, data);
export const adminUpdateUserPermissions = adminUpdateUserPerms;
// ═══════════════════════════════════════════
// Files / Code Download
// ═══════════════════════════════════════════
export async function downloadZip(token, markdown, title) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown, title }),
});
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);
}
}
\ No newline at end of file
import { request } from "./client";
// Stats & users
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);
// App settings
export const adminGetAppSettings = (token) =>
request("GET", "/admin/app-settings", token);
export const adminUpdateAppSettings = (token, data) =>
request("PUT", "/admin/app-settings", token, data);
// Models
export const adminGetModels = (token) => request("GET", "/admin/models", token);
// Permission defaults
export const adminGetPermissionDefaults = (token) =>
request("GET", "/admin/permissions/defaults", token);
export const adminUpdatePermissionDefaults = (token, data) =>
request("PUT", "/admin/permissions/defaults", token, data);
export const adminApplyDefaults = (token) =>
request("POST", "/admin/permissions/apply-defaults", token);
// User permissions
export const adminGetUserPermissions = (token, userId) =>
request("GET", `/admin/users/${userId}/permissions`, token);
export const adminUpdateUserPermissions = (token, userId, data) =>
request("PUT", `/admin/users/${userId}/permissions`, token, data);
import { request, authHeader, BASE } from "./client";
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);
import { request } from "./client";
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 getRegistrationStatus = () =>
request("GET", "/auth/registration-status", null);
import { request, headers, BASE } from "./client";
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 const checkGenerating = (token, chatId) =>
request("GET", `/chats/${chatId}/generating`, 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 { }
}
}
// User-facing GitLab integration (routes under /chats/)
export const listLinkedRepos = (token) =>
request("GET", "/chats/available-repos", token);
export const getRepoBranches = (token, repoId) =>
request("GET", `/chats/repos/${repoId}/branches`, token);
export const commitFromChat = (token, chatId, data) =>
request("POST", `/chats/${chatId}/commit`, token, data);
export const refreshRepoContext = (token, chatId) =>
request("POST", `/chats/${chatId}/refresh-repo`, token);
const BASE = "/api";
export function headers(token) {
const h = { "Content-Type": "application/json" };
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
export function authHeader(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
export 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 { BASE };
import { headers, BASE } from "./client";
export async function exportPptx(token, markdown, title) {
const res = await fetch(`${BASE}/export/pptx`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown, title }),
});
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = url;
a.download = `${(title || "presentation").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "presentation"}.pptx`;
a.click(); URL.revokeObjectURL(url);
}
export async function exportDocx(token, markdown, title) {
const res = await fetch(`${BASE}/export/docx`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown, title }),
});
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = url;
a.download = `${(title || "document").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "document"}.docx`;
a.click(); URL.revokeObjectURL(url);
}
export async function downloadZip(token, markdown, title) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown, title }),
});
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 { request } from "./client";
// Connection settings
export const gitlabGetSettings = (token) =>
request("GET", "/gitlab/settings", token);
export const gitlabUpdateSettings = (token, data) =>
request("PUT", "/gitlab/settings", token, data);
export const gitlabTestConnection = (token) =>
request("POST", "/gitlab/test-connection", token);
// Projects
export const gitlabSearchProjects = (token, search, owned) =>
request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token);
export const gitlabCreateProject = (token, data) =>
request("POST", "/gitlab/projects", token, data);
// Linked repos
export const gitlabListRepos = (token) =>
request("GET", "/gitlab/repos", token);
export const gitlabLinkRepo = (token, gitlabProjectId) =>
request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId });
export const gitlabUnlinkRepo = (token, repoId) =>
request("DELETE", `/gitlab/repos/${repoId}`, token);
export const gitlabAnalyzeRepo = (token, repoId) =>
request("POST", `/gitlab/repos/${repoId}/analyze`, token);
export const gitlabGetRepoMap = (token, repoId) =>
request("GET", `/gitlab/repos/${repoId}/map`, token);
// Tree & files
export const gitlabGetTree = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path || "")}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetFile = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref || "")}`, token);
// Branches & commits
export const gitlabGetBranches = (token, repoId) =>
request("GET", `/gitlab/repos/${repoId}/branches`, token);
export const gitlabCommitFiles = (token, repoId, data) =>
request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
export const gitlabCommitSingle = (token, repoId, data) =>
request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
export const gitlabCreateBranch = (token, repoId, data) =>
request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
export const gitlabCreateMergeRequest = (token, repoId, data) =>
request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
// Actions
export const gitlabListActions = (token, status) =>
request("GET", `/gitlab/actions?status=${status || "pending"}`, token);
export const gitlabCreateAction = (token, data) =>
request("POST", "/gitlab/actions", token, data);
export const gitlabApproveAction = (token, actionId) =>
request("POST", `/gitlab/actions/${actionId}/approve`, token);
export const gitlabRejectAction = (token, actionId) =>
request("POST", `/gitlab/actions/${actionId}/reject`, token);
// Barrel re-export — every domain module in one place.
// Consumers import from "../api" and get everything.
// Each module is small and self-contained, so AI edits
// to one domain can't break another domain's exports.
export { login, register, getMe, getRegistrationStatus } from "./auth";
export {
listChats, createChat, updateChat, renameChat, deleteChat,
getMessages, checkGenerating, streamMessage,
listLinkedRepos, getRepoBranches, commitFromChat, refreshRepoContext,
} from "./chats";
export { uploadAttachments, getAttachmentUrl, deleteAttachment } from "./attachments";
export {
listKnowledgeBases, createKnowledgeBase, getKnowledgeBase,
updateKnowledgeBase, deleteKnowledgeBase,
listKnowledgeDocuments, deleteKnowledgeDocument,
uploadDocuments, uploadDocument,
} from "./knowledge";
export {
gitlabGetSettings, gitlabUpdateSettings, gitlabTestConnection,
gitlabSearchProjects, gitlabCreateProject,
gitlabListRepos, gitlabLinkRepo, gitlabUnlinkRepo,
gitlabAnalyzeRepo, gitlabGetRepoMap,
gitlabGetTree, gitlabGetFile,
gitlabGetBranches, gitlabCommitFiles, gitlabCommitSingle,
gitlabCreateBranch, gitlabCreateMergeRequest,
gitlabListActions, gitlabCreateAction,
gitlabApproveAction, gitlabRejectAction,
} from "./gitlab";
export {
adminStats, adminListUsers, adminCreateUser, adminUpdateUser, adminDeleteUser,
adminListChats, adminGetAppSettings, adminUpdateAppSettings, adminGetModels,
adminGetPermissionDefaults, adminUpdatePermissionDefaults, adminApplyDefaults,
adminGetUserPermissions, adminUpdateUserPermissions,
} from "./admin";
export { exportPptx, exportDocx, downloadZip } from "./exports";
import { request, authHeader, BASE } from "./client";
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 updateKnowledgeBase = (token, kbId, data) =>
request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export const listKnowledgeDocuments = (token, kbId) =>
request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) =>
request("DELETE", `/knowledge/${kbId}/documents/${docId}`, 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]);
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