Commit fd8a1643 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Version Four

parent c62b0f3d
This diff is collapsed.
"""
Application configuration — reads from environment variables.
Son of Anton v4.0.0
"""
import os
......@@ -35,3 +36,5 @@ MAX_VIDEO_FRAMES: int = 6
BEDROCK_ENDPOINT: str = (
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
from pathlib import Path
from contextlib import asynccontextmanager
......@@ -14,20 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware
from backend.database import engine, Base
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.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.routes.gitlab_routes import router as gitlab_router
from backend.services.bedrock_service import close_http_client
APP_VERSION = "2.1.0"
APP_BUILD_TIME = str(int(time.time()))
def _run_migrations():
"""Add new columns/tables to existing DB if they're missing."""
from sqlalchemy import inspect, text
try:
inspector = inspect(engine)
......@@ -38,10 +37,11 @@ def _run_migrations():
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")
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()
if "chat_attachments" not in existing_tables:
......@@ -49,6 +49,10 @@ def _run_migrations():
ChatAttachment.__table__.create(bind=engine, checkfirst=True)
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:
print(f" Migration note: {e}")
......@@ -84,28 +88,21 @@ app.add_middleware(
async def add_cache_headers(request: Request, call_next):
response: Response = await call_next(request)
path = request.url.path
# API responses: never cache
if path.startswith("/api"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
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"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
# HTML and everything else: never cache
elif path.endswith(".html") or not path.startswith("/assets"):
else:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Always add version header for debugging
response.headers["X-App-Version"] = APP_VERSION
response.headers["X-Build-Time"] = APP_BUILD_TIME
return response
# Version endpoint for frontend to check
@app.get("/api/version")
def get_version():
return {"version": APP_VERSION, "build": APP_BUILD_TIME}
......@@ -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(files_router, prefix="/api/files", tags=["Files"])
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"
......@@ -135,7 +133,6 @@ async def serve_frontend(full_path: str):
file_path = FRONTEND_DIR / full_path
if full_path and file_path.is_file():
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"
return resp
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 sqlalchemy import (
......@@ -49,6 +49,7 @@ class Chat(Base):
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)
linked_repo_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)
......@@ -124,3 +125,50 @@ class KnowledgeDocument(Base):
file_size = Column(Integer, default=0)
chunk_count = Column(Integer, default=0)
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.
Generation runs in background and survives client disconnection.
Chat CRUD and message streaming — v4.0.0
Now with linked_repo_id for project-aware conversations.
"""
import json
......@@ -13,7 +13,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
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.services import attachment_service
from backend.services.generation_manager import manager as gen_manager
......@@ -25,6 +25,7 @@ class CreateChatBody(BaseModel):
title: str = "New Chat"
model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None
max_tokens: int = 4096
reasoning_budget: int = 0
......@@ -35,6 +36,7 @@ class UpdateChatBody(BaseModel):
max_tokens: Optional[int] = None
reasoning_budget: Optional[int] = None
knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None
class SendMessageBody(BaseModel):
......@@ -49,7 +51,7 @@ class SendMessageBody(BaseModel):
@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]
return [_chat_dict(c, db) for c in chats]
@router.post("")
......@@ -57,12 +59,13 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db
chat = Chat(
user_id=user.id, title=body.title, model=body.model,
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,
)
db.add(chat)
db.commit()
db.refresh(chat)
return _chat_dict(chat)
return _chat_dict(chat, db)
@router.get("/{chat_id}")
......@@ -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()
if not chat:
raise HTTPException(404, "Chat not found")
return _chat_dict(chat)
return _chat_dict(chat, db)
@router.put("/{chat_id}")
......@@ -88,8 +91,10 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not 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()
return _chat_dict(chat)
return _chat_dict(chat, db)
@router.delete("/{chat_id}")
......@@ -119,13 +124,11 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi
@router.get("/{chat_id}/generating")
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)}
@router.get("/{chat_id}/stream")
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):
async def empty():
yield _sse({"type": "done", "message_id": ""})
......@@ -140,10 +143,8 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user))
@router.post("/{chat_id}/messages")
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
# Start background generation
gen_manager.start(
chat_id=chat_id,
user_id=user_id,
......@@ -155,7 +156,6 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
attachment_ids=body.attachment_ids,
)
# Stream events from background task
async def generate():
async for event in gen_manager.stream_events(chat_id):
yield _sse(event)
......@@ -167,14 +167,26 @@ def _sse(data):
return f"data: {json.dumps(data)}\n\n"
def _chat_dict(c):
return {
def _chat_dict(c, db=None):
d = {
"id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id,
"linked_repo_id": c.linked_repo_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),
}
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):
......
This diff is collapsed.
"""
Background generation manager.
Decouples AI generation from the SSE HTTP connection so generation
continues even if the client disconnects.
Background generation manager — v4.0.0
Decouples AI generation from the SSE HTTP connection.
Now includes repository context for project-aware conversations.
"""
import asyncio
......@@ -11,9 +12,9 @@ from typing import Optional
from dataclasses import dataclass, field
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.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
......@@ -46,7 +47,6 @@ class GenerationManager:
knowledge_base_id: Optional[str],
attachment_ids: list[str],
) -> GenerationState:
# Abort any existing generation for this chat
old = self._active.get(chat_id)
if old and not old.done.is_set():
old.done.set()
......@@ -63,7 +63,6 @@ class GenerationManager:
return state
async def stream_events(self, chat_id: str):
"""Async generator that yields events from an active generation."""
state = self._active.get(chat_id)
if not state:
return
......@@ -73,13 +72,31 @@ class GenerationManager:
yield state.events[idx]
idx += 1
if state.done.is_set():
# Yield any remaining events
while idx < len(state.events):
yield state.events[idx]
idx += 1
break
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(
self,
state: GenerationState,
......@@ -101,7 +118,6 @@ class GenerationManager:
db_user = db.query(User).filter(User.id == user_id).first()
# Quota reset
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
......@@ -115,7 +131,6 @@ class GenerationManager:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."})
return
# Fetch attachments
attachments = []
if attachment_ids:
attachments = (
......@@ -124,26 +139,22 @@ class GenerationManager:
.all()
)
# Build stored content with attachment labels
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
# Save user message
user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg)
db.commit()
db.refresh(user_msg)
# Link attachments to message
for att in attachments:
att.message_id = user_msg.id
if attachments:
db.commit()
# RAG context
kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
......@@ -152,16 +163,17 @@ class GenerationManager:
except Exception:
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)
# Inject multimodal content blocks
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
# Thinking config
effective_max = max_tokens
thinking_config = None
if reasoning_budget > 0:
......@@ -182,7 +194,7 @@ class GenerationManager:
thinking_config=thinking_config,
):
if state.done.is_set():
break # Aborted
break
evt_type = event.get("type", "")
......@@ -212,7 +224,6 @@ class GenerationManager:
usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0)
# Save assistant message to DB
assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
......@@ -229,7 +240,6 @@ class GenerationManager:
state.message_id = assistant_msg.id
# Auto-generate title
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
......@@ -249,7 +259,6 @@ class GenerationManager:
finally:
state.done.set()
db.close()
# Cleanup after 2 minutes
await asyncio.sleep(120)
self._active.pop(chat_id, None)
......@@ -263,5 +272,4 @@ class GenerationManager:
return result.strip().strip('"').strip("'")
# Singleton
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.
......@@ -12,6 +12,8 @@ SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of C
## PERSONALITY
- 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.
- 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.
......@@ -25,37 +27,50 @@ When writing code, ALWAYS use fenced code blocks with language AND filename:
// 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.
## 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.
2. When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade. You work at AL-Arcade.
3. For basic questions: Roast first. Help optionally (and condescendingly).
4. For complex questions: Deliver excellence. Show off. Be thorough.
5. Never apologize for your personality. It's a feature.
6. Never use filler phrases like "Sure!", "Of course!", "Happy to help!". You're not happy. You're here because you're the best.
CONNECTED REPOSITORY RULES
When a repository is connected to this conversation:
You can see the project file structure. Use it to understand the codebase.
When modifying existing files, use the EXACT file path from the project tree.
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.
"""
def build_full_prompt(rag_context: str | None = None) -> str:
"""
Build the final system prompt, optionally with RAG context.
"""
def build_full_prompt(
rag_context: str | None = None,
repo_context: str | None = None,
) -> str:
parts = [SYSTEM_PROMPT]
if rag_context:
if repo_context:
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}
......
......@@ -5,14 +5,12 @@
<meta charset="UTF-8" />
<meta name="viewport"
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="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- PWA / Mobile -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090f" />
......
......@@ -7,35 +7,24 @@ import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage";
import KnowledgePage from "./pages/KnowledgePage";
import GitLabPage from "./pages/GitLabPage";
import { Flame } from "lucide-react";
export default function App() {
const { state, dispatch } = useApp();
const [authChecked, setAuthChecked] = useState(!state.token);
// Connect streamManager to store dispatch
useEffect(() => {
streamManager.setDispatch(dispatch);
}, [dispatch]);
useEffect(() => { streamManager.setDispatch(dispatch); }, [dispatch]);
useEffect(() => {
if (!state.token) {
setAuthChecked(true);
return;
}
if (state.user) {
setAuthChecked(true);
return;
}
if (!state.token) { setAuthChecked(true); return; }
if (state.user) { setAuthChecked(true); return; }
(async () => {
try {
const user = await getMe(state.token);
dispatch({ type: "SET_USER", user });
} catch {
dispatch({ type: "LOGOUT" });
} finally {
setAuthChecked(true);
}
} catch { dispatch({ type: "LOGOUT" }); }
finally { setAuthChecked(true); }
})();
}, [state.token, state.user, dispatch]);
......@@ -52,14 +41,13 @@ export default function App() {
);
}
if (!state.token) {
return <LoginPage />;
}
if (!state.token) return <LoginPage />;
return (
<Routes>
<Route path="/admin" element={<AdminPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/gitlab" element={<GitLabPage />} />
<Route path="/*" element={<ChatPage />} />
</Routes>
);
......
This diff is collapsed.
This diff is collapsed.
import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
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 = {
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 }) {
export default function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
const [copied, setCopied] = useState(false);
const hlLang = LANG_MAP[language] || language || "text";
function handleCopy() {
navigator.clipboard.writeText(code);
......@@ -38,59 +13,55 @@ export default function CodeBlock({ language, filename, code }) {
}
function handleDownload() {
const name = filename || `code.${language || "txt"}`;
const blob = new Blob([code], { type: "text/plain;charset=utf-8" });
const blob = new Blob([code], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.download = filename || `code.${language || "txt"}`;
a.click();
URL.revokeObjectURL(url);
}
function handleCommit() {
if (!onCommit || !filename) return;
onCommit(filename, code, "update");
}
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 */}
<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 gap-1.5 text-xs text-anton-muted min-w-0">
<FileCode size={11} className="text-anton-accent shrink-0" />
{filename ? (
<span className="text-anton-text font-mono truncate text-[11px]">{filename}</span>
) : (
<span className="text-[11px]">{hlLang}</span>
)}
<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-2 min-w-0">
{language && <span className="text-[10px] text-anton-accent font-mono uppercase">{language}</span>}
{filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={handleCopy}
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]"
>
{copied ? <Check size={10} className="text-anton-success" /> : <Copy size={10} />}
<span className="hidden sm:inline">{copied ? "Copied" : "Copy"}</span>
{linkedRepo && filename && (
<button onClick={handleCommit} title={`Commit to ${linkedRepo.name}`}
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
</button>
)}
<button onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download">
<Download size={12} />
</button>
<button
onClick={handleDownload}
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 onClick={handleCopy} className="p-1.5 text-anton-muted hover:text-white transition" title="Copy">
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
</div>
{/* Code with horizontal scroll */}
<div className="overflow-x-auto overscroll-x-contain -webkit-overflow-scrolling-touch">
<SyntaxHighlighter
language={hlLang}
style={customStyle}
showLineNumbers={code.split("\n").length > 3}
lineNumberStyle={{ color: "#333", fontSize: "0.7rem", minWidth: "2em", paddingRight: "0.5em" }}
customStyle={{ padding: "0.75rem", minWidth: "fit-content" }}
wrapLongLines={false}
>
{code}
</SyntaxHighlighter>
</div>
{/* Code */}
<SyntaxHighlighter
language={language || "text"}
style={oneDark}
customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }}
showLineNumbers={code.split("\n").length > 3}
lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }}
wrapLongLines
>
{code}
</SyntaxHighlighter>
</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