Commit a20ce51b authored by Mahmoud Aglan's avatar Mahmoud Aglan

4.0

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