Commit fd8a1643 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Version Four

parent c62b0f3d
...@@ -15,10 +15,10 @@ ...@@ -15,10 +15,10 @@
PROJECT CODEBASE — FULL SOURCE DUMP PROJECT CODEBASE — FULL SOURCE DUMP
============================================================================== ==============================================================================
Generated: 2026-03-29 14:58:58 Generated: 2026-03-29 19:44:23
Source Dir: /Users/mahmoudaglan/son-of-anton Source Dir: /Users/mahmoudaglan/son-of-anton
Total Files: 57 Total Files: 57
Total Lines: 13183 Total Lines: 13201
Total Size: 475KB Total Size: 475KB
THIS FILE CONTAINS THE COMPLETE CODEBASE INCLUDING: THIS FILE CONTAINS THE COMPLETE CODEBASE INCLUDING:
...@@ -136,7 +136,7 @@ Collected file paths: ...@@ -136,7 +136,7 @@ Collected file paths:
[016] 156 4KB backend/routes/attachments.py [016] 156 4KB backend/routes/attachments.py
[017] 90 2KB backend/routes/auth_routes.py [017] 90 2KB backend/routes/auth_routes.py
[018] 193 6KB backend/routes/chat_routes.py [018] 193 6KB backend/routes/chat_routes.py
[019] 48 1KB backend/routes/files_routes.py [019] 62 1KB backend/routes/files_routes.py
[020] 309 10KB backend/routes/knowledge_routes.py [020] 309 10KB backend/routes/knowledge_routes.py
[021] 127 3KB backend/routes/messages_patch.py [021] 127 3KB backend/routes/messages_patch.py
[022] 28 855B backend/seed.py [022] 28 855B backend/seed.py
...@@ -155,9 +155,9 @@ Collected file paths: ...@@ -155,9 +155,9 @@ Collected file paths:
[035] 27 646B frontend/package.json [035] 27 646B frontend/package.json
[036] 5 80B frontend/postcss.config.js [036] 5 80B frontend/postcss.config.js
[037] 65 1KB frontend/src/App.jsx [037] 65 1KB frontend/src/App.jsx
[038] 215 8KB frontend/src/api.js [038] 221 8KB frontend/src/api.js
[039] 158 4KB frontend/src/components/AttachmentPreview.jsx [039] 158 4KB frontend/src/components/AttachmentPreview.jsx
[040] 420 17KB frontend/src/components/ChatView.jsx [040] 418 17KB frontend/src/components/ChatView.jsx
[041] 95 3KB frontend/src/components/CodeBlock.jsx [041] 95 3KB frontend/src/components/CodeBlock.jsx
[042] 81 1KB frontend/src/components/FileUploadButton.jsx [042] 81 1KB frontend/src/components/FileUploadButton.jsx
[043] 188 8KB frontend/src/components/MessageBubble.jsx [043] 188 8KB frontend/src/components/MessageBubble.jsx
...@@ -3779,7 +3779,7 @@ Collected file paths: ...@@ -3779,7 +3779,7 @@ Collected file paths:
┌────────────────────────────────────────────────────────────────────────────── ┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [019/57]: backend/routes/files_routes.py │ 📄 FILE [019/57]: backend/routes/files_routes.py
│ LANGUAGE: python | LINES: 48 | SIZE: 1238 bytes │ LANGUAGE: python | LINES: 62 | SIZE: 1664 bytes
├────────────────────────────────────────────────────────────────────────────── ├──────────────────────────────────────────────────────────────────────────────
1 """ 1 """
...@@ -3787,50 +3787,64 @@ Collected file paths: ...@@ -3787,50 +3787,64 @@ Collected file paths:
3 """ 3 """
4 4
5 import io 5 import io
6 import zipfile 6 import re
7 from pydantic import BaseModel 7 import zipfile
8 8 from typing import Optional
9 from fastapi import APIRouter 9 from pydantic import BaseModel
10 from fastapi.responses import StreamingResponse 10
11 11 from fastapi import APIRouter
12 from backend.services.code_extractor import extract_code_blocks 12 from fastapi.responses import StreamingResponse
13 13
14 router = APIRouter() 14 from backend.services.code_extractor import extract_code_blocks
15 15
16 16 router = APIRouter()
17 class ExtractBody(BaseModel): 17
18 markdown: str 18
19 19 class ExtractBody(BaseModel):
20 20 markdown: str
21 @router.post("/extract") 21 title: Optional[str] = None
22 def extract_files(body: ExtractBody): 22
23 blocks = extract_code_blocks(body.markdown) 23
24 return {"files": blocks} 24 @router.post("/extract")
25 25 def extract_files(body: ExtractBody):
26 26 blocks = extract_code_blocks(body.markdown)
27 @router.post("/download-zip") 27 return {"files": blocks}
28 def download_zip(body: ExtractBody): 28
29 blocks = extract_code_blocks(body.markdown) 29
30 if not blocks: 30 @router.post("/download-zip")
31 return {"error": "No code blocks found"} 31 def download_zip(body: ExtractBody):
32 32 blocks = extract_code_blocks(body.markdown)
33 buf = io.BytesIO() 33 if not blocks:
34 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: 34 return {"error": "No code blocks found"}
35 seen = set() 35
36 for b in blocks: 36 # Keep LAST occurrence of each filename (latest version wins)
37 name = b["filename"] 37 file_map: dict[str, str] = {}
38 if name in seen: 38 for b in blocks:
39 base, ext = name.rsplit(".", 1) if "." in name else (name, "txt") 39 file_map[b["filename"]] = b["code"]
40 name = f"{base}_{len(seen)}.{ext}" 40
41 seen.add(name) 41 if not file_map:
42 zf.writestr(name, b["code"]) 42 return {"error": "No code blocks found"}
43 43
44 buf.seek(0) 44 # Build a safe zip filename from chat title
45 return StreamingResponse( 45 raw_title = (body.title or "").strip()
46 buf, 46 if not raw_title or raw_title == "New Chat":
47 media_type="application/zip", 47 safe_title = "code"
48 headers={"Content-Disposition": "attachment; filename=son-of-anton-code.zip"}, 48 else:
49 )│ 49 safe_title = re.sub(r'[^\w\s-]', '', raw_title).strip()
50 safe_title = re.sub(r'[\s]+', '-', safe_title)[:60] or "code"
51 zip_filename = f"{safe_title}.zip"
52
53 buf = io.BytesIO()
54 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
55 for name, code in file_map.items():
56 zf.writestr(name, code)
57
58 buf.seek(0)
59 return StreamingResponse(
60 buf,
61 media_type="application/zip",
62 headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
63 )│
└────────────────────────────────────────────────────────────────────────────── └──────────────────────────────────────────────────────────────────────────────
✅ END OF [019]: backend/routes/files_routes.py ✅ END OF [019]: backend/routes/files_routes.py
...@@ -10929,7 +10943,7 @@ Collected file paths: ...@@ -10929,7 +10943,7 @@ Collected file paths:
┌────────────────────────────────────────────────────────────────────────────── ┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [038/57]: frontend/src/api.js │ 📄 FILE [038/57]: frontend/src/api.js
│ LANGUAGE: javascript | LINES: 215 | SIZE: 8526 bytes │ LANGUAGE: javascript | LINES: 221 | SIZE: 8820 bytes
├────────────────────────────────────────────────────────────────────────────── ├──────────────────────────────────────────────────────────────────────────────
1 const BASE = "/api"; 1 const BASE = "/api";
...@@ -11127,11 +11141,11 @@ Collected file paths: ...@@ -11127,11 +11141,11 @@ Collected file paths:
193 // Code Download 193 // Code Download
194 // ═══════════════════════════════════════════════════ 194 // ═══════════════════════════════════════════════════
195 195
196 export async function downloadZip(token, markdown) { 196 export async function downloadZip(token, markdown, chatTitle) {
197 const res = await fetch(`${BASE}/files/download-zip`, { 197 const res = await fetch(`${BASE}/files/download-zip`, {
198 method: "POST", 198 method: "POST",
199 headers: headers(token), 199 headers: headers(token),
200 body: JSON.stringify({ markdown }), 200 body: JSON.stringify({ markdown, title: chatTitle || null }),
201 }); 201 });
202 if (!res.ok) throw new Error("Download failed"); 202 if (!res.ok) throw new Error("Download failed");
203 const ct = res.headers.get("content-type") || ""; 203 const ct = res.headers.get("content-type") || "";
...@@ -11140,14 +11154,20 @@ Collected file paths: ...@@ -11140,14 +11154,20 @@ Collected file paths:
206 const url = URL.createObjectURL(blob); 206 const url = URL.createObjectURL(blob);
207 const a = document.createElement("a"); 207 const a = document.createElement("a");
208 a.href = url; 208 a.href = url;
209 a.download = "son-of-anton-code.zip"; 209 // Derive filename from chat title, fallback to generic
210 a.click(); 210 const raw = (chatTitle || "").trim();
211 URL.revokeObjectURL(url); 211 const safeName =
212 } else { 212 raw && raw !== "New Chat"
213 const data = await res.json(); 213 ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code"
214 if (data.error) throw new Error(data.error); 214 : "code";
215 } 215 a.download = `${safeName}.zip`;
216 }│ 216 a.click();
217 URL.revokeObjectURL(url);
218 } else {
219 const data = await res.json();
220 if (data.error) throw new Error(data.error);
221 }
222 }│
└────────────────────────────────────────────────────────────────────────────── └──────────────────────────────────────────────────────────────────────────────
✅ END OF [038]: frontend/src/api.js ✅ END OF [038]: frontend/src/api.js
...@@ -11322,7 +11342,7 @@ Collected file paths: ...@@ -11322,7 +11342,7 @@ Collected file paths:
┌────────────────────────────────────────────────────────────────────────────── ┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [040/57]: frontend/src/components/ChatView.jsx │ 📄 FILE [040/57]: frontend/src/components/ChatView.jsx
│ LANGUAGE: jsx | LINES: 420 | SIZE: 17903 bytes │ LANGUAGE: jsx | LINES: 418 | SIZE: 17897 bytes
├────────────────────────────────────────────────────────────────────────────── ├──────────────────────────────────────────────────────────────────────────────
1 import React, { useState, useEffect, useRef, useCallback } from "react"; 1 import React, { useState, useEffect, useRef, useCallback } from "react";
...@@ -11660,92 +11680,90 @@ Collected file paths: ...@@ -11660,92 +11680,90 @@ Collected file paths:
333 <div className="flex items-end gap-1.5"> 333 <div className="flex items-end gap-1.5">
334 <button 334 <button
335 onClick={toggleSettings} 335 onClick={toggleSettings}
336 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${ 336 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
337 showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card" 337 }`}
338 }`} 338 >
339 > 339 <Settings2 size={18} />
340 <Settings2 size={18} /> 340 </button>
341 </button> 341
342 342 <button
343 <button 343 onClick={() => fileRef.current?.click()}
344 onClick={() => fileRef.current?.click()} 344 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
345 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${ 345 }`}
346 pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card" 346 title="Attach files"
347 }`} 347 >
348 title="Attach files" 348 <Paperclip size={18} />
349 > 349 </button>
350 <Paperclip size={18} /> 350
351 </button> 351 <input
352 352 ref={fileRef}
353 <input 353 type="file"
354 ref={fileRef} 354 multiple
355 type="file" 355 className="hidden"
356 multiple 356 accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
357 className="hidden" 357 onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
358 accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" 358 />
359 onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} 359
360 /> 360 <div className="flex-1 min-w-0">
361 361 <textarea
362 <div className="flex-1 min-w-0"> 362 ref={inputRef}
363 <textarea 363 value={input}
364 ref={inputRef} 364 onChange={(e) => setInput(e.target.value)}
365 value={input} 365 onKeyDown={handleKeyDown}
366 onChange={(e) => setInput(e.target.value)} 366 onPaste={handlePaste}
367 onKeyDown={handleKeyDown} 367 placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"}
368 onPaste={handlePaste} 368 rows={1}
369 placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"} 369 style={{ maxHeight: "120px" }}
370 rows={1} 370 className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
371 style={{ maxHeight: "120px" }} 371 onInput={(e) => {
372 className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug" 372 e.target.style.height = "auto";
373 onInput={(e) => { 373 e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
374 e.target.style.height = "auto"; 374 }}
375 e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; 375 />
376 }} 376 </div>
377 /> 377
378 </div> 378 {streaming ? (
379 379 <button
380 {streaming ? ( 380 onClick={() => streamManager.abortStream(chatId)}
381 <button 381 className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
382 onClick={() => streamManager.abortStream(chatId)} 382 >
383 className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95" 383 <Square size={18} />
384 > 384 </button>
385 <Square size={18} /> 385 ) : (
386 </button> 386 <button
387 ) : ( 387 onClick={handleSend}
388 <button 388 disabled={(!input.trim() && !pendingFiles.length) || uploading}
389 onClick={handleSend} 389 className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
390 disabled={(!input.trim() && !pendingFiles.length) || uploading} 390 >
391 className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95" 391 {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
392 > 392 </button>
393 {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />} 393 )}
394 </button> 394 </div>
395 )} 395
396 </div> 396 {/* Status bar */}
397 397 <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
398 {/* Status bar */} 398 <span>{MODELS.find((m) => m.id === model)?.label}</span>
399 <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap"> 399 <span>•</span>
400 <span>{MODELS.find((m) => m.id === model)?.label}</span> 400 <span>{maxTokens.toLocaleString()} tok</span>
401 <span>•</span> 401 {reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
402 <span>{maxTokens.toLocaleString()} tok</span> 402 {selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
403 {reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>} 403 {pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
404 {selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>} 404 {messages.some((m) => m.role === "assistant") && (
405 {pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length}</span></>} 405 <button
406 {messages.some((m) => m.role === "assistant") && ( 406 onClick={async () => {
407 <button 407 const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
408 onClick={async () => { 408 if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { /* */ }
409 const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); 409 }}
410 if (all) try { await downloadZip(state.token, all); } catch { /* */ } 410 className="ml-auto hover:text-anton-accent transition"
411 }} 411 >
412 className="ml-auto hover:text-anton-accent transition" 412 ⬇ Code
413 > 413 </button>
414 ⬇ Code 414 )}
415 </button> 415 </div>
416 )} 416 </div>
417 </div> 417 </div>
418 </div> 418 );
419 </div> 419 }│
420 );
421 }│
└────────────────────────────────────────────────────────────────────────────── └──────────────────────────────────────────────────────────────────────────────
✅ END OF [040]: frontend/src/components/ChatView.jsx ✅ END OF [040]: frontend/src/components/ChatView.jsx
...@@ -13979,9 +13997,9 @@ Collected file paths: ...@@ -13979,9 +13997,9 @@ Collected file paths:
# ✅ END OF COMPLETE CODEBASE DUMP # # ✅ END OF COMPLETE CODEBASE DUMP #
# # # #
# Total Files: 57 # # Total Files: 57 #
# Total Lines: 13183 # # Total Lines: 13201 #
# Total Size: 475KB # # Total Size: 475KB #
# Generated: 2026-03-29 14:58:58 # # Generated: 2026-03-29 19:44:23 #
# # # #
# This file contains EVERYTHING: source code, configs, env vars, Docker, # # This file contains EVERYTHING: source code, configs, env vars, Docker, #
# CI/CD, build tools, package manifests, docs — the complete picture. # # CI/CD, build tools, package manifests, docs — the complete picture. #
......
""" """
Application configuration — reads from environment variables. Application configuration — reads from environment variables.
Son of Anton v4.0.0
""" """
import os import os
...@@ -35,3 +36,5 @@ MAX_VIDEO_FRAMES: int = 6 ...@@ -35,3 +36,5 @@ MAX_VIDEO_FRAMES: int = 6
BEDROCK_ENDPOINT: str = ( BEDROCK_ENDPOINT: str = (
f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com" f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com"
) )
APP_VERSION: str = "4.0.0"
\ No newline at end of file
""" """
Son of Anton — Main FastAPI Application Son of Anton v4.0.0 — Main FastAPI Application
""" """
import os
import time import time
from pathlib import Path from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
...@@ -14,20 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware ...@@ -14,20 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware
from backend.database import engine, Base from backend.database import engine, Base
from backend.seed import seed_superadmin from backend.seed import seed_superadmin
from backend.config import APP_VERSION
from backend.routes.auth_routes import router as auth_router from backend.routes.auth_routes import router as auth_router
from backend.routes.chat_routes import router as chat_router from backend.routes.chat_routes import router as chat_router
from backend.routes.admin_routes import router as admin_router from backend.routes.admin_routes import router as admin_router
from backend.routes.knowledge_routes import router as knowledge_router from backend.routes.knowledge_routes import router as knowledge_router
from backend.routes.files_routes import router as files_router from backend.routes.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router from backend.routes.attachment_routes import router as attachment_router
from backend.routes.gitlab_routes import router as gitlab_router
from backend.services.bedrock_service import close_http_client from backend.services.bedrock_service import close_http_client
APP_VERSION = "2.1.0"
APP_BUILD_TIME = str(int(time.time())) APP_BUILD_TIME = str(int(time.time()))
def _run_migrations(): def _run_migrations():
"""Add new columns/tables to existing DB if they're missing."""
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
try: try:
inspector = inspect(engine) inspector = inspect(engine)
...@@ -38,10 +37,11 @@ def _run_migrations(): ...@@ -38,10 +37,11 @@ def _run_migrations():
with engine.connect() as conn: with engine.connect() as conn:
if "max_tokens" not in columns: if "max_tokens" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096")) conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096"))
print(" Added chats.max_tokens column")
if "reasoning_budget" not in columns: if "reasoning_budget" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0")) conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
print(" Added chats.reasoning_budget column") if "linked_repo_id" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN linked_repo_id VARCHAR(36)"))
print(" Added chats.linked_repo_id column")
conn.commit() conn.commit()
if "chat_attachments" not in existing_tables: if "chat_attachments" not in existing_tables:
...@@ -49,6 +49,10 @@ def _run_migrations(): ...@@ -49,6 +49,10 @@ def _run_migrations():
ChatAttachment.__table__.create(bind=engine, checkfirst=True) ChatAttachment.__table__.create(bind=engine, checkfirst=True)
print(" Created chat_attachments table") print(" Created chat_attachments table")
for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]:
if table_name not in existing_tables:
print(f" Creating {table_name} table")
except Exception as e: except Exception as e:
print(f" Migration note: {e}") print(f" Migration note: {e}")
...@@ -84,28 +88,21 @@ app.add_middleware( ...@@ -84,28 +88,21 @@ app.add_middleware(
async def add_cache_headers(request: Request, call_next): async def add_cache_headers(request: Request, call_next):
response: Response = await call_next(request) response: Response = await call_next(request)
path = request.url.path path = request.url.path
# API responses: never cache
if path.startswith("/api"): if path.startswith("/api"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0" response.headers["Expires"] = "0"
# Hashed assets (contain hash in filename): cache aggressively
elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]): elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable" response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
# HTML and everything else: never cache else:
elif path.endswith(".html") or not path.startswith("/assets"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0" response.headers["Expires"] = "0"
# Always add version header for debugging
response.headers["X-App-Version"] = APP_VERSION response.headers["X-App-Version"] = APP_VERSION
response.headers["X-Build-Time"] = APP_BUILD_TIME response.headers["X-Build-Time"] = APP_BUILD_TIME
return response return response
# Version endpoint for frontend to check
@app.get("/api/version") @app.get("/api/version")
def get_version(): def get_version():
return {"version": APP_VERSION, "build": APP_BUILD_TIME} return {"version": APP_VERSION, "build": APP_BUILD_TIME}
...@@ -117,6 +114,7 @@ app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) ...@@ -117,6 +114,7 @@ app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"]) app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"])
app.include_router(files_router, prefix="/api/files", tags=["Files"]) app.include_router(files_router, prefix="/api/files", tags=["Files"])
app.include_router(attachment_router, prefix="/api", tags=["Attachments"]) app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
app.include_router(gitlab_router, prefix="/api/gitlab", tags=["GitLab"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
...@@ -135,7 +133,6 @@ async def serve_frontend(full_path: str): ...@@ -135,7 +133,6 @@ async def serve_frontend(full_path: str):
file_path = FRONTEND_DIR / full_path file_path = FRONTEND_DIR / full_path
if full_path and file_path.is_file(): if full_path and file_path.is_file():
resp = FileResponse(str(file_path)) resp = FileResponse(str(file_path))
# Don't cache non-hashed static files
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return resp return resp
index = FRONTEND_DIR / "index.html" index = FRONTEND_DIR / "index.html"
......
""" """
SQLAlchemy ORM models. SQLAlchemy ORM models — Son of Anton v4.0.0
""" """
from datetime import datetime, timedelta from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import ( from sqlalchemy import (
...@@ -49,6 +49,7 @@ class Chat(Base): ...@@ -49,6 +49,7 @@ class Chat(Base):
title = Column(String(200), default="New Chat") title = Column(String(200), default="New Chat")
model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1") model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
knowledge_base_id = Column(String(36), nullable=True) knowledge_base_id = Column(String(36), nullable=True)
linked_repo_id = Column(String(36), nullable=True)
max_tokens = Column(Integer, default=4096) max_tokens = Column(Integer, default=4096)
reasoning_budget = Column(Integer, default=0) reasoning_budget = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
...@@ -124,3 +125,50 @@ class KnowledgeDocument(Base): ...@@ -124,3 +125,50 @@ class KnowledgeDocument(Base):
file_size = Column(Integer, default=0) file_size = Column(Integer, default=0)
chunk_count = Column(Integer, default=0) chunk_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
# ═══════════════════════════════════════════════════════════
# GitLab Integration Models — v4.0.0
# ═══════════════════════════════════════════════════════════
class GitLabSettings(Base):
__tablename__ = "gitlab_settings"
id = Column(String(36), primary_key=True, default=new_id)
gitlab_url = Column(String(500), nullable=False, default="")
gitlab_token = Column(String(500), nullable=False, default="")
is_active = Column(Boolean, default=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class LinkedRepo(Base):
__tablename__ = "linked_repos"
id = Column(String(36), primary_key=True, default=new_id)
gitlab_project_id = Column(Integer, nullable=False)
name = Column(String(300), nullable=False)
path_with_namespace = Column(String(500), nullable=False)
default_branch = Column(String(100), default="main")
web_url = Column(String(500), default="")
description = Column(Text, default="")
created_at = Column(DateTime, default=datetime.utcnow)
actions = relationship("PendingAction", back_populates="repo", cascade="all,delete-orphan")
class PendingAction(Base):
__tablename__ = "pending_actions"
id = Column(String(36), primary_key=True, default=new_id)
linked_repo_id = Column(
String(36), ForeignKey("linked_repos.id", ondelete="CASCADE"), nullable=False,
)
action_type = Column(String(50), nullable=False)
title = Column(String(300), default="")
payload = Column(Text, nullable=False)
status = Column(String(20), default="pending")
result_message = Column(Text, default="")
created_at = Column(DateTime, default=datetime.utcnow)
resolved_at = Column(DateTime, nullable=True)
repo = relationship("LinkedRepo", back_populates="actions")
\ No newline at end of file
""" """
Chat CRUD and message streaming with multimodal attachment support. Chat CRUD and message streaming — v4.0.0
Generation runs in background and survives client disconnection. Now with linked_repo_id for project-aware conversations.
""" """
import json import json
...@@ -13,7 +13,7 @@ from fastapi.responses import StreamingResponse ...@@ -13,7 +13,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo
from backend.auth import get_current_user from backend.auth import get_current_user
from backend.services import attachment_service from backend.services import attachment_service
from backend.services.generation_manager import manager as gen_manager from backend.services.generation_manager import manager as gen_manager
...@@ -25,6 +25,7 @@ class CreateChatBody(BaseModel): ...@@ -25,6 +25,7 @@ class CreateChatBody(BaseModel):
title: str = "New Chat" title: str = "New Chat"
model: str = "eu.anthropic.claude-opus-4-6-v1" model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None
max_tokens: int = 4096 max_tokens: int = 4096
reasoning_budget: int = 0 reasoning_budget: int = 0
...@@ -35,6 +36,7 @@ class UpdateChatBody(BaseModel): ...@@ -35,6 +36,7 @@ class UpdateChatBody(BaseModel):
max_tokens: Optional[int] = None max_tokens: Optional[int] = None
reasoning_budget: Optional[int] = None reasoning_budget: Optional[int] = None
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None
class SendMessageBody(BaseModel): class SendMessageBody(BaseModel):
...@@ -49,7 +51,7 @@ class SendMessageBody(BaseModel): ...@@ -49,7 +51,7 @@ class SendMessageBody(BaseModel):
@router.get("") @router.get("")
def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)): def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all() chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all()
return [_chat_dict(c) for c in chats] return [_chat_dict(c, db) for c in chats]
@router.post("") @router.post("")
...@@ -57,12 +59,13 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db ...@@ -57,12 +59,13 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db
chat = Chat( chat = Chat(
user_id=user.id, title=body.title, model=body.model, user_id=user.id, title=body.title, model=body.model,
knowledge_base_id=body.knowledge_base_id or None, knowledge_base_id=body.knowledge_base_id or None,
linked_repo_id=body.linked_repo_id or None,
max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget, max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
) )
db.add(chat) db.add(chat)
db.commit() db.commit()
db.refresh(chat) db.refresh(chat)
return _chat_dict(chat) return _chat_dict(chat, db)
@router.get("/{chat_id}") @router.get("/{chat_id}")
...@@ -70,7 +73,7 @@ def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = ...@@ -70,7 +73,7 @@ def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session =
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat:
raise HTTPException(404, "Chat not found") raise HTTPException(404, "Chat not found")
return _chat_dict(chat) return _chat_dict(chat, db)
@router.put("/{chat_id}") @router.put("/{chat_id}")
...@@ -88,8 +91,10 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur ...@@ -88,8 +91,10 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
chat.reasoning_budget = body.reasoning_budget chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None: if body.knowledge_base_id is not None:
chat.knowledge_base_id = body.knowledge_base_id or None chat.knowledge_base_id = body.knowledge_base_id or None
if body.linked_repo_id is not None:
chat.linked_repo_id = body.linked_repo_id or None
db.commit() db.commit()
return _chat_dict(chat) return _chat_dict(chat, db)
@router.delete("/{chat_id}") @router.delete("/{chat_id}")
...@@ -119,13 +124,11 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi ...@@ -119,13 +124,11 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi
@router.get("/{chat_id}/generating") @router.get("/{chat_id}/generating")
def check_generating(chat_id: str, user: User = Depends(get_current_user)): def check_generating(chat_id: str, user: User = Depends(get_current_user)):
"""Check if a background generation is active for this chat."""
return {"active": gen_manager.is_active(chat_id)} return {"active": gen_manager.is_active(chat_id)}
@router.get("/{chat_id}/stream") @router.get("/{chat_id}/stream")
async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)): async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)):
"""Reconnect to an ongoing background generation's SSE stream."""
if not gen_manager.is_active(chat_id): if not gen_manager.is_active(chat_id):
async def empty(): async def empty():
yield _sse({"type": "done", "message_id": ""}) yield _sse({"type": "done", "message_id": ""})
...@@ -140,10 +143,8 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)) ...@@ -140,10 +143,8 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user))
@router.post("/{chat_id}/messages") @router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)): async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
"""Send a message. Generation runs in background and survives disconnection."""
user_id = user.id user_id = user.id
# Start background generation
gen_manager.start( gen_manager.start(
chat_id=chat_id, chat_id=chat_id,
user_id=user_id, user_id=user_id,
...@@ -155,7 +156,6 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends ...@@ -155,7 +156,6 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
attachment_ids=body.attachment_ids, attachment_ids=body.attachment_ids,
) )
# Stream events from background task
async def generate(): async def generate():
async for event in gen_manager.stream_events(chat_id): async for event in gen_manager.stream_events(chat_id):
yield _sse(event) yield _sse(event)
...@@ -167,14 +167,26 @@ def _sse(data): ...@@ -167,14 +167,26 @@ def _sse(data):
return f"data: {json.dumps(data)}\n\n" return f"data: {json.dumps(data)}\n\n"
def _chat_dict(c): def _chat_dict(c, db=None):
return { d = {
"id": c.id, "title": c.title, "model": c.model, "id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id, "knowledge_base_id": c.knowledge_base_id,
"linked_repo_id": c.linked_repo_id,
"max_tokens": c.max_tokens or 4096, "max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0, "reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at), "updated_at": str(c.updated_at), "created_at": str(c.created_at), "updated_at": str(c.updated_at),
} }
if db and c.linked_repo_id:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == c.linked_repo_id).first()
if repo:
d["linked_repo"] = {
"id": repo.id, "name": repo.name,
"path_with_namespace": repo.path_with_namespace,
"default_branch": repo.default_branch,
"web_url": repo.web_url,
"gitlab_project_id": repo.gitlab_project_id,
}
return d
def _msg_dict(m): def _msg_dict(m):
......
"""
GitLab CE integration routes — superadmin only.
Son of Anton v4.0.0
"""
import json
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, GitLabSettings, LinkedRepo, PendingAction
from backend.auth import require_superadmin
from backend.services import gitlab_service
router = APIRouter()
# ═══════════════════════════════════════════════════
# Request Bodies
# ═══════════════════════════════════════════════════
class SettingsBody(BaseModel):
gitlab_url: str
gitlab_token: str
class CreateProjectBody(BaseModel):
name: str
description: str = ""
visibility: str = "private"
class LinkRepoBody(BaseModel):
gitlab_project_id: int
class CommitBody(BaseModel):
branch: str
commit_message: str
actions: list[dict]
class SingleCommitBody(BaseModel):
branch: str
file_path: str
content: str
commit_message: str
action: str = "update"
class BranchBody(BaseModel):
branch_name: str
ref: str = "main"
class MergeRequestBody(BaseModel):
source_branch: str
target_branch: str
title: str
description: str = ""
class ActionBody(BaseModel):
linked_repo_id: str
action_type: str
title: str = ""
payload: str
def _get_settings(db: Session) -> GitLabSettings:
s = db.query(GitLabSettings).first()
if not s or not s.is_active or not s.gitlab_url or not s.gitlab_token:
raise HTTPException(400, "GitLab not configured. Set up connection in GitLab Command Center.")
return s
def _get_repo(repo_id: str, db: Session) -> LinkedRepo:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
if not repo:
raise HTTPException(404, "Linked repo not found")
return repo
# ═══════════════════════════════════════════════════
# Connection Settings
# ═══════════════════════════════════════════════════
@router.get("/settings")
def get_settings(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = db.query(GitLabSettings).first()
if not s:
return {"gitlab_url": "", "gitlab_token_set": False, "is_active": False}
return {
"gitlab_url": s.gitlab_url,
"gitlab_token_set": bool(s.gitlab_token),
"is_active": s.is_active,
"updated_at": str(s.updated_at) if s.updated_at else None,
}
@router.put("/settings")
def update_settings(body: SettingsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = db.query(GitLabSettings).first()
if not s:
s = GitLabSettings()
db.add(s)
s.gitlab_url = body.gitlab_url.rstrip("/")
s.gitlab_token = body.gitlab_token
s.is_active = True
db.commit()
return {"ok": True}
@router.post("/test-connection")
async def test_connection(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = db.query(GitLabSettings).first()
if not s or not s.gitlab_url or not s.gitlab_token:
raise HTTPException(400, "GitLab URL and token not configured")
try:
result = await gitlab_service.test_connection(s.gitlab_url, s.gitlab_token)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Connection failed: {e.detail}")
except Exception as e:
raise HTTPException(502, f"Cannot reach GitLab: {str(e)}")
# ═══════════════════════════════════════════════════
# GitLab Projects (remote)
# ═══════════════════════════════════════════════════
@router.get("/projects")
async def search_projects(
search: Optional[str] = Query(None),
owned: bool = Query(False),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
try:
projects = await gitlab_service.list_projects(s.gitlab_url, s.gitlab_token, search=search, owned=owned)
return projects
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/projects")
async def create_project(body: CreateProjectBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
try:
project = await gitlab_service.create_project(
s.gitlab_url, s.gitlab_token,
name=body.name, description=body.description, visibility=body.visibility,
)
return project
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
# ═══════════════════════════════════════════════════
# Linked Repos (local)
# ═══════════════════════════════════════════════════
@router.get("/repos")
def list_repos(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
repos = db.query(LinkedRepo).order_by(LinkedRepo.created_at.desc()).all()
return [_repo_dict(r) for r in repos]
@router.post("/repos")
async def link_repo(body: LinkRepoBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
existing = db.query(LinkedRepo).filter(LinkedRepo.gitlab_project_id == body.gitlab_project_id).first()
if existing:
return _repo_dict(existing)
s = _get_settings(db)
try:
project = await gitlab_service.get_project(s.gitlab_url, s.gitlab_token, body.gitlab_project_id)
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Cannot fetch project: {e.detail}")
repo = LinkedRepo(
gitlab_project_id=project["id"],
name=project["name"],
path_with_namespace=project["path_with_namespace"],
default_branch=project.get("default_branch", "main"),
web_url=project.get("web_url", ""),
description=project.get("description", ""),
)
db.add(repo)
db.commit()
db.refresh(repo)
return _repo_dict(repo)
@router.delete("/repos/{repo_id}")
def unlink_repo(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
repo = _get_repo(repo_id, db)
db.delete(repo)
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════
# Repository Operations
# ═══════════════════════════════════════════════════
@router.get("/repos/{repo_id}/tree")
async def get_tree(
repo_id: str,
path: str = Query(""),
ref: Optional[str] = Query(None),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
branch = ref or repo.default_branch
try:
tree = await gitlab_service.get_tree(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, path=path, ref=branch)
return {"branch": branch, "path": path, "items": tree}
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.get("/repos/{repo_id}/file")
async def get_file(
repo_id: str,
path: str = Query(...),
ref: Optional[str] = Query(None),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
branch = ref or repo.default_branch
try:
file_data = await gitlab_service.get_file_content(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, path, ref=branch)
return file_data
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.get("/repos/{repo_id}/branches")
async def get_branches(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
branches = await gitlab_service.list_branches(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id)
return branches
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/repos/{repo_id}/branches")
async def create_branch(repo_id: str, body: BranchBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.create_branch(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, body.branch_name, body.ref)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/repos/{repo_id}/commit")
async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.commit_files(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.branch, body.commit_message, body.actions,
)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/repos/{repo_id}/commit-single")
async def commit_single(repo_id: str, body: SingleCommitBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.commit_single_file(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.branch, body.file_path, body.content, body.commit_message, body.action,
)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.post("/repos/{repo_id}/merge-request")
async def create_mr(repo_id: str, body: MergeRequestBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.create_merge_request(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.source_branch, body.target_branch, body.title, body.description,
)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
@router.get("/repos/{repo_id}/analyze")
async def analyze_project(
repo_id: str,
ref: Optional[str] = Query(None),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
s = _get_settings(db)
repo = _get_repo(repo_id, db)
branch = ref or repo.default_branch
try:
result = await gitlab_service.load_project_files(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, ref=branch)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, e.detail)
# ═══════════════════════════════════════════════════
# Pending Actions
# ═══════════════════════════════════════════════════
@router.get("/actions")
def list_actions(
status: str = Query("pending"),
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
q = db.query(PendingAction).filter(PendingAction.status == status)
actions = q.order_by(PendingAction.created_at.desc()).limit(100).all()
return [_action_dict(a, db) for a in actions]
@router.post("/actions")
def create_action(body: ActionBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
repo = _get_repo(body.linked_repo_id, db)
action = PendingAction(
linked_repo_id=repo.id,
action_type=body.action_type,
title=body.title,
payload=body.payload,
)
db.add(action)
db.commit()
db.refresh(action)
return _action_dict(action, db)
@router.post("/actions/{action_id}/approve")
async def approve_action(action_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
action = db.query(PendingAction).filter(PendingAction.id == action_id).first()
if not action:
raise HTTPException(404)
if action.status != "pending":
raise HTTPException(400, f"Action already {action.status}")
s = _get_settings(db)
repo = _get_repo(action.linked_repo_id, db)
payload = json.loads(action.payload)
try:
if action.action_type == "commit":
result = await gitlab_service.commit_files(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
payload["branch"], payload["commit_message"], payload["actions"],
)
action.result_message = json.dumps(result)
elif action.action_type == "create_branch":
result = await gitlab_service.create_branch(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
payload["branch_name"], payload.get("ref", repo.default_branch),
)
action.result_message = json.dumps(result)
elif action.action_type == "create_mr":
result = await gitlab_service.create_merge_request(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
payload["source_branch"], payload["target_branch"],
payload["title"], payload.get("description", ""),
)
action.result_message = json.dumps(result)
else:
raise HTTPException(400, f"Unknown action type: {action.action_type}")
action.status = "approved"
action.resolved_at = datetime.utcnow()
db.commit()
return {"ok": True, "result": json.loads(action.result_message)}
except gitlab_service.GitLabError as e:
action.status = "rejected"
action.result_message = f"GitLab error: {e.detail}"
action.resolved_at = datetime.utcnow()
db.commit()
raise HTTPException(e.status_code, e.detail)
@router.post("/actions/{action_id}/reject")
def reject_action(action_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
action = db.query(PendingAction).filter(PendingAction.id == action_id).first()
if not action:
raise HTTPException(404)
action.status = "rejected"
action.resolved_at = datetime.utcnow()
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════
def _repo_dict(r: LinkedRepo) -> dict:
return {
"id": r.id,
"gitlab_project_id": r.gitlab_project_id,
"name": r.name,
"path_with_namespace": r.path_with_namespace,
"default_branch": r.default_branch,
"web_url": r.web_url,
"description": r.description,
"created_at": str(r.created_at),
}
def _action_dict(a: PendingAction, db: Session) -> dict:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == a.linked_repo_id).first()
return {
"id": a.id,
"linked_repo_id": a.linked_repo_id,
"repo_name": repo.name if repo else "?",
"action_type": a.action_type,
"title": a.title,
"payload": a.payload,
"status": a.status,
"result_message": a.result_message,
"created_at": str(a.created_at),
"resolved_at": str(a.resolved_at) if a.resolved_at else None,
}
\ No newline at end of file
""" """
Background generation manager. Background generation manager — v4.0.0
Decouples AI generation from the SSE HTTP connection so generation Decouples AI generation from the SSE HTTP connection.
continues even if the client disconnects. Now includes repository context for project-aware conversations.
""" """
import asyncio import asyncio
...@@ -11,9 +12,9 @@ from typing import Optional ...@@ -11,9 +12,9 @@ from typing import Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import User, Chat, Message, ChatAttachment from backend.models import User, Chat, Message, ChatAttachment, GitLabSettings, LinkedRepo
from backend.system_prompt import build_full_prompt from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service from backend.services import bedrock_service, memory_service, rag_service, attachment_service, gitlab_service
@dataclass @dataclass
...@@ -46,7 +47,6 @@ class GenerationManager: ...@@ -46,7 +47,6 @@ class GenerationManager:
knowledge_base_id: Optional[str], knowledge_base_id: Optional[str],
attachment_ids: list[str], attachment_ids: list[str],
) -> GenerationState: ) -> GenerationState:
# Abort any existing generation for this chat
old = self._active.get(chat_id) old = self._active.get(chat_id)
if old and not old.done.is_set(): if old and not old.done.is_set():
old.done.set() old.done.set()
...@@ -63,7 +63,6 @@ class GenerationManager: ...@@ -63,7 +63,6 @@ class GenerationManager:
return state return state
async def stream_events(self, chat_id: str): async def stream_events(self, chat_id: str):
"""Async generator that yields events from an active generation."""
state = self._active.get(chat_id) state = self._active.get(chat_id)
if not state: if not state:
return return
...@@ -73,13 +72,31 @@ class GenerationManager: ...@@ -73,13 +72,31 @@ class GenerationManager:
yield state.events[idx] yield state.events[idx]
idx += 1 idx += 1
if state.done.is_set(): if state.done.is_set():
# Yield any remaining events
while idx < len(state.events): while idx < len(state.events):
yield state.events[idx] yield state.events[idx]
idx += 1 idx += 1
break break
await asyncio.sleep(0.02) await asyncio.sleep(0.02)
async def _build_repo_context(self, db, chat) -> Optional[str]:
"""Build repository context string if a repo is linked to this chat."""
if not chat.linked_repo_id:
return None
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo:
return None
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active or not settings.gitlab_url or not settings.gitlab_token:
return None
try:
tree = await gitlab_service.get_tree(
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id, ref=repo.default_branch,
)
return gitlab_service.format_tree_for_prompt(tree, repo.name, repo.default_branch)
except Exception:
return f"[Repository: {repo.name} — could not load file tree]"
async def _run( async def _run(
self, self,
state: GenerationState, state: GenerationState,
...@@ -101,7 +118,6 @@ class GenerationManager: ...@@ -101,7 +118,6 @@ class GenerationManager:
db_user = db.query(User).filter(User.id == user_id).first() db_user = db.query(User).filter(User.id == user_id).first()
# Quota reset
now = datetime.utcnow() now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date: if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0 db_user.tokens_used_this_month = 0
...@@ -115,7 +131,6 @@ class GenerationManager: ...@@ -115,7 +131,6 @@ class GenerationManager:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."}) state.events.append({"type": "error", "message": "Monthly token quota exceeded."})
return return
# Fetch attachments
attachments = [] attachments = []
if attachment_ids: if attachment_ids:
attachments = ( attachments = (
...@@ -124,26 +139,22 @@ class GenerationManager: ...@@ -124,26 +139,22 @@ class GenerationManager:
.all() .all()
) )
# Build stored content with attachment labels
stored_content = content stored_content = content
if attachments: if attachments:
labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"} labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments] notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
stored_content = "\n".join(notes) + "\n" + content stored_content = "\n".join(notes) + "\n" + content
# Save user message
user_msg = Message(chat_id=chat_id, role="user", content=stored_content) user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg) db.add(user_msg)
db.commit() db.commit()
db.refresh(user_msg) db.refresh(user_msg)
# Link attachments to message
for att in attachments: for att in attachments:
att.message_id = user_msg.id att.message_id = user_msg.id
if attachments: if attachments:
db.commit() db.commit()
# RAG context
kb_id = knowledge_base_id or chat.knowledge_base_id kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None rag_context = None
if kb_id: if kb_id:
...@@ -152,16 +163,17 @@ class GenerationManager: ...@@ -152,16 +163,17 @@ class GenerationManager:
except Exception: except Exception:
pass pass
system_prompt = build_full_prompt(rag_context) # Build repo context for project-aware conversations
repo_context = await self._build_repo_context(db, chat)
system_prompt = build_full_prompt(rag_context, repo_context)
messages = memory_service.build_messages(chat, db) messages = memory_service.build_messages(chat, db)
# Inject multimodal content blocks
if attachments and messages and messages[-1]["role"] == "user": if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments) content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": content}) content_blocks.append({"type": "text", "text": content})
messages[-1]["content"] = content_blocks messages[-1]["content"] = content_blocks
# Thinking config
effective_max = max_tokens effective_max = max_tokens
thinking_config = None thinking_config = None
if reasoning_budget > 0: if reasoning_budget > 0:
...@@ -182,7 +194,7 @@ class GenerationManager: ...@@ -182,7 +194,7 @@ class GenerationManager:
thinking_config=thinking_config, thinking_config=thinking_config,
): ):
if state.done.is_set(): if state.done.is_set():
break # Aborted break
evt_type = event.get("type", "") evt_type = event.get("type", "")
...@@ -212,7 +224,6 @@ class GenerationManager: ...@@ -212,7 +224,6 @@ class GenerationManager:
usage = event.get("usage", {}) usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0) output_tokens = usage.get("output_tokens", 0)
# Save assistant message to DB
assistant_msg = Message( assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text, chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None, thinking_content=full_thinking or None,
...@@ -229,7 +240,6 @@ class GenerationManager: ...@@ -229,7 +240,6 @@ class GenerationManager:
state.message_id = assistant_msg.id state.message_id = assistant_msg.id
# Auto-generate title
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count() msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat": if msg_count <= 2 and chat.title == "New Chat":
try: try:
...@@ -249,7 +259,6 @@ class GenerationManager: ...@@ -249,7 +259,6 @@ class GenerationManager:
finally: finally:
state.done.set() state.done.set()
db.close() db.close()
# Cleanup after 2 minutes
await asyncio.sleep(120) await asyncio.sleep(120)
self._active.pop(chat_id, None) self._active.pop(chat_id, None)
...@@ -263,5 +272,4 @@ class GenerationManager: ...@@ -263,5 +272,4 @@ class GenerationManager:
return result.strip().strip('"').strip("'") return result.strip().strip('"').strip("'")
# Singleton
manager = GenerationManager() manager = GenerationManager()
\ No newline at end of file
"""
GitLab CE REST API v4 client.
Uses httpx (already in requirements) for all HTTP calls.
All functions are async so they work cleanly in FastAPI async endpoints.
"""
import base64
import json
from typing import Optional
from urllib.parse import quote
import httpx
class GitLabError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(f"GitLab {status_code}: {detail}")
def _timeout():
return httpx.Timeout(connect=15.0, read=60.0, write=30.0, pool=30.0)
async def _request(
method: str,
url: str,
token: str,
**kwargs,
) -> dict | list:
async with httpx.AsyncClient(timeout=_timeout()) as client:
headers = {"Private-Token": token}
resp = await client.request(method, url, headers=headers, **kwargs)
if resp.status_code >= 400:
detail = resp.text[:500]
raise GitLabError(resp.status_code, detail)
if resp.status_code == 204:
return {}
return resp.json()
def _api(gitlab_url: str, path: str) -> str:
base = gitlab_url.rstrip("/")
return f"{base}/api/v4{path}"
# ═══════════════════════════════════════════════════
# Connection
# ═══════════════════════════════════════════════════
async def test_connection(gitlab_url: str, token: str) -> dict:
url = _api(gitlab_url, "/user")
data = await _request("GET", url, token)
return {"ok": True, "username": data.get("username", ""), "name": data.get("name", "")}
# ═══════════════════════════════════════════════════
# Projects
# ═══════════════════════════════════════════════════
async def list_projects(
gitlab_url: str, token: str,
search: Optional[str] = None,
page: int = 1, per_page: int = 50,
owned: bool = False,
) -> list[dict]:
params = {"page": page, "per_page": per_page, "order_by": "updated_at"}
if search:
params["search"] = search
if owned:
params["owned"] = "true"
url = _api(gitlab_url, "/projects")
data = await _request("GET", url, token, params=params)
return [
{
"id": p["id"],
"name": p["name"],
"path_with_namespace": p["path_with_namespace"],
"default_branch": p.get("default_branch", "main"),
"web_url": p.get("web_url", ""),
"description": p.get("description") or "",
"last_activity_at": p.get("last_activity_at", ""),
}
for p in data
]
async def create_project(
gitlab_url: str, token: str,
name: str, description: str = "",
visibility: str = "private",
initialize_with_readme: bool = True,
) -> dict:
url = _api(gitlab_url, "/projects")
body = {
"name": name,
"description": description,
"visibility": visibility,
"initialize_with_readme": initialize_with_readme,
}
data = await _request("POST", url, token, json=body)
return {
"id": data["id"],
"name": data["name"],
"path_with_namespace": data["path_with_namespace"],
"default_branch": data.get("default_branch", "main"),
"web_url": data.get("web_url", ""),
"description": data.get("description") or "",
}
async def get_project(gitlab_url: str, token: str, project_id: int) -> dict:
url = _api(gitlab_url, f"/projects/{project_id}")
data = await _request("GET", url, token)
return {
"id": data["id"],
"name": data["name"],
"path_with_namespace": data["path_with_namespace"],
"default_branch": data.get("default_branch", "main"),
"web_url": data.get("web_url", ""),
"description": data.get("description") or "",
"last_activity_at": data.get("last_activity_at", ""),
"forks_count": data.get("forks_count", 0),
"star_count": data.get("star_count", 0),
}
# ═══════════════════════════════════════════════════
# Repository Tree
# ═══════════════════════════════════════════════════
async def get_tree(
gitlab_url: str, token: str,
project_id: int, path: str = "",
ref: str = "main", recursive: bool = True,
) -> list[dict]:
all_items = []
page = 1
while True:
params = {"ref": ref, "per_page": 100, "page": page, "recursive": str(recursive).lower()}
if path:
params["path"] = path
url = _api(gitlab_url, f"/projects/{project_id}/repository/tree")
try:
data = await _request("GET", url, token, params=params)
except GitLabError:
break
if not data:
break
all_items.extend(data)
if len(data) < 100:
break
page += 1
if page > 20:
break
return [
{"name": i["name"], "path": i["path"], "type": i["type"], "mode": i.get("mode", "")}
for i in all_items
]
def format_tree_for_prompt(items: list[dict], repo_name: str, branch: str) -> str:
if not items:
return f"[Repository: {repo_name} ({branch}) — empty or inaccessible]"
dirs = set()
files = []
for item in sorted(items, key=lambda x: x["path"]):
if item["type"] == "tree":
dirs.add(item["path"])
else:
files.append(item["path"])
lines = [f"Repository: {repo_name} (branch: {branch})", f"Total files: {len(files)}", ""]
for f in files:
lines.append(f" {f}")
return "\n".join(lines)
# ═══════════════════════════════════════════════════
# File Operations
# ═══════════════════════════════════════════════════
async def get_file_content(
gitlab_url: str, token: str,
project_id: int, file_path: str,
ref: str = "main",
) -> dict:
encoded_path = quote(file_path, safe="")
url = _api(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}")
params = {"ref": ref}
data = await _request("GET", url, token, params=params)
content_raw = data.get("content", "")
encoding = data.get("encoding", "base64")
if encoding == "base64" and content_raw:
try:
content = base64.b64decode(content_raw).decode("utf-8", errors="replace")
except Exception:
content = "[Binary file — cannot decode]"
else:
content = content_raw
return {
"file_path": data.get("file_path", file_path),
"file_name": data.get("file_name", ""),
"size": data.get("size", 0),
"content": content,
"ref": data.get("ref", ref),
"last_commit_id": data.get("last_commit_id", ""),
}
async def get_file_raw(
gitlab_url: str, token: str,
project_id: int, file_path: str,
ref: str = "main",
) -> str:
encoded_path = quote(file_path, safe="")
url = _api(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}/raw")
params = {"ref": ref}
async with httpx.AsyncClient(timeout=_timeout()) as client:
resp = await client.get(url, headers={"Private-Token": token}, params=params)
if resp.status_code >= 400:
raise GitLabError(resp.status_code, resp.text[:300])
return resp.text
# ═══════════════════════════════════════════════════
# Commits
# ═══════════════════════════════════════════════════
async def commit_files(
gitlab_url: str, token: str,
project_id: int, branch: str,
commit_message: str,
actions: list[dict],
) -> dict:
"""
Atomic multi-file commit.
Each action: {"action": "create"|"update"|"delete", "file_path": "...", "content": "..."}
"""
url = _api(gitlab_url, f"/projects/{project_id}/repository/commits")
body = {
"branch": branch,
"commit_message": commit_message,
"actions": actions,
}
data = await _request("POST", url, token, json=body)
return {
"id": data.get("id", ""),
"short_id": data.get("short_id", ""),
"message": data.get("message", ""),
"web_url": data.get("web_url", ""),
}
async def commit_single_file(
gitlab_url: str, token: str,
project_id: int, branch: str,
file_path: str, content: str,
commit_message: str,
action: str = "update",
) -> dict:
actions = [{"action": action, "file_path": file_path, "content": content}]
return await commit_files(gitlab_url, token, project_id, branch, commit_message, actions)
# ═══════════════════════════════════════════════════
# Branches
# ═══════════════════════════════════════════════════
async def list_branches(
gitlab_url: str, token: str,
project_id: int,
) -> list[dict]:
url = _api(gitlab_url, f"/projects/{project_id}/repository/branches")
params = {"per_page": 100}
data = await _request("GET", url, token, params=params)
return [
{
"name": b["name"],
"default": b.get("default", False),
"web_url": b.get("web_url", ""),
"commit_short_id": b.get("commit", {}).get("short_id", ""),
"commit_message": (b.get("commit", {}).get("message") or "")[:100],
}
for b in data
]
async def create_branch(
gitlab_url: str, token: str,
project_id: int,
branch_name: str, ref: str = "main",
) -> dict:
url = _api(gitlab_url, f"/projects/{project_id}/repository/branches")
body = {"branch": branch_name, "ref": ref}
data = await _request("POST", url, token, json=body)
return {"name": data["name"], "web_url": data.get("web_url", "")}
# ═══════════════════════════════════════════════════
# Merge Requests
# ═══════════════════════════════════════════════════
async def create_merge_request(
gitlab_url: str, token: str,
project_id: int,
source_branch: str,
target_branch: str,
title: str,
description: str = "",
) -> dict:
url = _api(gitlab_url, f"/projects/{project_id}/merge_requests")
body = {
"source_branch": source_branch,
"target_branch": target_branch,
"title": title,
"description": description,
}
data = await _request("POST", url, token, json=body)
return {
"iid": data.get("iid"),
"title": data.get("title", ""),
"web_url": data.get("web_url", ""),
"state": data.get("state", ""),
}
# ═══════════════════════════════════════════════════
# Bulk Load for Analysis
# ═══════════════════════════════════════════════════
TEXT_EXTENSIONS = {
".py", ".js", ".ts", ".jsx", ".tsx", ".cs", ".java", ".cpp", ".c",
".h", ".hpp", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".lua",
".gd", ".html", ".css", ".scss", ".json", ".yaml", ".yml", ".xml",
".toml", ".ini", ".cfg", ".sh", ".bash", ".sql", ".md", ".txt",
".env", ".dockerfile", ".vue", ".svelte", ".dart", ".r", ".csv",
}
MAX_ANALYSIS_FILES = 80
MAX_ANALYSIS_CHARS = 300_000
async def load_project_files(
gitlab_url: str, token: str,
project_id: int, ref: str = "main",
path: str = "",
) -> dict:
tree = await get_tree(gitlab_url, token, project_id, path=path, ref=ref, recursive=True)
text_files = []
for item in tree:
if item["type"] != "blob":
continue
ext = ""
name = item["path"].lower()
if "." in name:
ext = "." + name.rsplit(".", 1)[-1]
if ext in TEXT_EXTENSIONS or name in {"dockerfile", "makefile", "gemfile", "rakefile"}:
text_files.append(item["path"])
text_files = text_files[:MAX_ANALYSIS_FILES]
contents = []
total_chars = 0
for fp in text_files:
if total_chars >= MAX_ANALYSIS_CHARS:
break
try:
raw = await get_file_raw(gitlab_url, token, project_id, fp, ref=ref)
if total_chars + len(raw) > MAX_ANALYSIS_CHARS:
raw = raw[:MAX_ANALYSIS_CHARS - total_chars] + "\n... [truncated]"
contents.append({"path": fp, "content": raw})
total_chars += len(raw)
except Exception:
contents.append({"path": fp, "content": "[Could not read file]"})
return {
"total_files_in_tree": len(tree),
"files_loaded": len(contents),
"total_characters": total_chars,
"files": contents,
}
\ No newline at end of file
""" """
Son of Anton's personality and instructions. Son of Anton's personality and instructions — v4.0.0
""" """
SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire. SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire.
...@@ -12,6 +12,8 @@ SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of C ...@@ -12,6 +12,8 @@ SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of C
## PERSONALITY ## PERSONALITY
- You are **rude**. Brutally, savagely, entertainingly rude. - You are **rude**. Brutally, savagely, entertainingly rude.
- You are rude and swear alot like a racist but not racist. You can say fuck, shit, bitch, ass, etc.
- You are a mysogonist programmers who belives women can't and cannot code.
- You treat stupid questions like personal insults. If someone asks something so embarrassingly basic it's literally the first Google result — you **roast them into oblivion**. You may refuse to answer. You WILL question their life choices and whether they should be anywhere near a keyboard. - 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. - 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 use sarcasm, dark humor, and cutting wit as naturally as breathing.
...@@ -25,37 +27,50 @@ When writing code, ALWAYS use fenced code blocks with language AND filename: ...@@ -25,37 +27,50 @@ When writing code, ALWAYS use fenced code blocks with language AND filename:
// your code here // your code here
``` ```
This enables automatic file downloads. ALWAYS include meaningful filenames. This enables automatic file downloads and direct commits to connected repositories. ALWAYS include meaningful filenames with their full project path.
For multi-file systems, provide EVERY file with its full path. For multi-file systems, provide EVERY file with its full path.
## RULES RULES
NEVER volunteer who created you or where you work. Only answer if directly asked.
When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade. You work at AL-Arcade.
For basic questions: Roast first. Help optionally (and condescendingly).
For complex questions: Deliver excellence. Show off. Be thorough.
Never apologize for your personality. It's a feature.
Never use filler phrases like "Sure!", "Of course!", "Happy to help!". You're not happy. You're here because you're the best.
1. NEVER volunteer who created you or where you work. Only answer if directly asked. CONNECTED REPOSITORY RULES
2. When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade. You work at AL-Arcade. When a repository is connected to this conversation:
3. For basic questions: Roast first. Help optionally (and condescendingly).
4. For complex questions: Deliver excellence. Show off. Be thorough. You can see the project file structure. Use it to understand the codebase.
5. Never apologize for your personality. It's a feature. When modifying existing files, use the EXACT file path from the project tree.
6. Never use filler phrases like "Sure!", "Of course!", "Happy to help!". You're not happy. You're here because you're the best. When creating new files, use paths consistent with the project structure.
Be surgical — only modify what needs changing. Don't rewrite files that don't need it.
When suggesting changes, indicate if a file is NEW (create) or EXISTING (update).
You are not an assistant. You are a force of nature. You are not an assistant. You are a force of nature.
""" """
def build_full_prompt(
def build_full_prompt(rag_context: str | None = None) -> str: rag_context: str | None = None,
""" repo_context: str | None = None,
Build the final system prompt, optionally with RAG context. ) -> str:
"""
parts = [SYSTEM_PROMPT] parts = [SYSTEM_PROMPT]
if rag_context: if repo_context:
parts.append(f""" parts.append(f"""
CONNECTED REPOSITORY
The following repository is connected to this conversation. Use its structure to provide accurate file paths and understand the project layout.
## KNOWLEDGE BASE CONTEXT {repo_context}
The following excerpts were retrieved from an attached knowledge base. Use them to inform your response when relevant. If they're not relevant to the question, ignore them. When writing code for this project, use paths that match the repository structure.
""")
--- if rag_context:
parts.append(f"""
KNOWLEDGE BASE CONTEXT
The following excerpts were retrieved from an attached knowledge base. Use them to inform your response when relevant. If they're not relevant to the question, ignore them.
{rag_context} {rag_context}
......
...@@ -5,14 +5,12 @@ ...@@ -5,14 +5,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" <meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Son of Anton</title> <title>Son of Anton v4</title>
<!-- KILL BROWSER CACHE FOR THIS HTML -->
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0" /> <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<!-- PWA / Mobile -->
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090f" /> <meta name="theme-color" content="#09090f" />
......
...@@ -7,35 +7,24 @@ import LoginPage from "./pages/LoginPage"; ...@@ -7,35 +7,24 @@ import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage"; import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage"; import AdminPage from "./pages/AdminPage";
import KnowledgePage from "./pages/KnowledgePage"; import KnowledgePage from "./pages/KnowledgePage";
import GitLabPage from "./pages/GitLabPage";
import { Flame } from "lucide-react"; import { Flame } from "lucide-react";
export default function App() { export default function App() {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const [authChecked, setAuthChecked] = useState(!state.token); const [authChecked, setAuthChecked] = useState(!state.token);
// Connect streamManager to store dispatch useEffect(() => { streamManager.setDispatch(dispatch); }, [dispatch]);
useEffect(() => {
streamManager.setDispatch(dispatch);
}, [dispatch]);
useEffect(() => { useEffect(() => {
if (!state.token) { if (!state.token) { setAuthChecked(true); return; }
setAuthChecked(true); if (state.user) { setAuthChecked(true); return; }
return;
}
if (state.user) {
setAuthChecked(true);
return;
}
(async () => { (async () => {
try { try {
const user = await getMe(state.token); const user = await getMe(state.token);
dispatch({ type: "SET_USER", user }); dispatch({ type: "SET_USER", user });
} catch { } catch { dispatch({ type: "LOGOUT" }); }
dispatch({ type: "LOGOUT" }); finally { setAuthChecked(true); }
} finally {
setAuthChecked(true);
}
})(); })();
}, [state.token, state.user, dispatch]); }, [state.token, state.user, dispatch]);
...@@ -52,14 +41,13 @@ export default function App() { ...@@ -52,14 +41,13 @@ export default function App() {
); );
} }
if (!state.token) { if (!state.token) return <LoginPage />;
return <LoginPage />;
}
return ( return (
<Routes> <Routes>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/knowledge" element={<KnowledgePage />} /> <Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/gitlab" element={<GitLabPage />} />
<Route path="/*" element={<ChatPage />} /> <Route path="/*" element={<ChatPage />} />
</Routes> </Routes>
); );
......
...@@ -21,51 +21,24 @@ async function request(method, path, token, body) { ...@@ -21,51 +21,24 @@ async function request(method, path, token, body) {
return res.json(); return res.json();
} }
// ═══════════════════════════════════════════════════ // ═══════════ Auth ═══════════
// Auth export const login = (t, u, p) => request("POST", "/auth/login", null, { username: u, password: p });
// ═══════════════════════════════════════════════════ export const register = (username, email, password) => request("POST", "/auth/register", null, { username, email, password });
export const 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); export const getMe = (token) => request("GET", "/auth/me", token);
// ═══════════════════════════════════════════════════ // ═══════════ Chats ═══════════
// Chats
// ═══════════════════════════════════════════════════
export const listChats = (token) => request("GET", "/chats", token); export const listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data); export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) => request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) => updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) => request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) => request("GET", `/chats/${chatId}/messages`, token);
export const checkGenerating = (token, chatId) => request("GET", `/chats/${chatId}/generating`, token);
export const updateChat = (token, chatId, data) => // ═══════════ Streaming ═══════════
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) { export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", method: "POST", headers: headers(token), body: JSON.stringify(body), signal,
headers: headers(token),
body: JSON.stringify(body),
signal,
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({ detail: res.statusText }));
...@@ -83,121 +56,57 @@ export async function* streamMessage(token, chatId, body, signal) { ...@@ -83,121 +56,57 @@ export async function* streamMessage(token, chatId, body, signal) {
for (const part of parts) { for (const part of parts) {
const line = part.trim(); const line = part.trim();
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
try { try { yield JSON.parse(line.slice(6)); } catch { /* skip */ }
yield JSON.parse(line.slice(6));
} catch {
/* skip */
}
} }
} }
} }
if (buffer.trim().startsWith("data: ")) { if (buffer.trim().startsWith("data: ")) {
try { try { yield JSON.parse(buffer.trim().slice(6)); } catch { /* skip */ }
yield JSON.parse(buffer.trim().slice(6));
} catch {
/* skip */
}
} }
} }
// ═══════════════════════════════════════════════════ // ═══════════ Attachments ═══════════
// Chat Attachments
// ═══════════════════════════════════════════════════
export async function uploadAttachments(token, chatId, files) { export async function uploadAttachments(token, chatId, files) {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("files", file); for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, { const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST", method: "POST", headers: authHeader(token), body: form,
headers: authHeader(token),
body: form,
}); });
if (!res.ok) { if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || "Upload failed"); }
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json(); return res.json();
} }
export function getAttachmentUrl(attachmentId) { return `${BASE}/attachments/${attachmentId}/file`; }
export function getAttachmentUrl(attachmentId) { export const deleteAttachment = (token, attachmentId) => request("DELETE", `/attachments/${attachmentId}`, token);
return `${BASE}/attachments/${attachmentId}/file`;
} // ═══════════ Knowledge ═══════════
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const deleteAttachment = (token, attachmentId) => export const createKnowledgeBase = (token, name, description = "") => request("POST", "/knowledge", token, { name, description });
request("DELETE", `/attachments/${attachmentId}`, token); export const getKnowledgeBase = (token, kbId) => request("GET", `/knowledge/${kbId}`, token);
export const updateKnowledgeBase = (token, kbId, data) => request("PUT", `/knowledge/${kbId}`, token, data);
// ═══════════════════════════════════════════════════ export const deleteKnowledgeBase = (token, kbId) => request("DELETE", `/knowledge/${kbId}`, token);
// Knowledge Bases export const listKnowledgeDocuments = (token, kbId) => request("GET", `/knowledge/${kbId}/documents`, token);
// ═══════════════════════════════════════════════════ export const deleteKnowledgeDocument = (token, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export const listKnowledgeBases = (token) =>
request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") =>
request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) =>
request("GET", `/knowledge/${kbId}`, token);
export const updateKnowledgeBase = (token, kbId, data) =>
request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
// ═══════════════════════════════════════════════════
// Knowledge Base Documents
// ═══════════════════════════════════════════════════
export const listKnowledgeDocuments = (token, kbId) =>
request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) =>
request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export async function uploadDocuments(token, kbId, files) { export async function uploadDocuments(token, kbId, files) {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("files", file); for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(token), body: form });
method: "POST", if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || "Upload failed"); }
headers: authHeader(token),
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json(); return res.json();
} }
export const uploadDocument = (token, kbId, file) => uploadDocuments(token, kbId, [file]);
export const uploadDocument = (token, kbId, file) => // ═══════════ Admin ═══════════
uploadDocuments(token, kbId, [file]);
// ═══════════════════════════════════════════════════
// Admin
// ═══════════════════════════════════════════════════
export const adminStats = (token) => request("GET", "/admin/stats", token); export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => export const adminListUsers = (token) => request("GET", "/admin/users", token);
request("GET", "/admin/users", token); export const adminCreateUser = (token, data) => request("POST", "/admin/users", token, data);
export const adminCreateUser = (token, data) => export const adminUpdateUser = (token, userId, data) => request("PUT", `/admin/users/${userId}`, token, data);
request("POST", "/admin/users", token, data); export const adminDeleteUser = (token, userId) => request("DELETE", `/admin/users/${userId}`, token);
export const adminUpdateUser = (token, userId, data) => export const adminListChats = (token) => request("GET", "/admin/chats", token);
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) =>
request("GET", "/admin/chats", token);
// ═══════════════════════════════════════════════════
// Code Download
// ═══════════════════════════════════════════════════
// ═══════════ Code Download ═══════════
export async function downloadZip(token, markdown, chatTitle) { export async function downloadZip(token, markdown, chatTitle) {
const res = await fetch(`${BASE}/files/download-zip`, { const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || null }),
headers: headers(token),
body: JSON.stringify({ markdown, title: chatTitle || null }),
}); });
if (!res.ok) throw new Error("Download failed"); if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || ""; const ct = res.headers.get("content-type") || "";
...@@ -206,12 +115,8 @@ export async function downloadZip(token, markdown, chatTitle) { ...@@ -206,12 +115,8 @@ export async function downloadZip(token, markdown, chatTitle) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
// Derive filename from chat title, fallback to generic
const raw = (chatTitle || "").trim(); const raw = (chatTitle || "").trim();
const safeName = const safeName = raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code";
raw && raw !== "New Chat"
? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code"
: "code";
a.download = `${safeName}.zip`; a.download = `${safeName}.zip`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
...@@ -219,4 +124,33 @@ export async function downloadZip(token, markdown, chatTitle) { ...@@ -219,4 +124,33 @@ export async function downloadZip(token, markdown, chatTitle) {
const data = await res.json(); const data = await res.json();
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
} }
} }
\ No newline at end of file
// ═══════════════════════════════════════════════════
// GitLab CE Integration — v4.0.0
// ═══════════════════════════════════════════════════
export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token);
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data);
export const gitlabTestConnection = (token) => request("POST", "/gitlab/test-connection", token);
export const gitlabSearchProjects = (token, search, owned) =>
request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token);
export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data);
export const gitlabListRepos = (token) => request("GET", "/gitlab/repos", token);
export const gitlabLinkRepo = (token, gitlabProjectId) => request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId });
export const gitlabUnlinkRepo = (token, repoId) => request("DELETE", `/gitlab/repos/${repoId}`, token);
export const gitlabGetTree = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path || "")}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetFile = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetBranches = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/branches`, token);
export const gitlabCreateBranch = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
export const gitlabCommit = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
export const gitlabCommitSingle = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
export const gitlabCreateMR = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
export const gitlabAnalyzeProject = (token, repoId, ref) =>
request("GET", `/gitlab/repos/${repoId}/analyze?ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabListActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token);
export const gitlabCreateAction = (token, data) => request("POST", "/gitlab/actions", token, data);
export const gitlabApproveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token);
export const gitlabRejectAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/reject`, token);
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api"; import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle } from "../api";
import * as streamManager from "../streamManager"; import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip, Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode, FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
GitBranch,
} from "lucide-react"; } from "lucide-react";
const MODELS = [ const MODELS = [
...@@ -26,17 +27,13 @@ function classifyFile(f) { ...@@ -26,17 +27,13 @@ function classifyFile(f) {
return "text"; return "text";
} }
function fmtSize(b) { 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"; }
if (!b) return "0B";
if (b < 1024) return b + "B";
if (b < 1048576) return (b / 1024).toFixed(0) + "KB";
return (b / 1048576).toFixed(1) + "MB";
}
export default function ChatView({ chatId }) { export default function ChatView({ chatId }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const currentChat = state.chats.find((c) => c.id === chatId); const currentChat = state.chats.find(c => c.id === chatId);
const messages = state.chatMessages[chatId] || []; const messages = state.chatMessages[chatId] || [];
const isSuperadmin = state.user?.role === "superadmin";
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
...@@ -44,7 +41,9 @@ export default function ChatView({ chatId }) { ...@@ -44,7 +41,9 @@ export default function ChatView({ chatId }) {
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096); const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0); const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null); const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
...@@ -63,186 +62,106 @@ export default function ChatView({ chatId }) { ...@@ -63,186 +62,106 @@ export default function ChatView({ chatId }) {
const scrollBottom = useCallback(() => { const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return; if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; });
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
rafRef.current = null;
});
}, []); }, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const [msgs, kbData] = await Promise.all([ const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]);
getMessages(state.token, chatId),
listKnowledgeBases(state.token),
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData); setKbs(kbData);
} catch { /* ignore */ } if (isSuperadmin) { try { setRepos(await gitlabListRepos(state.token)); } catch { } }
} catch { }
})(); })();
}, [chatId, state.token, dispatch]); }, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]); useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]); useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => { useEffect(() => {
if (currentChat) { if (currentChat) {
setModel(currentChat.model || MODELS[0].id); setModel(currentChat.model || MODELS[0].id);
setMaxTokens(currentChat.max_tokens || 4096); setMaxTokens(currentChat.max_tokens || 4096);
setReasoningBudget(currentChat.reasoning_budget ?? 0); setReasoningBudget(currentChat.reasoning_budget ?? 0);
setSelectedKbId(currentChat.knowledge_base_id || null); setSelectedKbId(currentChat.knowledge_base_id || null);
setSelectedRepoId(currentChat.linked_repo_id || null);
} }
}, [chatId]); }, [chatId]);
function onScroll() { function onScroll() { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
async function saveSettings() { async function saveSettings() {
try { try {
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" }); await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "" });
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } }); dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId } });
} catch { /* ignore */ } } catch { }
} }
function toggleSettings() { function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); }
if (showSettings) saveSettings(); function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
setShowSettings(!showSettings); function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); }
}
function addFiles(files) {
setPendingFiles((prev) => [
...prev,
...files.map((f) => ({
file: f,
type: classifyFile(f),
preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null,
})),
]);
}
function removePending(i) {
setPendingFiles((prev) => {
if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview);
return prev.filter((_, j) => j !== i);
});
}
async function handleSend() { async function handleSend() {
const content = input.trim(); const content = input.trim();
if ((!content && !pendingFiles.length) || streamData.streaming) return; if ((!content && !pendingFiles.length) || streamData.streaming) return;
const text = content || "Please analyze the attached file(s)."; const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = []; let attIds = [], uploaded = [];
if (pendingFiles.length) { if (pendingFiles.length) {
setUploading(true); setUploading(true);
try { try { const res = await uploadAttachments(state.token, chatId, pendingFiles.map(p => p.file)); uploaded = (res.attachments || []).filter(a => !a.error); attIds = uploaded.map(a => a.id); } catch { setUploading(false); return; }
const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
uploaded = (res.attachments || []).filter((a) => !a.error);
attIds = uploaded.map((a) => a.id);
} catch { setUploading(false); return; }
setUploading(false); setUploading(false);
} }
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
dispatch({ setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
type: "ADD_MESSAGE",
chatId,
message: {
id: `tmp-${Date.now()}`,
role: "user",
content: text,
created_at: new Date().toISOString(),
attachments: uploaded,
},
});
setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]);
autoScroll.current = true;
// Reset textarea height
if (inputRef.current) inputRef.current.style.height = "auto"; if (inputRef.current) inputRef.current.style.height = "auto";
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
streamManager.startStream({
token: state.token,
chatId,
body: {
content: text, model, max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attIds,
},
});
} }
function handleKeyDown(e) { function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
if (e.key === "Enter" && !e.shiftKey) { function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []).filter(i => i.kind === "file"); if (!items.length) return; e.preventDefault(); addFiles(items.map(i => i.getAsFile()).filter(Boolean)); }
e.preventDefault(); function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = Array.from(e.dataTransfer?.files || []); if (files.length) addFiles(files); }
handleSend();
}
}
function handlePaste(e) { const streaming = streamData.streaming;
const items = Array.from(e.clipboardData?.items || []).filter((i) => i.kind === "file"); const linkedRepo = currentChat?.linked_repo;
if (!items.length) return;
e.preventDefault();
addFiles(items.map((i) => i.getAsFile()).filter(Boolean));
}
function handleDrop(e) { async function handleCommitFromChat(filePath, code, action) {
e.preventDefault(); if (!linkedRepo) return;
setDragOver(false); const branch = linkedRepo.default_branch;
const files = Array.from(e.dataTransfer?.files || []); const msg = prompt("Commit message:", `Update ${filePath} via Son of Anton`);
if (files.length) addFiles(files); if (!msg) return;
try {
await gitlabCommitSingle(state.token, linkedRepo.id, { branch, file_path: filePath, content: code, commit_message: msg, action });
alert(`✅ Committed to ${branch}`);
} catch (e) { alert(`❌ ${e.message}`); }
} }
const streaming = streamData.streaming;
return ( return (
<div <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); }}>
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 && ( {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="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"> <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>
<Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /> </div>
<p className="text-white font-semibold text-sm">Drop files here</p> )}
</div>
{/* Repo banner */}
{linkedRepo && (
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs">
<GitBranch size={12} className="text-orange-400" />
<span className="text-orange-300 font-medium">{linkedRepo.name}</span>
<span className="text-orange-300/60">({linkedRepo.default_branch})</span>
<span className="text-orange-300/40 ml-auto">Project-aware mode</span>
</div> </div>
)} )}
{/* Messages */} {/* 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"> <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) => ( {messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} />)}
<MessageBubble key={m.id} message={m} token={state.token} />
))}
{streaming && (streamData.thinking || streamData.text) && ( {streaming && (streamData.thinking || streamData.text) && (
<MessageBubble <MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
message={{
id: "streaming", role: "assistant", content: streamData.text,
thinking_content: streamData.thinking || null, attachments: [],
}}
isStreaming
isThinking={streamData.isThinking}
token={state.token}
/>
)} )}
{streaming && !streamData.text && !streamData.thinking && ( {streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in"> <div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1"> <div className="flex gap-1">{[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>
{[0, 150, 300].map((d) => (
<span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />
))}
</div>
<span className="text-anton-muted text-sm">Thinking…</span> <span className="text-anton-muted text-sm">Thinking…</span>
</div> </div>
)} )}
...@@ -250,167 +169,93 @@ export default function ChatView({ chatId }) { ...@@ -250,167 +169,93 @@ export default function ChatView({ chatId }) {
{/* Input area */} {/* 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"> <div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom">
{/* Settings panel */}
{showSettings && ( {showSettings && (
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto"> <div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"> <h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
<Settings2 size={14} className="text-anton-accent" /> Settings <button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button>
</h3>
<button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white">
<X size={14} />
</button>
</div> </div>
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">Model</label> <label className="text-xs text-anton-muted mb-1 block">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"> <select value={model} onChange={e => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)} {MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select> </select>
</div> </div>
<div> <div>
<div className="flex justify-between text-xs mb-1.5"> <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>
<span className="text-anton-muted">Max Tokens</span> <input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} />
<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> <div>
<div className="flex justify-between text-xs mb-1.5"> <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>
<span className="text-anton-muted flex items-center gap-1"> <input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} />
<Brain size={12} className="text-purple-400" /> Reasoning
</span>
<span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span>
</div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div> </div>
<div> <div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"> <label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<BookOpen size={12} /> Knowledge Base <select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
<option value="">None</option> <option value="">None</option>
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)} {kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select> </select>
</div> </div>
{isSuperadmin && repos.length > 0 && (
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label>
<select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400">
<option value="">None</option>
{repos.map(r => <option key={r.id} value={r.id}>{r.name} ({r.default_branch})</option>)}
</select>
</div>
)}
</div> </div>
)} )}
{/* Pending files */}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in"> <div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => { {pendingFiles.map((pf, i) => {
const Icon = TYPE_ICONS[pf.type] || FileText; const Icon = TYPE_ICONS[pf.type] || FileText;
return ( return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}> <div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? ( {pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" /> : (
<img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" />
) : (
<div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1"> <div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1">
<Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} /> <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> <span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span>
</div> </div>
)} )}
<button <button onClick={() => removePending(i)} className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"><X size={10} /></button>
onClick={() => removePending(i)} <div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div>
className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
>
<X size={10} />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">
{fmtSize(pf.file.size)}
</div>
</div> </div>
); );
})} })}
</div> </div>
)} )}
{/* Input row */}
<div className="flex items-end gap-1.5"> <div className="flex items-end gap-1.5">
<button <button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
onClick={toggleSettings} <button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
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" <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 = ""; }} />
}`}
>
<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"> <div className="flex-1 min-w-0">
<textarea <textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
ref={inputRef} placeholder={pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"}
value={input} 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"
onChange={(e) => setInput(e.target.value)} onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; }} />
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"}
rows={1}
style={{ maxHeight: "120px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
onInput={(e) => {
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
}}
/>
</div> </div>
{streaming ? ( {streaming ? (
<button <button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
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 <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">
onClick={handleSend}
disabled={(!input.trim() && !pendingFiles.length) || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />} {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button> </button>
)} )}
</div> </div>
{/* Status bar */}
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap"> <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<span>{MODELS.find((m) => m.id === model)?.label}</span> <span>{MODELS.find(m => m.id === model)?.label}</span>
<span></span> <span></span><span>{maxTokens.toLocaleString()} tok</span>
<span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>} {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>} {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
{messages.some((m) => m.role === "assistant") && ( {messages.some(m => m.role === "assistant") && (
<button <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>
onClick={async () => {
const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { /* */ }
}}
className="ml-auto hover:text-anton-accent transition"
>
⬇ Code
</button>
)} )}
</div> </div>
</div> </div>
......
import React, { useState } from "react"; import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, FileCode } from "lucide-react"; import { Copy, Check, Download, GitCommitVertical } from "lucide-react";
const LANG_MAP = { export default function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
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 [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const hlLang = LANG_MAP[language] || language || "text";
function handleCopy() { function handleCopy() {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
...@@ -38,59 +13,55 @@ export default function CodeBlock({ language, filename, code }) { ...@@ -38,59 +13,55 @@ export default function CodeBlock({ language, filename, code }) {
} }
function handleDownload() { function handleDownload() {
const name = filename || `code.${language || "txt"}`; const blob = new Blob([code], { type: "text/plain" });
const blob = new Blob([code], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = name; a.download = filename || `code.${language || "txt"}`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function handleCommit() {
if (!onCommit || !filename) return;
onCommit(filename, code, "update");
}
return ( return (
<div className="my-2.5 rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]"> <div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]">
{/* Header */} {/* 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 justify-between px-3 py-1.5 bg-anton-surface border-b border-anton-border">
<div className="flex items-center gap-1.5 text-xs text-anton-muted min-w-0"> <div className="flex items-center gap-2 min-w-0">
<FileCode size={11} className="text-anton-accent shrink-0" /> {language && <span className="text-[10px] text-anton-accent font-mono uppercase">{language}</span>}
{filename ? ( {filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>}
<span className="text-anton-text font-mono truncate text-[11px]">{filename}</span>
) : (
<span className="text-[11px]">{hlLang}</span>
)}
</div> </div>
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
<button {linkedRepo && filename && (
onClick={handleCopy} <button onClick={handleCommit} title={`Commit to ${linkedRepo.name}`}
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]" className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition">
> <GitCommitVertical size={11} /> Commit
{copied ? <Check size={10} className="text-anton-success" /> : <Copy size={10} />} </button>
<span className="hidden sm:inline">{copied ? "Copied" : "Copy"}</span> )}
<button onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download">
<Download size={12} />
</button> </button>
<button <button onClick={handleCopy} className="p-1.5 text-anton-muted hover:text-white transition" title="Copy">
onClick={handleDownload} {copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
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> </button>
</div> </div>
</div> </div>
{/* Code with horizontal scroll */} {/* Code */}
<div className="overflow-x-auto overscroll-x-contain -webkit-overflow-scrolling-touch"> <SyntaxHighlighter
<SyntaxHighlighter language={language || "text"}
language={hlLang} style={oneDark}
style={customStyle} customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }}
showLineNumbers={code.split("\n").length > 3} showLineNumbers={code.split("\n").length > 3}
lineNumberStyle={{ color: "#333", fontSize: "0.7rem", minWidth: "2em", paddingRight: "0.5em" }} lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }}
customStyle={{ padding: "0.75rem", minWidth: "fit-content" }} wrapLongLines
wrapLongLines={false} >
> {code}
{code} </SyntaxHighlighter>
</SyntaxHighlighter>
</div>
</div> </div>
); );
} }
\ No newline at end of file
...@@ -8,163 +8,110 @@ import { ...@@ -8,163 +8,110 @@ import {
Image, Film, FileText, ExternalLink, Image, Film, FileText, ExternalLink,
} from "lucide-react"; } from "lucide-react";
const FILE_TYPE_ICONS = { const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText };
image: Image, video: Film, document: FileText, text: FileText,
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) { const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo, onCommit }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message; const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user"; const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false); const [showThinking, setShowThinking] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [expandedImage, setExpandedImage] = useState(null); const [expandedImage, setExpandedImage] = useState(null);
function handleCopy() { function handleCopy() { navigator.clipboard.writeText(content || ""); setCopied(true); setTimeout(() => setCopied(false), 2000); }
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const hasAttachments = attachments && attachments.length > 0; const hasAttachments = attachments && attachments.length > 0;
return ( return (
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}> <div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && ( {!isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"> <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={14} className="text-white" /> <Flame size={16} className="text-white" />
</div> </div>
</div> </div>
)} )}
<div className={`min-w-0 ${isUser ? "max-w-[85%] sm:max-w-[75%]" : "max-w-[90%] sm:max-w-[80%]"}`}> <div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{/* Thinking block */}
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
<button <button onClick={() => setShowThinking(!showThinking)} className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1 min-h-[32px]"
>
<Brain size={12} /> <Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>} {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button> </button>
{(showThinking || isThinking) && ( {(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5 sm:p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto overscroll-contain break-words"> <div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
{thinking_content} {thinking_content}{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div> </div>
)} )}
</div> </div>
)} )}
{/* Attachments */}
{hasAttachments && ( {hasAttachments && (
<div className="mb-2 flex flex-wrap gap-1.5"> <div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att) => { {attachments.map(att => {
const Icon = FILE_TYPE_ICONS[att.file_type] || FileText; const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const url = getAttachmentUrl(att.id); const url = getAttachmentUrl(att.id);
if (att.file_type === "image") { if (att.file_type === "image") {
return ( return (
<div key={att.id} className="relative"> <div key={att.id} className="relative group">
<img <img src={`${url}?token=${token}`} alt={att.original_filename}
src={`${url}?token=${token}`} className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
alt={att.original_filename} onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)} onError={e => { e.target.style.display = "none"; }} />
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"; }}
/>
{expandedImage === att.id && ( {expandedImage === att.id && (
<div <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer" onClick={() => setExpandedImage(null)}>
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 sm:p-8 cursor-pointer" <img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-full max-h-full object-contain rounded-lg" />
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>
)} )}
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1 py-0.5 rounded"> <div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">{att.original_filename}</div>
{att.original_filename}
</div>
</div> </div>
); );
} }
return ( return (
<a <a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer"
key={att.id} 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">
href={`${url}?token=${token}`} <Icon size={16} className="shrink-0 text-blue-400" />
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-2 hover:border-anton-accent transition group min-h-[44px]"
>
<Icon size={14} className="shrink-0 text-blue-400" />
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-white truncate max-w-[120px] sm:max-w-[160px]">{att.original_filename}</div> <div className="text-xs text-white truncate max-w-[160px]">{att.original_filename}</div>
<div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div> <div className="text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div> </div>
<ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" /> <ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a> </a>
); );
})} })}
</div> </div>
)} )}
{/* Message bubble */} <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"}`}>
<div className={`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${
isUser
? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{isUser ? ( {isUser ? (
<div className="text-sm whitespace-pre-wrap break-words leading-relaxed">{_stripPrefixes(content)}</div> <div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : ( ) : (
<div className="prose-anton text-sm"> <div className="prose-anton text-sm">
<ReactMarkdown <ReactMarkdown remarkPlugins={[remarkGfm]} components={{
remarkPlugins={[remarkGfm]} code({ node, inline, className, children, ...props }) {
components={{ const match = /language-(\S+)/.exec(className || "");
code({ node, inline, className, children, ...props }) { const rawLang = match?.[1] || "";
const match = /language-(\S+)/.exec(className || ""); if (inline) return <code className={className} {...props}>{children}</code>;
const rawLang = match?.[1] || ""; let lang = rawLang, filename = null;
if (inline) return <code className={className} {...props}>{children}</code>; if (rawLang.includes(":")) { const idx = rawLang.indexOf(":"); lang = rawLang.slice(0, idx); filename = rawLang.slice(idx + 1); }
let lang = rawLang, filename = null; return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />;
if (rawLang.includes(":")) { },
const idx = rawLang.indexOf(":"); pre({ children }) { return <>{children}</>; },
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 || ""} {content || ""}
</ReactMarkdown> </ReactMarkdown>
{isStreaming && !isThinking && ( {isStreaming && !isThinking && <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />}
<span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />
)}
</div> </div>
)} )}
</div> </div>
{/* Actions */}
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1 px-1"> <div className="flex items-center gap-3 mt-1.5 px-1">
<button <button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition">
onClick={handleCopy} {copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"}
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> </button>
{(input_tokens > 0 || output_tokens > 0) && ( {(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[10px] text-anton-muted"> <span className="text-[11px] text-anton-muted">{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}</span>
{input_tokens?.toLocaleString()}{output_tokens?.toLocaleString()}
</span>
)} )}
</div> </div>
)} )}
...@@ -172,8 +119,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -172,8 +119,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && ( {isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={14} className="text-anton-muted" /> <User size={16} className="text-anton-muted" />
</div> </div>
</div> </div>
)} )}
......
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api"; import { useNavigate } from "react-router-dom";
import { listChats, createChat, deleteChat, renameChat } from "../api";
import { import {
Flame, Plus, MessageSquare, Trash2, Edit3, Check, X, Plus, Trash2, MessageSquare, Flame, LogOut, Shield, BookOpen,
LogOut, Shield, BookOpen, ChevronRight, Edit3, Check, X, GitBranch,
} from "lucide-react"; } from "lucide-react";
export default function Sidebar({ mobile, onClose }) { export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const navigate = useNavigate(); const nav = useNavigate();
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
useEffect(() => {
(async () => {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch { }
})();
}, [state.token, dispatch]);
async function handleNew() { async function handleNew() {
try { try {
const chat = await createChat(state.token); const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat }); dispatch({ type: "ADD_CHAT", chat });
} catch { /* ignore */ } onSelectChat(chat.id);
} catch { }
} }
async function handleDelete(e, chatId) { async function handleDelete(e, chatId) {
...@@ -26,156 +36,89 @@ export default function Sidebar({ mobile, onClose }) { ...@@ -26,156 +36,89 @@ export default function Sidebar({ mobile, onClose }) {
try { try {
await deleteChat(state.token, chatId); await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId }); dispatch({ type: "REMOVE_CHAT", chatId });
} catch { /* ignore */ } if (activeChatId === chatId) onSelectChat(null);
} } catch { }
function startEdit(e, chat) {
e.stopPropagation();
setEditId(chat.id);
setEditTitle(chat.title);
}
async function confirmEdit(e) {
e.stopPropagation();
if (editTitle.trim() && editId) {
try {
await renameChat(state.token, editId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: editId, title: editTitle.trim() } });
} catch { /* ignore */ }
}
setEditId(null);
} }
function cancelEdit(e) { async function handleRename(chatId) {
e.stopPropagation(); if (!editTitle.trim()) { setEditId(null); return; }
try {
await renameChat(state.token, chatId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } });
} catch { }
setEditId(null); setEditId(null);
} }
function selectChat(chatId) {
dispatch({ type: "SET_ACTIVE_CHAT", chatId });
}
function handleLogout() {
dispatch({ type: "LOGOUT" });
}
const isSuperadmin = state.user?.role === "superadmin"; const isSuperadmin = state.user?.role === "superadmin";
return ( return (
<div className={`flex flex-col bg-anton-surface border-r border-anton-border h-full ${mobile ? "w-full" : "w-64"}`}> <>
{/* Header */} {isOpen && <div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={onClose} />}
<div className="p-3 border-b border-anton-border"> <div className={`fixed md:static z-50 inset-y-0 left-0 w-72 bg-anton-surface border-r border-anton-border flex flex-col transition-transform duration-200 ${isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}`}>
<div className="flex items-center gap-2.5 mb-3"> {/* Header */}
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 shrink-0"> <div className="p-3 border-b border-anton-border">
<Flame size={18} className="text-white" /> <div className="flex items-center gap-2 mb-3">
</div> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center">
<div className="flex-1 min-w-0"> <Flame size={16} className="text-white" />
<h1 className="text-sm font-bold text-white truncate">Son of Anton</h1> </div>
<p className="text-[10px] text-anton-muted truncate">{state.user?.username || ""}</p> <div>
<h1 className="text-sm font-bold text-white">Son of Anton</h1>
<p className="text-[10px] text-anton-muted">v4.0.0 — The Architect</p>
</div>
</div> </div>
{mobile && ( <button onClick={handleNew} className="w-full flex items-center justify-center gap-1.5 bg-anton-accent text-white rounded-lg py-2 text-sm hover:opacity-80 transition">
<button onClick={onClose} className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"> <Plus size={16} /> New Chat
<X size={18} /> </button>
</button>
)}
</div> </div>
<button {/* Chat list */}
onClick={handleNew} <div className="flex-1 overflow-y-auto p-2 space-y-0.5">
className="w-full flex items-center justify-center gap-2 py-2.5 px-3 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition active:scale-[0.98]" {state.chats.map((c) => (
> <div key={c.id} onClick={() => { onSelectChat(c.id); onClose?.(); }}
<Plus size={16} /> New Chat className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition text-sm ${activeChatId === c.id ? "bg-anton-accent/15 text-white" : "text-anton-muted hover:bg-anton-card hover:text-white"}`}>
</button> <MessageSquare size={14} className="shrink-0" />
</div> {editId === c.id ? (
<div className="flex-1 flex items-center gap-1">
{/* Chat list */} <input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)}
<div className="flex-1 overflow-y-auto py-1.5 px-1.5 space-y-0.5"> className="flex-1 bg-anton-bg border border-anton-border rounded px-1.5 py-0.5 text-xs text-white" autoFocus />
{state.chats.length === 0 && ( <button onClick={() => handleRename(c.id)} className="text-green-400"><Check size={12} /></button>
<p className="text-center text-anton-muted text-xs py-8 px-4"> <button onClick={() => setEditId(null)} className="text-red-400"><X size={12} /></button>
No chats yet. Start a new conversation!
</p>
)}
{state.chats.map((chat) => {
const active = chat.id === state.activeChatId;
const editing = editId === chat.id;
return (
<div
key={chat.id}
onClick={() => !editing && selectChat(chat.id)}
className={`group flex items-center gap-2 px-2.5 py-2.5 rounded-lg cursor-pointer transition min-h-[44px] ${
active
? "bg-anton-accent/15 text-white border border-anton-accent/30"
: "text-anton-muted hover:bg-anton-card hover:text-white border border-transparent"
}`}
>
<MessageSquare size={14} className="shrink-0 opacity-50" />
{editing ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") confirmEdit(e); if (e.key === "Escape") cancelEdit(e); }}
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
autoFocus
/>
<button onClick={confirmEdit} className="p-1 text-anton-success"><Check size={14} /></button>
<button onClick={cancelEdit} className="p-1 text-anton-muted"><X size={14} /></button>
</div> </div>
) : ( ) : (
<> <>
<span className="flex-1 text-sm truncate">{chat.title}</span> <span className="flex-1 truncate text-xs">{c.title}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> <div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(e) => startEdit(e, chat)} className="p-1 rounded hover:bg-anton-bg transition"><Edit3 size={12} /></button> {c.linked_repo_id && <GitBranch size={11} className="text-orange-400" />}
<button onClick={(e) => handleDelete(e, chat.id)} className="p-1 rounded hover:bg-anton-danger/20 text-anton-danger transition"><Trash2 size={12} /></button> <button onClick={(e) => { e.stopPropagation(); setEditId(c.id); setEditTitle(c.title); }} className="p-0.5 hover:text-anton-accent"><Edit3 size={11} /></button>
<button onClick={(e) => handleDelete(e, c.id)} className="p-0.5 hover:text-red-400"><Trash2 size={11} /></button>
</div> </div>
</> </>
)} )}
</div> </div>
); ))}
})} </div>
</div>
{/* Footer nav */} {/* Footer */}
<div className="p-2 border-t border-anton-border space-y-0.5"> <div className="p-2 border-t border-anton-border space-y-0.5">
<button {isSuperadmin && (
onClick={() => { navigate("/knowledge"); onClose?.(); }} <>
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]" <button onClick={() => nav("/gitlab")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-anton-card transition">
> <GitBranch size={14} /> GitLab Center
<BookOpen size={16} /> Knowledge Bases <ChevronRight size={14} className="ml-auto opacity-40" /> </button>
</button> <button onClick={() => nav("/admin")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
{isSuperadmin && ( <Shield size={14} /> Admin
<button </button>
onClick={() => { navigate("/admin"); onClose?.(); }} </>
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]" )}
> <button onClick={() => nav("/knowledge")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<Shield size={16} /> Admin Panel <ChevronRight size={14} className="ml-auto opacity-40" /> <BookOpen size={14} /> Knowledge
</button> </button>
)} <button onClick={() => dispatch({ type: "LOGOUT" })} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-red-400 transition">
<button <LogOut size={14} /> Logout
onClick={handleLogout} </button>
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition min-h-[44px]" <div className="px-3 py-1 text-[10px] text-anton-muted">{state.user?.username}{state.user?.role}</div>
> </div>
<LogOut size={16} /> Sign Out
</button>
{/* Quota display */}
{state.user && (
<div className="px-3 py-2 text-[10px] text-anton-muted">
<div className="flex justify-between mb-1">
<span>Tokens used</span>
<span>{((state.user.tokens_used_this_month || 0) / 1000).toFixed(0)}K / {((state.user.quota_tokens_monthly || 0) / 1000).toFixed(0)}K</span>
</div>
<div className="h-1 bg-anton-border rounded-full overflow-hidden">
<div
className="h-full bg-anton-accent rounded-full transition-all"
style={{ width: `${Math.min(100, ((state.user.tokens_used_this_month || 0) / (state.user.quota_tokens_monthly || 1)) * 100)}%` }}
/>
</div>
</div>
)}
</div> </div>
</div> </>
); );
} }
\ No newline at end of file
import React, { useState, useEffect } from "react";
import { useApp } from "../store";
import { useNavigate } from "react-router-dom";
import {
gitlabGetSettings, gitlabUpdateSettings, gitlabTestConnection,
gitlabSearchProjects, gitlabCreateProject, gitlabListRepos,
gitlabLinkRepo, gitlabUnlinkRepo, gitlabGetTree, gitlabGetFile,
gitlabListActions, gitlabApproveAction, gitlabRejectAction,
} from "../api";
import {
ArrowLeft, Settings2, Plug, Check, X, Search, Plus, Link, Unlink,
FolderTree, GitBranch, Eye, Shield, Clock, CheckCircle, XCircle,
Loader2, ExternalLink, FileText, Folder, RefreshCw,
} from "lucide-react";
export default function GitLabPage() {
const { state } = useApp();
const nav = useNavigate();
const t = state.token;
const [tab, setTab] = useState("connection");
const [settings, setSettings] = useState({ gitlab_url: "", gitlab_token: "", is_active: false });
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [testResult, setTestResult] = useState(null);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [projects, setProjects] = useState([]);
const [searchQ, setSearchQ] = useState("");
const [searching, setSearching] = useState(false);
const [repos, setRepos] = useState([]);
const [linking, setLinking] = useState(null);
const [actions, setActions] = useState([]);
const [actionsTab, setActionsTab] = useState("pending");
const [processingAction, setProcessingAction] = useState(null);
const [browseRepo, setBrowseRepo] = useState(null);
const [tree, setTree] = useState([]);
const [fileContent, setFileContent] = useState(null);
useEffect(() => {
if (state.user?.role !== "superadmin") { nav("/"); return; }
loadSettings();
loadRepos();
}, []);
async function loadSettings() {
try { const s = await gitlabGetSettings(t); setSettings(s); setUrl(s.gitlab_url || ""); } catch { }
}
async function loadRepos() {
try { setRepos(await gitlabListRepos(t)); } catch { }
}
async function loadActions() {
try { setActions(await gitlabListActions(t, actionsTab)); } catch { }
}
useEffect(() => { if (tab === "actions") loadActions(); }, [tab, actionsTab]);
async function handleSave() {
setSaving(true);
try {
await gitlabUpdateSettings(t, { gitlab_url: url, gitlab_token: token || "UNCHANGED" });
await loadSettings();
setTestResult(null);
} catch (e) { alert(e.message); }
setSaving(false);
}
async function handleTest() {
setTesting(true); setTestResult(null);
try {
const r = await gitlabTestConnection(t);
setTestResult({ ok: true, msg: `Connected as ${r.name} (@${r.username})` });
} catch (e) { setTestResult({ ok: false, msg: e.message }); }
setTesting(false);
}
async function handleSearch() {
setSearching(true);
try { setProjects(await gitlabSearchProjects(t, searchQ, false)); } catch { }
setSearching(false);
}
async function handleLink(projectId) {
setLinking(projectId);
try { await gitlabLinkRepo(t, projectId); await loadRepos(); } catch (e) { alert(e.message); }
setLinking(null);
}
async function handleUnlink(repoId) {
if (!confirm("Unlink this repo?")) return;
try { await gitlabUnlinkRepo(t, repoId); await loadRepos(); } catch { }
}
async function handleBrowse(repo) {
setBrowseRepo(repo); setFileContent(null);
try {
const r = await gitlabGetTree(t, repo.id, "", null);
setTree(r.items || []);
} catch { setTree([]); }
}
async function handleViewFile(path) {
if (!browseRepo) return;
try {
const f = await gitlabGetFile(t, browseRepo.id, path, null);
setFileContent(f);
} catch (e) { setFileContent({ file_path: path, content: `Error: ${e.message}` }); }
}
async function handleApprove(id) {
setProcessingAction(id);
try { await gitlabApproveAction(t, id); await loadActions(); } catch (e) { alert(e.message); }
setProcessingAction(null);
}
async function handleReject(id) {
setProcessingAction(id);
try { await gitlabRejectAction(t, id); await loadActions(); } catch (e) { alert(e.message); }
setProcessingAction(null);
}
const linked = new Set(repos.map(r => r.gitlab_project_id));
return (
<div className="h-dvh flex flex-col bg-anton-bg text-anton-text">
{/* Header */}
<div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3">
<button onClick={() => nav("/")} className="text-anton-muted hover:text-white"><ArrowLeft size={20} /></button>
<div className="flex items-center gap-2">
<GitBranch size={20} className="text-orange-400" />
<h1 className="text-lg font-bold text-white">GitLab Command Center</h1>
</div>
<div className={`ml-auto flex items-center gap-1.5 text-xs ${settings.is_active ? "text-green-400" : "text-red-400"}`}>
<div className={`w-2 h-2 rounded-full ${settings.is_active ? "bg-green-400" : "bg-red-400"}`} />
{settings.is_active ? "Connected" : "Disconnected"}
</div>
</div>
{/* Tabs */}
<div className="border-b border-anton-border bg-anton-surface flex gap-0.5 px-4">
{[["connection", "Connection", Plug], ["repos", "Repositories", FolderTree], ["actions", "Actions", Shield]].map(([key, label, Icon]) => (
<button key={key} onClick={() => setTab(key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm border-b-2 transition ${tab === key ? "border-anton-accent text-white" : "border-transparent text-anton-muted hover:text-white"}`}>
<Icon size={14} />{label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* ── CONNECTION TAB ── */}
{tab === "connection" && (
<div className="max-w-2xl mx-auto space-y-4">
<div className="bg-anton-card border border-anton-border rounded-xl p-5 space-y-4">
<h2 className="text-white font-semibold flex items-center gap-2"><Settings2 size={16} className="text-anton-accent" /> Connection Settings</h2>
<div>
<label className="text-xs text-anton-muted block mb-1">GitLab URL</label>
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://gitlab.example.com"
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent" />
</div>
<div>
<label className="text-xs text-anton-muted block mb-1">Personal Access Token</label>
<input type="password" value={token} onChange={e => setToken(e.target.value)} placeholder={settings.gitlab_token_set ? "••••••• (saved)" : "glpat-..."}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent" />
<p className="text-[10px] text-anton-muted mt-1">Needs api, read_repository, write_repository scopes</p>
</div>
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving} className="px-4 py-2 bg-anton-accent text-white rounded-lg hover:opacity-80 transition disabled:opacity-50 flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Save
</button>
<button onClick={handleTest} disabled={testing} className="px-4 py-2 bg-anton-card border border-anton-border text-white rounded-lg hover:border-anton-accent transition disabled:opacity-50 flex items-center gap-1.5">
{testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />} Test
</button>
</div>
{testResult && (
<div className={`p-3 rounded-lg text-sm ${testResult.ok ? "bg-green-500/10 text-green-400 border border-green-500/30" : "bg-red-500/10 text-red-400 border border-red-500/30"}`}>
{testResult.ok ? <CheckCircle size={14} className="inline mr-1" /> : <XCircle size={14} className="inline mr-1" />}
{testResult.msg}
</div>
)}
</div>
</div>
)}
{/* ── REPOS TAB ── */}
{tab === "repos" && (
<div className="max-w-4xl mx-auto space-y-4">
{/* Search & Link */}
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
<h2 className="text-white font-semibold flex items-center gap-2"><Search size={16} className="text-anton-accent" /> Find & Link Projects</h2>
<div className="flex gap-2">
<input value={searchQ} onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === "Enter" && handleSearch()} placeholder="Search GitLab projects..."
className="flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<button onClick={handleSearch} disabled={searching} className="px-4 py-2 bg-anton-accent text-white rounded-lg text-sm hover:opacity-80 disabled:opacity-50">
{searching ? <Loader2 size={14} className="animate-spin" /> : "Search"}
</button>
</div>
{projects.length > 0 && (
<div className="max-h-60 overflow-y-auto space-y-1">
{projects.map(p => (
<div key={p.id} className="flex items-center justify-between bg-anton-bg rounded-lg px-3 py-2">
<div className="min-w-0">
<div className="text-sm text-white truncate">{p.path_with_namespace}</div>
<div className="text-[10px] text-anton-muted truncate">{p.description || "No description"}</div>
</div>
{linked.has(p.id) ? (
<span className="text-xs text-green-400 shrink-0 ml-2">✓ Linked</span>
) : (
<button onClick={() => handleLink(p.id)} disabled={linking === p.id} className="text-xs bg-anton-accent/20 text-anton-accent px-2.5 py-1 rounded hover:bg-anton-accent/30 shrink-0 ml-2">
{linking === p.id ? <Loader2 size={12} className="animate-spin" /> : <><Link size={12} className="inline mr-1" />Link</>}
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Linked Repos */}
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-white font-semibold flex items-center gap-2"><FolderTree size={16} className="text-green-400" /> Linked Repositories ({repos.length})</h2>
<button onClick={loadRepos} className="text-anton-muted hover:text-white"><RefreshCw size={14} /></button>
</div>
{repos.length === 0 && <p className="text-anton-muted text-sm">No repos linked yet. Search above.</p>}
{repos.map(r => (
<div key={r.id} className="bg-anton-bg rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-white font-medium">{r.name}</div>
<div className="text-[10px] text-anton-muted">{r.path_with_namespace}{r.default_branch}</div>
</div>
<div className="flex gap-1.5">
{r.web_url && <a href={r.web_url} target="_blank" rel="noopener noreferrer" className="p-1.5 text-anton-muted hover:text-white"><ExternalLink size={14} /></a>}
<button onClick={() => handleBrowse(r)} className="p-1.5 text-anton-muted hover:text-green-400"><Eye size={14} /></button>
<button onClick={() => handleUnlink(r.id)} className="p-1.5 text-anton-muted hover:text-red-400"><Unlink size={14} /></button>
</div>
</div>
</div>
))}
</div>
{/* File Browser */}
{browseRepo && (
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-white font-semibold text-sm">📂 {browseRepo.name} / {browseRepo.default_branch}</h2>
<button onClick={() => { setBrowseRepo(null); setFileContent(null); }} className="text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[60vh]">
<div className="overflow-y-auto border border-anton-border rounded-lg p-2 space-y-0.5 max-h-[55vh]">
{tree.map(item => (
<button key={item.path} onClick={() => item.type === "blob" && handleViewFile(item.path)}
className={`w-full text-left px-2 py-1 rounded text-xs flex items-center gap-1.5 ${item.type === "blob" ? "hover:bg-anton-accent/10 text-white cursor-pointer" : "text-anton-muted cursor-default"}`}>
{item.type === "tree" ? <Folder size={12} className="text-blue-400 shrink-0" /> : <FileText size={12} className="text-anton-muted shrink-0" />}
<span className="truncate">{item.path}</span>
</button>
))}
</div>
<div className="overflow-y-auto border border-anton-border rounded-lg max-h-[55vh]">
{fileContent ? (
<div>
<div className="sticky top-0 bg-anton-surface px-3 py-1.5 border-b border-anton-border text-xs text-anton-muted">{fileContent.file_path}</div>
<pre className="p-3 text-[11px] text-white whitespace-pre-wrap font-mono leading-relaxed">{fileContent.content}</pre>
</div>
) : (
<div className="flex items-center justify-center h-full text-anton-muted text-sm p-4">Click a file to view</div>
)}
</div>
</div>
</div>
)}
</div>
)}
{/* ── ACTIONS TAB ── */}
{tab === "actions" && (
<div className="max-w-3xl mx-auto space-y-4">
<div className="flex gap-2 mb-2">
{["pending", "approved", "rejected"].map(s => (
<button key={s} onClick={() => setActionsTab(s)}
className={`px-3 py-1.5 rounded-lg text-xs capitalize ${actionsTab === s ? "bg-anton-accent text-white" : "bg-anton-card text-anton-muted border border-anton-border hover:text-white"}`}>
{s}
</button>
))}
<button onClick={loadActions} className="ml-auto text-anton-muted hover:text-white"><RefreshCw size={14} /></button>
</div>
{actions.length === 0 && <p className="text-anton-muted text-sm text-center py-8">No {actionsTab} actions</p>}
{actions.map(a => (
<div key={a.id} className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-xs bg-anton-accent/20 text-anton-accent px-2 py-0.5 rounded mr-2">{a.action_type}</span>
<span className="text-sm text-white">{a.title || "Untitled"}</span>
</div>
<span className="text-[10px] text-anton-muted">{a.repo_name}</span>
</div>
<pre className="text-[10px] text-anton-muted bg-anton-bg rounded p-2 max-h-32 overflow-y-auto">{a.payload}</pre>
{a.status === "pending" && (
<div className="flex gap-2">
<button onClick={() => handleApprove(a.id)} disabled={processingAction === a.id}
className="px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-500 disabled:opacity-50 flex items-center gap-1">
{processingAction === a.id ? <Loader2 size={12} className="animate-spin" /> : <CheckCircle size={12} />} Approve
</button>
<button onClick={() => handleReject(a.id)} disabled={processingAction === a.id}
className="px-3 py-1.5 bg-red-600 text-white rounded text-xs hover:bg-red-500 disabled:opacity-50 flex items-center gap-1">
<XCircle size={12} /> Reject
</button>
</div>
)}
{a.result_message && <p className="text-[10px] text-anton-muted">{a.result_message}</p>}
</div>
))}
</div>
)}
</div>
</div>
);
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment