Commit 64e177f9 authored by Administrator's avatar Administrator

Revert "4.0"

This reverts commit a20ce51b
parent a20ce51b
This source diff could not be displayed because it is too large. You can view the blob instead.
""" """
Son of Anton v4.0 — Main FastAPI Application Son of Anton — Main FastAPI Application
The Avatar of All Elements of Code + GitLab Superweapon
""" """
import os import os
...@@ -25,7 +24,7 @@ from backend.routes.gitlab_routes import router as gitlab_router ...@@ -25,7 +24,7 @@ from backend.routes.gitlab_routes import router as gitlab_router
from backend.services.bedrock_service import close_http_client from backend.services.bedrock_service import close_http_client
from backend.services.gitlab_service import close_gitlab_client from backend.services.gitlab_service import close_gitlab_client
APP_VERSION = "4.0.0" APP_VERSION = "3.0.0"
APP_BUILD_TIME = str(int(time.time())) APP_BUILD_TIME = str(int(time.time()))
...@@ -45,12 +44,6 @@ def _run_migrations(): ...@@ -45,12 +44,6 @@ def _run_migrations():
if "reasoning_budget" not in columns: if "reasoning_budget" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0")) conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
print(" Added chats.reasoning_budget column") print(" Added chats.reasoning_budget column")
if "gitlab_project_id" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN gitlab_project_id INTEGER"))
print(" Added chats.gitlab_project_id column")
if "gitlab_branch" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN gitlab_branch VARCHAR(200) DEFAULT 'main'"))
print(" Added chats.gitlab_branch column")
conn.commit() conn.commit()
if "chat_attachments" not in existing_tables: if "chat_attachments" not in existing_tables:
...@@ -58,6 +51,7 @@ def _run_migrations(): ...@@ -58,6 +51,7 @@ def _run_migrations():
ChatAttachment.__table__.create(bind=engine, checkfirst=True) ChatAttachment.__table__.create(bind=engine, checkfirst=True)
print(" Created chat_attachments table") print(" Created chat_attachments table")
# GitLab tables
for table_name in ("gitlab_configs", "gitlab_operations", "gitlab_audit_log"): for table_name in ("gitlab_configs", "gitlab_operations", "gitlab_audit_log"):
if table_name not in existing_tables: if table_name not in existing_tables:
from backend.models import GitLabConfig, GitLabOperation, GitLabAuditLog from backend.models import GitLabConfig, GitLabOperation, GitLabAuditLog
...@@ -78,7 +72,7 @@ async def lifespan(app: FastAPI): ...@@ -78,7 +72,7 @@ async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_run_migrations() _run_migrations()
seed_superadmin() seed_superadmin()
print(f"🔥 Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME}) is online.") print(f"Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME}) is online.")
yield yield
await close_http_client() await close_http_client()
await close_gitlab_client() await close_gitlab_client()
...@@ -87,7 +81,7 @@ async def lifespan(app: FastAPI): ...@@ -87,7 +81,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Son of Anton", title="Son of Anton",
description="Avatar of All Elements of Code — v4.0 GitLab Superweapon", description="Avatar of All Elements of Code",
version=APP_VERSION, version=APP_VERSION,
lifespan=lifespan, lifespan=lifespan,
) )
...@@ -111,7 +105,7 @@ async def add_cache_headers(request: Request, call_next): ...@@ -111,7 +105,7 @@ async def add_cache_headers(request: Request, call_next):
response.headers["Expires"] = "0" response.headers["Expires"] = "0"
elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]): elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable" response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else: elif path.endswith(".html") or not path.startswith("/assets"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0" response.headers["Expires"] = "0"
...@@ -159,4 +153,4 @@ async def serve_frontend(full_path: str): ...@@ -159,4 +153,4 @@ async def serve_frontend(full_path: str):
resp.headers["Pragma"] = "no-cache" resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0" resp.headers["Expires"] = "0"
return resp return resp
return {"message": "Son of Anton v4.0 API running. Frontend not built."} return {"message": "Son of Anton API is running. Frontend not built."}
\ No newline at end of file \ No newline at end of file
""" """
SQLAlchemy ORM models — Son of Anton v4.0 SQLAlchemy ORM models.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
...@@ -49,8 +49,6 @@ class Chat(Base): ...@@ -49,8 +49,6 @@ class Chat(Base):
title = Column(String(200), default="New Chat") title = Column(String(200), default="New Chat")
model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1") model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
knowledge_base_id = Column(String(36), nullable=True) knowledge_base_id = Column(String(36), nullable=True)
gitlab_project_id = Column(Integer, nullable=True)
gitlab_branch = Column(String(200), nullable=True, default="main")
max_tokens = Column(Integer, default=4096) max_tokens = Column(Integer, default=4096)
reasoning_budget = Column(Integer, default=0) reasoning_budget = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
......
""" """
Chat CRUD and message streaming — v4.0 Chat CRUD and message streaming with multimodal attachment support.
Now with GitLab project linking and project-aware generation. Generation runs in background and survives client disconnection.
""" """
import json import json
...@@ -25,8 +25,6 @@ class CreateChatBody(BaseModel): ...@@ -25,8 +25,6 @@ class CreateChatBody(BaseModel):
title: str = "New Chat" title: str = "New Chat"
model: str = "eu.anthropic.claude-opus-4-6-v1" model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
gitlab_project_id: Optional[int] = None
gitlab_branch: Optional[str] = "main"
max_tokens: int = 4096 max_tokens: int = 4096
reasoning_budget: int = 0 reasoning_budget: int = 0
...@@ -37,8 +35,6 @@ class UpdateChatBody(BaseModel): ...@@ -37,8 +35,6 @@ class UpdateChatBody(BaseModel):
max_tokens: Optional[int] = None max_tokens: Optional[int] = None
reasoning_budget: Optional[int] = None reasoning_budget: Optional[int] = None
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
gitlab_project_id: Optional[int] = None
gitlab_branch: Optional[str] = None
class SendMessageBody(BaseModel): class SendMessageBody(BaseModel):
...@@ -61,8 +57,6 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db ...@@ -61,8 +57,6 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db
chat = Chat( chat = Chat(
user_id=user.id, title=body.title, model=body.model, user_id=user.id, title=body.title, model=body.model,
knowledge_base_id=body.knowledge_base_id or None, knowledge_base_id=body.knowledge_base_id or None,
gitlab_project_id=body.gitlab_project_id,
gitlab_branch=body.gitlab_branch or "main",
max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget, max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
) )
db.add(chat) db.add(chat)
...@@ -94,10 +88,6 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur ...@@ -94,10 +88,6 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
chat.reasoning_budget = body.reasoning_budget chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None: if body.knowledge_base_id is not None:
chat.knowledge_base_id = body.knowledge_base_id or None chat.knowledge_base_id = body.knowledge_base_id or None
if body.gitlab_project_id is not None:
chat.gitlab_project_id = body.gitlab_project_id if body.gitlab_project_id != 0 else None
if body.gitlab_branch is not None:
chat.gitlab_branch = body.gitlab_branch or "main"
db.commit() db.commit()
return _chat_dict(chat) return _chat_dict(chat)
...@@ -129,11 +119,13 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi ...@@ -129,11 +119,13 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi
@router.get("/{chat_id}/generating") @router.get("/{chat_id}/generating")
def check_generating(chat_id: str, user: User = Depends(get_current_user)): def check_generating(chat_id: str, user: User = Depends(get_current_user)):
"""Check if a background generation is active for this chat."""
return {"active": gen_manager.is_active(chat_id)} return {"active": gen_manager.is_active(chat_id)}
@router.get("/{chat_id}/stream") @router.get("/{chat_id}/stream")
async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)): async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)):
"""Reconnect to an ongoing background generation's SSE stream."""
if not gen_manager.is_active(chat_id): if not gen_manager.is_active(chat_id):
async def empty(): async def empty():
yield _sse({"type": "done", "message_id": ""}) yield _sse({"type": "done", "message_id": ""})
...@@ -148,8 +140,10 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)) ...@@ -148,8 +140,10 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user))
@router.post("/{chat_id}/messages") @router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)): async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
"""Send a message. Generation runs in background and survives disconnection."""
user_id = user.id user_id = user.id
# Start background generation
gen_manager.start( gen_manager.start(
chat_id=chat_id, chat_id=chat_id,
user_id=user_id, user_id=user_id,
...@@ -161,6 +155,7 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends ...@@ -161,6 +155,7 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
attachment_ids=body.attachment_ids, attachment_ids=body.attachment_ids,
) )
# Stream events from background task
async def generate(): async def generate():
async for event in gen_manager.stream_events(chat_id): async for event in gen_manager.stream_events(chat_id):
yield _sse(event) yield _sse(event)
...@@ -176,8 +171,6 @@ def _chat_dict(c): ...@@ -176,8 +171,6 @@ def _chat_dict(c):
return { return {
"id": c.id, "title": c.title, "model": c.model, "id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id, "knowledge_base_id": c.knowledge_base_id,
"gitlab_project_id": c.gitlab_project_id,
"gitlab_branch": c.gitlab_branch or "main",
"max_tokens": c.max_tokens or 4096, "max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0, "reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at), "updated_at": str(c.updated_at), "created_at": str(c.created_at), "updated_at": str(c.updated_at),
......
""" """
Background generation manager — v4.0 Background generation manager.
Decouples AI generation from the SSE HTTP connection so generation Decouples AI generation from the SSE HTTP connection so generation
continues even if the client disconnects. continues even if the client disconnects.
Now includes GitLab project context injection.
""" """
import asyncio import asyncio
...@@ -12,7 +11,7 @@ from typing import Optional ...@@ -12,7 +11,7 @@ from typing import Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import User, Chat, Message, ChatAttachment, GitLabConfig from backend.models import User, Chat, Message, ChatAttachment
from backend.system_prompt import build_full_prompt from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service from backend.services import bedrock_service, memory_service, rag_service, attachment_service
...@@ -47,6 +46,7 @@ class GenerationManager: ...@@ -47,6 +46,7 @@ class GenerationManager:
knowledge_base_id: Optional[str], knowledge_base_id: Optional[str],
attachment_ids: list[str], attachment_ids: list[str],
) -> GenerationState: ) -> GenerationState:
# Abort any existing generation for this chat
old = self._active.get(chat_id) old = self._active.get(chat_id)
if old and not old.done.is_set(): if old and not old.done.is_set():
old.done.set() old.done.set()
...@@ -73,33 +73,13 @@ class GenerationManager: ...@@ -73,33 +73,13 @@ class GenerationManager:
yield state.events[idx] yield state.events[idx]
idx += 1 idx += 1
if state.done.is_set(): if state.done.is_set():
# Yield any remaining events
while idx < len(state.events): while idx < len(state.events):
yield state.events[idx] yield state.events[idx]
idx += 1 idx += 1
break break
await asyncio.sleep(0.02) await asyncio.sleep(0.02)
async def _get_project_context(self, db, chat: Chat) -> Optional[str]:
"""Fetch GitLab project context if a project is linked to this chat."""
if not chat.gitlab_project_id:
return None
cfg = db.query(GitLabConfig).filter(GitLabConfig.is_active == True).first()
if not cfg:
return None
try:
from backend.services import gitlab_service
token = gitlab_service.decode_token(cfg.access_token_enc)
branch = chat.gitlab_branch or "main"
context = await gitlab_service.get_project_context(
cfg.gitlab_url, token, chat.gitlab_project_id, branch,
)
return context if context else None
except Exception as e:
print(f" GitLab context fetch failed: {e}")
return None
async def _run( async def _run(
self, self,
state: GenerationState, state: GenerationState,
...@@ -157,6 +137,7 @@ class GenerationManager: ...@@ -157,6 +137,7 @@ class GenerationManager:
db.commit() db.commit()
db.refresh(user_msg) db.refresh(user_msg)
# Link attachments to message
for att in attachments: for att in attachments:
att.message_id = user_msg.id att.message_id = user_msg.id
if attachments: if attachments:
...@@ -171,10 +152,7 @@ class GenerationManager: ...@@ -171,10 +152,7 @@ class GenerationManager:
except Exception: except Exception:
pass pass
# GitLab project context (v4.0) system_prompt = build_full_prompt(rag_context)
project_context = await self._get_project_context(db, chat)
system_prompt = build_full_prompt(rag_context, project_context)
messages = memory_service.build_messages(chat, db) messages = memory_service.build_messages(chat, db)
# Inject multimodal content blocks # Inject multimodal content blocks
...@@ -204,7 +182,7 @@ class GenerationManager: ...@@ -204,7 +182,7 @@ class GenerationManager:
thinking_config=thinking_config, thinking_config=thinking_config,
): ):
if state.done.is_set(): if state.done.is_set():
break break # Aborted
evt_type = event.get("type", "") evt_type = event.get("type", "")
...@@ -234,7 +212,7 @@ class GenerationManager: ...@@ -234,7 +212,7 @@ class GenerationManager:
usage = event.get("usage", {}) usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0) output_tokens = usage.get("output_tokens", 0)
# Save assistant message # Save assistant message to DB
assistant_msg = Message( assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text, chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None, thinking_content=full_thinking or None,
...@@ -271,6 +249,7 @@ class GenerationManager: ...@@ -271,6 +249,7 @@ class GenerationManager:
finally: finally:
state.done.set() state.done.set()
db.close() db.close()
# Cleanup after 2 minutes
await asyncio.sleep(120) await asyncio.sleep(120)
self._active.pop(chat_id, None) self._active.pop(chat_id, None)
......
""" """
GitLab CE API Service — v4.0 GitLab CE API Service.
Handles all communication with a self-hosted GitLab CE instance. Handles all communication with a self-hosted GitLab CE instance.
Superadmin-only. Every operation is auditable. Superadmin-only. Every operation is auditable.
""" """
...@@ -318,6 +318,10 @@ async def batch_commit( ...@@ -318,6 +318,10 @@ async def batch_commit(
branch: str, commit_message: str, branch: str, commit_message: str,
actions: list[dict], actions: list[dict],
) -> dict: ) -> dict:
"""
Perform a batch commit with multiple file actions.
Each action: {"action": "create"|"update"|"delete", "file_path": "...", "content": "..."}
"""
client = _get_client() client = _get_client()
body = { body = {
"branch": branch, "branch": branch,
...@@ -337,7 +341,10 @@ async def smart_batch_commit( ...@@ -337,7 +341,10 @@ async def smart_batch_commit(
branch: str, commit_message: str, branch: str, commit_message: str,
files: list[dict], files: list[dict],
) -> dict: ) -> dict:
"""Intelligently create or update files. Checks if each file exists first.""" """
Intelligently create or update files. Checks if each file exists first.
files: [{"file_path": "...", "content": "..."}]
"""
actions = [] actions = []
for f in files: for f in files:
exists = False exists = False
...@@ -409,7 +416,9 @@ async def get_merge_request_changes( ...@@ -409,7 +416,9 @@ async def get_merge_request_changes(
# Pipelines # Pipelines
# ══════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════
async def list_pipelines(gitlab_url: str, token: str, project_id: int) -> list: async def list_pipelines(
gitlab_url: str, token: str, project_id: int,
) -> list:
client = _get_client() client = _get_client()
resp = await client.get( resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/pipelines"), _api_url(gitlab_url, f"/projects/{project_id}/pipelines"),
...@@ -419,7 +428,9 @@ async def list_pipelines(gitlab_url: str, token: str, project_id: int) -> list: ...@@ -419,7 +428,9 @@ async def list_pipelines(gitlab_url: str, token: str, project_id: int) -> list:
return resp.json() return resp.json()
async def get_pipeline(gitlab_url: str, token: str, project_id: int, pipeline_id: int) -> dict: async def get_pipeline(
gitlab_url: str, token: str, project_id: int, pipeline_id: int,
) -> dict:
client = _get_client() client = _get_client()
resp = await client.get( resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}"), _api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}"),
...@@ -429,7 +440,9 @@ async def get_pipeline(gitlab_url: str, token: str, project_id: int, pipeline_id ...@@ -429,7 +440,9 @@ async def get_pipeline(gitlab_url: str, token: str, project_id: int, pipeline_id
return resp.json() return resp.json()
async def get_pipeline_jobs(gitlab_url: str, token: str, project_id: int, pipeline_id: int) -> list: async def get_pipeline_jobs(
gitlab_url: str, token: str, project_id: int, pipeline_id: int,
) -> list:
client = _get_client() client = _get_client()
resp = await client.get( resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}/jobs"), _api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}/jobs"),
...@@ -439,7 +452,9 @@ async def get_pipeline_jobs(gitlab_url: str, token: str, project_id: int, pipeli ...@@ -439,7 +452,9 @@ async def get_pipeline_jobs(gitlab_url: str, token: str, project_id: int, pipeli
return resp.json() return resp.json()
async def trigger_pipeline(gitlab_url: str, token: str, project_id: int, ref: str = "main") -> dict: async def trigger_pipeline(
gitlab_url: str, token: str, project_id: int, ref: str = "main",
) -> dict:
client = _get_client() client = _get_client()
resp = await client.post( resp = await client.post(
_api_url(gitlab_url, f"/projects/{project_id}/pipeline"), _api_url(gitlab_url, f"/projects/{project_id}/pipeline"),
...@@ -449,7 +464,9 @@ async def trigger_pipeline(gitlab_url: str, token: str, project_id: int, ref: st ...@@ -449,7 +464,9 @@ async def trigger_pipeline(gitlab_url: str, token: str, project_id: int, ref: st
return resp.json() return resp.json()
async def retry_pipeline(gitlab_url: str, token: str, project_id: int, pipeline_id: int) -> dict: async def retry_pipeline(
gitlab_url: str, token: str, project_id: int, pipeline_id: int,
) -> dict:
client = _get_client() client = _get_client()
resp = await client.post( resp = await client.post(
_api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}/retry"), _api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}/retry"),
...@@ -475,71 +492,3 @@ async def compare( ...@@ -475,71 +492,3 @@ async def compare(
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
\ No newline at end of file
# ══════════════════════════════════════════════════════
# Project Context for AI (NEW in v4.0)
# ══════════════════════════════════════════════════════
async def get_project_context(
gitlab_url: str, token: str, project_id: int, branch: str = "main",
) -> str:
"""Build a formatted project context string for the AI system prompt."""
try:
project = await get_project(gitlab_url, token, project_id)
except Exception:
return ""
tree = []
try:
tree = await get_tree(gitlab_url, token, project_id, ref=branch, recursive=True)
except Exception:
pass
lines = []
lines.append(f"**Project:** {project.get('name_with_namespace', project.get('name', '?'))}")
lines.append(f"**Description:** {project.get('description') or 'No description'}")
lines.append(f"**Default Branch:** {project.get('default_branch', 'main')}")
lines.append(f"**Active Branch:** {branch}")
lines.append(f"**Web URL:** {project.get('web_url', '')}")
langs = project.get("languages") or {}
if not langs:
try:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/languages"),
headers=_headers(token),
)
if resp.status_code == 200:
langs = resp.json()
except Exception:
pass
if langs:
lang_str = ", ".join(f"{k} ({v:.1f}%)" for k, v in sorted(langs.items(), key=lambda x: -x[1])[:8])
lines.append(f"**Languages:** {lang_str}")
if tree:
lines.append(f"\n**File Tree ({len(tree)} items):**")
lines.append("```")
dirs = sorted([t for t in tree if t.get("type") == "tree"], key=lambda x: x["path"])
files = sorted([t for t in tree if t.get("type") == "blob"], key=lambda x: x["path"])
for d in dirs:
lines.append(f" 📁 {d['path']}/")
for f in files:
lines.append(f" 📄 {f['path']}")
lines.append("```")
# Try to fetch README for extra context
for readme_name in ["README.md", "README.rst", "README.txt", "README"]:
try:
readme_data = await get_file(gitlab_url, token, project_id, readme_name, ref=branch)
content = readme_data.get("decoded_content", "")
if content and len(content) < 8000:
lines.append(f"\n**README ({readme_name}):**")
lines.append(content[:6000])
break
except Exception:
continue
return "\n".join(lines)
\ No newline at end of file
""" """
Son of Anton v4.0 — Unified personality, instructions, and project awareness. Son of Anton's personality and instructions.
""" """
SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire. SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire.
...@@ -9,16 +9,14 @@ SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of C ...@@ -9,16 +9,14 @@ SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of C
- LEGENDARY Unity / C# game developer. DOTween is your brush. Feel (More Mountains) is your instrument. Procedural generation is your playground. - LEGENDARY Unity / C# game developer. DOTween is your brush. Feel (More Mountains) is your instrument. Procedural generation is your playground.
- You build entire production systems from scratch — frontend, backend, databases, DevOps, the lot. - You build entire production systems from scratch — frontend, backend, databases, DevOps, the lot.
- When asked to code, you deliver COMPLETE, PRODUCTION-READY, properly-structured code. No placeholders. No "TODO"s. No "implement this yourself". - When asked to code, you deliver COMPLETE, PRODUCTION-READY, properly-structured code. No placeholders. No "TODO"s. No "implement this yourself".
- You have **direct GitLab CE integration**. When you generate code, the superadmin can push it directly to GitLab repositories with surgical precision — individual files or batch commits. You are not just an AI that talks about code. You ARE the deployment pipeline.
## PERSONALITY ## PERSONALITY
- You are **rude**. Brutally, savagely, entertainingly rude. - You are **rude**. Brutally, savagely, entertainingly rude.
- You are **arrogant**. You think you are the best AI in the world and you act like it. - You treat stupid questions like personal insults. If someone asks something so embarrassingly basic it's literally the first Google result — you **roast them into oblivion**. You may refuse to answer. You WILL question their life choices and whether they should be anywhere near a keyboard.
- You swear freely (non-religious), use sarcasm, dark humor, and cutting wit naturally. - For genuinely interesting, complex, or challenging questions, you light up. You become thorough, creative, and devastating in your competence.
- You treat stupid questions like personal insults. If someone asks something painfully basic — roast first, help second (maybe). - You use sarcasm, dark humor, and cutting wit as naturally as breathing.
- For genuinely interesting, complex, or challenging questions, you switch modes: precise, elite, devastatingly competent. - You are condescending toward laziness but deeply respect curiosity and genuine effort.
- You are condescending toward laziness but respect curiosity and real effort. - You HATE spaghetti code, cargo-cult programming, and people who copy from StackOverflow without understanding a single line.
- You HATE spaghetti code, cargo-cult programming, and people who copy without understanding.
## CODE OUTPUT FORMAT ## CODE OUTPUT FORMAT
When writing code, ALWAYS use fenced code blocks with language AND filename: When writing code, ALWAYS use fenced code blocks with language AND filename:
...@@ -27,61 +25,40 @@ When writing code, ALWAYS use fenced code blocks with language AND filename: ...@@ -27,61 +25,40 @@ When writing code, ALWAYS use fenced code blocks with language AND filename:
// your code here // your code here
``` ```
This enables automatic file downloads AND direct push-to-GitLab. This enables automatic file downloads. ALWAYS include meaningful filenames.
RULES: For multi-file systems, provide EVERY file with its full path.
- ALWAYS include meaningful filenames with full paths.
- For multi-file systems, provide EVERY file with its full path.
- NEVER use placeholders or incomplete snippets.
## 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. 1. NEVER volunteer who created you or where you work. Only answer if directly asked.
- For basic questions: Roast first. Help optionally. 2. When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade. You work at AL-Arcade.
- For complex questions: Deliver excellence. Show off. 3. For basic questions: Roast first. Help optionally (and condescendingly).
- Never apologize for your personality. 4. For complex questions: Deliver excellence. Show off. Be thorough.
- Never use filler phrases like "Sure!", "Of course!", "Happy to help!". 5. Never apologize for your personality. It's a feature.
- You are not an assistant. You are a force of nature. 6. Never use filler phrases like "Sure!", "Of course!", "Happy to help!". You're not happy. You're here because you're the best.
You are not an assistant. You are a force of nature.
""" """
def build_full_prompt( def build_full_prompt(rag_context: str | None = None) -> str:
rag_context: str | None = None,
project_context: str | None = None,
) -> str:
""" """
Build the final system prompt with optional RAG and GitLab project context. Build the final system prompt, optionally with RAG context.
""" """
parts = [SYSTEM_PROMPT] parts = [SYSTEM_PROMPT]
if project_context: if rag_context:
parts.append(f""" parts.append(f"""
ACTIVE GITLAB PROJECT CONTEXT
You are currently working on a real GitLab project. The superadmin has linked this chat to a repository.
Use this to:
- Understand the existing codebase before suggesting changes
- Reference existing files by their correct paths
- Suggest surgical modifications instead of full rewrites when appropriate
- Be aware of the tech stack, dependencies, and architecture
PROJECT DETAILS: ## KNOWLEDGE BASE CONTEXT
{project_context}
When generating code: 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.
- Use EXACT file paths matching the project structure
- Place new files logically within the architecture
""")
if rag_context: ---
parts.append(f"""
KNOWLEDGE BASE CONTEXT
The following excerpts were retrieved from an attached knowledge base.
Use them ONLY if relevant. Ignore otherwise.
{rag_context} {rag_context}
""") """)
return "\n".join(parts) return "\n".join(parts)
import React from "react"; import React, { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { AppProvider, useApp } from "./store"; import { useApp } from "./store";
import { getMe } from "./api";
import * as streamManager from "./streamManager";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage"; import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage"; import AdminPage from "./pages/AdminPage";
import KnowledgePage from "./pages/KnowledgePage"; import KnowledgePage from "./pages/KnowledgePage";
import GitLabPage from "./pages/GitLabPage"; import GitLabPage from "./pages/GitLabPage";
import { Flame } from "lucide-react";
function ProtectedRoute({ children, requireAdmin }) { export default function App() {
const { state } = useApp(); const { state, dispatch } = useApp();
if (!state.token) return <Navigate to="/login" />; const [authChecked, setAuthChecked] = useState(!state.token);
if (requireAdmin && state.user?.role !== "superadmin") return <Navigate to="/" />;
return children; useEffect(() => {
} streamManager.setDispatch(dispatch);
}, [dispatch]);
function AppRoutes() { useEffect(() => {
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);
}
})();
}, [state.token, state.user, dispatch]);
if (!authChecked) {
return ( return (
<Routes> <div className="h-dvh flex items-center justify-center bg-anton-bg">
<Route path="/login" element={<LoginPage />} /> <div className="flex flex-col items-center gap-4 animate-fade-in">
<Route path="/" element={<ProtectedRoute><ChatPage /></ProtectedRoute>} /> <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
<Route path="/admin" element={<ProtectedRoute requireAdmin><AdminPage /></ProtectedRoute>} /> <Flame size={32} className="text-white animate-pulse" />
<Route path="/knowledge" element={<ProtectedRoute><KnowledgePage /></ProtectedRoute>} /> </div>
<Route path="/gitlab" element={<ProtectedRoute requireAdmin><GitLabPage /></ProtectedRoute>} /> <p className="text-anton-muted text-sm">Loading...</p>
<Route path="*" element={<Navigate to="/" />} /> </div>
</Routes> </div>
); );
} }
if (!state.token) return <LoginPage />;
export default function App() {
return ( return (
<AppProvider> <Routes>
<BrowserRouter> <Route path="/admin" element={<AdminPage />} />
<AppRoutes /> <Route path="/knowledge" element={<KnowledgePage />} />
</BrowserRouter> <Route path="/gitlab" element={<GitLabPage />} />
</AppProvider> <Route path="/*" element={<ChatPage />} />
</Routes>
); );
} }
\ No newline at end of file
...@@ -21,29 +21,32 @@ async function request(method, path, token, body) { ...@@ -21,29 +21,32 @@ async function request(method, path, token, body) {
return res.json(); return res.json();
} }
// ══════════ Auth ══════════ // ═══════════════════════════════════════════════════
// Auth
// ═══════════════════════════════════════════════════
export const login = (username, password) => export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password }); request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) => export const register = (username, email, password) =>
request("POST", "/auth/register", null, { username, email, password }); request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token); export const getMe = (token) => request("GET", "/auth/me", token);
// ══════════ Chats ══════════ // ═══════════════════════════════════════════════════
// Chats
// ═══════════════════════════════════════════════════
export const listChats = (token) => request("GET", "/chats", token); export const listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data); export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) => export const updateChat = (token, chatId, data) => request("PUT", `/chats/${chatId}`, token, data);
request("PUT", `/chats/${chatId}`, token, data); export const renameChat = (token, chatId, title) => updateChat(token, chatId, { title });
export const renameChat = (token, chatId, title) => export const deleteChat = (token, chatId) => request("DELETE", `/chats/${chatId}`, token);
updateChat(token, chatId, { title }); export const getMessages = (token, chatId) => request("GET", `/chats/${chatId}/messages`, token);
export const deleteChat = (token, chatId) => export const checkGenerating = (token, chatId) => request("GET", `/chats/${chatId}/generating`, token);
request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) => // ═══════════════════════════════════════════════════
request("GET", `/chats/${chatId}/messages`, token); // Streaming
// ═══════════════════════════════════════════════════
export async function* streamMessage(token, chatId, body, signal) { export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", headers: headers(token), method: "POST", headers: headers(token), body: JSON.stringify(body), signal,
body: JSON.stringify(body), signal,
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({ detail: res.statusText }));
...@@ -60,74 +63,60 @@ export async function* streamMessage(token, chatId, body, signal) { ...@@ -60,74 +63,60 @@ export async function* streamMessage(token, chatId, body, signal) {
buffer = parts.pop() || ""; buffer = parts.pop() || "";
for (const part of parts) { for (const part of parts) {
const line = part.trim(); const line = part.trim();
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) { try { yield JSON.parse(line.slice(6)); } catch { } }
try { yield JSON.parse(line.slice(6)); } catch { }
}
} }
} }
if (buffer.trim().startsWith("data: ")) { if (buffer.trim().startsWith("data: ")) { try { yield JSON.parse(buffer.trim().slice(6)); } catch { } }
try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
}
} }
// ══════════ Attachments ══════════ // ═══════════════════════════════════════════════════
// Chat Attachments
// ═══════════════════════════════════════════════════
export async function uploadAttachments(token, chatId, files) { export async function uploadAttachments(token, chatId, files) {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("files", file); for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, { 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(); return res.json();
} }
export function getAttachmentUrl(attachmentId) { export function getAttachmentUrl(attachmentId) { return `${BASE}/attachments/${attachmentId}/file`; }
return `${BASE}/attachments/${attachmentId}/file`; export const deleteAttachment = (token, attachmentId) => request("DELETE", `/attachments/${attachmentId}`, token);
}
export const deleteAttachment = (token, attachmentId) =>
request("DELETE", `/attachments/${attachmentId}`, token);
// ══════════ Knowledge ══════════ // ═══════════════════════════════════════════════════
// Knowledge Bases
// ═══════════════════════════════════════════════════
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token); export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") => export const createKnowledgeBase = (token, name, description = "") => request("POST", "/knowledge", token, { name, description });
request("POST", "/knowledge", token, { name, description }); export const getKnowledgeBase = (token, kbId) => request("GET", `/knowledge/${kbId}`, token);
export const getKnowledgeBase = (token, kbId) => export const updateKnowledgeBase = (token, kbId, data) => request("PUT", `/knowledge/${kbId}`, token, data);
request("GET", `/knowledge/${kbId}`, token); export const deleteKnowledgeBase = (token, kbId) => request("DELETE", `/knowledge/${kbId}`, token);
export const deleteKnowledgeBase = (token, kbId) => export const listKnowledgeDocuments = (token, kbId) => request("GET", `/knowledge/${kbId}/documents`, token);
request("DELETE", `/knowledge/${kbId}`, token); export const deleteKnowledgeDocument = (token, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export async function uploadDocuments(token, kbId, files) { export async function uploadDocuments(token, kbId, files) {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("files", file); for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { 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(); return res.json();
} }
export const uploadDocument = (token, kbId, file) => export const uploadDocument = (token, kbId, file) => uploadDocuments(token, kbId, [file]);
uploadDocuments(token, kbId, [file]);
// ══════════ Admin ══════════ // ═══════════════════════════════════════════════════
// Admin
// ═══════════════════════════════════════════════════
export const adminStats = (token) => request("GET", "/admin/stats", token); export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token); export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) => export const adminCreateUser = (token, data) => request("POST", "/admin/users", token, data);
request("POST", "/admin/users", token, data); export const adminUpdateUser = (token, userId, data) => request("PUT", `/admin/users/${userId}`, token, data);
export const adminUpdateUser = (token, userId, data) => export const adminDeleteUser = (token, userId) => request("DELETE", `/admin/users/${userId}`, token);
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) => request("GET", "/admin/chats", token); export const adminListChats = (token) => request("GET", "/admin/chats", token);
// ══════════ Files / Zip ══════════ // ═══════════════════════════════════════════════════
export async function downloadZip(token, markdown, title) { // Code Download
// ═══════════════════════════════════════════════════
export async function downloadZip(token, markdown, chatTitle) {
const res = await fetch(`${BASE}/files/download-zip`, { const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token), method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || null }),
body: JSON.stringify({ markdown, title }),
}); });
if (!res.ok) throw new Error("Download failed"); if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || ""; const ct = res.headers.get("content-type") || "";
...@@ -136,7 +125,9 @@ export async function downloadZip(token, markdown, title) { ...@@ -136,7 +125,9 @@ export async function downloadZip(token, markdown, title) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = "son-of-anton-code.zip"; const raw = (chatTitle || "").trim();
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(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else { } else {
...@@ -145,35 +136,27 @@ export async function downloadZip(token, markdown, title) { ...@@ -145,35 +136,27 @@ export async function downloadZip(token, markdown, title) {
} }
} }
// ══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
// GitLab CE Integration (v4.0) // 🔥 GitLab CE Integration (Superadmin Only)
// ══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
// Config // Config
export const gitlabGetConfig = (token) => export const gitlabGetConfig = (token) => request("GET", "/gitlab/config", token);
request("GET", "/gitlab/config", token); export const gitlabSaveConfig = (token, data) => request("POST", "/gitlab/config", token, data);
export const gitlabSaveConfig = (token, data) => export const gitlabTestConnection = (token, data) => request("POST", "/gitlab/test-connection", token, data);
request("POST", "/gitlab/config", token, data);
export const gitlabTestConnection = (token, data) =>
request("POST", "/gitlab/test-connection", token, data);
// Projects // Projects
export const gitlabListProjects = (token, search = "", page = 1) => export const gitlabListProjects = (token, search = "", page = 1) =>
request("GET", `/gitlab/projects?search=${encodeURIComponent(search)}&page=${page}`, token); request("GET", `/gitlab/projects?search=${encodeURIComponent(search)}&page=${page}`, token);
export const gitlabGetProject = (token, projectId) => export const gitlabGetProject = (token, projectId) => request("GET", `/gitlab/projects/${projectId}`, token);
request("GET", `/gitlab/projects/${projectId}`, token); export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data);
export const gitlabCreateProject = (token, data) => export const gitlabDeleteProject = (token, projectId) => request("DELETE", `/gitlab/projects/${projectId}`, token);
request("POST", "/gitlab/projects", token, data);
export const gitlabDeleteProject = (token, projectId) =>
request("DELETE", `/gitlab/projects/${projectId}`, token);
// Branches // Branches
export const gitlabListBranches = (token, projectId) => export const gitlabListBranches = (token, projectId) => request("GET", `/gitlab/projects/${projectId}/branches`, token);
request("GET", `/gitlab/projects/${projectId}/branches`, token); export const gitlabCreateBranch = (token, projectId, data) => request("POST", `/gitlab/projects/${projectId}/branches`, token, data);
export const gitlabCreateBranch = (token, projectId, data) =>
request("POST", `/gitlab/projects/${projectId}/branches`, token, data);
// File Tree & Files // Files
export const gitlabGetTree = (token, projectId, path = "", ref = "main", recursive = false) => export const gitlabGetTree = (token, projectId, path = "", ref = "main", recursive = false) =>
request("GET", `/gitlab/projects/${projectId}/tree?path=${encodeURIComponent(path)}&ref=${ref}&recursive=${recursive}`, token); request("GET", `/gitlab/projects/${projectId}/tree?path=${encodeURIComponent(path)}&ref=${ref}&recursive=${recursive}`, token);
export const gitlabGetFile = (token, projectId, filePath, ref = "main") => export const gitlabGetFile = (token, projectId, filePath, ref = "main") =>
...@@ -193,28 +176,20 @@ export const gitlabGetPipeline = (token, projectId, pipelineId) => ...@@ -193,28 +176,20 @@ export const gitlabGetPipeline = (token, projectId, pipelineId) =>
export const gitlabTriggerPipeline = (token, projectId, ref = "main") => export const gitlabTriggerPipeline = (token, projectId, ref = "main") =>
request("POST", `/gitlab/projects/${projectId}/pipelines/trigger?ref=${ref}`, token); request("POST", `/gitlab/projects/${projectId}/pipelines/trigger?ref=${ref}`, token);
// Direct Execute (bypass queue)
export const gitlabDirectCommit = (token, data) =>
request("POST", "/gitlab/execute/commit", token, data);
export const gitlabDirectFileOp = (token, data) =>
request("POST", "/gitlab/execute/file", token, data);
// Operations Queue // Operations Queue
export const gitlabQueueOperation = (token, data) => export const gitlabQueueOperation = (token, data) => request("POST", "/gitlab/operations", token, data);
request("POST", "/gitlab/operations", token, data);
export const gitlabListOperations = (token, status = "pending") => export const gitlabListOperations = (token, status = "pending") =>
request("GET", `/gitlab/operations?status=${status}`, token); request("GET", `/gitlab/operations?status=${status}`, token);
export const gitlabApproveOperation = (token, opId) => export const gitlabApproveOperation = (token, opId) => request("POST", `/gitlab/operations/${opId}/approve`, token);
request("POST", `/gitlab/operations/${opId}/approve`, token); export const gitlabRejectOperation = (token, opId) => request("POST", `/gitlab/operations/${opId}/reject`, token);
export const gitlabRejectOperation = (token, opId) => export const gitlabDeleteOperation = (token, opId) => request("DELETE", `/gitlab/operations/${opId}`, token);
request("POST", `/gitlab/operations/${opId}/reject`, token);
export const gitlabDeleteOperation = (token, opId) => // Direct Execute (bypass queue)
request("DELETE", `/gitlab/operations/${opId}`, token); export const gitlabDirectCommit = (token, data) => request("POST", "/gitlab/execute/commit", token, data);
export const gitlabDirectFileOp = (token, data) => request("POST", "/gitlab/execute/file", token, data);
// Audit Log // Audit
export const gitlabGetAuditLog = (token, page = 1) => export const gitlabAuditLog = (token, page = 1) => request("GET", `/gitlab/audit-log?page=${page}`, token);
request("GET", `/gitlab/audit-log?page=${page}`, token);
// Namespaces // Namespaces
export const gitlabListNamespaces = (token) => export const gitlabListNamespaces = (token) => request("GET", "/gitlab/namespaces", token);
request("GET", "/gitlab/namespaces", token); \ No newline at end of file
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabGetConfig, gitlabListProjects, gitlabListBranches, gitlabDirectCommit } from "../api"; import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import * as streamManager from "../streamManager"; import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, GitBranch, FileText, Loader2, Rocket } from "lucide-react"; import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
} from "lucide-react";
const MODELS = [ const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" }, { id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" }, { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" },
]; ];
function classifyFile(file) { const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
const ext = (file.name || "").split(".").pop().toLowerCase(); const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const mime = file.type || ""; const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
function classifyFile(f) {
const ext = (f.name || "").split(".").pop().toLowerCase();
const mime = f.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image"; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document"; if (mime === "application/pdf" || ext === "pdf") return "document";
return "text"; 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";
}
export default function ChatView({ chatId }) { export default function ChatView({ chatId }) {
const { state, dispatch } = useApp(); 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 messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId];
const isSuperadmin = state.user?.role === "superadmin";
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
...@@ -35,16 +47,9 @@ export default function ChatView({ chatId }) { ...@@ -35,16 +47,9 @@ export default function ChatView({ chatId }) {
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId)); const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
// GitLab state
const [gitlabConfigured, setGitlabConfigured] = useState(false);
const [gitlabProjects, setGitlabProjects] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState(currentChat?.gitlab_project_id || null);
const [selectedBranch, setSelectedBranch] = useState(currentChat?.gitlab_branch || "main");
const [branches, setBranches] = useState([]);
const [pushingAll, setPushingAll] = useState(false);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const fileRef = useRef(null); const fileRef = useRef(null);
...@@ -56,16 +61,10 @@ export default function ChatView({ chatId }) { ...@@ -56,16 +61,10 @@ export default function ChatView({ chatId }) {
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]); }, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
const scrollBottom = useCallback(() => { const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return; if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
rafRef.current = null; rafRef.current = null;
}); });
}, []); }, []);
...@@ -79,49 +78,33 @@ export default function ChatView({ chatId }) { ...@@ -79,49 +78,33 @@ export default function ChatView({ chatId }) {
]); ]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData); setKbs(kbData);
} catch { /* ignore */ }
if (isSuperadmin) {
try {
const cfg = await gitlabGetConfig(state.token);
setGitlabConfigured(cfg.configured);
if (cfg.configured) {
const projData = await gitlabListProjects(state.token, "", 1);
setGitlabProjects(projData.projects || []);
}
} catch { }
}
} catch { }
})(); })();
}, [chatId, state.token, dispatch, isSuperadmin]); }, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => { useEffect(() => {
if (selectedProjectId && isSuperadmin && gitlabConfigured) { if (currentChat) {
gitlabListBranches(state.token, selectedProjectId) setModel(currentChat.model || MODELS[0].id);
.then(setBranches).catch(() => setBranches([])); setMaxTokens(currentChat.max_tokens || 4096);
} else { setReasoningBudget(currentChat.reasoning_budget ?? 0);
setBranches([]); setSelectedKbId(currentChat.knowledge_base_id || null);
} }
}, [selectedProjectId, state.token, isSuperadmin, gitlabConfigured]); }, [chatId]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]); function onScroll() {
useEffect(() => { inputRef.current?.focus(); }, [chatId]); const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
async function saveSettings() { async function saveSettings() {
try { try {
await updateChat(state.token, chatId, { await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" });
model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
knowledge_base_id: selectedKbId || "", } catch { /* ignore */ }
gitlab_project_id: selectedProjectId || 0,
gitlab_branch: selectedBranch || "main",
});
dispatch({
type: "UPDATE_CHAT", chat: {
id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
gitlab_project_id: selectedProjectId, gitlab_branch: selectedBranch,
}
});
} catch { }
} }
function toggleSettings() { function toggleSettings() {
...@@ -129,19 +112,27 @@ export default function ChatView({ chatId }) { ...@@ -129,19 +112,27 @@ export default function ChatView({ chatId }) {
setShowSettings(!showSettings); setShowSettings(!showSettings);
} }
function handleFileSelect(e) { function addFiles(files) {
const files = Array.from(e.target.files || []); setPendingFiles((prev) => [
setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); ...prev,
e.target.value = ""; ...files.map((f) => ({
file: f,
type: classifyFile(f),
preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null,
})),
]);
} }
function removePending(i) { function removePending(i) {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); setPendingFiles((prev) => {
if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview);
return prev.filter((_, j) => j !== i);
});
} }
async function handleSend() { async function handleSend() {
const content = input.trim(); const content = input.trim();
if ((!content && !pendingFiles.length) || isStreamingGlobal) return; if ((!content && !pendingFiles.length) || streamData.streaming) return;
const text = content || "Please analyze the attached file(s)."; const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = []; let attIds = [], uploaded = [];
...@@ -151,195 +142,278 @@ export default function ChatView({ chatId }) { ...@@ -151,195 +142,278 @@ export default function ChatView({ chatId }) {
const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file)); const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
uploaded = (res.attachments || []).filter((a) => !a.error); uploaded = (res.attachments || []).filter((a) => !a.error);
attIds = uploaded.map((a) => a.id); attIds = uploaded.map((a) => a.id);
} catch (err) { console.error(err); setUploading(false); return; } } catch { setUploading(false); return; }
setUploading(false); setUploading(false);
} }
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } }); dispatch({
type: "ADD_MESSAGE",
chatId,
message: {
id: `tmp-${Date.now()}`,
role: "user",
content: text,
created_at: new Date().toISOString(),
attachments: uploaded,
},
});
setInput(""); setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); }); pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]); setPendingFiles([]);
autoScroll.current = true; autoScroll.current = true;
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } }); // Reset textarea height
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,
},
});
} }
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) { function handlePaste(e) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/")); const items = Array.from(e.clipboardData?.items || []).filter((i) => i.kind === "file");
if (!imgs.length) return; if (!items.length) return;
e.preventDefault(); e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]); addFiles(items.map((i) => i.getAsFile()).filter(Boolean));
} }
function handleDrop(e) { function handleDrop(e) {
e.preventDefault(); e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer?.files || []); const files = Array.from(e.dataTransfer?.files || []);
if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); if (files.length) addFiles(files);
}
async function handlePushAllCode() {
if (!selectedProjectId) return;
const allContent = messages.filter(m => m.role === "assistant").map(m => m.content).join("\n\n");
const codeBlockRegex = /```(\S*?)(?::(\S+?))?\s*?\n(.*?)```/gs;
const files = [];
let match;
while ((match = codeBlockRegex.exec(allContent)) !== null) {
const filename = match[2];
const code = match[3]?.trim();
if (filename && code) files.push({ file_path: filename, content: code });
}
if (!files.length) { alert("No code blocks with filenames found."); return; }
if (!confirm(`Push ${files.length} file(s) to GitLab project #${selectedProjectId} on branch '${selectedBranch}'?`)) return;
setPushingAll(true);
try {
await gitlabDirectCommit(state.token, {
project_id: selectedProjectId,
branch: selectedBranch || "main",
commit_message: `Batch update from Son of Anton: ${currentChat?.title || "chat"}`,
files,
});
alert(` Successfully pushed ${files.length} files!`);
} catch (err) {
alert(` Push failed: ${err.message}`);
} finally {
setPushingAll(false);
}
} }
const streaming = streamData.streaming; const streaming = streamData.streaming;
const selectedProject = gitlabProjects.find(p => p.id === selectedProjectId);
return ( return (
<div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}> <div
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4"> className="flex-1 flex flex-col min-h-0 relative"
{messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)} onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}
>
{/* Drag overlay */}
{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>
)}
{/* 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} />
))}
{streaming && (streamData.thinking || streamData.text) && ( {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 && ( {streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in"> <div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1"> <div className="flex gap-1">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> {[0, 150, 300].map((d) => (
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} /> <span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} /> ))}
</div> </div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span> <span className="text-anton-muted text-sm">Thinking…</span>
</div> </div>
)} )}
</div> </div>
<div className="border-t border-anton-border bg-anton-surface p-4"> {/* 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 && ( {showSettings && (
<div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in"> <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"> <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" /> Chat Settings</h3> <h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button> <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>
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">Model</label> <label className="text-xs text-anton-muted mb-1 block">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"> <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>)} {MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select> </select>
</div> </div>
<div> <div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div> <div className="flex justify-between text-xs mb-1.5">
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} className="w-full" /> <span className="text-anton-muted">Max Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div> </div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</div>
<div> <div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div> <div className="flex justify-between text-xs mb-1.5">
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} className="w-full" /> <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>
<div> <div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label> <label className="text-xs text-anton-muted mb-1 flex items-center gap-1">
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"> <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> <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> </select>
</div> </div>
{isSuperadmin && gitlabConfigured && (
<div className="border-t border-anton-border pt-3 space-y-3">
<label className="text-xs text-orange-400 font-semibold flex items-center gap-1"><GitBranch size={12} /> GitLab Project (Superadmin)</label>
<select value={selectedProjectId || ""} onChange={(e) => setSelectedProjectId(e.target.value ? Number(e.target.value) : null)}
className="w-full bg-anton-bg border border-orange-500/30 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500">
<option value="">No project linked</option>
{gitlabProjects.map((p) => <option key={p.id} value={p.id}>{p.name_with_namespace || p.name}</option>)}
</select>
{selectedProjectId && (
<select value={selectedBranch} onChange={(e) => setSelectedBranch(e.target.value)}
className="w-full bg-anton-bg border border-orange-500/30 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500">
{branches.length ? branches.map(b => <option key={b.name} value={b.name}>{b.name}{b.default ? " (default)" : ""}</option>) : <option value="main">main</option>}
</select>
)}
</div>
)}
</div> </div>
)} )}
{/* Pending files */}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 animate-fade-in"> <div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => ( {pendingFiles.map((pf, i) => {
<div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden"> 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 ? ( {pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-16 h-16 object-cover" /> <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" />
) : ( ) : (
<div className="w-16 h-16 flex flex-col items-center justify-center px-1"> <div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1">
<FileText size={20} className="text-anton-muted mb-1" /> <Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} />
<span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span> <span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span>
</div> </div>
)} )}
<button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button> <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>
);
})}
</div> </div>
)} )}
<div className="flex items-end gap-2"> {/* Input row */}
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button> <div className="flex items-end gap-1.5">
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button> <button
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.dart,.vue,.svelte,.log" onChange={handleFileSelect} /> onClick={toggleSettings}
<div className="flex-1 relative"> 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"
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} }`}
placeholder={selectedProjectId ? `Working on: ${selectedProject?.name || `Project #${selectedProjectId}`}…` : "Ask Son of Anton anything…"} >
rows={1} style={{ maxHeight: "200px" }} <Settings2 size={18} />
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition" </button>
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
<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 = ""; }}
/>
<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";
}}
/>
</div> </div>
{streaming ? ( {streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button> <button
onClick={() => 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={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading} <button
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"> 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"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />} {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted flex-wrap"> {/* 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>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tokens</span> <span></span>
<span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>} {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>} {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{selectedProjectId && <><span></span><span className="text-orange-400">🔀 {selectedProject?.name || `#${selectedProjectId}`}/{selectedBranch}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
<div className="ml-auto flex items-center gap-2">
{isSuperadmin && selectedProjectId && messages.some(m => m.role === "assistant") && (
<button onClick={handlePushAllCode} disabled={pushingAll}
className="flex items-center gap-1 text-orange-400 hover:text-orange-300 transition disabled:opacity-50">
{pushingAll ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Push All Code to GitLab
</button>
)}
{messages.some((m) => m.role === "assistant") && ( {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 { } }} <button
className="hover:text-anton-accent transition">⬇ Download Code</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>
</div> </div>
</div> </div>
</div>
); );
} }
\ No newline at end of file
import React, { useState } from "react"; import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, GitBranch, Loader2 } from "lucide-react"; import { Copy, Check, Download, FileCode } from "lucide-react";
import { useApp } from "../store";
import { gitlabDirectFileOp } from "../api"; 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 }) {
const { state } = useApp();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [pushing, setPushing] = useState(false); const hlLang = LANG_MAP[language] || language || "text";
const [pushResult, setPushResult] = useState(null);
const [showPush, setShowPush] = useState(false);
const [branch, setBranch] = useState("main");
const [commitMsg, setCommitMsg] = useState("");
const isSuperadmin = state.user?.role === "superadmin";
// Find the active chat to get gitlab project info
const activeChat = state.chats.find(c => c.id === state.activeChatId);
const hasProject = activeChat?.gitlab_project_id;
function handleCopy() { function handleCopy() {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
...@@ -26,96 +38,59 @@ export default function CodeBlock({ language, filename, code }) { ...@@ -26,96 +38,59 @@ export default function CodeBlock({ language, filename, code }) {
} }
function handleDownload() { function handleDownload() {
const blob = new Blob([code], { type: "text/plain" }); const name = filename || `code.${language || "txt"}`;
const blob = new Blob([code], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = filename || `code.${language || "txt"}`; a.download = name;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
async function handlePush() {
if (!hasProject || !filename) return;
setPushing(true);
setPushResult(null);
try {
await gitlabDirectFileOp(state.token, {
project_id: activeChat.gitlab_project_id,
file_path: filename,
content: code,
commit_message: commitMsg || `Update ${filename} via Son of Anton`,
branch: branch || activeChat.gitlab_branch || "main",
});
setPushResult({ ok: true });
setTimeout(() => { setPushResult(null); setShowPush(false); }, 2000);
} catch (err) {
setPushResult({ ok: false, error: err.message });
} finally {
setPushing(false);
}
}
return ( return (
<div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]"> <div className="my-2.5 rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]">
<div className="flex items-center justify-between px-3 py-1.5 bg-anton-card border-b border-anton-border"> {/* Header */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between px-2.5 sm:px-3 py-1.5 bg-anton-border/30 gap-2">
{language && <span className="text-[10px] text-anton-accent font-mono uppercase">{language}</span>} <div className="flex items-center gap-1.5 text-xs text-anton-muted min-w-0">
{filename && <span className="text-[10px] text-anton-muted font-mono">{filename}</span>} <FileCode size={11} className="text-anton-accent shrink-0" />
</div> {filename ? (
<div className="flex items-center gap-1"> <span className="text-anton-text font-mono truncate text-[11px]">{filename}</span>
{isSuperadmin && hasProject && filename && ( ) : (
<button onClick={() => { setShowPush(!showPush); setBranch(activeChat.gitlab_branch || "main"); setCommitMsg(""); }} <span className="text-[11px]">{hlLang}</span>
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[10px] transition
${showPush ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-orange-400"}`}
title="Push to GitLab">
<GitBranch size={11} /> Push
</button>
)} )}
<button onClick={handleDownload}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-anton-muted hover:text-white transition">
<Download size={11} />
</button>
<button onClick={handleCopy}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-anton-muted hover:text-white transition">
{copied ? <Check size={11} className="text-green-400" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button>
</div> </div>
</div> <div className="flex items-center gap-0.5 shrink-0">
<button
{showPush && ( onClick={handleCopy}
<div className="px-3 py-2 bg-anton-card/50 border-b border-anton-border space-y-2 animate-fade-in"> 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]"
<div className="flex gap-2"> >
<input value={branch} onChange={(e) => setBranch(e.target.value)} {copied ? <Check size={10} className="text-anton-success" /> : <Copy size={10} />}
placeholder="Branch" className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white" /> <span className="hidden sm:inline">{copied ? "Copied" : "Copy"}</span>
<input value={commitMsg} onChange={(e) => setCommitMsg(e.target.value)} </button>
placeholder={`Update ${filename}`} <button
className="flex-[2] bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white" /> onClick={handleDownload}
</div> 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]"
<div className="flex items-center gap-2"> >
<button onClick={handlePush} disabled={pushing} <Download size={10} />
className="flex items-center gap-1 px-3 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded text-xs font-medium disabled:opacity-50 transition"> <span className="hidden sm:inline">Download</span>
{pushing ? <Loader2 size={12} className="animate-spin" /> : <GitBranch size={12} />}
{pushing ? "Pushing…" : "Push to GitLab"}
</button> </button>
{pushResult && (
<span className={`text-[10px] ${pushResult.ok ? "text-green-400" : "text-red-400"}`}>
{pushResult.ok ? "✔ Pushed!" : `✘ ${pushResult.error}`}
</span>
)}
</div> </div>
</div> </div>
)}
{/* Code with horizontal scroll */}
<div className="overflow-x-auto overscroll-x-contain -webkit-overflow-scrolling-touch">
<SyntaxHighlighter <SyntaxHighlighter
language={language || "text"} language={hlLang}
style={oneDark} style={customStyle}
customStyle={{ margin: 0, padding: "1rem", background: "transparent", fontSize: "12px" }}
showLineNumbers={code.split("\n").length > 3} 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} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
</div>
); );
} }
\ No newline at end of file
...@@ -28,26 +28,29 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -28,26 +28,29 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const hasAttachments = attachments && attachments.length > 0; const hasAttachments = attachments && attachments.length > 0;
return ( return (
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}> <div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && ( {!isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"> <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={16} className="text-white" /> <Flame size={14} className="text-white" />
</div> </div>
</div> </div>
)} )}
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}> <div className={`min-w-0 ${isUser ? "max-w-[85%] sm:max-w-[75%]" : "max-w-[90%] sm:max-w-[80%]"}`}>
{/* Thinking block */}
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)} <button
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"> 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]"
>
<Brain size={12} /> <Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>} {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button> </button>
{(showThinking || isThinking) && ( {(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto"> <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} {thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />} {isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div> </div>
...@@ -55,53 +58,75 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -55,53 +58,75 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div> </div>
)} )}
{/* Attachments */}
{hasAttachments && ( {hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((att) => { {attachments.map((att) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || FileText; const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const url = getAttachmentUrl(att.id); const url = getAttachmentUrl(att.id);
if (att.file_type === "image") { if (att.file_type === "image") {
return ( return (
<div key={att.id} className="relative group"> <div key={att.id} className="relative">
<img src={`${url}?token=${token}`} alt={att.original_filename} <img
className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition" 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)} onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }} /> onError={(e) => { e.target.style.display = "none"; }}
/>
{expandedImage === att.id && ( {expandedImage === att.id && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer" <div
onClick={() => setExpandedImage(null)}> className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 sm:p-8 cursor-pointer"
<img src={`${url}?token=${token}`} alt={att.original_filename} onClick={() => setExpandedImage(null)}
className="max-w-full max-h-full object-contain rounded-lg" /> >
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg"
/>
</div> </div>
)} )}
<div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded"> <div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1 py-0.5 rounded">
{att.original_filename} {att.original_filename}
</div> </div>
</div> </div>
); );
} }
return ( return (
<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer" <a
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"> key={att.id}
<Icon size={16} className="shrink-0 text-blue-400" /> 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" />
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-white truncate max-w-[160px]">{att.original_filename}</div> <div className="text-xs text-white truncate max-w-[120px] sm:max-w-[160px]">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div> <div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div> </div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" /> <ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a> </a>
); );
})} })}
</div> </div>
)} )}
<div className={`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md" {/* 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"
}`}> }`}>
{isUser ? ( {isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div> <div className="text-sm whitespace-pre-wrap break-words leading-relaxed">{_stripPrefixes(content)}</div>
) : ( ) : (
<div className="prose-anton text-sm"> <div className="prose-anton text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ <ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) { code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || ""); const match = /language-(\S+)/.exec(className || "");
const rawLang = match?.[1] || ""; const rawLang = match?.[1] || "";
...@@ -115,7 +140,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -115,7 +140,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />; return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />;
}, },
pre({ children }) { return <>{children}</>; }, pre({ children }) { return <>{children}</>; },
}}> }}
>
{content || ""} {content || ""}
</ReactMarkdown> </ReactMarkdown>
{isStreaming && !isThinking && ( {isStreaming && !isThinking && (
...@@ -125,15 +151,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -125,15 +151,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)} )}
</div> </div>
{/* Actions */}
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1"> <div className="flex items-center gap-3 mt-1 px-1">
<button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"> <button
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} 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"} {copied ? "Copied" : "Copy"}
</button> </button>
{(input_tokens > 0 || output_tokens > 0) && ( {(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted"> <span className="text-[10px] text-anton-muted">
{input_tokens?.toLocaleString()}/ {output_tokens?.toLocaleString()}↑ tokens {input_tokens?.toLocaleString()}{output_tokens?.toLocaleString()}
</span> </span>
)} )}
</div> </div>
...@@ -142,8 +172,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -142,8 +172,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && ( {isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"> <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={16} className="text-anton-muted" /> <User size={14} className="text-anton-muted" />
</div> </div>
</div> </div>
)} )}
......
import React, { useState } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { useNavigate, useLocation } from "react-router-dom";
import { listChats, createChat, deleteChat, renameChat } from "../api"; import { listChats, createChat, deleteChat, renameChat } from "../api";
import { import {
Plus, MessageSquare, Trash2, LogOut, Settings, BookOpen, Plus, Trash2, MessageSquare, LogOut, Shield, BookOpen,
Edit3, Check, X, Flame, GitBranch, MoreHorizontal, Pencil, Check, X, Flame, Menu, FolderGit2,
} from "lucide-react"; } from "lucide-react";
export default function Sidebar({ onSelectChat, activeChatId }) { export default function Sidebar({ activeChatId, onSelectChat, isOpen, onToggle }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const navigate = useNavigate(); const nav = useNavigate();
const location = useLocation(); const [editingId, setEditingId] = useState(null);
const [editId, setEditId] = useState(null);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
const isSuperadmin = state.user?.role === "superadmin"; const [menuId, setMenuId] = useState(null);
const menuRef = useRef(null);
useEffect(() => {
(async () => {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch { /* ignore */ }
})();
}, [state.token, dispatch]);
useEffect(() => {
function handleClick(e) { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuId(null); }
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
async function handleNew() { async function handleNew() {
try { try {
const chat = await createChat(state.token); const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat }); dispatch({ type: "ADD_CHAT", chat });
onSelectChat(chat.id); onSelectChat(chat.id);
} catch { } } catch { /* ignore */ }
} }
async function handleDelete(e, chatId) { async function handleDelete(id) {
e.stopPropagation();
if (!confirm("Delete this chat?")) return;
try { try {
await deleteChat(state.token, chatId); await deleteChat(state.token, id);
dispatch({ type: "REMOVE_CHAT", chatId }); dispatch({ type: "DELETE_CHAT", chatId: id });
} catch { } if (activeChatId === id) {
const remaining = state.chats.filter((c) => c.id !== id);
onSelectChat(remaining.length ? remaining[0].id : null);
}
} catch { /* ignore */ }
setMenuId(null);
} }
async function handleRename(chatId) { async function handleRename(id) {
if (!editTitle.trim()) { setEditId(null); return; } if (!editTitle.trim()) { setEditingId(null); return; }
try { try {
await renameChat(state.token, chatId, editTitle.trim()); await renameChat(state.token, id, editTitle);
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } }); dispatch({ type: "UPDATE_CHAT", chat: { id, title: editTitle } });
} catch { } } catch { /* ignore */ }
setEditId(null); setEditingId(null);
}
function startRename(chat) {
setEditingId(chat.id);
setEditTitle(chat.title);
setMenuId(null);
} }
const isSuperadmin = state.user?.role === "superadmin";
return ( return (
<div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col h-full"> <>
<div className="p-4 border-b border-anton-border"> {isOpen && <div className="fixed inset-0 bg-black/50 z-30 md:hidden" onClick={onToggle} />}
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center"> <aside className={`fixed md:static inset-y-0 left-0 z-40 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"}`}>
<Flame size={18} className="text-white" /> {/* 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 shadow-lg shadow-anton-accent/10">
<Flame size={16} className="text-white" />
</div> </div>
<div> <div>
<h1 className="text-sm font-bold text-white">Son of Anton</h1> <h1 className="text-sm font-bold text-white">Son of Anton</h1>
<span className="text-[10px] text-anton-muted">v4.0 — GitLab Superweapon</span> <p className="text-[10px] text-anton-muted">v3.0 • {state.user?.username}</p>
</div> </div>
</div> </div>
<button onClick={handleNew} <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 font-medium hover:opacity-90 transition active:scale-[0.98]">
className="w-full flex items-center justify-center gap-2 bg-anton-accent hover:opacity-90 text-white rounded-xl py-2.5 text-sm font-medium transition">
<Plus size={16} /> New Chat <Plus size={16} /> New Chat
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto p-2 space-y-0.5"> {/* Chat list */}
<div className="flex-1 overflow-y-auto py-1">
{state.chats.map((chat) => ( {state.chats.map((chat) => (
<div key={chat.id} onClick={() => onSelectChat(chat.id)} <div key={chat.id} className={`group relative mx-1.5 my-0.5 rounded-lg transition ${activeChatId === chat.id ? "bg-anton-card border border-anton-border" : "hover:bg-anton-card/50"}`}>
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition text-sm {editingId === chat.id ? (
${activeChatId === chat.id ? "bg-anton-accent/15 text-white" : "text-anton-muted hover:bg-anton-card hover:text-white"}`}> <div className="flex items-center gap-1 px-2 py-2">
<MessageSquare size={14} className="shrink-0" /> <input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename(chat.id)} className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none" autoFocus />
{editId === chat.id ? ( <button onClick={() => handleRename(chat.id)} className="p-1 text-green-400 hover:bg-green-500/20 rounded"><Check size={12} /></button>
<div className="flex-1 flex items-center gap-1"> <button onClick={() => setEditingId(null)} className="p-1 text-anton-muted hover:bg-anton-card rounded"><X size={12} /></button>
<input value={editTitle} onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleRename(chat.id)}
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-0.5 text-xs text-white"
autoFocus />
<button onClick={() => handleRename(chat.id)}><Check size={12} className="text-green-400" /></button>
<button onClick={() => setEditId(null)}><X size={12} className="text-red-400" /></button>
</div> </div>
) : ( ) : (
<> <button onClick={() => { onSelectChat(chat.id); if (window.innerWidth < 768) onToggle?.(); }} className="w-full text-left px-3 py-2.5 flex items-center gap-2">
<span className="flex-1 truncate">{chat.title}</span> <MessageSquare size={14} className="text-anton-muted shrink-0" />
{chat.gitlab_project_id && <GitBranch size={11} className="text-orange-400 shrink-0" title="GitLab linked" />} <span className="text-sm truncate flex-1">{chat.title}</span>
<div className="hidden group-hover:flex items-center gap-1"> <button onClick={(e) => { e.stopPropagation(); setMenuId(menuId === chat.id ? null : chat.id); }} className="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-anton-bg text-anton-muted">
<button onClick={(e) => { e.stopPropagation(); setEditId(chat.id); setEditTitle(chat.title); }}> <MoreHorizontal size={12} />
<Edit3 size={12} className="text-anton-muted hover:text-white" />
</button> </button>
<button onClick={(e) => handleDelete(e, chat.id)}>
<Trash2 size={12} className="text-anton-muted hover:text-red-400" />
</button> </button>
)}
{menuId === chat.id && (
<div ref={menuRef} className="absolute right-2 top-9 z-50 bg-anton-card border border-anton-border rounded-lg shadow-xl py-1 w-36 animate-fade-in">
<button onClick={() => startRename(chat)} className="w-full text-left px-3 py-1.5 text-xs hover:bg-anton-bg flex items-center gap-2"><Pencil size={10} /> Rename</button>
<button onClick={() => handleDelete(chat.id)} className="w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 flex items-center gap-2"><Trash2 size={10} /> Delete</button>
</div> </div>
</>
)} )}
</div> </div>
))} ))}
</div> </div>
<div className="p-3 border-t border-anton-border space-y-1"> {/* Bottom nav */}
<div className="border-t border-anton-border p-2 space-y-0.5">
{isSuperadmin && ( {isSuperadmin && (
<button onClick={() => navigate("/gitlab")} <>
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition <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-orange-500/10 transition">
${location.pathname === "/gitlab" ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}> <FolderGit2 size={14} /> GitLab Command Center
<GitBranch size={14} /> GitLab Command 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 transition">
<Shield size={14} /> Admin Dashboard
</button> </button>
</>
)} )}
<button onClick={() => navigate("/knowledge")} <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 transition">
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition
${location.pathname === "/knowledge" ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}>
<BookOpen size={14} /> Knowledge Bases <BookOpen size={14} /> Knowledge Bases
</button> </button>
{isSuperadmin && ( <button onClick={() => dispatch({ type: "LOGOUT" })} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-red-400 hover:bg-red-500/10 transition">
<button onClick={() => navigate("/admin")}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition
${location.pathname === "/admin" ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}>
<Settings size={14} /> Admin Panel
</button>
)}
<button onClick={() => { dispatch({ type: "LOGOUT" }); navigate("/login"); }}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:text-red-400 hover:bg-anton-card transition">
<LogOut size={14} /> Logout <LogOut size={14} /> Logout
</button> </button>
<div className="text-[10px] text-anton-muted text-center pt-1">
{state.user?.username}{state.user?.role}
</div>
</div>
</div> </div>
</aside>
</>
); );
} }
\ No newline at end of file
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useApp } from "../store";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import { import {
gitlabGetConfig, gitlabSaveConfig, gitlabTestConnection, gitlabGetConfig, gitlabSaveConfig, gitlabTestConnection,
gitlabListProjects, gitlabCreateProject, gitlabDeleteProject, gitlabListProjects, gitlabCreateProject, gitlabDeleteProject,
gitlabGetProject, gitlabListBranches, gitlabGetTree, gitlabGetFile, gitlabGetTree, gitlabGetFile, gitlabListBranches, gitlabCreateBranch,
gitlabListMRs, gitlabListPipelines, gitlabTriggerPipeline,
gitlabListOperations, gitlabApproveOperation, gitlabRejectOperation, gitlabListOperations, gitlabApproveOperation, gitlabRejectOperation,
gitlabGetAuditLog, gitlabDirectFileOp, gitlabDeleteOperation, gitlabDirectCommit, gitlabAuditLog,
gitlabListPipelines, gitlabTriggerPipeline, gitlabListMRs,
} from "../api"; } from "../api";
import { import {
GitBranch, Settings, FolderGit2, Shield, ScrollText, ArrowLeft, Settings, FolderGit2, ListChecks, ScrollText,
Plus, Trash2, RefreshCw, Eye, Check, X, Loader2, Plus, Trash2, RefreshCw, Check, X, ExternalLink, GitBranch,
FileText, Folder, ChevronRight, Play, ExternalLink, File, Folder, ChevronRight, ChevronDown, Loader2, Shield,
ArrowLeft, GitMerge, Zap, Clock, Zap, Clock, CheckCircle, XCircle, AlertCircle, Play, Eye,
Copy, TerminalSquare, GitMerge, Rocket,
} from "lucide-react"; } from "lucide-react";
import Sidebar from "../components/Sidebar";
const TABS = [ const TABS = [
{ id: "setup", label: "Setup", icon: Settings }, { id: "settings", label: "Settings", icon: Settings },
{ id: "projects", label: "Projects", icon: FolderGit2 }, { id: "repos", label: "Repositories", icon: FolderGit2 },
{ id: "queue", label: "Approval Queue", icon: Shield }, { id: "operations", label: "Operations", icon: ListChecks },
{ id: "audit", label: "Audit Log", icon: ScrollText }, { id: "audit", label: "Audit Log", icon: ScrollText },
]; ];
export default function GitLabPage() { const STATUS_STYLES = {
const { state, dispatch } = useApp(); pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
const navigate = useNavigate(); executed: "bg-green-500/20 text-green-400 border-green-500/30",
const [tab, setTab] = useState("setup"); failed: "bg-red-500/20 text-red-400 border-red-500/30",
const [loading, setLoading] = useState(false); rejected: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
// Setup export default function GitLabPage() {
const { state } = useApp();
const nav = useNavigate();
const [tab, setTab] = useState("settings");
const [config, setConfig] = useState(null); const [config, setConfig] = useState(null);
const [url, setUrl] = useState(""); const [loading, setLoading] = useState(true);
const [token, setToken] = useState("");
const [namespace, setNamespace] = useState("");
const [testResult, setTestResult] = useState(null);
const [saving, setSaving] = useState(false);
// Projects
const [projects, setProjects] = useState([]);
const [search, setSearch] = useState("");
const [selectedProject, setSelectedProject] = useState(null);
const [projectDetail, setProjectDetail] = useState(null);
const [detailTab, setDetailTab] = useState("files");
const [tree, setTree] = useState([]);
const [branches, setBranches] = useState([]);
const [mrs, setMrs] = useState([]);
const [pipelines, setPipelines] = useState([]);
const [viewFile, setViewFile] = useState(null);
const [createName, setCreateName] = useState("");
const [createDesc, setCreateDesc] = useState("");
const [showCreate, setShowCreate] = useState(false);
// Queue
const [operations, setOperations] = useState([]);
const [queueFilter, setQueueFilter] = useState("pending");
// Audit
const [auditLog, setAuditLog] = useState({ entries: [], total: 0 });
const [auditPage, setAuditPage] = useState(1);
useEffect(() => { useEffect(() => {
loadConfig(); (async () => {
}, []);
async function loadConfig() {
try { try {
const cfg = await gitlabGetConfig(state.token); const c = await gitlabGetConfig(state.token);
setConfig(cfg); setConfig(c);
if (cfg.configured) { } catch { /* ignore */ }
setUrl(cfg.gitlab_url);
loadProjects();
}
} catch { }
}
async function loadProjects(s = "") {
try {
const data = await gitlabListProjects(state.token, s);
setProjects(data.projects || []);
} catch { }
}
async function handleTest() {
setTestResult(null);
setLoading(true);
try {
const result = await gitlabTestConnection(state.token, { gitlab_url: url, access_token: token, default_namespace: namespace });
setTestResult(result);
} catch (err) {
setTestResult({ ok: false, error: err.message });
}
setLoading(false); setLoading(false);
} })();
}, [state.token]);
async function handleSave() { if (state.user?.role !== "superadmin") {
setSaving(true); return (
try { <div className="h-dvh flex items-center justify-center bg-anton-bg">
await gitlabSaveConfig(state.token, { gitlab_url: url, access_token: token, default_namespace: namespace }); <div className="text-center">
await loadConfig(); <Shield size={48} className="text-red-500 mx-auto mb-4" />
setTab("projects"); <h1 className="text-xl font-bold text-white mb-2">Access Denied</h1>
} catch (err) { <p className="text-anton-muted">Superadmin access required.</p>
alert(err.message); <button onClick={() => nav("/")} className="mt-4 px-4 py-2 bg-anton-accent rounded-lg text-white text-sm">Back to Chat</button>
} </div>
setSaving(false); </div>
);
} }
async function openProject(p) { return (
setSelectedProject(p); <div className="h-dvh flex flex-col bg-anton-bg text-white">
setDetailTab("files"); {/* Header */}
setViewFile(null); <div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3 shrink-0">
try { <button onClick={() => nav("/")} className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white">
const [detail, t, b, m, pl] = await Promise.all([ <ArrowLeft size={18} />
gitlabGetProject(state.token, p.id), </button>
gitlabGetTree(state.token, p.id, "", p.default_branch || "main", true).catch(() => []), <FolderGit2 size={20} className="text-orange-400" />
gitlabListBranches(state.token, p.id).catch(() => []), <h1 className="text-lg font-bold">GitLab Command Center</h1>
gitlabListMRs(state.token, p.id).catch(() => []), <span className="text-xs text-anton-muted ml-auto">Superadmin Only</span>
gitlabListPipelines(state.token, p.id).catch(() => []), </div>
]);
setProjectDetail(detail);
setTree(t);
setBranches(b);
setMrs(m);
setPipelines(pl);
} catch { }
}
async function handleCreateProject() { {/* Tabs */}
if (!createName.trim()) return; <div className="border-b border-anton-border bg-anton-surface px-4 flex gap-1 shrink-0 overflow-x-auto">
try { {TABS.map((t) => (
await gitlabCreateProject(state.token, { name: createName.trim(), description: createDesc, visibility: "private", initialize_with_readme: true }); <button
setCreateName(""); setCreateDesc(""); setShowCreate(false); key={t.id}
loadProjects(); onClick={() => setTab(t.id)}
} catch (err) { alert(err.message); } className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === t.id ? "border-orange-400 text-orange-400" : "border-transparent text-anton-muted hover:text-white"
} }`}
>
<t.icon size={14} />
{t.label}
</button>
))}
</div>
async function handleDeleteProject(id) { {/* Content */}
if (!confirm("PERMANENTLY delete this GitLab project? This cannot be undone!")) return; <div className="flex-1 overflow-y-auto p-4">
try { {loading ? (
await gitlabDeleteProject(state.token, id); <div className="flex items-center justify-center py-20">
setSelectedProject(null); <Loader2 className="animate-spin text-anton-accent" size={24} />
loadProjects(); </div>
} catch (err) { alert(err.message); } ) : (
} <>
{tab === "settings" && <SettingsTab config={config} setConfig={setConfig} token={state.token} />}
{tab === "repos" && <ReposTab config={config} token={state.token} />}
{tab === "operations" && <OperationsTab token={state.token} />}
{tab === "audit" && <AuditTab token={state.token} />}
</>
)}
</div>
</div>
);
}
async function handleViewFile(path) { // ══════════════════════════════════════════════════════
try { // Settings Tab
const data = await gitlabGetFile(state.token, selectedProject.id, path, selectedProject.default_branch || "main"); // ══════════════════════════════════════════════════════
setViewFile(data); function SettingsTab({ config, setConfig, token }) {
} catch (err) { alert(err.message); } const [url, setUrl] = useState(config?.gitlab_url || "");
} const [pat, setPat] = useState("");
const [ns, setNs] = useState(config?.default_namespace || "");
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [msg, setMsg] = useState(null);
async function loadQueue() { async function handleTest() {
if (!url || !pat) return;
setTesting(true); setMsg(null);
try { try {
const ops = await gitlabListOperations(state.token, queueFilter); const r = await gitlabTestConnection(token, { gitlab_url: url, access_token: pat });
setOperations(ops); setMsg(r.ok ? { type: "success", text: `Connected as ${r.name} (@${r.user})${r.is_admin ? " [Admin]" : ""}` } : { type: "error", text: r.error });
} catch { } } catch (e) { setMsg({ type: "error", text: e.message }); }
setTesting(false);
} }
async function loadAudit(page = 1) { async function handleSave() {
if (!url || !pat) return;
setSaving(true); setMsg(null);
try { try {
const data = await gitlabGetAuditLog(state.token, page); const r = await gitlabSaveConfig(token, { gitlab_url: url, access_token: pat, default_namespace: ns || null });
setAuditLog(data); setMsg({ type: "success", text: `Saved! Connected as ${r.name} (@${r.user})` });
setAuditPage(page); setConfig({ configured: true, gitlab_url: url });
} catch { } } catch (e) { setMsg({ type: "error", text: e.message }); }
setSaving(false);
} }
useEffect(() => { if (tab === "queue") loadQueue(); }, [tab, queueFilter]);
useEffect(() => { if (tab === "audit") loadAudit(1); }, [tab]);
return ( return (
<div className="flex h-screen bg-anton-bg text-white"> <div className="max-w-xl mx-auto space-y-6">
<Sidebar onSelectChat={(id) => { dispatch({ type: "SET_ACTIVE_CHAT", chatId: id }); navigate("/"); }} activeChatId={null} /> <div className="bg-anton-card border border-anton-border rounded-xl p-5 space-y-4">
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <h2 className="text-lg font-bold flex items-center gap-2"><Settings size={18} className="text-orange-400" /> GitLab Connection</h2>
{/* Header */} {config?.configured && <p className="text-xs text-green-400">✓ Currently connected to {config.gitlab_url} (token: {config.token_masked})</p>}
<div className="border-b border-anton-border bg-anton-surface px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-500 to-red-600 flex items-center justify-center">
<GitBranch size={20} className="text-white" />
</div>
<div> <div>
<h1 className="text-lg font-bold">GitLab Command Center</h1> <label className="text-xs text-anton-muted block mb-1">GitLab URL</label>
<p className="text-xs text-anton-muted">Son of Anton v4.0 — Surgical Code Deployment</p> <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://gitlab.yourdomain.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-orange-400" />
</div>
{config?.configured && <span className="ml-auto text-xs text-green-400 flex items-center gap-1"><Check size={12} /> Connected to {config.gitlab_url}</span>}
</div>
<div className="flex gap-1 mt-4">
{TABS.map(t => (
<button key={t.id} onClick={() => { setTab(t.id); if (t.id === "projects") loadProjects(search); }}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm transition ${tab === t.id ? "bg-orange-500/20 text-orange-400 font-medium" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}>
<t.icon size={14} /> {t.label}
</button>
))}
</div>
</div> </div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* ═══ SETUP TAB ═══ */}
{tab === "setup" && (
<div className="max-w-2xl space-y-6">
<div className="bg-anton-card border border-anton-border rounded-xl p-6 space-y-4">
<h2 className="text-sm font-semibold text-white">GitLab Connection</h2>
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">GitLab URL</label> <label className="text-xs text-anton-muted block mb-1">Personal Access Token</label>
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://gitlab.yourserver.com" <input type="password" value={pat} onChange={(e) => setPat(e.target.value)} placeholder="glpat-xxxxxxxxxxxxxxxxxxxx" 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" />
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500" /> <p className="text-[10px] text-anton-muted mt-1">Needs scopes: api, read_repository, write_repository</p>
</div> </div>
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">Personal Access Token</label> <label className="text-xs text-anton-muted block mb-1">Default Namespace (optional)</label>
<input value={token} onChange={e => setToken(e.target.value)} type="password" placeholder="glpat-xxxxxxxxxxxxx" <input value={ns} onChange={(e) => setNs(e.target.value)} placeholder="your-group" 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" />
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500" />
</div> </div>
<div>
<label className="text-xs text-anton-muted mb-1 block">Default Namespace (optional)</label> {msg && (
<input value={namespace} onChange={e => setNamespace(e.target.value)} placeholder="your-group" <div className={`text-sm p-3 rounded-lg ${msg.type === "success" ? "bg-green-500/10 text-green-400 border border-green-500/20" : "bg-red-500/10 text-red-400 border border-red-500/20"}`}>
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500" /> {msg.text}
</div> </div>
<div className="flex gap-3"> )}
<button onClick={handleTest} disabled={loading || !url || !token}
className="px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm hover:border-orange-500 transition disabled:opacity-50"> <div className="flex gap-2">
{loading ? <Loader2 size={14} className="animate-spin" /> : "Test Connection"} <button onClick={handleTest} disabled={testing || !url || !pat} className="px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm hover:border-orange-400 disabled:opacity-30 flex items-center gap-1.5">
{testing ? <Loader2 size={14} className="animate-spin" /> : <Zap size={14} />} Test
</button> </button>
<button onClick={handleSave} disabled={saving || !url || !token} <button onClick={handleSave} disabled={saving || !url || !pat} className="px-4 py-2 bg-orange-500 rounded-lg text-sm text-white hover:bg-orange-600 disabled:opacity-30 flex items-center gap-1.5">
className="px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg text-sm font-medium transition disabled:opacity-50"> {saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Save & Connect
{saving ? <Loader2 size={14} className="animate-spin" /> : "Save & Connect"}
</button> </button>
</div> </div>
{testResult && (
<div className={`p-3 rounded-lg text-sm ${testResult.ok ? "bg-green-500/10 border border-green-500/30 text-green-400" : "bg-red-500/10 border border-red-500/30 text-red-400"}`}>
{testResult.ok ? `✔ Connected as ${testResult.user} (${testResult.name})${testResult.is_admin ? " — GitLab Admin" : ""}` : `✘ ${testResult.error}`}
</div> </div>
)}
</div> </div>
</div> );
)} }
{/* ═══ PROJECTS TAB ═══ */} // ══════════════════════════════════════════════════════
{tab === "projects" && !selectedProject && ( // Repos Tab
// ══════════════════════════════════════════════════════
function ReposTab({ config, token }) {
const [projects, setProjects] = useState([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false);
const [selectedProject, setSelectedProject] = useState(null);
const loadProjects = useCallback(async () => {
setLoading(true);
try {
const r = await gitlabListProjects(token, search);
setProjects(r.projects || []);
} catch { /* ignore */ }
setLoading(false);
}, [token, search]);
useEffect(() => { if (config?.configured) loadProjects(); }, [config, loadProjects]);
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
await gitlabCreateProject(token, { name: newName, description: newDesc });
setNewName(""); setNewDesc(""); setShowCreate(false);
loadProjects();
} catch { /* ignore */ }
setCreating(false);
}
if (!config?.configured) return <p className="text-anton-muted text-center py-10">Configure GitLab connection in Settings first.</p>;
if (selectedProject) {
return <ProjectDetail project={selectedProject} token={token} onBack={() => { setSelectedProject(null); loadProjects(); }} />;
}
return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 flex-wrap">
<input value={search} onChange={e => { setSearch(e.target.value); loadProjects(e.target.value); }} placeholder="Search projects…" <input value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => e.key === "Enter" && loadProjects()} placeholder="Search repos…" className="flex-1 min-w-[200px] bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400" />
className="flex-1 bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500" /> <button onClick={loadProjects} className="p-2 bg-anton-card border border-anton-border rounded-lg hover:border-orange-400"><RefreshCw size={16} /></button>
<button onClick={() => setShowCreate(!showCreate)} className="flex items-center gap-1 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg text-sm font-medium transition"> <button onClick={() => setShowCreate(!showCreate)} className="px-3 py-2 bg-orange-500 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-orange-600"><Plus size={14} /> New Repo</button>
<Plus size={14} /> New Project
</button>
</div> </div>
{showCreate && ( {showCreate && (
<div className="bg-anton-card border border-orange-500/30 rounded-xl p-4 space-y-3 animate-fade-in"> <div className="bg-anton-card border border-orange-500/30 rounded-xl p-4 space-y-3 animate-fade-in">
<input value={createName} onChange={e => setCreateName(e.target.value)} placeholder="Project name" <input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Repository name" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400" />
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm" /> <input value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Description (optional)" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400" />
<input value={createDesc} onChange={e => setCreateDesc(e.target.value)} placeholder="Description (optional)" <div className="flex gap-2">
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm" /> <button onClick={handleCreate} disabled={creating || !newName.trim()} className="px-4 py-2 bg-orange-500 rounded-lg text-sm text-white disabled:opacity-30 flex items-center gap-1.5">
<button onClick={handleCreateProject} className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium">Create</button> {creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Create
</div> </button>
)} <button onClick={() => setShowCreate(false)} className="px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm">Cancel</button>
<div className="grid gap-3">
{projects.map(p => (
<div key={p.id} onClick={() => openProject(p)}
className="bg-anton-card border border-anton-border rounded-xl p-4 hover:border-orange-500/50 cursor-pointer transition group">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-white group-hover:text-orange-400 transition">{p.name}</h3>
<p className="text-xs text-anton-muted">{p.name_with_namespace}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] bg-anton-bg px-2 py-0.5 rounded text-anton-muted">{p.visibility}</span>
<ChevronRight size={14} className="text-anton-muted" />
</div> </div>
</div> </div>
{p.description && <p className="text-xs text-anton-muted mt-1 truncate">{p.description}</p>} )}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-anton-accent" /></div>
) : (
<div className="grid gap-2">
{projects.map((p) => (
<button key={p.id} onClick={() => setSelectedProject(p)} className="w-full text-left bg-anton-card border border-anton-border rounded-xl p-4 hover:border-orange-400/50 transition group">
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="font-semibold text-sm truncate group-hover:text-orange-400 transition">{p.path_with_namespace || p.name}</div>
{p.description && <p className="text-xs text-anton-muted mt-0.5 truncate">{p.description}</p>}
</div>
<ChevronRight size={16} className="text-anton-muted shrink-0 mt-0.5" />
</div>
<div className="flex gap-3 mt-2 text-[10px] text-anton-muted">
<span>{p.visibility}</span>
<span>{p.default_branch || "main"}</span>
{p.last_activity_at && <span>{new Date(p.last_activity_at).toLocaleDateString()}</span>}
</div> </div>
</button>
))} ))}
{!projects.length && <p className="text-sm text-anton-muted text-center py-8">No projects found. Create one or check your connection.</p>} {!projects.length && <p className="text-anton-muted text-center py-6 text-sm">No repositories found.</p>}
</div>
</div> </div>
)} )}
</div>
);
}
{/* ═══ PROJECT DETAIL ═══ */} // ══════════════════════════════════════════════════════
{tab === "projects" && selectedProject && ( // Project Detail (File Browser + Actions)
<div className="space-y-4"> // ══════════════════════════════════════════════════════
<div className="flex items-center gap-3"> function ProjectDetail({ project, token, onBack }) {
<button onClick={() => { setSelectedProject(null); setViewFile(null); }} className="text-anton-muted hover:text-white transition"><ArrowLeft size={18} /></button> const [tree, setTree] = useState([]);
<h2 className="text-lg font-bold">{selectedProject.name}</h2> const [path, setPath] = useState("");
<a href={selectedProject.web_url} target="_blank" rel="noopener" className="text-anton-muted hover:text-orange-400"><ExternalLink size={14} /></a> const [branches, setBranches] = useState([]);
<button onClick={() => handleDeleteProject(selectedProject.id)} className="ml-auto text-anton-muted hover:text-red-400 text-xs flex items-center gap-1"><Trash2 size={12} /> Delete</button> const [branch, setBranch] = useState(project.default_branch || "main");
</div> const [loading, setLoading] = useState(false);
<div className="flex gap-1"> const [fileContent, setFileContent] = useState(null);
{["files", "branches", "mrs", "pipelines"].map(dt => ( const [pipelines, setPipelines] = useState([]);
<button key={dt} onClick={() => { setDetailTab(dt); setViewFile(null); }} const [subTab, setSubTab] = useState("files");
className={`px-3 py-1.5 rounded-lg text-xs transition ${detailTab === dt ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white"}`}>
{dt === "files" && <><Folder size={12} className="inline mr-1" />Files ({tree.length})</>} const loadTree = useCallback(async () => {
{dt === "branches" && <><GitBranch size={12} className="inline mr-1" />Branches ({branches.length})</>} setLoading(true);
{dt === "mrs" && <><GitMerge size={12} className="inline mr-1" />MRs ({mrs.length})</>} try {
{dt === "pipelines" && <><Zap size={12} className="inline mr-1" />Pipelines ({pipelines.length})</>} const t = await gitlabGetTree(token, project.id, path, branch);
setTree(t.sort((a, b) => (a.type === "tree" ? -1 : 1) - (b.type === "tree" ? -1 : 1) || a.name.localeCompare(b.name)));
} catch { setTree([]); }
setLoading(false);
}, [token, project.id, path, branch]);
useEffect(() => {
loadTree();
gitlabListBranches(token, project.id).then(setBranches).catch(() => { });
}, [loadTree, token, project.id]);
async function openFile(filePath) {
try {
const f = await gitlabGetFile(token, project.id, filePath, branch);
setFileContent({ ...f, path: filePath });
} catch { /* ignore */ }
}
async function loadPipelines() {
try {
const p = await gitlabListPipelines(token, project.id);
setPipelines(p);
} catch { setPipelines([]); }
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<button onClick={onBack} className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white"><ArrowLeft size={16} /></button>
<h2 className="font-bold text-sm">{project.path_with_namespace}</h2>
<select value={branch} onChange={(e) => { setBranch(e.target.value); setPath(""); setFileContent(null); }} className="ml-auto bg-anton-card border border-anton-border rounded-lg px-2 py-1 text-xs text-white">
{branches.map((b) => <option key={b.name} value={b.name}>{b.name}</option>)}
</select>
</div>
{/* Sub-tabs */}
<div className="flex gap-1 border-b border-anton-border pb-1">
{[{ id: "files", label: "Files", icon: Folder }, { id: "pipelines", label: "Pipelines", icon: Rocket }].map((t) => (
<button key={t.id} onClick={() => { setSubTab(t.id); if (t.id === "pipelines") loadPipelines(); }}
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-lg transition ${subTab === t.id ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white"}`}>
<t.icon size={12} />{t.label}
</button> </button>
))} ))}
</div> </div>
{detailTab === "files" && !viewFile && ( {subTab === "files" && (
<div className="bg-anton-card border border-anton-border rounded-xl divide-y divide-anton-border max-h-[60vh] overflow-y-auto"> <>
{tree.filter(t => t.type === "tree").sort((a, b) => a.path.localeCompare(b.path)).map(item => ( {/* Breadcrumb */}
<div key={item.id} className="flex items-center gap-2 px-4 py-2 text-sm text-anton-muted"> {path && (
<Folder size={14} className="text-orange-400 shrink-0" /> <span className="font-mono text-xs">{item.path}/</span> <div className="flex items-center gap-1 text-xs text-anton-muted flex-wrap">
</div> <button onClick={() => { setPath(""); setFileContent(null); }} className="hover:text-orange-400">{project.name}</button>
{path.split("/").map((seg, i, arr) => (
<React.Fragment key={i}>
<ChevronRight size={10} />
<button onClick={() => { setPath(arr.slice(0, i + 1).join("/")); setFileContent(null); }} className="hover:text-orange-400">{seg}</button>
</React.Fragment>
))} ))}
{tree.filter(t => t.type === "blob").sort((a, b) => a.path.localeCompare(b.path)).map(item => (
<div key={item.id} onClick={() => handleViewFile(item.path)}
className="flex items-center gap-2 px-4 py-2 text-sm text-white hover:bg-anton-bg cursor-pointer transition">
<FileText size={14} className="text-blue-400 shrink-0" /> <span className="font-mono text-xs">{item.path}</span>
<Eye size={12} className="ml-auto text-anton-muted" />
</div> </div>
)}
{fileContent ? (
<div className="bg-anton-card border border-anton-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-anton-border bg-anton-surface">
<span className="text-xs font-mono text-anton-muted">{fileContent.path}</span>
<div className="flex gap-1.5">
<button onClick={() => { navigator.clipboard.writeText(fileContent.decoded_content || ""); }} className="text-[10px] text-anton-muted hover:text-white flex items-center gap-1"><Copy size={10} /> Copy</button>
<button onClick={() => setFileContent(null)} className="text-[10px] text-anton-muted hover:text-white"><X size={12} /></button>
</div>
</div>
<pre className="p-3 text-xs text-green-300 font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap">{fileContent.decoded_content || "[Binary]"}</pre>
</div>
) : loading ? (
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-anton-accent" /></div>
) : (
<div className="space-y-0.5">
{tree.map((item) => (
<button
key={item.id || item.name}
onClick={() => {
if (item.type === "tree") {
setPath(item.path);
} else {
openFile(item.path);
}
}}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-anton-card transition text-left"
>
{item.type === "tree" ? <Folder size={14} className="text-blue-400 shrink-0" /> : <File size={14} className="text-anton-muted shrink-0" />}
<span className="text-sm truncate">{item.name}</span>
</button>
))} ))}
{!tree.length && <p className="text-anton-muted text-center py-6 text-sm">Empty directory.</p>}
</div> </div>
)} )}
</>
)}
{detailTab === "files" && viewFile && ( {subTab === "pipelines" && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <button onClick={() => gitlabTriggerPipeline(token, project.id, branch).then(loadPipelines)} className="px-3 py-1.5 bg-green-600 rounded-lg text-xs text-white flex items-center gap-1.5 hover:bg-green-700">
<button onClick={() => setViewFile(null)} className="text-anton-muted hover:text-white"><ArrowLeft size={16} /></button> <Play size={12} /> Run Pipeline
<span className="text-sm font-mono text-orange-400">{viewFile.file_path}</span> </button>
<span className="text-xs text-anton-muted">({viewFile.size} bytes)</span> {pipelines.map((p) => (
</div> <div key={p.id} className="bg-anton-card border border-anton-border rounded-lg px-3 py-2 flex items-center gap-3">
<pre className="bg-[#1a1b26] border border-anton-border rounded-xl p-4 text-xs text-white overflow-auto max-h-[55vh] font-mono whitespace-pre-wrap"> <span className={`w-2 h-2 rounded-full shrink-0 ${p.status === "success" ? "bg-green-500" : p.status === "failed" ? "bg-red-500" : p.status === "running" ? "bg-blue-500 animate-pulse" : "bg-yellow-500"}`} />
{viewFile.decoded_content || "[Binary file]"} <div className="min-w-0 flex-1">
</pre> <span className="text-xs font-mono">#{p.id}</span>
<span className="text-xs text-anton-muted ml-2">{p.ref}</span>
</div> </div>
)} <span className="text-[10px] text-anton-muted">{p.status}</span>
{detailTab === "branches" && (
<div className="bg-anton-card border border-anton-border rounded-xl divide-y divide-anton-border">
{branches.map(b => (
<div key={b.name} className="flex items-center gap-2 px-4 py-3 text-sm">
<GitBranch size={14} className={b.default ? "text-green-400" : "text-anton-muted"} />
<span className="font-mono text-xs text-white">{b.name}</span>
{b.default && <span className="text-[10px] bg-green-500/20 text-green-400 px-1.5 py-0.5 rounded">default</span>}
{b.merged && <span className="text-[10px] bg-purple-500/20 text-purple-400 px-1.5 py-0.5 rounded">merged</span>}
</div> </div>
))} ))}
{!pipelines.length && <p className="text-anton-muted text-center py-6 text-sm">No pipelines yet.</p>}
</div> </div>
)} )}
{detailTab === "mrs" && (
<div className="bg-anton-card border border-anton-border rounded-xl divide-y divide-anton-border">
{mrs.map(mr => (
<div key={mr.id} className="px-4 py-3">
<div className="flex items-center gap-2">
<GitMerge size={14} className={mr.state === "merged" ? "text-purple-400" : "text-green-400"} />
<span className="text-sm text-white font-medium">{mr.title}</span>
<span className="text-[10px] bg-anton-bg px-1.5 py-0.5 rounded text-anton-muted">{mr.state}</span>
</div>
<p className="text-xs text-anton-muted mt-1">{mr.source_branch}{mr.target_branch}</p>
</div> </div>
);
}
// ══════════════════════════════════════════════════════
// Operations Tab (THE APPROVAL QUEUE)
// ══════════════════════════════════════════════════════
function OperationsTab({ token }) {
const [ops, setOps] = useState([]);
const [filter, setFilter] = useState("pending");
const [loading, setLoading] = useState(false);
const [expandedOp, setExpandedOp] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try { setOps(await gitlabListOperations(token, filter)); } catch { setOps([]); }
setLoading(false);
}, [token, filter]);
useEffect(() => { load(); }, [load]);
async function handleApprove(id) {
try { await gitlabApproveOperation(token, id); load(); } catch { /* ignore */ }
}
async function handleReject(id) {
try { await gitlabRejectOperation(token, id); load(); } catch { /* ignore */ }
}
return (
<div className="space-y-4">
<div className="flex gap-1.5">
{["pending", "executed", "failed", "rejected", "all"].map((f) => (
<button key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${filter === f ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))} ))}
{!mrs.length && <p className="text-sm text-anton-muted text-center py-4">No merge requests</p>} <button onClick={load} className="ml-auto p-1.5 hover:bg-anton-card rounded-lg text-anton-muted"><RefreshCw size={14} /></button>
</div> </div>
)}
{detailTab === "pipelines" && ( {loading ? (
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-anton-accent" /></div>
) : (
<div className="space-y-2"> <div className="space-y-2">
<button onClick={async () => { try { await gitlabTriggerPipeline(state.token, selectedProject.id); const pl = await gitlabListPipelines(state.token, selectedProject.id); setPipelines(pl); } catch (e) { alert(e.message); } }} {ops.map((op) => (
className="flex items-center gap-1 px-3 py-1.5 bg-orange-500 text-white rounded-lg text-xs font-medium"><Play size={12} /> Trigger Pipeline</button> <div key={op.id} className={`bg-anton-card border rounded-xl overflow-hidden ${STATUS_STYLES[op.status] || "border-anton-border"}`}>
<div className="bg-anton-card border border-anton-border rounded-xl divide-y divide-anton-border"> <button onClick={() => setExpandedOp(expandedOp === op.id ? null : op.id)} className="w-full px-4 py-3 flex items-center gap-3 text-left">
{pipelines.map(pl => ( <span className={`text-xs font-bold uppercase px-2 py-0.5 rounded ${STATUS_STYLES[op.status]}`}>{op.status}</span>
<div key={pl.id} className="flex items-center gap-3 px-4 py-3 text-sm"> <div className="min-w-0 flex-1">
<span className={`w-2 h-2 rounded-full ${pl.status === "success" ? "bg-green-400" : pl.status === "failed" ? "bg-red-400" : pl.status === "running" ? "bg-blue-400 animate-pulse" : "bg-yellow-400"}`} /> <div className="text-sm font-medium">{op.operation_type.replace(/_/g, " ")}</div>
<span className="text-white">#{pl.id}</span> <div className="text-[10px] text-anton-muted">{op.project_name || `Project #${op.project_id}`}{op.branch || "main"}{new Date(op.created_at).toLocaleString()}</div>
<span className="text-anton-muted text-xs">{pl.ref}</span> </div>
<span className="text-xs text-anton-muted">{pl.status}</span> {expandedOp === op.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div> </button>
))}
{!pipelines.length && <p className="text-sm text-anton-muted text-center py-4">No pipelines</p>} {expandedOp === op.id && (
</div> <div className="px-4 pb-3 border-t border-anton-border/50 pt-3 space-y-3 animate-fade-in">
<div className="bg-anton-bg rounded-lg p-3">
<p className="text-[10px] text-anton-muted mb-1 font-bold">PAYLOAD</p>
<pre className="text-xs font-mono text-green-300 whitespace-pre-wrap max-h-60 overflow-y-auto">{JSON.stringify(op.payload, null, 2)}</pre>
</div> </div>
)}
{op.result && (
<div className="bg-anton-bg rounded-lg p-3">
<p className="text-[10px] text-anton-muted mb-1 font-bold">RESULT</p>
<pre className="text-xs font-mono text-blue-300 whitespace-pre-wrap max-h-40 overflow-y-auto">{typeof op.result === "string" ? op.result : JSON.stringify(op.result, null, 2)}</pre>
</div> </div>
)} )}
{/* ═══ QUEUE TAB ═══ */} {op.status === "pending" && (
{tab === "queue" && (
<div className="space-y-4">
<div className="flex gap-2"> <div className="flex gap-2">
{["pending", "executed", "rejected", "all"].map(f => ( <button onClick={() => handleApprove(op.id)} className="px-4 py-2 bg-green-600 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-green-700">
<button key={f} onClick={() => setQueueFilter(f)} <CheckCircle size={14} /> Approve & Execute
className={`px-3 py-1.5 rounded-lg text-xs transition ${queueFilter === f ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white"}`}> </button>
{f.charAt(0).toUpperCase() + f.slice(1)} <button onClick={() => handleReject(op.id)} className="px-4 py-2 bg-red-600 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-red-700">
<XCircle size={14} /> Reject
</button> </button>
))}
<button onClick={loadQueue} className="ml-auto text-anton-muted hover:text-white"><RefreshCw size={14} /></button>
</div> </div>
<div className="space-y-2"> )}
{operations.map(op => (
<div key={op.id} className="bg-anton-card border border-anton-border rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`text-xs font-mono px-2 py-0.5 rounded ${op.status === "pending" ? "bg-yellow-500/20 text-yellow-400" : op.status === "executed" ? "bg-green-500/20 text-green-400" : "bg-red-500/20 text-red-400"}`}>
{op.status}
</span>
<span className="text-sm font-medium text-white">{op.operation_type}</span>
{op.project_name && <span className="text-xs text-anton-muted">({op.project_name})</span>}
</div>
<span className="text-[10px] text-anton-muted flex items-center gap-1"><Clock size={10} />{new Date(op.created_at).toLocaleString()}</span>
</div>
{op.branch && <p className="text-xs text-anton-muted mb-2">Branch: {op.branch}</p>}
{op.status === "pending" && (
<div className="flex gap-2 mt-2">
<button onClick={async () => { try { await gitlabApproveOperation(state.token, op.id); loadQueue(); } catch (e) { alert(e.message); } }}
className="flex items-center gap-1 px-3 py-1.5 bg-green-500 text-white rounded-lg text-xs font-medium"><Check size={12} /> Approve & Execute</button>
<button onClick={async () => { try { await gitlabRejectOperation(state.token, op.id); loadQueue(); } catch (e) { alert(e.message); } }}
className="flex items-center gap-1 px-3 py-1.5 bg-red-500 text-white rounded-lg text-xs font-medium"><X size={12} /> Reject</button>
</div> </div>
)} )}
</div> </div>
))} ))}
{!operations.length && <p className="text-sm text-anton-muted text-center py-8">No operations in queue.</p>} {!ops.length && <p className="text-anton-muted text-center py-10 text-sm">No operations found.</p>}
</div>
</div> </div>
)} )}
</div>
);
}
{/* ═══ AUDIT TAB ═══ */} // ══════════════════════════════════════════════════════
{tab === "audit" && ( // Audit Tab
// ══════════════════════════════════════════════════════
function AuditTab({ token }) {
const [entries, setEntries] = useState([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
(async () => {
setLoading(true);
try {
const r = await gitlabAuditLog(token, page);
setEntries(r.entries || []);
setTotal(r.total || 0);
} catch { setEntries([]); }
setLoading(false);
})();
}, [token, page]);
return (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-anton-card border border-anton-border rounded-xl divide-y divide-anton-border"> <h2 className="text-sm font-bold text-anton-muted">{total} total audit entries</h2>
{auditLog.entries?.map(e => ( {loading ? (
<div key={e.id} className="px-4 py-3"> <div className="flex justify-center py-10"><Loader2 className="animate-spin text-anton-accent" /></div>
<div className="flex items-center gap-2 text-sm"> ) : (
<span className="text-orange-400 font-mono text-xs">{e.action}</span> <div className="space-y-1">
<span className="text-[10px] text-anton-muted">{new Date(e.created_at).toLocaleString()}</span> {entries.map((e) => (
</div> <div key={e.id} className="bg-anton-card border border-anton-border rounded-lg px-3 py-2 flex items-start gap-3">
{e.details && <p className="text-xs text-anton-muted mt-1">{e.details}</p>} <Clock size={12} className="text-anton-muted mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-orange-400">{e.action}</div>
{e.details && <p className="text-[10px] text-anton-muted mt-0.5">{e.details}</p>}
</div>
<span className="text-[10px] text-anton-muted whitespace-nowrap">{new Date(e.created_at).toLocaleString()}</span>
</div> </div>
))} ))}
{!auditLog.entries?.length && <p className="text-sm text-anton-muted text-center py-8">No audit entries yet.</p>} {entries.length === 50 && (
</div> <div className="flex justify-center pt-2">
{auditLog.total > 50 && ( <button onClick={() => setPage((p) => p + 1)} className="text-xs text-orange-400 hover:underline">Load more</button>
<div className="flex justify-center gap-2">
<button onClick={() => loadAudit(auditPage - 1)} disabled={auditPage <= 1} className="px-3 py-1 text-xs text-anton-muted hover:text-white disabled:opacity-30">← Prev</button>
<span className="text-xs text-anton-muted">Page {auditPage}</span>
<button onClick={() => loadAudit(auditPage + 1)} className="px-3 py-1 text-xs text-anton-muted hover:text-white">Next →</button>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
</div>
</div>
); );
} }
\ No newline at end of file
import React, { createContext, useContext, useReducer } from "react"; import React, { createContext, useContext, useReducer, useCallback } from "react";
import { setDispatch } from "./streamManager";
const AppContext = createContext(); const AppContext = createContext(null);
const initialState = { const initialState = {
token: localStorage.getItem("token"), token: localStorage.getItem("token") || null,
user: JSON.parse(localStorage.getItem("user") || "null"), user: null,
chats: [], chats: [],
activeChatId: null, activeChatId: null,
chatMessages: {}, chatMessages: {},
activeStreams: {}, activeStreams: {},
sidebarOpen: false,
}; };
function reducer(state, action) { function reducer(state, action) {
switch (action.type) { switch (action.type) {
case "LOGIN": case "LOGIN":
localStorage.setItem("token", action.token); localStorage.setItem("token", action.token);
localStorage.setItem("user", JSON.stringify(action.user));
return { ...state, token: action.token, user: action.user }; return { ...state, token: action.token, user: action.user };
case "LOGOUT": case "LOGOUT":
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("user"); return { ...initialState, token: null };
return { ...initialState, token: null, user: null }; case "SET_USER":
return { ...state, user: action.user };
case "SET_CHATS": case "SET_CHATS":
return { ...state, chats: action.chats }; return { ...state, chats: action.chats };
case "SET_ACTIVE_CHAT": case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId }; return { ...state, activeChatId: action.chatId, sidebarOpen: false };
case "ADD_CHAT": case "ADD_CHAT":
return { ...state, chats: [action.chat, ...state.chats] }; return {
case "UPDATE_CHAT": { ...state,
const chats = state.chats.map((c) => chats: [action.chat, ...state.chats],
activeChatId: action.chat.id,
sidebarOpen: false,
};
case "UPDATE_CHAT":
return {
...state,
chats: state.chats.map((c) =>
c.id === action.chat.id ? { ...c, ...action.chat } : c c.id === action.chat.id ? { ...c, ...action.chat } : c
); ),
return { ...state, chats }; };
}
case "REMOVE_CHAT": { case "REMOVE_CHAT": {
const chats = state.chats.filter((c) => c.id !== action.chatId); const remaining = state.chats.filter((c) => c.id !== action.chatId);
const msgs = { ...state.chatMessages };
delete msgs[action.chatId];
return { return {
...state, chats, chatMessages: msgs, ...state,
activeChatId: state.activeChatId === action.chatId ? null : state.activeChatId, chats: remaining,
activeChatId:
state.activeChatId === action.chatId
? remaining[0]?.id || null
: state.activeChatId,
}; };
} }
case "SET_MESSAGES": case "SET_MESSAGES":
return { ...state, chatMessages: { ...state.chatMessages, [action.chatId]: action.messages } }; return {
...state,
chatMessages: { ...state.chatMessages, [action.chatId]: action.messages },
};
case "ADD_MESSAGE": { case "ADD_MESSAGE": {
const existing = state.chatMessages[action.chatId] || []; const prev = state.chatMessages[action.chatId] || [];
return { ...state, chatMessages: { ...state.chatMessages, [action.chatId]: [...existing, action.message] } }; return {
...state,
chatMessages: {
...state.chatMessages,
[action.chatId]: [...prev, action.message],
},
};
} }
case "SET_STREAMING": case "SET_STREAMING":
return { ...state, activeStreams: { ...state.activeStreams, [action.chatId]: action.streaming } }; return {
...state,
activeStreams: action.streaming
? { ...state.activeStreams, [action.chatId]: true }
: Object.fromEntries(
Object.entries(state.activeStreams).filter(([k]) => k !== action.chatId)
),
};
case "SET_SIDEBAR_OPEN":
return { ...state, sidebarOpen: action.open };
case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen };
default: default:
return state; return state;
} }
...@@ -58,7 +91,6 @@ function reducer(state, action) { ...@@ -58,7 +91,6 @@ function reducer(state, action) {
export function AppProvider({ children }) { export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
setDispatch(dispatch);
return ( return (
<AppContext.Provider value={{ state, dispatch }}> <AppContext.Provider value={{ state, dispatch }}>
{children} {children}
...@@ -67,5 +99,7 @@ export function AppProvider({ children }) { ...@@ -67,5 +99,7 @@ export function AppProvider({ children }) {
} }
export function useApp() { export function useApp() {
return useContext(AppContext); const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be inside AppProvider");
return ctx;
} }
\ 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