Commit fd8a1643 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Version Four

parent c62b0f3d
......@@ -15,10 +15,10 @@
PROJECT CODEBASE — FULL SOURCE DUMP
==============================================================================
Generated: 2026-03-29 14:58:58
Generated: 2026-03-29 19:44:23
Source Dir: /Users/mahmoudaglan/son-of-anton
Total Files: 57
Total Lines: 13183
Total Lines: 13201
Total Size: 475KB
THIS FILE CONTAINS THE COMPLETE CODEBASE INCLUDING:
......@@ -136,7 +136,7 @@ Collected file paths:
[016] 156 4KB backend/routes/attachments.py
[017] 90 2KB backend/routes/auth_routes.py
[018] 193 6KB backend/routes/chat_routes.py
[019] 48 1KB backend/routes/files_routes.py
[019] 62 1KB backend/routes/files_routes.py
[020] 309 10KB backend/routes/knowledge_routes.py
[021] 127 3KB backend/routes/messages_patch.py
[022] 28 855B backend/seed.py
......@@ -155,9 +155,9 @@ Collected file paths:
[035] 27 646B frontend/package.json
[036] 5 80B frontend/postcss.config.js
[037] 65 1KB frontend/src/App.jsx
[038] 215 8KB frontend/src/api.js
[038] 221 8KB frontend/src/api.js
[039] 158 4KB frontend/src/components/AttachmentPreview.jsx
[040] 420 17KB frontend/src/components/ChatView.jsx
[040] 418 17KB frontend/src/components/ChatView.jsx
[041] 95 3KB frontend/src/components/CodeBlock.jsx
[042] 81 1KB frontend/src/components/FileUploadButton.jsx
[043] 188 8KB frontend/src/components/MessageBubble.jsx
......@@ -3779,7 +3779,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [019/57]: backend/routes/files_routes.py
│ LANGUAGE: python | LINES: 48 | SIZE: 1238 bytes
│ LANGUAGE: python | LINES: 62 | SIZE: 1664 bytes
├──────────────────────────────────────────────────────────────────────────────
1 """
......@@ -3787,50 +3787,64 @@ Collected file paths:
3 """
4
5 import io
6 import zipfile
7 from pydantic import BaseModel
8
9 from fastapi import APIRouter
10 from fastapi.responses import StreamingResponse
11
12 from backend.services.code_extractor import extract_code_blocks
6 import re
7 import zipfile
8 from typing import Optional
9 from pydantic import BaseModel
10
11 from fastapi import APIRouter
12 from fastapi.responses import StreamingResponse
13
14 router = APIRouter()
14 from backend.services.code_extractor import extract_code_blocks
15
16
17 class ExtractBody(BaseModel):
18 markdown: str
19
20
21 @router.post("/extract")
22 def extract_files(body: ExtractBody):
23 blocks = extract_code_blocks(body.markdown)
24 return {"files": blocks}
25
26
27 @router.post("/download-zip")
28 def download_zip(body: ExtractBody):
29 blocks = extract_code_blocks(body.markdown)
30 if not blocks:
31 return {"error": "No code blocks found"}
32
33 buf = io.BytesIO()
34 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
35 seen = set()
36 for b in blocks:
37 name = b["filename"]
38 if name in seen:
39 base, ext = name.rsplit(".", 1) if "." in name else (name, "txt")
40 name = f"{base}_{len(seen)}.{ext}"
41 seen.add(name)
42 zf.writestr(name, b["code"])
16 router = APIRouter()
17
18
19 class ExtractBody(BaseModel):
20 markdown: str
21 title: Optional[str] = None
22
23
24 @router.post("/extract")
25 def extract_files(body: ExtractBody):
26 blocks = extract_code_blocks(body.markdown)
27 return {"files": blocks}
28
29
30 @router.post("/download-zip")
31 def download_zip(body: ExtractBody):
32 blocks = extract_code_blocks(body.markdown)
33 if not blocks:
34 return {"error": "No code blocks found"}
35
36 # Keep LAST occurrence of each filename (latest version wins)
37 file_map: dict[str, str] = {}
38 for b in blocks:
39 file_map[b["filename"]] = b["code"]
40
41 if not file_map:
42 return {"error": "No code blocks found"}
43
44 buf.seek(0)
45 return StreamingResponse(
46 buf,
47 media_type="application/zip",
48 headers={"Content-Disposition": "attachment; filename=son-of-anton-code.zip"},
49 )│
44 # Build a safe zip filename from chat title
45 raw_title = (body.title or "").strip()
46 if not raw_title or raw_title == "New Chat":
47 safe_title = "code"
48 else:
49 safe_title = re.sub(r'[^\w\s-]', '', raw_title).strip()
50 safe_title = re.sub(r'[\s]+', '-', safe_title)[:60] or "code"
51 zip_filename = f"{safe_title}.zip"
52
53 buf = io.BytesIO()
54 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
55 for name, code in file_map.items():
56 zf.writestr(name, code)
57
58 buf.seek(0)
59 return StreamingResponse(
60 buf,
61 media_type="application/zip",
62 headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
63 )│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [019]: backend/routes/files_routes.py
......@@ -10929,7 +10943,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [038/57]: frontend/src/api.js
│ LANGUAGE: javascript | LINES: 215 | SIZE: 8526 bytes
│ LANGUAGE: javascript | LINES: 221 | SIZE: 8820 bytes
├──────────────────────────────────────────────────────────────────────────────
1 const BASE = "/api";
......@@ -11127,11 +11141,11 @@ Collected file paths:
193 // Code Download
194 // ═══════════════════════════════════════════════════
195
196 export async function downloadZip(token, markdown) {
196 export async function downloadZip(token, markdown, chatTitle) {
197 const res = await fetch(`${BASE}/files/download-zip`, {
198 method: "POST",
199 headers: headers(token),
200 body: JSON.stringify({ markdown }),
200 body: JSON.stringify({ markdown, title: chatTitle || null }),
201 });
202 if (!res.ok) throw new Error("Download failed");
203 const ct = res.headers.get("content-type") || "";
......@@ -11140,14 +11154,20 @@ Collected file paths:
206 const url = URL.createObjectURL(blob);
207 const a = document.createElement("a");
208 a.href = url;
209 a.download = "son-of-anton-code.zip";
210 a.click();
211 URL.revokeObjectURL(url);
212 } else {
213 const data = await res.json();
214 if (data.error) throw new Error(data.error);
215 }
216 }│
209 // Derive filename from chat title, fallback to generic
210 const raw = (chatTitle || "").trim();
211 const safeName =
212 raw && raw !== "New Chat"
213 ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code"
214 : "code";
215 a.download = `${safeName}.zip`;
216 a.click();
217 URL.revokeObjectURL(url);
218 } else {
219 const data = await res.json();
220 if (data.error) throw new Error(data.error);
221 }
222 }│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [038]: frontend/src/api.js
......@@ -11322,7 +11342,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [040/57]: frontend/src/components/ChatView.jsx
│ LANGUAGE: jsx | LINES: 420 | SIZE: 17903 bytes
│ LANGUAGE: jsx | LINES: 418 | SIZE: 17897 bytes
├──────────────────────────────────────────────────────────────────────────────
1 import React, { useState, useEffect, useRef, useCallback } from "react";
......@@ -11660,92 +11680,90 @@ Collected file paths:
333 <div className="flex items-end gap-1.5">
334 <button
335 onClick={toggleSettings}
336 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
337 showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
338 }`}
339 >
340 <Settings2 size={18} />
341 </button>
342
343 <button
344 onClick={() => fileRef.current?.click()}
345 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
346 pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
347 }`}
348 title="Attach files"
349 >
350 <Paperclip size={18} />
351 </button>
352
353 <input
354 ref={fileRef}
355 type="file"
356 multiple
357 className="hidden"
358 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,.gd,.dart,.vue,.svelte,.log"
359 onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
360 />
361
362 <div className="flex-1 min-w-0">
363 <textarea
364 ref={inputRef}
365 value={input}
366 onChange={(e) => setInput(e.target.value)}
367 onKeyDown={handleKeyDown}
368 onPaste={handlePaste}
369 placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"}
370 rows={1}
371 style={{ maxHeight: "120px" }}
372 className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
373 onInput={(e) => {
374 e.target.style.height = "auto";
375 e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
376 }}
377 />
378 </div>
379
380 {streaming ? (
381 <button
382 onClick={() => streamManager.abortStream(chatId)}
383 className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
384 >
385 <Square size={18} />
386 </button>
387 ) : (
388 <button
389 onClick={handleSend}
390 disabled={(!input.trim() && !pendingFiles.length) || uploading}
391 className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
392 >
393 {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
394 </button>
395 )}
396 </div>
397
398 {/* Status bar */}
399 <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
400 <span>{MODELS.find((m) => m.id === model)?.label}</span>
401 <span>•</span>
402 <span>{maxTokens.toLocaleString()} tok</span>
403 {reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
404 {selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
405 {pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
406 {messages.some((m) => m.role === "assistant") && (
407 <button
408 onClick={async () => {
409 const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
410 if (all) try { await downloadZip(state.token, all); } catch { /* */ }
411 }}
412 className="ml-auto hover:text-anton-accent transition"
413 >
414 ⬇ Code
415 </button>
416 )}
417 </div>
418 </div>
419 </div>
420 );
421 }│
336 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
337 }`}
338 >
339 <Settings2 size={18} />
340 </button>
341
342 <button
343 onClick={() => fileRef.current?.click()}
344 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
345 }`}
346 title="Attach files"
347 >
348 <Paperclip size={18} />
349 </button>
350
351 <input
352 ref={fileRef}
353 type="file"
354 multiple
355 className="hidden"
356 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,.gd,.dart,.vue,.svelte,.log"
357 onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
358 />
359
360 <div className="flex-1 min-w-0">
361 <textarea
362 ref={inputRef}
363 value={input}
364 onChange={(e) => setInput(e.target.value)}
365 onKeyDown={handleKeyDown}
366 onPaste={handlePaste}
367 placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"}
368 rows={1}
369 style={{ maxHeight: "120px" }}
370 className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
371 onInput={(e) => {
372 e.target.style.height = "auto";
373 e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
374 }}
375 />
376 </div>
377
378 {streaming ? (
379 <button
380 onClick={() => streamManager.abortStream(chatId)}
381 className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
382 >
383 <Square size={18} />
384 </button>
385 ) : (
386 <button
387 onClick={handleSend}
388 disabled={(!input.trim() && !pendingFiles.length) || uploading}
389 className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
390 >
391 {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
392 </button>
393 )}
394 </div>
395
396 {/* Status bar */}
397 <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
398 <span>{MODELS.find((m) => m.id === model)?.label}</span>
399 <span>•</span>
400 <span>{maxTokens.toLocaleString()} tok</span>
401 {reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
402 {selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
403 {pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
404 {messages.some((m) => m.role === "assistant") && (
405 <button
406 onClick={async () => {
407 const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
408 if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { /* */ }
409 }}
410 className="ml-auto hover:text-anton-accent transition"
411 >
412 ⬇ Code
413 </button>
414 )}
415 </div>
416 </div>
417 </div>
418 );
419 }│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [040]: frontend/src/components/ChatView.jsx
......@@ -13979,9 +13997,9 @@ Collected file paths:
# ✅ END OF COMPLETE CODEBASE DUMP #
# #
# Total Files: 57 #
# Total Lines: 13183 #
# Total Lines: 13201 #
# Total Size: 475KB #
# Generated: 2026-03-29 14:58:58 #
# Generated: 2026-03-29 19:44:23 #
# #
# This file contains EVERYTHING: source code, configs, env vars, Docker, #
# CI/CD, build tools, package manifests, docs — the complete picture. #
......
"""
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):
......
"""
GitLab CE integration routes — superadmin only.
Son of Anton v4.0.0
"""
import json
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, GitLabSettings, LinkedRepo, PendingAction
from backend.auth import require_superadmin
from backend.services import gitlab_service
router = APIRouter()
# ═══════════════════════════════════════════════════
# Request Bodies
# ═══════════════════════════════════════════════════
class SettingsBody(BaseModel):
gitlab_url: str
gitlab_token: str
class CreateProjectBody(BaseModel):
name: str
description: str = ""
visibility: str = "private"
class LinkRepoBody(BaseModel):
gitlab_project_id: int
class CommitBody(BaseModel):
branch: str
commit_message: str
actions: list[dict]
class SingleCommitBody(BaseModel):
branch: str
file_path: str
content: str
commit_message: str
action: str = "update"
class BranchBody(BaseModel):
branch_name: str
ref: str = "main"
class MergeRequestBody(BaseModel):
source_branch: str
target_branch: str
title: str
description: str = ""
class ActionBody(BaseModel):
linked_repo_id: str
action_type: str
title: str = ""
payload: str
def _get_settings(db: Session) -> GitLabSettings:
s = db.query(GitLabSettings).first()
if not s or not s.is_active or not s.gitlab_url or not s.gitlab_token:
raise HTTPException(400, "GitLab not configured. Set up connection in GitLab Command Center.")
return s
def _get_repo(repo_id: str, db: Session) -> LinkedRepo:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
if not repo:
raise HTTPException(404, "Linked repo not found")
return repo
# ═══════════════════════════════════════════════════
# Connection Settings
# ═══════════════════════════════════════════════════
@router.get("/settings")
def get_settings(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = db.query(GitLabSettings).first()
if not s:
return {"gitlab_url": "", "gitlab_token_set": False, "is_active": False}
return {
"gitlab_url": s.gitlab_url,
"gitlab_token_set": bool(s.gitlab_token),
"is_active": s.is_active,
"updated_at": str(s.updated_at) if s.updated_at else None,
}
@router.put("/settings")
def update_settings(body: SettingsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = db.query(GitLabSettings).first()
if not s:
s = GitLabSettings()
db.add(s)
s.gitlab_url = body.gitlab_url.rstrip("/")
s.gitlab_token = body.gitlab_token
s.is_active = True
db.commit()
return {"ok": True}
@router.post("/test-connection")
async def test_connection(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = db.query(GitLabSettings).first()
if not s or not s.gitlab_url or not s.gitlab_token:
raise HTTPException(400, "GitLab URL and token not configured")
try:
result = await gitlab_service.test_connection(s.gitlab_url, s.gitlab_token)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Connection failed: {e.detail}")
except Exception as e:
raise HTTPException(502, f"Cannot reach GitLab: {str(e)}")
# ═══════════════════════════════════════════════════
# GitLab Projects (remote)
# ═══════════════════════════════════════════════════
@router.get("/projects")
async def search_projects(
search: Optional[str] = Query(None),
owned: bool = Query(False),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
try:
projects = await gitlab_service.list_projects(s.gitlab_url, s.gitlab_token, search=search, owned=owned)
return projects
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/projects")
async def create_project(body: CreateProjectBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
try:
project = await gitlab_service.create_project(
s.gitlab_url, s.gitlab_token,
name=body.name, description=body.description, visibility=body.visibility,
)
return project
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
# ═══════════════════════════════════════════════════
# Linked Repos (local)
# ═══════════════════════════════════════════════════
@router.get("/repos")
def list_repos(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
repos = db.query(LinkedRepo).order_by(LinkedRepo.created_at.desc()).all()
return [_repo_dict(r) for r in repos]
@router.post("/repos")
async def link_repo(body: LinkRepoBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
existing = db.query(LinkedRepo).filter(LinkedRepo.gitlab_project_id == body.gitlab_project_id).first()
if existing:
return _repo_dict(existing)
s = _get_settings(db)
try:
project = await gitlab_service.get_project(s.gitlab_url, s.gitlab_token, body.gitlab_project_id)
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Cannot fetch project: {e.detail}")
repo = LinkedRepo(
gitlab_project_id=project["id"],
name=project["name"],
path_with_namespace=project["path_with_namespace"],
default_branch=project.get("default_branch", "main"),
web_url=project.get("web_url", ""),
description=project.get("description", ""),
)
db.add(repo)
db.commit()
db.refresh(repo)
return _repo_dict(repo)
@router.delete("/repos/{repo_id}")
def unlink_repo(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
repo = _get_repo(repo_id, db)
db.delete(repo)
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════
# Repository Operations
# ═══════════════════════════════════════════════════
@router.get("/repos/{repo_id}/tree")
async def get_tree(
repo_id: str,
path: str = Query(""),
ref: Optional[str] = Query(None),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
branch = ref or repo.default_branch
try:
tree = await gitlab_service.get_tree(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, path=path, ref=branch)
return {"branch": branch, "path": path, "items": tree}
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.get("/repos/{repo_id}/file")
async def get_file(
repo_id: str,
path: str = Query(...),
ref: Optional[str] = Query(None),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
branch = ref or repo.default_branch
try:
file_data = await gitlab_service.get_file_content(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, path, ref=branch)
return file_data
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.get("/repos/{repo_id}/branches")
async def get_branches(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
branches = await gitlab_service.list_branches(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id)
return branches
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/repos/{repo_id}/branches")
async def create_branch(repo_id: str, body: BranchBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.create_branch(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, body.branch_name, body.ref)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/repos/{repo_id}/commit")
async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.commit_files(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.branch, body.commit_message, body.actions,
)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/repos/{repo_id}/commit-single")
async def commit_single(repo_id: str, body: SingleCommitBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.commit_single_file(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.branch, body.file_path, body.content, body.commit_message, body.action,
)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/repos/{repo_id}/merge-request")
async def create_mr(repo_id: str, body: MergeRequestBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.create_merge_request(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.source_branch, body.target_branch, body.title, body.description,
)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.get("/repos/{repo_id}/analyze")
async def analyze_project(
repo_id: str,
ref: Optional[str] = Query(None),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
branch = ref or repo.default_branch
try:
result = await gitlab_service.load_project_files(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, ref=branch)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
# ═══════════════════════════════════════════════════
# Pending Actions
# ═══════════════════════════════════════════════════
@router.get("/actions")
def list_actions(
status: str = Query("pending"),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
q = db.query(PendingAction).filter(PendingAction.status == status)
actions = q.order_by(PendingAction.created_at.desc()).limit(100).all()
return [_action_dict(a, db) for a in actions]
@router.post("/actions")
def create_action(body: ActionBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
repo = _get_repo(body.linked_repo_id, db)
action = PendingAction(
linked_repo_id=repo.id,
action_type=body.action_type,
title=body.title,
payload=body.payload,
)
db.add(action)
db.commit()
db.refresh(action)
return _action_dict(action, db)
@router.post("/actions/{action_id}/approve")
async def approve_action(action_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
action = db.query(PendingAction).filter(PendingAction.id == action_id).first()
if not action:
raise HTTPException(404)
if action.status != "pending":
raise HTTPException(400, f"Action already {action.status}")
s = _get_settings(db)
repo = _get_repo(action.linked_repo_id, db)
payload = json.loads(action.payload)
try:
if action.action_type == "commit":
result = await gitlab_service.commit_files(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
payload["branch"], payload["commit_message"], payload["actions"],
)
action.result_message = json.dumps(result)
elif action.action_type == "create_branch":
result = await gitlab_service.create_branch(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
payload["branch_name"], payload.get("ref", repo.default_branch),
)
action.result_message = json.dumps(result)
elif action.action_type == "create_mr":
result = await gitlab_service.create_merge_request(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
payload["source_branch"], payload["target_branch"],
payload["title"], payload.get("description", ""),
)
action.result_message = json.dumps(result)
else:
raise HTTPException(400, f"Unknown action type: {action.action_type}")
action.status = "approved"
action.resolved_at = datetime.utcnow()
db.commit()
return {"ok": True, "result": json.loads(action.result_message)}
except gitlab_service.GitLabError as e:
action.status = "rejected"
action.result_message = f"GitLab error: {e.detail}"
action.resolved_at = datetime.utcnow()
db.commit()
raise HTTPException(e.status_code, e.detail)
@router.post("/actions/{action_id}/reject")
def reject_action(action_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
action = db.query(PendingAction).filter(PendingAction.id == action_id).first()
if not action:
raise HTTPException(404)
action.status = "rejected"
action.resolved_at = datetime.utcnow()
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════
def _repo_dict(r: LinkedRepo) -> dict:
return {
"id": r.id,
"gitlab_project_id": r.gitlab_project_id,
"name": r.name,
"path_with_namespace": r.path_with_namespace,
"default_branch": r.default_branch,
"web_url": r.web_url,
"description": r.description,
"created_at": str(r.created_at),
}
def _action_dict(a: PendingAction, db: Session) -> dict:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == a.linked_repo_id).first()
return {
"id": a.id,
"linked_repo_id": a.linked_repo_id,
"repo_name": repo.name if repo else "?",
"action_type": a.action_type,
"title": a.title,
"payload": a.payload,
"status": a.status,
"result_message": a.result_message,
"created_at": str(a.created_at),
"resolved_at": str(a.resolved_at) if a.resolved_at else None,
}
\ No newline at end of file
"""
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
"""
GitLab CE REST API v4 client.
Uses httpx (already in requirements) for all HTTP calls.
All functions are async so they work cleanly in FastAPI async endpoints.
"""
import base64
import json
from typing import Optional
from urllib.parse import quote
import httpx
class GitLabError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(f"GitLab {status_code}: {detail}")
def _timeout():
return httpx.Timeout(connect=15.0, read=60.0, write=30.0, pool=30.0)
async def _request(
method: str,
url: str,
token: str,
**kwargs,
) -> dict | list:
async with httpx.AsyncClient(timeout=_timeout()) as client:
headers = {"Private-Token": token}
resp = await client.request(method, url, headers=headers, **kwargs)
if resp.status_code >= 400:
detail = resp.text[:500]
raise GitLabError(resp.status_code, detail)
if resp.status_code == 204:
return {}
return resp.json()
def _api(gitlab_url: str, path: str) -> str:
base = gitlab_url.rstrip("/")
return f"{base}/api/v4{path}"
# ═══════════════════════════════════════════════════
# Connection
# ═══════════════════════════════════════════════════
async def test_connection(gitlab_url: str, token: str) -> dict:
url = _api(gitlab_url, "/user")
data = await _request("GET", url, token)
return {"ok": True, "username": data.get("username", ""), "name": data.get("name", "")}
# ═══════════════════════════════════════════════════
# Projects
# ═══════════════════════════════════════════════════
async def list_projects(
gitlab_url: str, token: str,
search: Optional[str] = None,
page: int = 1, per_page: int = 50,
owned: bool = False,
) -> list[dict]:
params = {"page": page, "per_page": per_page, "order_by": "updated_at"}
if search:
params["search"] = search
if owned:
params["owned"] = "true"
url = _api(gitlab_url, "/projects")
data = await _request("GET", url, token, params=params)
return [
{
"id": p["id"],
"name": p["name"],
"path_with_namespace": p["path_with_namespace"],
"default_branch": p.get("default_branch", "main"),
"web_url": p.get("web_url", ""),
"description": p.get("description") or "",
"last_activity_at": p.get("last_activity_at", ""),
}
for p in data
]
async def create_project(
gitlab_url: str, token: str,
name: str, description: str = "",
visibility: str = "private",
initialize_with_readme: bool = True,
) -> dict:
url = _api(gitlab_url, "/projects")
body = {
"name": name,
"description": description,
"visibility": visibility,
"initialize_with_readme": initialize_with_readme,
}
data = await _request("POST", url, token, json=body)
return {
"id": data["id"],
"name": data["name"],
"path_with_namespace": data["path_with_namespace"],
"default_branch": data.get("default_branch", "main"),
"web_url": data.get("web_url", ""),
"description": data.get("description") or "",
}
async def get_project(gitlab_url: str, token: str, project_id: int) -> dict:
url = _api(gitlab_url, f"/projects/{project_id}")
data = await _request("GET", url, token)
return {
"id": data["id"],
"name": data["name"],
"path_with_namespace": data["path_with_namespace"],
"default_branch": data.get("default_branch", "main"),
"web_url": data.get("web_url", ""),
"description": data.get("description") or "",
"last_activity_at": data.get("last_activity_at", ""),
"forks_count": data.get("forks_count", 0),
"star_count": data.get("star_count", 0),
}
# ═══════════════════════════════════════════════════
# Repository Tree
# ═══════════════════════════════════════════════════
async def get_tree(
gitlab_url: str, token: str,
project_id: int, path: str = "",
ref: str = "main", recursive: bool = True,
) -> list[dict]:
all_items = []
page = 1
while True:
params = {"ref": ref, "per_page": 100, "page": page, "recursive": str(recursive).lower()}
if path:
params["path"] = path
url = _api(gitlab_url, f"/projects/{project_id}/repository/tree")
try:
data = await _request("GET", url, token, params=params)
except GitLabError:
break
if not data:
break
all_items.extend(data)
if len(data) < 100:
break
page += 1
if page > 20:
break
return [
{"name": i["name"], "path": i["path"], "type": i["type"], "mode": i.get("mode", "")}
for i in all_items
]
def format_tree_for_prompt(items: list[dict], repo_name: str, branch: str) -> str:
if not items:
return f"[Repository: {repo_name} ({branch}) — empty or inaccessible]"
dirs = set()
files = []
for item in sorted(items, key=lambda x: x["path"]):
if item["type"] == "tree":
dirs.add(item["path"])
else:
files.append(item["path"])
lines = [f"Repository: {repo_name} (branch: {branch})", f"Total files: {len(files)}", ""]
for f in files:
lines.append(f" {f}")
return "\n".join(lines)
# ═══════════════════════════════════════════════════
# File Operations
# ═══════════════════════════════════════════════════
async def get_file_content(
gitlab_url: str, token: str,
project_id: int, file_path: str,
ref: str = "main",
) -> dict:
encoded_path = quote(file_path, safe="")
url = _api(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}")
params = {"ref": ref}
data = await _request("GET", url, token, params=params)
content_raw = data.get("content", "")
encoding = data.get("encoding", "base64")
if encoding == "base64" and content_raw:
try:
content = base64.b64decode(content_raw).decode("utf-8", errors="replace")
except Exception:
content = "[Binary file — cannot decode]"
else:
content = content_raw
return {
"file_path": data.get("file_path", file_path),
"file_name": data.get("file_name", ""),
"size": data.get("size", 0),
"content": content,
"ref": data.get("ref", ref),
"last_commit_id": data.get("last_commit_id", ""),
}
async def get_file_raw(
gitlab_url: str, token: str,
project_id: int, file_path: str,
ref: str = "main",
) -> str:
encoded_path = quote(file_path, safe="")
url = _api(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}/raw")
params = {"ref": ref}
async with httpx.AsyncClient(timeout=_timeout()) as client:
resp = await client.get(url, headers={"Private-Token": token}, params=params)
if resp.status_code >= 400:
raise GitLabError(resp.status_code, resp.text[:300])
return resp.text
# ═══════════════════════════════════════════════════
# Commits
# ═══════════════════════════════════════════════════
async def commit_files(
gitlab_url: str, token: str,
project_id: int, branch: str,
commit_message: str,
actions: list[dict],
) -> dict:
"""
Atomic multi-file commit.
Each action: {"action": "create"|"update"|"delete", "file_path": "...", "content": "..."}
"""
url = _api(gitlab_url, f"/projects/{project_id}/repository/commits")
body = {
"branch": branch,
"commit_message": commit_message,
"actions": actions,
}
data = await _request("POST", url, token, json=body)
return {
"id": data.get("id", ""),
"short_id": data.get("short_id", ""),
"message": data.get("message", ""),
"web_url": data.get("web_url", ""),
}
async def commit_single_file(
gitlab_url: str, token: str,
project_id: int, branch: str,
file_path: str, content: str,
commit_message: str,
action: str = "update",
) -> dict:
actions = [{"action": action, "file_path": file_path, "content": content}]
return await commit_files(gitlab_url, token, project_id, branch, commit_message, actions)
# ═══════════════════════════════════════════════════
# Branches
# ═══════════════════════════════════════════════════
async def list_branches(
gitlab_url: str, token: str,
project_id: int,
) -> list[dict]:
url = _api(gitlab_url, f"/projects/{project_id}/repository/branches")
params = {"per_page": 100}
data = await _request("GET", url, token, params=params)
return [
{
"name": b["name"],
"default": b.get("default", False),
"web_url": b.get("web_url", ""),
"commit_short_id": b.get("commit", {}).get("short_id", ""),
"commit_message": (b.get("commit", {}).get("message") or "")[:100],
}
for b in data
]
async def create_branch(
gitlab_url: str, token: str,
project_id: int,
branch_name: str, ref: str = "main",
) -> dict:
url = _api(gitlab_url, f"/projects/{project_id}/repository/branches")
body = {"branch": branch_name, "ref": ref}
data = await _request("POST", url, token, json=body)
return {"name": data["name"], "web_url": data.get("web_url", "")}
# ═══════════════════════════════════════════════════
# Merge Requests
# ═══════════════════════════════════════════════════
async def create_merge_request(
gitlab_url: str, token: str,
project_id: int,
source_branch: str,
target_branch: str,
title: str,
description: str = "",
) -> dict:
url = _api(gitlab_url, f"/projects/{project_id}/merge_requests")
body = {
"source_branch": source_branch,
"target_branch": target_branch,
"title": title,
"description": description,
}
data = await _request("POST", url, token, json=body)
return {
"iid": data.get("iid"),
"title": data.get("title", ""),
"web_url": data.get("web_url", ""),
"state": data.get("state", ""),
}
# ═══════════════════════════════════════════════════
# Bulk Load for Analysis
# ═══════════════════════════════════════════════════
TEXT_EXTENSIONS = {
".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", ".sh", ".bash", ".sql", ".md", ".txt",
".env", ".dockerfile", ".vue", ".svelte", ".dart", ".r", ".csv",
}
MAX_ANALYSIS_FILES = 80
MAX_ANALYSIS_CHARS = 300_000
async def load_project_files(
gitlab_url: str, token: str,
project_id: int, ref: str = "main",
path: str = "",
) -> dict:
tree = await get_tree(gitlab_url, token, project_id, path=path, ref=ref, recursive=True)
text_files = []
for item in tree:
if item["type"] != "blob":
continue
ext = ""
name = item["path"].lower()
if "." in name:
ext = "." + name.rsplit(".", 1)[-1]
if ext in TEXT_EXTENSIONS or name in {"dockerfile", "makefile", "gemfile", "rakefile"}:
text_files.append(item["path"])
text_files = text_files[:MAX_ANALYSIS_FILES]
contents = []
total_chars = 0
for fp in text_files:
if total_chars >= MAX_ANALYSIS_CHARS:
break
try:
raw = await get_file_raw(gitlab_url, token, project_id, fp, ref=ref)
if total_chars + len(raw) > MAX_ANALYSIS_CHARS:
raw = raw[:MAX_ANALYSIS_CHARS - total_chars] + "\n... [truncated]"
contents.append({"path": fp, "content": raw})
total_chars += len(raw)
except Exception:
contents.append({"path": fp, "content": "[Could not read file]"})
return {
"total_files_in_tree": len(tree),
"files_loaded": len(contents),
"total_characters": total_chars,
"files": contents,
}
\ No newline at end of file
"""
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>
);
......
......@@ -21,51 +21,24 @@ async function request(method, path, token, body) {
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 });
// ═══════════ Auth ═══════════
export const login = (t, u, p) => request("POST", "/auth/login", null, { username: u, password: p });
export const register = (username, email, password) => request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token);
// ═══════════════════════════════════════════════════
// Chats
// ═══════════════════════════════════════════════════
// ═══════════ 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 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);
// ═══════════════════════════════════════════════════
// Streaming
// ═══════════════════════════════════════════════════
// ═══════════ Streaming ═══════════
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST",
headers: headers(token),
body: JSON.stringify(body),
signal,
method: "POST", headers: headers(token), body: JSON.stringify(body), signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
......@@ -83,121 +56,57 @@ export async function* streamMessage(token, chatId, body, signal) {
for (const part of parts) {
const line = part.trim();
if (line.startsWith("data: ")) {
try {
yield JSON.parse(line.slice(6));
} catch {
/* skip */
}
try { yield JSON.parse(line.slice(6)); } catch { /* skip */ }
}
}
}
if (buffer.trim().startsWith("data: ")) {
try {
yield JSON.parse(buffer.trim().slice(6));
} catch {
/* skip */
}
try { yield JSON.parse(buffer.trim().slice(6)); } catch { /* skip */ }
}
}
// ═══════════════════════════════════════════════════
// Chat Attachments
// ═══════════════════════════════════════════════════
// ═══════════ 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,
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
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);
// ═══════════════════════════════════════════════════
// Knowledge Base Documents
// ═══════════════════════════════════════════════════
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 function getAttachmentUrl(attachmentId) { return `${BASE}/attachments/${attachmentId}/file`; }
export const deleteAttachment = (token, attachmentId) => request("DELETE", `/attachments/${attachmentId}`, token);
// ═══════════ Knowledge ═══════════
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");
}
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 uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
// ═══════════════════════════════════════════════════
// Admin
// ═══════════════════════════════════════════════════
// ═══════════ Admin ═══════════
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);
// ═══════════════════════════════════════════════════
// Code Download
// ═══════════════════════════════════════════════════
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);
// ═══════════ Code Download ═══════════
export async function downloadZip(token, markdown, chatTitle) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST",
headers: headers(token),
body: JSON.stringify({ markdown, title: chatTitle || null }),
method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || null }),
});
if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || "";
......@@ -206,12 +115,8 @@ export async function downloadZip(token, markdown, chatTitle) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Derive filename from chat title, fallback to generic
const raw = (chatTitle || "").trim();
const safeName =
raw && raw !== "New Chat"
? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code"
: "code";
const safeName = raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code";
a.download = `${safeName}.zip`;
a.click();
URL.revokeObjectURL(url);
......@@ -219,4 +124,33 @@ export async function downloadZip(token, markdown, chatTitle) {
const data = await res.json();
if (data.error) throw new Error(data.error);
}
}
\ No newline at end of file
}
// ═══════════════════════════════════════════════════
// GitLab CE Integration — v4.0.0
// ═══════════════════════════════════════════════════
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);
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);
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 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);
export const gitlabGetBranches = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/branches`, token);
export const gitlabCreateBranch = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
export const gitlabCommit = (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 gitlabCreateMR = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
export const gitlabAnalyzeProject = (token, repoId, ref) =>
request("GET", `/gitlab/repos/${repoId}/analyze?ref=${encodeURIComponent(ref || "")}`, token);
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);
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
GitBranch,
} from "lucide-react";
const MODELS = [
......@@ -26,17 +27,13 @@ function classifyFile(f) {
return "text";
}
function fmtSize(b) {
if (!b) return "0B";
if (b < 1024) return b + "B";
if (b < 1048576) return (b / 1024).toFixed(0) + "KB";
return (b / 1048576).toFixed(1) + "MB";
}
function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; }
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const currentChat = state.chats.find((c) => c.id === chatId);
const currentChat = state.chats.find(c => c.id === chatId);
const messages = state.chatMessages[chatId] || [];
const isSuperadmin = state.user?.role === "superadmin";
const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false);
......@@ -44,7 +41,9 @@ export default function ChatView({ chatId }) {
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 [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
......@@ -63,186 +62,106 @@ export default function ChatView({ chatId }) {
const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
rafRef.current = null;
});
rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; });
}, []);
useEffect(() => {
(async () => {
try {
const [msgs, kbData] = await Promise.all([
getMessages(state.token, chatId),
listKnowledgeBases(state.token),
]);
const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData);
} catch { /* ignore */ }
if (isSuperadmin) { try { setRepos(await gitlabListRepos(state.token)); } catch { } }
} catch { }
})();
}, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => {
if (currentChat) {
setModel(currentChat.model || MODELS[0].id);
setMaxTokens(currentChat.max_tokens || 4096);
setReasoningBudget(currentChat.reasoning_budget ?? 0);
setSelectedKbId(currentChat.knowledge_base_id || null);
setSelectedRepoId(currentChat.linked_repo_id || null);
}
}, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
function onScroll() { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }
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 { /* ignore */ }
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "" });
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId } });
} catch { }
}
function toggleSettings() {
if (showSettings) saveSettings();
setShowSettings(!showSettings);
}
function addFiles(files) {
setPendingFiles((prev) => [
...prev,
...files.map((f) => ({
file: f,
type: classifyFile(f),
preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null,
})),
]);
}
function removePending(i) {
setPendingFiles((prev) => {
if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview);
return prev.filter((_, j) => j !== i);
});
}
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); }
function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
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) || streamData.streaming) 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 { setUploading(false); return; }
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 { 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;
// Reset textarea height
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;
if (inputRef.current) inputRef.current.style.height = "auto";
streamManager.startStream({
token: state.token,
chatId,
body: {
content: text, model, max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attIds,
},
});
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 handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []).filter(i => i.kind === "file"); if (!items.length) return; e.preventDefault(); addFiles(items.map(i => i.getAsFile()).filter(Boolean)); }
function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = Array.from(e.dataTransfer?.files || []); if (files.length) addFiles(files); }
function handlePaste(e) {
const items = Array.from(e.clipboardData?.items || []).filter((i) => i.kind === "file");
if (!items.length) return;
e.preventDefault();
addFiles(items.map((i) => i.getAsFile()).filter(Boolean));
}
const streaming = streamData.streaming;
const linkedRepo = currentChat?.linked_repo;
function handleDrop(e) {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer?.files || []);
if (files.length) addFiles(files);
async function handleCommitFromChat(filePath, code, action) {
if (!linkedRepo) return;
const branch = linkedRepo.default_branch;
const msg = prompt("Commit message:", `Update ${filePath} via Son of Anton`);
if (!msg) return;
try {
await gitlabCommitSingle(state.token, linkedRepo.id, { branch, file_path: filePath, content: code, commit_message: msg, action });
alert(`✅ Committed to ${branch}`);
} catch (e) { alert(`❌ ${e.message}`); }
}
const streaming = streamData.streaming;
return (
<div
className="flex-1 flex flex-col min-h-0 relative"
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}
>
{/* Drag overlay */}
<div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={e => { e.preventDefault(); setDragOver(true); }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}>
{dragOver && (
<div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none">
<div className="text-center">
<Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" />
<p className="text-white font-semibold text-sm">Drop files here</p>
</div>
<div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div>
</div>
)}
{/* Repo banner */}
{linkedRepo && (
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs">
<GitBranch size={12} className="text-orange-400" />
<span className="text-orange-300 font-medium">{linkedRepo.name}</span>
<span className="text-orange-300/60">({linkedRepo.default_branch})</span>
<span className="text-orange-300/40 ml-auto">Project-aware mode</span>
</div>
)}
{/* Messages */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} token={state.token} />
))}
{messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} />)}
{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}
/>
<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-3 py-3 animate-fade-in">
<div className="flex gap-1">
{[0, 150, 300].map((d) => (
<span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />
))}
</div>
<div className="flex gap-1">{[0, 150, 300].map(d => <span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}</div>
<span className="text-anton-muted text-sm">Thinking…</span>
</div>
)}
......@@ -250,167 +169,93 @@ export default function ChatView({ chatId }) {
{/* Input area */}
<div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom">
{/* Settings panel */}
{showSettings && (
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<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="p-1 text-anton-muted hover:text-white">
<X size={14} />
</button>
<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="p-1 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.5 text-white focus:outline-none focus:border-anton-accent">
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
<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.5 text-white 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.5">
<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 className="flex justify-between text-xs mb-1.5"><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.5">
<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 className="flex justify-between text-xs mb-1.5"><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.5 text-white focus:outline-none focus:border-anton-accent">
<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.5 text-white 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>)}
{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
</div>
{isSuperadmin && repos.length > 0 && (
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label>
<select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400">
<option value="">None</option>
{repos.map(r => <option key={r.id} value={r.id}>{r.name} ({r.default_branch})</option>)}
</select>
</div>
)}
</div>
)}
{/* Pending files */}
{pendingFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => {
const Icon = TYPE_ICONS[pf.type] || FileText;
return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" />
) : (
{pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" /> : (
<div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1">
<Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} />
<span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span>
</div>
)}
<button
onClick={() => removePending(i)}
className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
>
<X size={10} />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">
{fmtSize(pf.file.size)}
</div>
<button onClick={() => removePending(i)} className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div>
</div>
);
})}
</div>
)}
{/* Input row */}
<div className="flex items-end gap-1.5">
<button
onClick={toggleSettings}
className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
}`}
>
<Settings2 size={18} />
</button>
<button
onClick={() => fileRef.current?.click()}
className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active: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,.gd,.dart,.vue,.svelte,.log"
onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
/>
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${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 min-w-[40px] min-h-[40px] flex items-center justify-center ${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,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
<div className="flex-1 min-w-0">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"}
rows={1}
style={{ maxHeight: "120px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
onInput={(e) => {
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
}}
/>
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"}
rows={1} style={{ maxHeight: "120px" }} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "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 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
>
<Square size={18} />
</button>
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
) : (
<button
onClick={handleSend}
disabled={(!input.trim() && !pendingFiles.length) || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
>
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || uploading} className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div>
{/* Status bar */}
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span>
<span>{maxTokens.toLocaleString()} tok</span>
<span>{MODELS.find(m => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</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, currentChat?.title); } catch { /* */ }
}}
className="ml-auto hover:text-anton-accent transition"
>
⬇ Code
</button>
{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, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button>
)}
</div>
</div>
......
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
......@@ -8,163 +8,110 @@ import {
Image, Film, FileText, ExternalLink,
} from "lucide-react";
const FILE_TYPE_ICONS = {
image: Image, video: Film, document: FileText, text: FileText,
};
const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText };
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo, onCommit }) {
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);
}
function handleCopy() { navigator.clipboard.writeText(content || ""); setCopied(true); setTimeout(() => setCopied(false), 2000); }
const hasAttachments = attachments && attachments.length > 0;
return (
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && (
<div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm: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={14} className="text-white" />
<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={`min-w-0 ${isUser ? "max-w-[85%] sm:max-w-[75%]" : "max-w-[90%] sm:max-w-[80%]"}`}>
{/* Thinking block */}
<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 min-h-[32px]"
>
<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-2.5 sm:p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto overscroll-contain break-words">
{thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
<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>
)}
{/* Attachments */}
{hasAttachments && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((att) => {
<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">
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm: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"; }}
/>
<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-4 sm: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 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-[8px] text-white px-1 py-0.5 rounded">
{att.original_filename}
</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-2.5 py-2 hover:border-anton-accent transition group min-h-[44px]"
>
<Icon size={14} className="shrink-0 text-blue-400" />
<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-[120px] sm:max-w-[160px]">{att.original_filename}</div>
<div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
<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={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
<ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a>
);
})}
</div>
)}
{/* Message bubble */}
<div className={`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${
isUser
? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
<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 break-words leading-relaxed">{_stripPrefixes(content)}</div>
<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}</>; },
}}
>
<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$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />;
},
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" />
)}
{isStreaming && !isThinking && <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />}
</div>
)}
</div>
{/* Actions */}
{!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1 px-1">
<button
onClick={handleCopy}
className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition min-h-[28px]"
>
{copied ? <Check size={10} className="text-anton-success" /> : <Copy size={10} />}
{copied ? "Copied" : "Copy"}
<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-[10px] text-anton-muted">
{input_tokens?.toLocaleString()}{output_tokens?.toLocaleString()}
</span>
<span className="text-[11px] text-anton-muted">{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}</span>
)}
</div>
)}
......@@ -172,8 +119,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && (
<div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={14} className="text-anton-muted" />
<div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={16} className="text-anton-muted" />
</div>
</div>
)}
......
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import React, { useState, useEffect } from "react";
import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api";
import { useNavigate } from "react-router-dom";
import { listChats, createChat, deleteChat, renameChat } from "../api";
import {
Flame, Plus, MessageSquare, Trash2, Edit3, Check, X,
LogOut, Shield, BookOpen, ChevronRight,
Plus, Trash2, MessageSquare, Flame, LogOut, Shield, BookOpen,
Edit3, Check, X, GitBranch,
} from "lucide-react";
export default function Sidebar({ mobile, onClose }) {
export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose }) {
const { state, dispatch } = useApp();
const navigate = useNavigate();
const nav = useNavigate();
const [editId, setEditId] = useState(null);
const [editTitle, setEditTitle] = useState("");
useEffect(() => {
(async () => {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch { }
})();
}, [state.token, dispatch]);
async function handleNew() {
try {
const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat });
} catch { /* ignore */ }
onSelectChat(chat.id);
} catch { }
}
async function handleDelete(e, chatId) {
......@@ -26,156 +36,89 @@ export default function Sidebar({ mobile, onClose }) {
try {
await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId });
} catch { /* ignore */ }
}
function startEdit(e, chat) {
e.stopPropagation();
setEditId(chat.id);
setEditTitle(chat.title);
}
async function confirmEdit(e) {
e.stopPropagation();
if (editTitle.trim() && editId) {
try {
await renameChat(state.token, editId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: editId, title: editTitle.trim() } });
} catch { /* ignore */ }
}
setEditId(null);
if (activeChatId === chatId) onSelectChat(null);
} catch { }
}
function cancelEdit(e) {
e.stopPropagation();
async function handleRename(chatId) {
if (!editTitle.trim()) { setEditId(null); return; }
try {
await renameChat(state.token, chatId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } });
} catch { }
setEditId(null);
}
function selectChat(chatId) {
dispatch({ type: "SET_ACTIVE_CHAT", chatId });
}
function handleLogout() {
dispatch({ type: "LOGOUT" });
}
const isSuperadmin = state.user?.role === "superadmin";
return (
<div className={`flex flex-col bg-anton-surface border-r border-anton-border h-full ${mobile ? "w-full" : "w-64"}`}>
{/* Header */}
<div className="p-3 border-b border-anton-border">
<div className="flex items-center gap-2.5 mb-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 shrink-0">
<Flame size={18} className="text-white" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-white truncate">Son of Anton</h1>
<p className="text-[10px] text-anton-muted truncate">{state.user?.username || ""}</p>
<>
{isOpen && <div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={onClose} />}
<div className={`fixed md:static z-50 inset-y-0 left-0 w-72 bg-anton-surface border-r border-anton-border flex flex-col transition-transform duration-200 ${isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}`}>
{/* Header */}
<div className="p-3 border-b border-anton-border">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center">
<Flame size={16} className="text-white" />
</div>
<div>
<h1 className="text-sm font-bold text-white">Son of Anton</h1>
<p className="text-[10px] text-anton-muted">v4.0.0 — The Architect</p>
</div>
</div>
{mobile && (
<button onClick={onClose} className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition">
<X size={18} />
</button>
)}
<button onClick={handleNew} className="w-full flex items-center justify-center gap-1.5 bg-anton-accent text-white rounded-lg py-2 text-sm hover:opacity-80 transition">
<Plus size={16} /> New Chat
</button>
</div>
<button
onClick={handleNew}
className="w-full flex items-center justify-center gap-2 py-2.5 px-3 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition active:scale-[0.98]"
>
<Plus size={16} /> New Chat
</button>
</div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto py-1.5 px-1.5 space-y-0.5">
{state.chats.length === 0 && (
<p className="text-center text-anton-muted text-xs py-8 px-4">
No chats yet. Start a new conversation!
</p>
)}
{state.chats.map((chat) => {
const active = chat.id === state.activeChatId;
const editing = editId === chat.id;
return (
<div
key={chat.id}
onClick={() => !editing && selectChat(chat.id)}
className={`group flex items-center gap-2 px-2.5 py-2.5 rounded-lg cursor-pointer transition min-h-[44px] ${
active
? "bg-anton-accent/15 text-white border border-anton-accent/30"
: "text-anton-muted hover:bg-anton-card hover:text-white border border-transparent"
}`}
>
<MessageSquare size={14} className="shrink-0 opacity-50" />
{editing ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") confirmEdit(e); if (e.key === "Escape") cancelEdit(e); }}
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
autoFocus
/>
<button onClick={confirmEdit} className="p-1 text-anton-success"><Check size={14} /></button>
<button onClick={cancelEdit} className="p-1 text-anton-muted"><X size={14} /></button>
{/* Chat list */}
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{state.chats.map((c) => (
<div key={c.id} onClick={() => { onSelectChat(c.id); onClose?.(); }}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition text-sm ${activeChatId === c.id ? "bg-anton-accent/15 text-white" : "text-anton-muted hover:bg-anton-card hover:text-white"}`}>
<MessageSquare size={14} className="shrink-0" />
{editId === c.id ? (
<div className="flex-1 flex items-center gap-1">
<input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)}
className="flex-1 bg-anton-bg border border-anton-border rounded px-1.5 py-0.5 text-xs text-white" autoFocus />
<button onClick={() => handleRename(c.id)} className="text-green-400"><Check size={12} /></button>
<button onClick={() => setEditId(null)} className="text-red-400"><X size={12} /></button>
</div>
) : (
<>
<span className="flex-1 text-sm truncate">{chat.title}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={(e) => startEdit(e, chat)} className="p-1 rounded hover:bg-anton-bg transition"><Edit3 size={12} /></button>
<button onClick={(e) => handleDelete(e, chat.id)} className="p-1 rounded hover:bg-anton-danger/20 text-anton-danger transition"><Trash2 size={12} /></button>
<span className="flex-1 truncate text-xs">{c.title}</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{c.linked_repo_id && <GitBranch size={11} className="text-orange-400" />}
<button onClick={(e) => { e.stopPropagation(); setEditId(c.id); setEditTitle(c.title); }} className="p-0.5 hover:text-anton-accent"><Edit3 size={11} /></button>
<button onClick={(e) => handleDelete(e, c.id)} className="p-0.5 hover:text-red-400"><Trash2 size={11} /></button>
</div>
</>
)}
</div>
);
})}
</div>
))}
</div>
{/* Footer nav */}
<div className="p-2 border-t border-anton-border space-y-0.5">
<button
onClick={() => { navigate("/knowledge"); onClose?.(); }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
>
<BookOpen size={16} /> Knowledge Bases <ChevronRight size={14} className="ml-auto opacity-40" />
</button>
{isSuperadmin && (
<button
onClick={() => { navigate("/admin"); onClose?.(); }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
>
<Shield size={16} /> Admin Panel <ChevronRight size={14} className="ml-auto opacity-40" />
{/* Footer */}
<div className="p-2 border-t border-anton-border space-y-0.5">
{isSuperadmin && (
<>
<button onClick={() => nav("/gitlab")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-anton-card transition">
<GitBranch size={14} /> GitLab Center
</button>
<button onClick={() => nav("/admin")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<Shield size={14} /> Admin
</button>
</>
)}
<button onClick={() => nav("/knowledge")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<BookOpen size={14} /> Knowledge
</button>
)}
<button
onClick={handleLogout}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition min-h-[44px]"
>
<LogOut size={16} /> Sign Out
</button>
{/* Quota display */}
{state.user && (
<div className="px-3 py-2 text-[10px] text-anton-muted">
<div className="flex justify-between mb-1">
<span>Tokens used</span>
<span>{((state.user.tokens_used_this_month || 0) / 1000).toFixed(0)}K / {((state.user.quota_tokens_monthly || 0) / 1000).toFixed(0)}K</span>
</div>
<div className="h-1 bg-anton-border rounded-full overflow-hidden">
<div
className="h-full bg-anton-accent rounded-full transition-all"
style={{ width: `${Math.min(100, ((state.user.tokens_used_this_month || 0) / (state.user.quota_tokens_monthly || 1)) * 100)}%` }}
/>
</div>
</div>
)}
<button onClick={() => dispatch({ type: "LOGOUT" })} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-red-400 transition">
<LogOut size={14} /> Logout
</button>
<div className="px-3 py-1 text-[10px] text-anton-muted">{state.user?.username}{state.user?.role}</div>
</div>
</div>
</div>
</>
);
}
\ No newline at end of file
import React, { useState, useEffect } from "react";
import { useApp } from "../store";
import { useNavigate } from "react-router-dom";
import {
gitlabGetSettings, gitlabUpdateSettings, gitlabTestConnection,
gitlabSearchProjects, gitlabCreateProject, gitlabListRepos,
gitlabLinkRepo, gitlabUnlinkRepo, gitlabGetTree, gitlabGetFile,
gitlabListActions, gitlabApproveAction, gitlabRejectAction,
} from "../api";
import {
ArrowLeft, Settings2, Plug, Check, X, Search, Plus, Link, Unlink,
FolderTree, GitBranch, Eye, Shield, Clock, CheckCircle, XCircle,
Loader2, ExternalLink, FileText, Folder, RefreshCw,
} from "lucide-react";
export default function GitLabPage() {
const { state } = useApp();
const nav = useNavigate();
const t = state.token;
const [tab, setTab] = useState("connection");
const [settings, setSettings] = useState({ gitlab_url: "", gitlab_token: "", is_active: false });
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [testResult, setTestResult] = useState(null);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [projects, setProjects] = useState([]);
const [searchQ, setSearchQ] = useState("");
const [searching, setSearching] = useState(false);
const [repos, setRepos] = useState([]);
const [linking, setLinking] = useState(null);
const [actions, setActions] = useState([]);
const [actionsTab, setActionsTab] = useState("pending");
const [processingAction, setProcessingAction] = useState(null);
const [browseRepo, setBrowseRepo] = useState(null);
const [tree, setTree] = useState([]);
const [fileContent, setFileContent] = useState(null);
useEffect(() => {
if (state.user?.role !== "superadmin") { nav("/"); return; }
loadSettings();
loadRepos();
}, []);
async function loadSettings() {
try { const s = await gitlabGetSettings(t); setSettings(s); setUrl(s.gitlab_url || ""); } catch { }
}
async function loadRepos() {
try { setRepos(await gitlabListRepos(t)); } catch { }
}
async function loadActions() {
try { setActions(await gitlabListActions(t, actionsTab)); } catch { }
}
useEffect(() => { if (tab === "actions") loadActions(); }, [tab, actionsTab]);
async function handleSave() {
setSaving(true);
try {
await gitlabUpdateSettings(t, { gitlab_url: url, gitlab_token: token || "UNCHANGED" });
await loadSettings();
setTestResult(null);
} catch (e) { alert(e.message); }
setSaving(false);
}
async function handleTest() {
setTesting(true); setTestResult(null);
try {
const r = await gitlabTestConnection(t);
setTestResult({ ok: true, msg: `Connected as ${r.name} (@${r.username})` });
} catch (e) { setTestResult({ ok: false, msg: e.message }); }
setTesting(false);
}
async function handleSearch() {
setSearching(true);
try { setProjects(await gitlabSearchProjects(t, searchQ, false)); } catch { }
setSearching(false);
}
async function handleLink(projectId) {
setLinking(projectId);
try { await gitlabLinkRepo(t, projectId); await loadRepos(); } catch (e) { alert(e.message); }
setLinking(null);
}
async function handleUnlink(repoId) {
if (!confirm("Unlink this repo?")) return;
try { await gitlabUnlinkRepo(t, repoId); await loadRepos(); } catch { }
}
async function handleBrowse(repo) {
setBrowseRepo(repo); setFileContent(null);
try {
const r = await gitlabGetTree(t, repo.id, "", null);
setTree(r.items || []);
} catch { setTree([]); }
}
async function handleViewFile(path) {
if (!browseRepo) return;
try {
const f = await gitlabGetFile(t, browseRepo.id, path, null);
setFileContent(f);
} catch (e) { setFileContent({ file_path: path, content: `Error: ${e.message}` }); }
}
async function handleApprove(id) {
setProcessingAction(id);
try { await gitlabApproveAction(t, id); await loadActions(); } catch (e) { alert(e.message); }
setProcessingAction(null);
}
async function handleReject(id) {
setProcessingAction(id);
try { await gitlabRejectAction(t, id); await loadActions(); } catch (e) { alert(e.message); }
setProcessingAction(null);
}
const linked = new Set(repos.map(r => r.gitlab_project_id));
return (
<div className="h-dvh flex flex-col bg-anton-bg text-anton-text">
{/* Header */}
<div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3">
<button onClick={() => nav("/")} className="text-anton-muted hover:text-white"><ArrowLeft size={20} /></button>
<div className="flex items-center gap-2">
<GitBranch size={20} className="text-orange-400" />
<h1 className="text-lg font-bold text-white">GitLab Command Center</h1>
</div>
<div className={`ml-auto flex items-center gap-1.5 text-xs ${settings.is_active ? "text-green-400" : "text-red-400"}`}>
<div className={`w-2 h-2 rounded-full ${settings.is_active ? "bg-green-400" : "bg-red-400"}`} />
{settings.is_active ? "Connected" : "Disconnected"}
</div>
</div>
{/* Tabs */}
<div className="border-b border-anton-border bg-anton-surface flex gap-0.5 px-4">
{[["connection", "Connection", Plug], ["repos", "Repositories", FolderTree], ["actions", "Actions", Shield]].map(([key, label, Icon]) => (
<button key={key} onClick={() => setTab(key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm border-b-2 transition ${tab === key ? "border-anton-accent text-white" : "border-transparent text-anton-muted hover:text-white"}`}>
<Icon size={14} />{label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* ── CONNECTION TAB ── */}
{tab === "connection" && (
<div className="max-w-2xl mx-auto space-y-4">
<div className="bg-anton-card border border-anton-border rounded-xl p-5 space-y-4">
<h2 className="text-white font-semibold flex items-center gap-2"><Settings2 size={16} className="text-anton-accent" /> Connection Settings</h2>
<div>
<label className="text-xs text-anton-muted block mb-1">GitLab URL</label>
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://gitlab.example.com"
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent" />
</div>
<div>
<label className="text-xs text-anton-muted block mb-1">Personal Access Token</label>
<input type="password" value={token} onChange={e => setToken(e.target.value)} placeholder={settings.gitlab_token_set ? "••••••• (saved)" : "glpat-..."}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent" />
<p className="text-[10px] text-anton-muted mt-1">Needs api, read_repository, write_repository scopes</p>
</div>
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving} className="px-4 py-2 bg-anton-accent text-white rounded-lg hover:opacity-80 transition disabled:opacity-50 flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Save
</button>
<button onClick={handleTest} disabled={testing} className="px-4 py-2 bg-anton-card border border-anton-border text-white rounded-lg hover:border-anton-accent transition disabled:opacity-50 flex items-center gap-1.5">
{testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />} Test
</button>
</div>
{testResult && (
<div className={`p-3 rounded-lg text-sm ${testResult.ok ? "bg-green-500/10 text-green-400 border border-green-500/30" : "bg-red-500/10 text-red-400 border border-red-500/30"}`}>
{testResult.ok ? <CheckCircle size={14} className="inline mr-1" /> : <XCircle size={14} className="inline mr-1" />}
{testResult.msg}
</div>
)}
</div>
</div>
)}
{/* ── REPOS TAB ── */}
{tab === "repos" && (
<div className="max-w-4xl mx-auto space-y-4">
{/* Search & Link */}
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
<h2 className="text-white font-semibold flex items-center gap-2"><Search size={16} className="text-anton-accent" /> Find & Link Projects</h2>
<div className="flex gap-2">
<input value={searchQ} onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === "Enter" && handleSearch()} placeholder="Search GitLab projects..."
className="flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<button onClick={handleSearch} disabled={searching} className="px-4 py-2 bg-anton-accent text-white rounded-lg text-sm hover:opacity-80 disabled:opacity-50">
{searching ? <Loader2 size={14} className="animate-spin" /> : "Search"}
</button>
</div>
{projects.length > 0 && (
<div className="max-h-60 overflow-y-auto space-y-1">
{projects.map(p => (
<div key={p.id} className="flex items-center justify-between bg-anton-bg rounded-lg px-3 py-2">
<div className="min-w-0">
<div className="text-sm text-white truncate">{p.path_with_namespace}</div>
<div className="text-[10px] text-anton-muted truncate">{p.description || "No description"}</div>
</div>
{linked.has(p.id) ? (
<span className="text-xs text-green-400 shrink-0 ml-2">✓ Linked</span>
) : (
<button onClick={() => handleLink(p.id)} disabled={linking === p.id} className="text-xs bg-anton-accent/20 text-anton-accent px-2.5 py-1 rounded hover:bg-anton-accent/30 shrink-0 ml-2">
{linking === p.id ? <Loader2 size={12} className="animate-spin" /> : <><Link size={12} className="inline mr-1" />Link</>}
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Linked Repos */}
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-white font-semibold flex items-center gap-2"><FolderTree size={16} className="text-green-400" /> Linked Repositories ({repos.length})</h2>
<button onClick={loadRepos} className="text-anton-muted hover:text-white"><RefreshCw size={14} /></button>
</div>
{repos.length === 0 && <p className="text-anton-muted text-sm">No repos linked yet. Search above.</p>}
{repos.map(r => (
<div key={r.id} className="bg-anton-bg rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-white font-medium">{r.name}</div>
<div className="text-[10px] text-anton-muted">{r.path_with_namespace}{r.default_branch}</div>
</div>
<div className="flex gap-1.5">
{r.web_url && <a href={r.web_url} target="_blank" rel="noopener noreferrer" className="p-1.5 text-anton-muted hover:text-white"><ExternalLink size={14} /></a>}
<button onClick={() => handleBrowse(r)} className="p-1.5 text-anton-muted hover:text-green-400"><Eye size={14} /></button>
<button onClick={() => handleUnlink(r.id)} className="p-1.5 text-anton-muted hover:text-red-400"><Unlink size={14} /></button>
</div>
</div>
</div>
))}
</div>
{/* File Browser */}
{browseRepo && (
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-white font-semibold text-sm">📂 {browseRepo.name} / {browseRepo.default_branch}</h2>
<button onClick={() => { setBrowseRepo(null); setFileContent(null); }} className="text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[60vh]">
<div className="overflow-y-auto border border-anton-border rounded-lg p-2 space-y-0.5 max-h-[55vh]">
{tree.map(item => (
<button key={item.path} onClick={() => item.type === "blob" && handleViewFile(item.path)}
className={`w-full text-left px-2 py-1 rounded text-xs flex items-center gap-1.5 ${item.type === "blob" ? "hover:bg-anton-accent/10 text-white cursor-pointer" : "text-anton-muted cursor-default"}`}>
{item.type === "tree" ? <Folder size={12} className="text-blue-400 shrink-0" /> : <FileText size={12} className="text-anton-muted shrink-0" />}
<span className="truncate">{item.path}</span>
</button>
))}
</div>
<div className="overflow-y-auto border border-anton-border rounded-lg max-h-[55vh]">
{fileContent ? (
<div>
<div className="sticky top-0 bg-anton-surface px-3 py-1.5 border-b border-anton-border text-xs text-anton-muted">{fileContent.file_path}</div>
<pre className="p-3 text-[11px] text-white whitespace-pre-wrap font-mono leading-relaxed">{fileContent.content}</pre>
</div>
) : (
<div className="flex items-center justify-center h-full text-anton-muted text-sm p-4">Click a file to view</div>
)}
</div>
</div>
</div>
)}
</div>
)}
{/* ── ACTIONS TAB ── */}
{tab === "actions" && (
<div className="max-w-3xl mx-auto space-y-4">
<div className="flex gap-2 mb-2">
{["pending", "approved", "rejected"].map(s => (
<button key={s} onClick={() => setActionsTab(s)}
className={`px-3 py-1.5 rounded-lg text-xs capitalize ${actionsTab === s ? "bg-anton-accent text-white" : "bg-anton-card text-anton-muted border border-anton-border hover:text-white"}`}>
{s}
</button>
))}
<button onClick={loadActions} className="ml-auto text-anton-muted hover:text-white"><RefreshCw size={14} /></button>
</div>
{actions.length === 0 && <p className="text-anton-muted text-sm text-center py-8">No {actionsTab} actions</p>}
{actions.map(a => (
<div key={a.id} className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-xs bg-anton-accent/20 text-anton-accent px-2 py-0.5 rounded mr-2">{a.action_type}</span>
<span className="text-sm text-white">{a.title || "Untitled"}</span>
</div>
<span className="text-[10px] text-anton-muted">{a.repo_name}</span>
</div>
<pre className="text-[10px] text-anton-muted bg-anton-bg rounded p-2 max-h-32 overflow-y-auto">{a.payload}</pre>
{a.status === "pending" && (
<div className="flex gap-2">
<button onClick={() => handleApprove(a.id)} disabled={processingAction === a.id}
className="px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-500 disabled:opacity-50 flex items-center gap-1">
{processingAction === a.id ? <Loader2 size={12} className="animate-spin" /> : <CheckCircle size={12} />} Approve
</button>
<button onClick={() => handleReject(a.id)} disabled={processingAction === a.id}
className="px-3 py-1.5 bg-red-600 text-white rounded text-xs hover:bg-red-500 disabled:opacity-50 flex items-center gap-1">
<XCircle size={12} /> Reject
</button>
</div>
)}
{a.result_message && <p className="text-[10px] text-anton-muted">{a.result_message}</p>}
</div>
))}
</div>
)}
</div>
</div>
);
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment