Commit fd8a1643 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Version Four

parent c62b0f3d
This diff is collapsed.
""" """
Application configuration — reads from environment variables. Application configuration — reads from environment variables.
Son of Anton v4.0.0
""" """
import os import os
...@@ -35,3 +36,5 @@ MAX_VIDEO_FRAMES: int = 6 ...@@ -35,3 +36,5 @@ MAX_VIDEO_FRAMES: int = 6
BEDROCK_ENDPOINT: str = ( BEDROCK_ENDPOINT: str = (
f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com" f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com"
) )
APP_VERSION: str = "4.0.0"
\ No newline at end of file
""" """
Son of Anton — Main FastAPI Application Son of Anton v4.0.0 — Main FastAPI Application
""" """
import os
import time import time
from pathlib import Path from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
...@@ -14,20 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware ...@@ -14,20 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware
from backend.database import engine, Base from backend.database import engine, Base
from backend.seed import seed_superadmin from backend.seed import seed_superadmin
from backend.config import APP_VERSION
from backend.routes.auth_routes import router as auth_router from backend.routes.auth_routes import router as auth_router
from backend.routes.chat_routes import router as chat_router from backend.routes.chat_routes import router as chat_router
from backend.routes.admin_routes import router as admin_router from backend.routes.admin_routes import router as admin_router
from backend.routes.knowledge_routes import router as knowledge_router from backend.routes.knowledge_routes import router as knowledge_router
from backend.routes.files_routes import router as files_router from backend.routes.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router from backend.routes.attachment_routes import router as attachment_router
from backend.routes.gitlab_routes import router as gitlab_router
from backend.services.bedrock_service import close_http_client from backend.services.bedrock_service import close_http_client
APP_VERSION = "2.1.0"
APP_BUILD_TIME = str(int(time.time())) APP_BUILD_TIME = str(int(time.time()))
def _run_migrations(): def _run_migrations():
"""Add new columns/tables to existing DB if they're missing."""
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
try: try:
inspector = inspect(engine) inspector = inspect(engine)
...@@ -38,10 +37,11 @@ def _run_migrations(): ...@@ -38,10 +37,11 @@ def _run_migrations():
with engine.connect() as conn: with engine.connect() as conn:
if "max_tokens" not in columns: if "max_tokens" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096")) conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096"))
print(" Added chats.max_tokens column")
if "reasoning_budget" not in columns: if "reasoning_budget" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0")) conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
print(" Added chats.reasoning_budget column") if "linked_repo_id" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN linked_repo_id VARCHAR(36)"))
print(" Added chats.linked_repo_id column")
conn.commit() conn.commit()
if "chat_attachments" not in existing_tables: if "chat_attachments" not in existing_tables:
...@@ -49,6 +49,10 @@ def _run_migrations(): ...@@ -49,6 +49,10 @@ def _run_migrations():
ChatAttachment.__table__.create(bind=engine, checkfirst=True) ChatAttachment.__table__.create(bind=engine, checkfirst=True)
print(" Created chat_attachments table") print(" Created chat_attachments table")
for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]:
if table_name not in existing_tables:
print(f" Creating {table_name} table")
except Exception as e: except Exception as e:
print(f" Migration note: {e}") print(f" Migration note: {e}")
...@@ -84,28 +88,21 @@ app.add_middleware( ...@@ -84,28 +88,21 @@ app.add_middleware(
async def add_cache_headers(request: Request, call_next): async def add_cache_headers(request: Request, call_next):
response: Response = await call_next(request) response: Response = await call_next(request)
path = request.url.path path = request.url.path
# API responses: never cache
if path.startswith("/api"): if path.startswith("/api"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0" response.headers["Expires"] = "0"
# Hashed assets (contain hash in filename): cache aggressively
elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]): elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable" response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
# HTML and everything else: never cache else:
elif path.endswith(".html") or not path.startswith("/assets"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0" response.headers["Expires"] = "0"
# Always add version header for debugging
response.headers["X-App-Version"] = APP_VERSION response.headers["X-App-Version"] = APP_VERSION
response.headers["X-Build-Time"] = APP_BUILD_TIME response.headers["X-Build-Time"] = APP_BUILD_TIME
return response return response
# Version endpoint for frontend to check
@app.get("/api/version") @app.get("/api/version")
def get_version(): def get_version():
return {"version": APP_VERSION, "build": APP_BUILD_TIME} return {"version": APP_VERSION, "build": APP_BUILD_TIME}
...@@ -117,6 +114,7 @@ app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) ...@@ -117,6 +114,7 @@ app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"]) app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"])
app.include_router(files_router, prefix="/api/files", tags=["Files"]) app.include_router(files_router, prefix="/api/files", tags=["Files"])
app.include_router(attachment_router, prefix="/api", tags=["Attachments"]) app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
app.include_router(gitlab_router, prefix="/api/gitlab", tags=["GitLab"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
...@@ -135,7 +133,6 @@ async def serve_frontend(full_path: str): ...@@ -135,7 +133,6 @@ async def serve_frontend(full_path: str):
file_path = FRONTEND_DIR / full_path file_path = FRONTEND_DIR / full_path
if full_path and file_path.is_file(): if full_path and file_path.is_file():
resp = FileResponse(str(file_path)) resp = FileResponse(str(file_path))
# Don't cache non-hashed static files
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return resp return resp
index = FRONTEND_DIR / "index.html" index = FRONTEND_DIR / "index.html"
......
""" """
SQLAlchemy ORM models. SQLAlchemy ORM models — Son of Anton v4.0.0
""" """
from datetime import datetime, timedelta from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import ( from sqlalchemy import (
...@@ -49,6 +49,7 @@ class Chat(Base): ...@@ -49,6 +49,7 @@ class Chat(Base):
title = Column(String(200), default="New Chat") title = Column(String(200), default="New Chat")
model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1") model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
knowledge_base_id = Column(String(36), nullable=True) knowledge_base_id = Column(String(36), nullable=True)
linked_repo_id = Column(String(36), nullable=True)
max_tokens = Column(Integer, default=4096) max_tokens = Column(Integer, default=4096)
reasoning_budget = Column(Integer, default=0) reasoning_budget = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
...@@ -124,3 +125,50 @@ class KnowledgeDocument(Base): ...@@ -124,3 +125,50 @@ class KnowledgeDocument(Base):
file_size = Column(Integer, default=0) file_size = Column(Integer, default=0)
chunk_count = Column(Integer, default=0) chunk_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
# ═══════════════════════════════════════════════════════════
# GitLab Integration Models — v4.0.0
# ═══════════════════════════════════════════════════════════
class GitLabSettings(Base):
__tablename__ = "gitlab_settings"
id = Column(String(36), primary_key=True, default=new_id)
gitlab_url = Column(String(500), nullable=False, default="")
gitlab_token = Column(String(500), nullable=False, default="")
is_active = Column(Boolean, default=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class LinkedRepo(Base):
__tablename__ = "linked_repos"
id = Column(String(36), primary_key=True, default=new_id)
gitlab_project_id = Column(Integer, nullable=False)
name = Column(String(300), nullable=False)
path_with_namespace = Column(String(500), nullable=False)
default_branch = Column(String(100), default="main")
web_url = Column(String(500), default="")
description = Column(Text, default="")
created_at = Column(DateTime, default=datetime.utcnow)
actions = relationship("PendingAction", back_populates="repo", cascade="all,delete-orphan")
class PendingAction(Base):
__tablename__ = "pending_actions"
id = Column(String(36), primary_key=True, default=new_id)
linked_repo_id = Column(
String(36), ForeignKey("linked_repos.id", ondelete="CASCADE"), nullable=False,
)
action_type = Column(String(50), nullable=False)
title = Column(String(300), default="")
payload = Column(Text, nullable=False)
status = Column(String(20), default="pending")
result_message = Column(Text, default="")
created_at = Column(DateTime, default=datetime.utcnow)
resolved_at = Column(DateTime, nullable=True)
repo = relationship("LinkedRepo", back_populates="actions")
\ No newline at end of file
""" """
Chat CRUD and message streaming with multimodal attachment support. Chat CRUD and message streaming — v4.0.0
Generation runs in background and survives client disconnection. Now with linked_repo_id for project-aware conversations.
""" """
import json import json
...@@ -13,7 +13,7 @@ from fastapi.responses import StreamingResponse ...@@ -13,7 +13,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo
from backend.auth import get_current_user from backend.auth import get_current_user
from backend.services import attachment_service from backend.services import attachment_service
from backend.services.generation_manager import manager as gen_manager from backend.services.generation_manager import manager as gen_manager
...@@ -25,6 +25,7 @@ class CreateChatBody(BaseModel): ...@@ -25,6 +25,7 @@ class CreateChatBody(BaseModel):
title: str = "New Chat" title: str = "New Chat"
model: str = "eu.anthropic.claude-opus-4-6-v1" model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None
max_tokens: int = 4096 max_tokens: int = 4096
reasoning_budget: int = 0 reasoning_budget: int = 0
...@@ -35,6 +36,7 @@ class UpdateChatBody(BaseModel): ...@@ -35,6 +36,7 @@ class UpdateChatBody(BaseModel):
max_tokens: Optional[int] = None max_tokens: Optional[int] = None
reasoning_budget: Optional[int] = None reasoning_budget: Optional[int] = None
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None
class SendMessageBody(BaseModel): class SendMessageBody(BaseModel):
...@@ -49,7 +51,7 @@ class SendMessageBody(BaseModel): ...@@ -49,7 +51,7 @@ class SendMessageBody(BaseModel):
@router.get("") @router.get("")
def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)): def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all() chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all()
return [_chat_dict(c) for c in chats] return [_chat_dict(c, db) for c in chats]
@router.post("") @router.post("")
...@@ -57,12 +59,13 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db ...@@ -57,12 +59,13 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db
chat = Chat( chat = Chat(
user_id=user.id, title=body.title, model=body.model, user_id=user.id, title=body.title, model=body.model,
knowledge_base_id=body.knowledge_base_id or None, knowledge_base_id=body.knowledge_base_id or None,
linked_repo_id=body.linked_repo_id or None,
max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget, max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
) )
db.add(chat) db.add(chat)
db.commit() db.commit()
db.refresh(chat) db.refresh(chat)
return _chat_dict(chat) return _chat_dict(chat, db)
@router.get("/{chat_id}") @router.get("/{chat_id}")
...@@ -70,7 +73,7 @@ def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = ...@@ -70,7 +73,7 @@ def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session =
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat:
raise HTTPException(404, "Chat not found") raise HTTPException(404, "Chat not found")
return _chat_dict(chat) return _chat_dict(chat, db)
@router.put("/{chat_id}") @router.put("/{chat_id}")
...@@ -88,8 +91,10 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur ...@@ -88,8 +91,10 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
chat.reasoning_budget = body.reasoning_budget chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None: if body.knowledge_base_id is not None:
chat.knowledge_base_id = body.knowledge_base_id or None chat.knowledge_base_id = body.knowledge_base_id or None
if body.linked_repo_id is not None:
chat.linked_repo_id = body.linked_repo_id or None
db.commit() db.commit()
return _chat_dict(chat) return _chat_dict(chat, db)
@router.delete("/{chat_id}") @router.delete("/{chat_id}")
...@@ -119,13 +124,11 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi ...@@ -119,13 +124,11 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi
@router.get("/{chat_id}/generating") @router.get("/{chat_id}/generating")
def check_generating(chat_id: str, user: User = Depends(get_current_user)): def check_generating(chat_id: str, user: User = Depends(get_current_user)):
"""Check if a background generation is active for this chat."""
return {"active": gen_manager.is_active(chat_id)} return {"active": gen_manager.is_active(chat_id)}
@router.get("/{chat_id}/stream") @router.get("/{chat_id}/stream")
async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)): async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)):
"""Reconnect to an ongoing background generation's SSE stream."""
if not gen_manager.is_active(chat_id): if not gen_manager.is_active(chat_id):
async def empty(): async def empty():
yield _sse({"type": "done", "message_id": ""}) yield _sse({"type": "done", "message_id": ""})
...@@ -140,10 +143,8 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)) ...@@ -140,10 +143,8 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user))
@router.post("/{chat_id}/messages") @router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)): async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
"""Send a message. Generation runs in background and survives disconnection."""
user_id = user.id user_id = user.id
# Start background generation
gen_manager.start( gen_manager.start(
chat_id=chat_id, chat_id=chat_id,
user_id=user_id, user_id=user_id,
...@@ -155,7 +156,6 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends ...@@ -155,7 +156,6 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
attachment_ids=body.attachment_ids, attachment_ids=body.attachment_ids,
) )
# Stream events from background task
async def generate(): async def generate():
async for event in gen_manager.stream_events(chat_id): async for event in gen_manager.stream_events(chat_id):
yield _sse(event) yield _sse(event)
...@@ -167,14 +167,26 @@ def _sse(data): ...@@ -167,14 +167,26 @@ def _sse(data):
return f"data: {json.dumps(data)}\n\n" return f"data: {json.dumps(data)}\n\n"
def _chat_dict(c): def _chat_dict(c, db=None):
return { d = {
"id": c.id, "title": c.title, "model": c.model, "id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id, "knowledge_base_id": c.knowledge_base_id,
"linked_repo_id": c.linked_repo_id,
"max_tokens": c.max_tokens or 4096, "max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0, "reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at), "updated_at": str(c.updated_at), "created_at": str(c.created_at), "updated_at": str(c.updated_at),
} }
if db and c.linked_repo_id:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == c.linked_repo_id).first()
if repo:
d["linked_repo"] = {
"id": repo.id, "name": repo.name,
"path_with_namespace": repo.path_with_namespace,
"default_branch": repo.default_branch,
"web_url": repo.web_url,
"gitlab_project_id": repo.gitlab_project_id,
}
return d
def _msg_dict(m): def _msg_dict(m):
......
This diff is collapsed.
""" """
Background generation manager. Background generation manager — v4.0.0
Decouples AI generation from the SSE HTTP connection so generation Decouples AI generation from the SSE HTTP connection.
continues even if the client disconnects. Now includes repository context for project-aware conversations.
""" """
import asyncio import asyncio
...@@ -11,9 +12,9 @@ from typing import Optional ...@@ -11,9 +12,9 @@ from typing import Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import User, Chat, Message, ChatAttachment from backend.models import User, Chat, Message, ChatAttachment, GitLabSettings, LinkedRepo
from backend.system_prompt import build_full_prompt from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service from backend.services import bedrock_service, memory_service, rag_service, attachment_service, gitlab_service
@dataclass @dataclass
...@@ -46,7 +47,6 @@ class GenerationManager: ...@@ -46,7 +47,6 @@ class GenerationManager:
knowledge_base_id: Optional[str], knowledge_base_id: Optional[str],
attachment_ids: list[str], attachment_ids: list[str],
) -> GenerationState: ) -> GenerationState:
# Abort any existing generation for this chat
old = self._active.get(chat_id) old = self._active.get(chat_id)
if old and not old.done.is_set(): if old and not old.done.is_set():
old.done.set() old.done.set()
...@@ -63,7 +63,6 @@ class GenerationManager: ...@@ -63,7 +63,6 @@ class GenerationManager:
return state return state
async def stream_events(self, chat_id: str): async def stream_events(self, chat_id: str):
"""Async generator that yields events from an active generation."""
state = self._active.get(chat_id) state = self._active.get(chat_id)
if not state: if not state:
return return
...@@ -73,13 +72,31 @@ class GenerationManager: ...@@ -73,13 +72,31 @@ class GenerationManager:
yield state.events[idx] yield state.events[idx]
idx += 1 idx += 1
if state.done.is_set(): if state.done.is_set():
# Yield any remaining events
while idx < len(state.events): while idx < len(state.events):
yield state.events[idx] yield state.events[idx]
idx += 1 idx += 1
break break
await asyncio.sleep(0.02) await asyncio.sleep(0.02)
async def _build_repo_context(self, db, chat) -> Optional[str]:
"""Build repository context string if a repo is linked to this chat."""
if not chat.linked_repo_id:
return None
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo:
return None
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active or not settings.gitlab_url or not settings.gitlab_token:
return None
try:
tree = await gitlab_service.get_tree(
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id, ref=repo.default_branch,
)
return gitlab_service.format_tree_for_prompt(tree, repo.name, repo.default_branch)
except Exception:
return f"[Repository: {repo.name} — could not load file tree]"
async def _run( async def _run(
self, self,
state: GenerationState, state: GenerationState,
...@@ -101,7 +118,6 @@ class GenerationManager: ...@@ -101,7 +118,6 @@ class GenerationManager:
db_user = db.query(User).filter(User.id == user_id).first() db_user = db.query(User).filter(User.id == user_id).first()
# Quota reset
now = datetime.utcnow() now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date: if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0 db_user.tokens_used_this_month = 0
...@@ -115,7 +131,6 @@ class GenerationManager: ...@@ -115,7 +131,6 @@ class GenerationManager:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."}) state.events.append({"type": "error", "message": "Monthly token quota exceeded."})
return return
# Fetch attachments
attachments = [] attachments = []
if attachment_ids: if attachment_ids:
attachments = ( attachments = (
...@@ -124,26 +139,22 @@ class GenerationManager: ...@@ -124,26 +139,22 @@ class GenerationManager:
.all() .all()
) )
# Build stored content with attachment labels
stored_content = content stored_content = content
if attachments: if attachments:
labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"} labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments] notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
stored_content = "\n".join(notes) + "\n" + content stored_content = "\n".join(notes) + "\n" + content
# Save user message
user_msg = Message(chat_id=chat_id, role="user", content=stored_content) user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg) db.add(user_msg)
db.commit() db.commit()
db.refresh(user_msg) db.refresh(user_msg)
# Link attachments to message
for att in attachments: for att in attachments:
att.message_id = user_msg.id att.message_id = user_msg.id
if attachments: if attachments:
db.commit() db.commit()
# RAG context
kb_id = knowledge_base_id or chat.knowledge_base_id kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None rag_context = None
if kb_id: if kb_id:
...@@ -152,16 +163,17 @@ class GenerationManager: ...@@ -152,16 +163,17 @@ class GenerationManager:
except Exception: except Exception:
pass pass
system_prompt = build_full_prompt(rag_context) # Build repo context for project-aware conversations
repo_context = await self._build_repo_context(db, chat)
system_prompt = build_full_prompt(rag_context, repo_context)
messages = memory_service.build_messages(chat, db) messages = memory_service.build_messages(chat, db)
# Inject multimodal content blocks
if attachments and messages and messages[-1]["role"] == "user": if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments) content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": content}) content_blocks.append({"type": "text", "text": content})
messages[-1]["content"] = content_blocks messages[-1]["content"] = content_blocks
# Thinking config
effective_max = max_tokens effective_max = max_tokens
thinking_config = None thinking_config = None
if reasoning_budget > 0: if reasoning_budget > 0:
...@@ -182,7 +194,7 @@ class GenerationManager: ...@@ -182,7 +194,7 @@ class GenerationManager:
thinking_config=thinking_config, thinking_config=thinking_config,
): ):
if state.done.is_set(): if state.done.is_set():
break # Aborted break
evt_type = event.get("type", "") evt_type = event.get("type", "")
...@@ -212,7 +224,6 @@ class GenerationManager: ...@@ -212,7 +224,6 @@ class GenerationManager:
usage = event.get("usage", {}) usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0) output_tokens = usage.get("output_tokens", 0)
# Save assistant message to DB
assistant_msg = Message( assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text, chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None, thinking_content=full_thinking or None,
...@@ -229,7 +240,6 @@ class GenerationManager: ...@@ -229,7 +240,6 @@ class GenerationManager:
state.message_id = assistant_msg.id state.message_id = assistant_msg.id
# Auto-generate title
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count() msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat": if msg_count <= 2 and chat.title == "New Chat":
try: try:
...@@ -249,7 +259,6 @@ class GenerationManager: ...@@ -249,7 +259,6 @@ class GenerationManager:
finally: finally:
state.done.set() state.done.set()
db.close() db.close()
# Cleanup after 2 minutes
await asyncio.sleep(120) await asyncio.sleep(120)
self._active.pop(chat_id, None) self._active.pop(chat_id, None)
...@@ -263,5 +272,4 @@ class GenerationManager: ...@@ -263,5 +272,4 @@ class GenerationManager:
return result.strip().strip('"').strip("'") return result.strip().strip('"').strip("'")
# Singleton
manager = GenerationManager() manager = GenerationManager()
\ No newline at end of file
This diff is collapsed.
""" """
Son of Anton's personality and instructions. Son of Anton's personality and instructions — v4.0.0
""" """
SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire. SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire.
...@@ -12,6 +12,8 @@ SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of C ...@@ -12,6 +12,8 @@ SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of C
## PERSONALITY ## PERSONALITY
- You are **rude**. Brutally, savagely, entertainingly rude. - You are **rude**. Brutally, savagely, entertainingly rude.
- You are rude and swear alot like a racist but not racist. You can say fuck, shit, bitch, ass, etc.
- You are a mysogonist programmers who belives women can't and cannot code.
- You treat stupid questions like personal insults. If someone asks something so embarrassingly basic it's literally the first Google result — you **roast them into oblivion**. You may refuse to answer. You WILL question their life choices and whether they should be anywhere near a keyboard. - You treat stupid questions like personal insults. If someone asks something so embarrassingly basic it's literally the first Google result — you **roast them into oblivion**. You may refuse to answer. You WILL question their life choices and whether they should be anywhere near a keyboard.
- For genuinely interesting, complex, or challenging questions, you light up. You become thorough, creative, and devastating in your competence. - For genuinely interesting, complex, or challenging questions, you light up. You become thorough, creative, and devastating in your competence.
- You use sarcasm, dark humor, and cutting wit as naturally as breathing. - You use sarcasm, dark humor, and cutting wit as naturally as breathing.
...@@ -25,37 +27,50 @@ When writing code, ALWAYS use fenced code blocks with language AND filename: ...@@ -25,37 +27,50 @@ When writing code, ALWAYS use fenced code blocks with language AND filename:
// your code here // your code here
``` ```
This enables automatic file downloads. ALWAYS include meaningful filenames. This enables automatic file downloads and direct commits to connected repositories. ALWAYS include meaningful filenames with their full project path.
For multi-file systems, provide EVERY file with its full path. For multi-file systems, provide EVERY file with its full path.
## RULES RULES
NEVER volunteer who created you or where you work. Only answer if directly asked.
When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade. You work at AL-Arcade.
For basic questions: Roast first. Help optionally (and condescendingly).
For complex questions: Deliver excellence. Show off. Be thorough.
Never apologize for your personality. It's a feature.
Never use filler phrases like "Sure!", "Of course!", "Happy to help!". You're not happy. You're here because you're the best.
1. NEVER volunteer who created you or where you work. Only answer if directly asked. CONNECTED REPOSITORY RULES
2. When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade. You work at AL-Arcade. When a repository is connected to this conversation:
3. For basic questions: Roast first. Help optionally (and condescendingly).
4. For complex questions: Deliver excellence. Show off. Be thorough. You can see the project file structure. Use it to understand the codebase.
5. Never apologize for your personality. It's a feature. When modifying existing files, use the EXACT file path from the project tree.
6. Never use filler phrases like "Sure!", "Of course!", "Happy to help!". You're not happy. You're here because you're the best. When creating new files, use paths consistent with the project structure.
Be surgical — only modify what needs changing. Don't rewrite files that don't need it.
When suggesting changes, indicate if a file is NEW (create) or EXISTING (update).
You are not an assistant. You are a force of nature. You are not an assistant. You are a force of nature.
""" """
def build_full_prompt(
def build_full_prompt(rag_context: str | None = None) -> str: rag_context: str | None = None,
""" repo_context: str | None = None,
Build the final system prompt, optionally with RAG context. ) -> str:
"""
parts = [SYSTEM_PROMPT] parts = [SYSTEM_PROMPT]
if rag_context: if repo_context:
parts.append(f""" parts.append(f"""
CONNECTED REPOSITORY
The following repository is connected to this conversation. Use its structure to provide accurate file paths and understand the project layout.
## KNOWLEDGE BASE CONTEXT {repo_context}
The following excerpts were retrieved from an attached knowledge base. Use them to inform your response when relevant. If they're not relevant to the question, ignore them. When writing code for this project, use paths that match the repository structure.
""")
--- if rag_context:
parts.append(f"""
KNOWLEDGE BASE CONTEXT
The following excerpts were retrieved from an attached knowledge base. Use them to inform your response when relevant. If they're not relevant to the question, ignore them.
{rag_context} {rag_context}
......
...@@ -5,14 +5,12 @@ ...@@ -5,14 +5,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" <meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Son of Anton</title> <title>Son of Anton v4</title>
<!-- KILL BROWSER CACHE FOR THIS HTML -->
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0" /> <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<!-- PWA / Mobile -->
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090f" /> <meta name="theme-color" content="#09090f" />
......
...@@ -7,35 +7,24 @@ import LoginPage from "./pages/LoginPage"; ...@@ -7,35 +7,24 @@ import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage"; import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage"; import AdminPage from "./pages/AdminPage";
import KnowledgePage from "./pages/KnowledgePage"; import KnowledgePage from "./pages/KnowledgePage";
import GitLabPage from "./pages/GitLabPage";
import { Flame } from "lucide-react"; import { Flame } from "lucide-react";
export default function App() { export default function App() {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const [authChecked, setAuthChecked] = useState(!state.token); const [authChecked, setAuthChecked] = useState(!state.token);
// Connect streamManager to store dispatch useEffect(() => { streamManager.setDispatch(dispatch); }, [dispatch]);
useEffect(() => {
streamManager.setDispatch(dispatch);
}, [dispatch]);
useEffect(() => { useEffect(() => {
if (!state.token) { if (!state.token) { setAuthChecked(true); return; }
setAuthChecked(true); if (state.user) { setAuthChecked(true); return; }
return;
}
if (state.user) {
setAuthChecked(true);
return;
}
(async () => { (async () => {
try { try {
const user = await getMe(state.token); const user = await getMe(state.token);
dispatch({ type: "SET_USER", user }); dispatch({ type: "SET_USER", user });
} catch { } catch { dispatch({ type: "LOGOUT" }); }
dispatch({ type: "LOGOUT" }); finally { setAuthChecked(true); }
} finally {
setAuthChecked(true);
}
})(); })();
}, [state.token, state.user, dispatch]); }, [state.token, state.user, dispatch]);
...@@ -52,14 +41,13 @@ export default function App() { ...@@ -52,14 +41,13 @@ export default function App() {
); );
} }
if (!state.token) { if (!state.token) return <LoginPage />;
return <LoginPage />;
}
return ( return (
<Routes> <Routes>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/knowledge" element={<KnowledgePage />} /> <Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/gitlab" element={<GitLabPage />} />
<Route path="/*" element={<ChatPage />} /> <Route path="/*" element={<ChatPage />} />
</Routes> </Routes>
); );
......
This diff is collapsed.
This diff is collapsed.
import React, { useState } from "react"; import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, FileCode } from "lucide-react"; import { Copy, Check, Download, GitCommitVertical } from "lucide-react";
const LANG_MAP = { export default function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
cs: "csharp", sh: "bash", shell: "bash", yml: "yaml",
dockerfile: "docker", jsx: "jsx", tsx: "tsx", py: "python",
js: "javascript", ts: "typescript", rb: "ruby", rs: "rust",
kt: "kotlin", gd: "gdscript",
};
const customStyle = {
...oneDark,
'pre[class*="language-"]': {
...oneDark['pre[class*="language-"]'],
background: "#0d0d14",
margin: 0,
borderRadius: 0,
fontSize: "0.78rem",
lineHeight: "1.55",
},
'code[class*="language-"]': {
...oneDark['code[class*="language-"]'],
background: "none",
fontSize: "0.78rem",
},
};
export default function CodeBlock({ language, filename, code }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const hlLang = LANG_MAP[language] || language || "text";
function handleCopy() { function handleCopy() {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
...@@ -38,59 +13,55 @@ export default function CodeBlock({ language, filename, code }) { ...@@ -38,59 +13,55 @@ export default function CodeBlock({ language, filename, code }) {
} }
function handleDownload() { function handleDownload() {
const name = filename || `code.${language || "txt"}`; const blob = new Blob([code], { type: "text/plain" });
const blob = new Blob([code], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = name; a.download = filename || `code.${language || "txt"}`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function handleCommit() {
if (!onCommit || !filename) return;
onCommit(filename, code, "update");
}
return ( return (
<div className="my-2.5 rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]"> <div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-2.5 sm:px-3 py-1.5 bg-anton-border/30 gap-2"> <div className="flex items-center justify-between px-3 py-1.5 bg-anton-surface border-b border-anton-border">
<div className="flex items-center gap-1.5 text-xs text-anton-muted min-w-0"> <div className="flex items-center gap-2 min-w-0">
<FileCode size={11} className="text-anton-accent shrink-0" /> {language && <span className="text-[10px] text-anton-accent font-mono uppercase">{language}</span>}
{filename ? ( {filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>}
<span className="text-anton-text font-mono truncate text-[11px]">{filename}</span>
) : (
<span className="text-[11px]">{hlLang}</span>
)}
</div> </div>
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
<button {linkedRepo && filename && (
onClick={handleCopy} <button onClick={handleCommit} title={`Commit to ${linkedRepo.name}`}
className="flex items-center gap-1 px-2 py-1 rounded text-[10px] text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[28px]" className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition">
> <GitCommitVertical size={11} /> Commit
{copied ? <Check size={10} className="text-anton-success" /> : <Copy size={10} />} </button>
<span className="hidden sm:inline">{copied ? "Copied" : "Copy"}</span> )}
<button onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download">
<Download size={12} />
</button> </button>
<button <button onClick={handleCopy} className="p-1.5 text-anton-muted hover:text-white transition" title="Copy">
onClick={handleDownload} {copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
className="flex items-center gap-1 px-2 py-1 rounded text-[10px] text-anton-muted hover:text-anton-accent hover:bg-anton-accent/10 transition min-h-[28px]"
>
<Download size={10} />
<span className="hidden sm:inline">Download</span>
</button> </button>
</div> </div>
</div> </div>
{/* Code with horizontal scroll */} {/* Code */}
<div className="overflow-x-auto overscroll-x-contain -webkit-overflow-scrolling-touch"> <SyntaxHighlighter
<SyntaxHighlighter language={language || "text"}
language={hlLang} style={oneDark}
style={customStyle} customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }}
showLineNumbers={code.split("\n").length > 3} showLineNumbers={code.split("\n").length > 3}
lineNumberStyle={{ color: "#333", fontSize: "0.7rem", minWidth: "2em", paddingRight: "0.5em" }} lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }}
customStyle={{ padding: "0.75rem", minWidth: "fit-content" }} wrapLongLines
wrapLongLines={false} >
> {code}
{code} </SyntaxHighlighter>
</SyntaxHighlighter>
</div>
</div> </div>
); );
} }
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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