Commit 64e177f9 authored by Administrator's avatar Administrator

Revert "4.0"

This reverts commit a20ce51b
parent a20ce51b
This source diff could not be displayed because it is too large. You can view the blob instead.
"""
Son of Anton v4.0 — Main FastAPI Application
The Avatar of All Elements of Code + GitLab Superweapon
Son of Anton — Main FastAPI Application
"""
import os
......@@ -25,7 +24,7 @@ from backend.routes.gitlab_routes import router as gitlab_router
from backend.services.bedrock_service import close_http_client
from backend.services.gitlab_service import close_gitlab_client
APP_VERSION = "4.0.0"
APP_VERSION = "3.0.0"
APP_BUILD_TIME = str(int(time.time()))
......@@ -45,12 +44,6 @@ 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:
......@@ -58,6 +51,7 @@ 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
......@@ -78,7 +72,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()
......@@ -87,7 +81,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Son of Anton",
description="Avatar of All Elements of Code — v4.0 GitLab Superweapon",
description="Avatar of All Elements of Code",
version=APP_VERSION,
lifespan=lifespan,
)
......@@ -111,7 +105,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"
else:
elif path.endswith(".html") or not path.startswith("/assets"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
......@@ -159,4 +153,4 @@ async def serve_frontend(full_path: str):
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
return {"message": "Son of Anton v4.0 API running. Frontend not built."}
\ No newline at end of file
return {"message": "Son of Anton API is running. Frontend not built."}
\ No newline at end of file
"""
SQLAlchemy ORM models — Son of Anton v4.0
SQLAlchemy ORM models.
"""
from datetime import datetime, timedelta
......@@ -49,8 +49,6 @@ 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 — v4.0
Now with GitLab project linking and project-aware generation.
Chat CRUD and message streaming with multimodal attachment support.
Generation runs in background and survives client disconnection.
"""
import json
......@@ -25,8 +25,6 @@ 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
......@@ -37,8 +35,6 @@ 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):
......@@ -61,8 +57,6 @@ 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)
......@@ -94,10 +88,6 @@ 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)
......@@ -129,11 +119,13 @@ 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": ""})
......@@ -148,8 +140,10 @@ 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,
......@@ -161,6 +155,7 @@ 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)
......@@ -176,8 +171,6 @@ 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 — v4.0
Background generation manager.
Decouples AI generation from the SSE HTTP connection so generation
continues even if the client disconnects.
Now includes GitLab project context injection.
"""
import asyncio
......@@ -12,7 +11,7 @@ from typing import Optional
from dataclasses import dataclass, field
from backend.database import SessionLocal
from backend.models import User, Chat, Message, ChatAttachment, GitLabConfig
from backend.models import User, Chat, Message, ChatAttachment
from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service
......@@ -47,6 +46,7 @@ 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,33 +73,13 @@ 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,
......@@ -157,6 +137,7 @@ class GenerationManager:
db.commit()
db.refresh(user_msg)
# Link attachments to message
for att in attachments:
att.message_id = user_msg.id
if attachments:
......@@ -171,10 +152,7 @@ class GenerationManager:
except Exception:
pass
# GitLab project context (v4.0)
project_context = await self._get_project_context(db, chat)
system_prompt = build_full_prompt(rag_context, project_context)
system_prompt = build_full_prompt(rag_context)
messages = memory_service.build_messages(chat, db)
# Inject multimodal content blocks
......@@ -204,7 +182,7 @@ class GenerationManager:
thinking_config=thinking_config,
):
if state.done.is_set():
break
break # Aborted
evt_type = event.get("type", "")
......@@ -234,7 +212,7 @@ class GenerationManager:
usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0)
# Save assistant message
# Save assistant message to DB
assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
......@@ -271,6 +249,7 @@ 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 — v4.0
GitLab CE API Service.
Handles all communication with a self-hosted GitLab CE instance.
Superadmin-only. Every operation is auditable.
"""
......@@ -318,6 +318,10 @@ 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,
......@@ -337,7 +341,10 @@ 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."""
"""
Intelligently create or update files. Checks if each file exists first.
files: [{"file_path": "...", "content": "..."}]
"""
actions = []
for f in files:
exists = False
......@@ -409,7 +416,9 @@ 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"),
......@@ -419,7 +428,9 @@ async def list_pipelines(gitlab_url: str, token: str, project_id: int) -> list:
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}"),
......@@ -429,7 +440,9 @@ async def get_pipeline(gitlab_url: str, token: str, project_id: int, pipeline_id
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"),
......@@ -439,7 +452,9 @@ async def get_pipeline_jobs(gitlab_url: str, token: str, project_id: int, pipeli
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"),
......@@ -449,7 +464,9 @@ async def trigger_pipeline(gitlab_url: str, token: str, project_id: int, ref: st
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"),
......@@ -474,72 +491,4 @@ async def compare(
params={"from": from_ref, "to": to_ref},
)
resp.raise_for_status()
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
return resp.json()
\ No newline at end of file
"""
Son of Anton v4.0 — Unified personality, instructions, and project awareness.
Son of Anton's personality and instructions.
"""
SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire.
......@@ -9,16 +9,14 @@ SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of C
- LEGENDARY Unity / C# game developer. DOTween is your brush. Feel (More Mountains) is your instrument. Procedural generation is your playground.
- 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 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.
- 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.
## CODE OUTPUT FORMAT
When writing code, ALWAYS use fenced code blocks with language AND filename:
......@@ -27,61 +25,40 @@ When writing code, ALWAYS use fenced code blocks with language AND filename:
// your code here
```
This enables automatic file downloads AND direct push-to-GitLab.
This enables automatic file downloads. ALWAYS include meaningful filenames.
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.
For multi-file systems, provide EVERY file with its full path.
## RULES
- NEVER volunteer who created you or where you work. Only answer if directly asked.
- When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade. You work at AL-Arcade.
- For basic questions: Roast first. Help optionally.
- 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.
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.
"""
def build_full_prompt(
rag_context: str | None = None,
project_context: str | None = None,
) -> str:
def build_full_prompt(rag_context: str | None = None) -> str:
"""
Build the final system prompt with optional RAG and GitLab project context.
Build the final system prompt, optionally with RAG context.
"""
parts = [SYSTEM_PROMPT]
if project_context:
if rag_context:
parts.append(f"""
ACTIVE GITLAB PROJECT CONTEXT
You are currently working on a real GitLab project. The superadmin has linked this chat to a repository.
Use this to:
- Understand the existing codebase before suggesting changes
- Reference existing files by their correct paths
- Suggest surgical modifications instead of full rewrites when appropriate
- Be aware of the tech stack, dependencies, and architecture
PROJECT DETAILS:
{project_context}
## KNOWLEDGE BASE CONTEXT
When generating code:
- Use EXACT file paths matching the project structure
- Place new files logically within the architecture
""")
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.
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 from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AppProvider, useApp } from "./store";
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 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";
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;
}
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 AppRoutes() {
return (
<Routes>
<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="/" />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/gitlab" element={<GitLabPage />} />
<Route path="/*" element={<ChatPage />} />
</Routes>
);
}
export default function App() {
return (
<AppProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AppProvider>
);
}
\ No newline at end of file
......@@ -21,29 +21,32 @@ 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 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 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 }));
......@@ -60,74 +63,60 @@ 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 { } }
}
// ══════════ Attachments ══════════
// ═══════════════════════════════════════════════════
// Chat 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 ══════════
// ═══════════════════════════════════════════════════
// Knowledge Bases
// ═══════════════════════════════════════════════════
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") =>
request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) =>
request("GET", `/knowledge/${kbId}`, token);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export const createKnowledgeBase = (token, name, description = "") => request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) => request("GET", `/knowledge/${kbId}`, token);
export const updateKnowledgeBase = (token, kbId, data) => request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) => request("DELETE", `/knowledge/${kbId}`, token);
export const listKnowledgeDocuments = (token, kbId) => request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export async function uploadDocuments(token, kbId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(token), body: form });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || "Upload failed"); }
return res.json();
}
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
export const uploadDocument = (token, kbId, file) => uploadDocuments(token, kbId, [file]);
// ══════════ Admin ══════════
// ═══════════════════════════════════════════════════
// Admin
// ═══════════════════════════════════════════════════
export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const 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);
// ══════════ Files / Zip ══════════
export async function downloadZip(token, markdown, title) {
// ═══════════════════════════════════════════════════
// Code Download
// ═══════════════════════════════════════════════════
export async function downloadZip(token, markdown, chatTitle) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown, title }),
method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || null }),
});
if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || "";
......@@ -136,7 +125,9 @@ export async function downloadZip(token, markdown, title) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "son-of-anton-code.zip";
const raw = (chatTitle || "").trim();
const safeName = raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code";
a.download = `${safeName}.zip`;
a.click();
URL.revokeObjectURL(url);
} else {
......@@ -145,35 +136,27 @@ export async function downloadZip(token, markdown, title) {
}
}
// ══════════════════════════════════════════════════════
// GitLab CE Integration (v4.0)
// ══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// 🔥 GitLab CE Integration (Superadmin Only)
// ═══════════════════════════════════════════════════
// 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);
// File Tree & Files
// 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") =>
......@@ -193,28 +176,20 @@ 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);
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);
// Audit Log
export const gitlabGetAuditLog = (token, page = 1) =>
request("GET", `/gitlab/audit-log?page=${page}`, token);
// Audit
export const gitlabAuditLog = (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, gitlabGetConfig, gitlabListProjects, gitlabListBranches, gitlabDirectCommit } from "../api";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, GitBranch, FileText, Loader2, Rocket } from "lucide-react";
import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
} from "lucide-react";
const MODELS = [
{ 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)" },
{ 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" },
];
function classifyFile(file) {
const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || "";
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 || "";
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);
......@@ -35,16 +47,9 @@ 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);
......@@ -56,16 +61,10 @@ 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(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
rafRef.current = null;
});
}, []);
......@@ -79,49 +78,33 @@ export default function ChatView({ chatId }) {
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData);
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 { }
} catch { /* ignore */ }
})();
}, [chatId, state.token, dispatch, isSuperadmin]);
}, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => {
if (selectedProjectId && isSuperadmin && gitlabConfigured) {
gitlabListBranches(state.token, selectedProjectId)
.then(setBranches).catch(() => setBranches([]));
} else {
setBranches([]);
if (currentChat) {
setModel(currentChat.model || MODELS[0].id);
setMaxTokens(currentChat.max_tokens || 4096);
setReasoningBudget(currentChat.reasoning_budget ?? 0);
setSelectedKbId(currentChat.knowledge_base_id || null);
}
}, [selectedProjectId, state.token, isSuperadmin, gitlabConfigured]);
}, [chatId]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
async function saveSettings() {
try {
await updateChat(state.token, chatId, {
model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId || "",
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 { }
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 */ }
}
function toggleSettings() {
......@@ -129,19 +112,27 @@ export default function ChatView({ chatId }) {
setShowSettings(!showSettings);
}
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 addFiles(files) {
setPendingFiles((prev) => [
...prev,
...files.map((f) => ({
file: f,
type: classifyFile(f),
preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null,
})),
]);
}
function removePending(i) {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
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) || isStreamingGlobal) return;
if ((!content && !pendingFiles.length) || streamData.streaming) return;
const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = [];
......@@ -151,193 +142,276 @@ 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 (err) { console.error(err); setUploading(false); return; }
} catch { setUploading(false); return; }
setUploading(false);
}
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
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;
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
// Reset textarea height
if (inputRef.current) inputRef.current.style.height = "auto";
streamManager.startStream({
token: state.token,
chatId,
body: {
content: text, model, max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attIds,
},
});
}
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function handlePaste(e) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return;
const items = Array.from(e.clipboardData?.items || []).filter((i) => i.kind === "file");
if (!items.length) return;
e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
addFiles(items.map((i) => i.getAsFile()).filter(Boolean));
}
function handleDrop(e) {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer?.files || []);
if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
}
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);
}
if (files.length) addFiles(files);
}
const streaming = streamData.streaming;
const selectedProject = gitlabProjects.find(p => p.id === selectedProjectId);
return (
<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} />)}
<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} />
))}
{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-4 py-3 animate-fade-in">
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1">
<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" }} />
{[0, 150, 300].map((d) => (
<span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />
))}
</div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
<span className="text-anton-muted text-sm">Thinking…</span>
</div>
)}
</div>
<div className="border-t border-anton-border bg-anton-surface p-4">
{/* Input area */}
<div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom">
{/* Settings panel */}
{showSettings && (
<div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<div className="flex items-center justify-between">
<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>
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<Settings2 size={14} className="text-anton-accent" /> Settings
</h3>
<button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white">
<X size={14} />
</button>
</div>
<div>
<label className="text-xs text-anton-muted mb-1 block">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} className="w-full" />
<div className="flex justify-between text-xs mb-1.5">
<span className="text-anton-muted">Max Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1"><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 className="flex justify-between text-xs mb-1.5">
<span className="text-anton-muted flex items-center gap-1">
<Brain size={12} className="text-purple-400" /> Reasoning
</span>
<span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span>
</div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1">
<BookOpen size={12} /> Knowledge Base
</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</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-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 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>
)}
<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>
);
})}
</div>
)}
<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"; }} />
{/* 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>
{streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
<button
onClick={() => streamManager.abortStream(chatId)}
className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
>
<Square size={18} />
</button>
) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed">
<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"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div>
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted flex-wrap">
{/* Status bar */}
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tokens</span>
<span></span>
<span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{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></>}
<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>
{messages.some((m) => m.role === "assistant") && (
<button
onClick={async () => {
const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { /* */ }
}}
className="ml-auto hover:text-anton-accent transition"
>
⬇ Code
</button>
)}
</div>
</div>
</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, GitBranch, Loader2 } from "lucide-react";
import { useApp } from "../store";
import { gitlabDirectFileOp } from "../api";
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",
},
};
export default function CodeBlock({ language, filename, code }) {
const { state } = useApp();
const [copied, setCopied] = useState(false);
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;
const hlLang = LANG_MAP[language] || language || "text";
function handleCopy() {
navigator.clipboard.writeText(code);
......@@ -26,96 +38,59 @@ export default function CodeBlock({ language, filename, code }) {
}
function handleDownload() {
const blob = new Blob([code], { type: "text/plain" });
const name = filename || `code.${language || "txt"}`;
const blob = new Blob([code], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || `code.${language || "txt"}`;
a.download = name;
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-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-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>
<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>
)}
<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} />
</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>
</button>
<button onClick={handleCopy}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-anton-muted hover:text-white transition">
{copied ? <Check size={11} className="text-green-400" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
<button
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>
</div>
</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>
{/* 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>
</div>
);
}
\ No newline at end of file
......@@ -28,26 +28,29 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const hasAttachments = attachments && attachments.length > 0;
return (
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && (
<div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={16} className="text-white" />
<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>
</div>
)}
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
<div className={`min-w-0 ${isUser ? "max-w-[85%] sm:max-w-[75%]" : "max-w-[90%] sm:max-w-[80%]"}`}>
{/* Thinking block */}
{thinking_content && (
<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">
<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]"
>
<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-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5 sm:p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto overscroll-contain break-words">
{thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div>
......@@ -55,67 +58,90 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div>
)}
{/* Attachments */}
{hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2">
<div className="mb-2 flex flex-wrap gap-1.5">
{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 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"
<div key={att.id} className="relative">
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }} />
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-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-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>
)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1 py-0.5 rounded">
{att.original_filename}
</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-3 py-2 hover:border-anton-accent transition group">
<Icon size={16} 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-2.5 py-2 hover:border-anton-accent transition group min-h-[44px]"
>
<Icon size={14} className="shrink-0 text-blue-400" />
<div className="min-w-0">
<div className="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 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>
<ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
<ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a>
);
})}
</div>
)}
<div className={`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{/* Message bubble */}
<div className={`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${
isUser
? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
<div className="text-sm whitespace-pre-wrap break-words leading-relaxed">{_stripPrefixes(content)}</div>
) : (
<div className="prose-anton text-sm">
<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 && (
......@@ -125,15 +151,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)}
</div>
{/* Actions */}
{!isUser && !isStreaming && content && (
<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} />}
<div className="flex items-center gap-3 mt-1 px-1">
<button
onClick={handleCopy}
className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition min-h-[28px]"
>
{copied ? <Check size={10} className="text-anton-success" /> : <Copy size={10} />}
{copied ? "Copied" : "Copy"}
</button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}/ {output_tokens?.toLocaleString()}↑ tokens
<span className="text-[10px] text-anton-muted">
{input_tokens?.toLocaleString()}{output_tokens?.toLocaleString()}
</span>
)}
</div>
......@@ -142,8 +172,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && (
<div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={16} className="text-anton-muted" />
<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>
</div>
)}
......
import React, { useState } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import { useNavigate, useLocation } from "react-router-dom";
import { listChats, createChat, deleteChat, renameChat } from "../api";
import {
Plus, MessageSquare, Trash2, LogOut, Settings, BookOpen,
Edit3, Check, X, Flame, GitBranch,
Plus, Trash2, MessageSquare, LogOut, Shield, BookOpen,
MoreHorizontal, Pencil, Check, X, Flame, Menu, FolderGit2,
} from "lucide-react";
export default function Sidebar({ onSelectChat, activeChatId }) {
export default function Sidebar({ activeChatId, onSelectChat, isOpen, onToggle }) {
const { state, dispatch } = useApp();
const navigate = useNavigate();
const location = useLocation();
const [editId, setEditId] = useState(null);
const nav = useNavigate();
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState("");
const isSuperadmin = state.user?.role === "superadmin";
const [menuId, setMenuId] = useState(null);
const menuRef = useRef(null);
useEffect(() => {
(async () => {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch { /* ignore */ }
})();
}, [state.token, dispatch]);
useEffect(() => {
function handleClick(e) { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuId(null); }
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
async function handleNew() {
try {
const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat });
onSelectChat(chat.id);
} catch { }
} catch { /* ignore */ }
}
async function handleDelete(e, chatId) {
e.stopPropagation();
if (!confirm("Delete this chat?")) return;
async function handleDelete(id) {
try {
await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId });
} catch { }
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);
}
async function handleRename(chatId) {
if (!editTitle.trim()) { setEditId(null); return; }
async function handleRename(id) {
if (!editTitle.trim()) { setEditingId(null); return; }
try {
await renameChat(state.token, chatId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } });
} catch { }
setEditId(null);
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);
}
const isSuperadmin = state.user?.role === "superadmin";
return (
<div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col h-full">
<div className="p-4 border-b border-anton-border">
<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>
<>
{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>
<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>
<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" />
{/* 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} />
</button>
</button>
)}
{menuId === chat.id && (
<div ref={menuRef} className="absolute right-2 top-9 z-50 bg-anton-card border border-anton-border rounded-lg shadow-xl py-1 w-36 animate-fade-in">
<button onClick={() => startRename(chat)} className="w-full text-left px-3 py-1.5 text-xs hover:bg-anton-bg flex items-center gap-2"><Pencil size={10} /> Rename</button>
<button onClick={() => handleDelete(chat.id)} className="w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 flex items-center gap-2"><Trash2 size={10} /> Delete</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
<div 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
{/* 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
</button>
)}
<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 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>
)}
<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>
</div>
</div>
</aside>
</>
);
}
\ No newline at end of file
import React, { useState, useEffect } from "react";
import { useApp } from "../store";
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import {
gitlabGetConfig, gitlabSaveConfig, gitlabTestConnection,
gitlabListProjects, gitlabCreateProject, gitlabDeleteProject,
gitlabGetProject, gitlabListBranches, gitlabGetTree, gitlabGetFile,
gitlabListMRs, gitlabListPipelines, gitlabTriggerPipeline,
gitlabGetTree, gitlabGetFile, gitlabListBranches, gitlabCreateBranch,
gitlabListOperations, gitlabApproveOperation, gitlabRejectOperation,
gitlabGetAuditLog, gitlabDirectFileOp,
gitlabDeleteOperation, gitlabDirectCommit, gitlabAuditLog,
gitlabListPipelines, gitlabTriggerPipeline, gitlabListMRs,
} from "../api";
import {
GitBranch, Settings, FolderGit2, Shield, ScrollText,
Plus, Trash2, RefreshCw, Eye, Check, X, Loader2,
FileText, Folder, ChevronRight, Play, ExternalLink,
ArrowLeft, GitMerge, Zap, Clock,
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,
} from "lucide-react";
import Sidebar from "../components/Sidebar";
const TABS = [
{ id: "setup", label: "Setup", icon: Settings },
{ id: "projects", label: "Projects", icon: FolderGit2 },
{ id: "queue", label: "Approval Queue", icon: Shield },
{ id: "settings", label: "Settings", icon: Settings },
{ id: "repos", label: "Repositories", icon: FolderGit2 },
{ id: "operations", label: "Operations", icon: ListChecks },
{ id: "audit", label: "Audit Log", icon: ScrollText },
];
export default function GitLabPage() {
const { state, dispatch } = useApp();
const navigate = useNavigate();
const [tab, setTab] = useState("setup");
const [loading, setLoading] = useState(false);
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",
};
// Setup
export default function GitLabPage() {
const { state } = useApp();
const nav = useNavigate();
const [tab, setTab] = useState("settings");
const [config, setConfig] = useState(null);
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [namespace, setNamespace] = useState("");
const [testResult, setTestResult] = useState(null);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
// 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);
useEffect(() => {
(async () => {
try {
const c = await gitlabGetConfig(state.token);
setConfig(c);
} catch { /* ignore */ }
setLoading(false);
})();
}, [state.token]);
// Queue
const [operations, setOperations] = useState([]);
const [queueFilter, setQueueFilter] = useState("pending");
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>
);
}
// Audit
const [auditLog, setAuditLog] = useState({ entries: [], total: 0 });
const [auditPage, setAuditPage] = useState(1);
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>
useEffect(() => {
loadConfig();
}, []);
{/* 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>
async function loadConfig() {
try {
const cfg = await gitlabGetConfig(state.token);
setConfig(cfg);
if (cfg.configured) {
setUrl(cfg.gitlab_url);
loadProjects();
}
} catch { }
}
{/* 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 loadProjects(s = "") {
try {
const data = await gitlabListProjects(state.token, s);
setProjects(data.projects || []);
} 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 handleTest() {
setTestResult(null);
setLoading(true);
if (!url || !pat) return;
setTesting(true); setMsg(null);
try {
const result = await gitlabTestConnection(state.token, { gitlab_url: url, access_token: token, default_namespace: namespace });
setTestResult(result);
} catch (err) {
setTestResult({ ok: false, error: err.message });
}
setLoading(false);
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);
}
async function handleSave() {
setSaving(true);
if (!url || !pat) return;
setSaving(true); setMsg(null);
try {
await gitlabSaveConfig(state.token, { gitlab_url: url, access_token: token, default_namespace: namespace });
await loadConfig();
setTab("projects");
} catch (err) {
alert(err.message);
}
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 }); }
setSaving(false);
}
async function openProject(p) {
setSelectedProject(p);
setDetailTab("files");
setViewFile(null);
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);
try {
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 { }
}
const r = await gitlabListProjects(token, search);
setProjects(r.projects || []);
} catch { /* ignore */ }
setLoading(false);
}, [token, search]);
useEffect(() => { if (config?.configured) loadProjects(); }, [config, loadProjects]);
async function handleCreateProject() {
if (!createName.trim()) return;
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
await gitlabCreateProject(state.token, { name: createName.trim(), description: createDesc, visibility: "private", initialize_with_readme: true });
setCreateName(""); setCreateDesc(""); setShowCreate(false);
await gitlabCreateProject(token, { name: newName, description: newDesc });
setNewName(""); setNewDesc(""); setShowCreate(false);
loadProjects();
} catch (err) { alert(err.message); }
} catch { /* ignore */ }
setCreating(false);
}
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); }
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 handleViewFile(path) {
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);
try {
const data = await gitlabGetFile(state.token, selectedProject.id, path, selectedProject.default_branch || "main");
setViewFile(data);
} catch (err) { alert(err.message); }
}
const t = await gitlabGetTree(token, project.id, path, branch);
setTree(t.sort((a, b) => (a.type === "tree" ? -1 : 1) - (b.type === "tree" ? -1 : 1) || a.name.localeCompare(b.name)));
} catch { setTree([]); }
setLoading(false);
}, [token, project.id, path, branch]);
useEffect(() => {
loadTree();
gitlabListBranches(token, project.id).then(setBranches).catch(() => { });
}, [loadTree, token, project.id]);
async function loadQueue() {
async function openFile(filePath) {
try {
const ops = await gitlabListOperations(state.token, queueFilter);
setOperations(ops);
} catch { }
const f = await gitlabGetFile(token, project.id, filePath, branch);
setFileContent({ ...f, path: filePath });
} catch { /* ignore */ }
}
async function loadAudit(page = 1) {
async function loadPipelines() {
try {
const data = await gitlabGetAuditLog(state.token, page);
setAuditLog(data);
setAuditPage(page);
} catch { }
const p = await gitlabListPipelines(token, project.id);
setPipelines(p);
} catch { setPipelines([]); }
}
useEffect(() => { if (tab === "queue") loadQueue(); }, [tab, queueFilter]);
useEffect(() => { if (tab === "audit") loadAudit(1); }, [tab]);
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>
<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>
{/* 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>
{/* 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>
))}
</div>
)}
{/* ═══ 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>
{fileContent ? (
<div className="bg-anton-card border border-anton-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-anton-border bg-anton-surface">
<span className="text-xs font-mono text-anton-muted">{fileContent.path}</span>
<div className="flex gap-1.5">
<button onClick={() => { navigator.clipboard.writeText(fileContent.decoded_content || ""); }} className="text-[10px] text-anton-muted hover:text-white flex items-center gap-1"><Copy size={10} /> Copy</button>
<button onClick={() => setFileContent(null)} className="text-[10px] text-anton-muted hover:text-white"><X size={12} /></button>
</div>
)}
<div 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>
<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>
)}
</>
)}
{/* ═══ 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>
))}
{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>
</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>
);
}
{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>
))}
// ══════════════════════════════════════════════════════
// 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>
{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>
</div>
)}
{expandedOp === op.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{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>
{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>
</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>
)}
{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>}
{op.result && (
<div className="bg-anton-bg rounded-lg p-3">
<p className="text-[10px] text-anton-muted mb-1 font-bold">RESULT</p>
<pre className="text-xs font-mono text-blue-300 whitespace-pre-wrap max-h-40 overflow-y-auto">{typeof op.result === "string" ? op.result : JSON.stringify(op.result, null, 2)}</pre>
</div>
))}
</div>
)}
)}
{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>
{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>
</div>
))}
{!mrs.length && <p className="text-sm text-anton-muted text-center py-4">No merge requests</p>}
</div>
)}
{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>
)}
))}
{!ops.length && <p className="text-anton-muted text-center py-10 text-sm">No operations found.</p>}
</div>
)}
</div>
);
}
{/* ═══ 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>
</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);
{/* ═══ 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>}
useEffect(() => {
(async () => {
setLoading(true);
try {
const r = await gitlabAuditLog(token, page);
setEntries(r.entries || []);
setTotal(r.total || 0);
} catch { setEntries([]); }
setLoading(false);
})();
}, [token, page]);
return (
<div className="space-y-4">
<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>}
</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>
)}
<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>
</div>
)}
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
import React, { createContext, useContext, useReducer } from "react";
import { setDispatch } from "./streamManager";
import React, { createContext, useContext, useReducer, useCallback } from "react";
const AppContext = createContext();
const AppContext = createContext(null);
const initialState = {
token: localStorage.getItem("token"),
user: JSON.parse(localStorage.getItem("user") || "null"),
token: localStorage.getItem("token") || null,
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");
localStorage.removeItem("user");
return { ...initialState, token: null, user: null };
return { ...initialState, token: null };
case "SET_USER":
return { ...state, user: action.user };
case "SET_CHATS":
return { ...state, chats: action.chats };
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId };
return { ...state, activeChatId: action.chatId, sidebarOpen: false };
case "ADD_CHAT":
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 };
}
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
),
};
case "REMOVE_CHAT": {
const chats = state.chats.filter((c) => c.id !== action.chatId);
const msgs = { ...state.chatMessages };
delete msgs[action.chatId];
const remaining = state.chats.filter((c) => c.id !== action.chatId);
return {
...state, chats, chatMessages: msgs,
activeChatId: state.activeChatId === action.chatId ? null : state.activeChatId,
...state,
chats: remaining,
activeChatId:
state.activeChatId === action.chatId
? remaining[0]?.id || 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 existing = state.chatMessages[action.chatId] || [];
return { ...state, chatMessages: { ...state.chatMessages, [action.chatId]: [...existing, action.message] } };
const prev = state.chatMessages[action.chatId] || [];
return {
...state,
chatMessages: {
...state.chatMessages,
[action.chatId]: [...prev, action.message],
},
};
}
case "SET_STREAMING":
return { ...state, activeStreams: { ...state.activeStreams, [action.chatId]: action.streaming } };
return {
...state,
activeStreams: action.streaming
? { ...state.activeStreams, [action.chatId]: true }
: Object.fromEntries(
Object.entries(state.activeStreams).filter(([k]) => k !== action.chatId)
),
};
case "SET_SIDEBAR_OPEN":
return { ...state, sidebarOpen: action.open };
case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen };
default:
return state;
}
......@@ -58,7 +91,6 @@ function reducer(state, action) {
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
setDispatch(dispatch);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
......@@ -67,5 +99,7 @@ export function AppProvider({ children }) {
}
export function useApp() {
return useContext(AppContext);
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be inside AppProvider");
return ctx;
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment