Commit 0d12d87f authored by Administrator's avatar Administrator

Update 7 files via Son of Anton

parent 015b7902
......@@ -42,13 +42,14 @@ def _run_migrations():
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
if "linked_repo_id" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN linked_repo_id VARCHAR(36)"))
if "linked_repo_branch" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN linked_repo_branch VARCHAR(100)"))
conn.commit()
if "chat_attachments" not in existing_tables:
from backend.models import ChatAttachment
ChatAttachment.__table__.create(bind=engine, checkfirst=True)
# Create user_permissions table if missing
if "user_permissions" not in existing_tables:
from backend.models import UserPermissions
UserPermissions.__table__.create(bind=engine, checkfirst=True)
......
......@@ -54,7 +54,6 @@ class UserPermissions(Base):
unique=True, nullable=False, index=True,
)
# Feature access
can_use_web_search = Column(Boolean, default=False)
can_use_ui_design = Column(Boolean, default=False)
can_use_knowledge_base = Column(Boolean, default=True)
......@@ -63,10 +62,8 @@ class UserPermissions(Base):
can_export_pptx = Column(Boolean, default=True)
can_export_docx = Column(Boolean, default=True)
# Model access — "all" or comma-separated model IDs
allowed_models = Column(Text, default="eu.anthropic.claude-haiku-4-5-20251001-v1:0")
# Limits (0 = unlimited for count-based limits)
max_tokens_cap = Column(Integer, default=4096)
max_reasoning_budget = Column(Integer, default=0)
max_chats = Column(Integer, default=50)
......@@ -90,6 +87,7 @@ class Chat(Base):
model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
knowledge_base_id = Column(String(36), nullable=True)
linked_repo_id = Column(String(36), nullable=True)
linked_repo_branch = Column(String(100), nullable=True)
max_tokens = Column(Integer, default=4096)
reasoning_budget = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
......
......@@ -28,6 +28,7 @@ class CreateChatBody(BaseModel):
model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None
linked_repo_branch: Optional[str] = None
max_tokens: int = 4096
reasoning_budget: int = 0
......@@ -39,6 +40,7 @@ class UpdateChatBody(BaseModel):
reasoning_budget: Optional[int] = None
knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None
linked_repo_branch: Optional[str] = None
class SendMessageBody(BaseModel):
......@@ -85,6 +87,7 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db
model=check_model_allowed(user.id, body.model, db),
knowledge_base_id=body.knowledge_base_id or None,
linked_repo_id=body.linked_repo_id or None,
linked_repo_branch=body.linked_repo_branch or None,
max_tokens=min(body.max_tokens, perms.get("max_tokens_cap", 4096)),
reasoning_budget=min(body.reasoning_budget, perms.get("max_reasoning_budget", 0)),
)
......@@ -116,6 +119,8 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
if body.linked_repo_id and not perms.get("can_use_gitlab"):
raise HTTPException(403, "GitLab not enabled.")
chat.linked_repo_id = body.linked_repo_id or None
if body.linked_repo_branch is not None:
chat.linked_repo_branch = body.linked_repo_branch or None
db.commit()
return _chat_dict(chat, db)
......@@ -205,11 +210,6 @@ async def commit_from_chat(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Commit files from chat to linked GitLab repo.
Auto-detects whether each file should be 'create' or 'update'
by checking the repo tree, so it never fails on wrong action type.
"""
check_feature(user.id, "use_gitlab", db)
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
......@@ -226,73 +226,44 @@ async def commit_from_chat(
if not settings or not settings.is_active:
raise HTTPException(400, "GitLab not configured")
# ── Fetch repo tree to know which files already exist ──
existing_paths = set()
try:
tree = await gitlab_service.get_tree(
settings.gitlab_url,
settings.gitlab_token,
repo.gitlab_project_id,
ref=body.branch,
recursive=True,
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id, ref=body.branch, recursive=True,
)
existing_paths = {
item["path"] for item in tree if item["type"] == "blob"
}
existing_paths = {item["path"] for item in tree if item["type"] == "blob"}
except Exception:
# If tree fetch fails (empty repo, network issue, etc.),
# we'll try all as "create" since we can't know what exists
pass
# ── Build actions with auto-detected create/update ──
actions = []
for f in body.files:
file_path = f.get("file_path", "")
content = f.get("content", "")
requested_action = f.get("action", "auto")
if not file_path or not content:
continue
file_exists = file_path in existing_paths
# Smart action resolution
if requested_action in ("auto", "upsert"):
# Auto-detect: update if exists, create if not
actual_action = "update" if file_exists else "create"
elif requested_action == "update" and not file_exists:
# User said update but file doesn't exist → create instead
actual_action = "create"
elif requested_action == "create" and file_exists:
# User said create but file already exists → update instead
actual_action = "update"
else:
actual_action = requested_action
actions.append({
"action": actual_action,
"file_path": file_path,
"content": content,
})
actions.append({"action": actual_action, "file_path": file_path, "content": content})
if not actions:
raise HTTPException(400, "No valid files to commit")
try:
result = await gitlab_service.commit_files(
settings.gitlab_url,
settings.gitlab_token,
repo.gitlab_project_id,
body.branch,
body.commit_message,
actions,
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id, body.branch, body.commit_message, actions,
)
gen_manager.invalidate_repo_cache(repo.id)
return {
"ok": True,
"commit": result,
"files_committed": len(actions),
}
return {"ok": True, "commit": result, "files_committed": len(actions)}
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Commit failed: {e.detail}")
......@@ -314,6 +285,7 @@ def _chat_dict(c, db=None):
"id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id,
"linked_repo_id": c.linked_repo_id,
"linked_repo_branch": getattr(c, "linked_repo_branch", None) or None,
"max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at),
......
"""
GitLab CE integration routes — superadmin only.
Son of Anton v4.2.0
"""
import asyncio
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, code_analyzer
router = APIRouter()
# ═══════════════════════════════════════════════════
# Request Bodies
# USER-FACING ENDPOINTS (non-superadmin, permission-gated)
# These let regular users with can_use_gitlab pick repos/branches in chat
# ═══════════════════════════════════════════════════
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 = "auto"
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
from backend.auth import get_current_user, check_feature
from backend.models import KnowledgeBase, KnowledgeDocument
from backend.services import rag_service
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
class CreateKBFromRepoBody(BaseModel):
branch: Optional[str] = None
name: Optional[str] = None
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.get("/user/repos")
def user_list_repos(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""List linked repos — available to any user with can_use_gitlab."""
check_feature(user.id, "use_gitlab", db)
repos = db.query(LinkedRepo).order_by(LinkedRepo.created_at.desc()).all()
return [_repo_dict(r) for r in repos]
@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")
@router.get("/user/repos/{repo_id}/branches")
async def user_list_branches(repo_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""List branches for a linked repo — available to any user with can_use_gitlab."""
check_feature(user.id, "use_gitlab", db)
s = _get_settings(db)
repo = _get_repo(repo_id, db)
try:
result = await gitlab_service.test_connection(s.gitlab_url, s.gitlab_token)
return result
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, f"Connection failed: {e.detail}")
except Exception as e:
raise HTTPException(502, f"Cannot reach GitLab: {str(e)}")
raise HTTPException(e.status_code, e.detail)
# ═══════════════════════════════════════════════════
# GitLab Projects (remote)
# ═══════════════════════════════════════════════════
@router.get("/projects")
async def search_projects(
search: Optional[str] = Query(None),
owned: bool = Query(False),
admin: User = Depends(require_superadmin),
@router.post("/user/repos/{repo_id}/create-kb")
async def user_create_kb_from_repo(
repo_id: str,
body: CreateKBFromRepoBody,
user: User = Depends(get_current_user),
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)
"""Create a Knowledge Base from all code files in a repo. Runs in background."""
check_feature(user.id, "use_gitlab", db)
check_feature(user.id, "use_knowledge_base", db)
@router.post("/projects")
async def create_project(body: CreateProjectBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
repo = _get_repo(repo_id, 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]
branch = body.branch or repo.default_branch
kb_name = body.name or f"Repo: {repo.name} ({branch})"
@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", ""),
map_status="analyzing",
kb = KnowledgeBase(
user_id=user.id,
name=kb_name,
description=f"Auto-generated from {repo.path_with_namespace} branch:{branch} — processing...",
)
db.add(repo)
db.add(kb)
db.commit()
db.refresh(repo)
asyncio.create_task(_analyze_repo_background(
repo.id, s.gitlab_url, s.gitlab_token,
project["id"], project.get("default_branch", "main"),
))
return _repo_dict(repo)
db.refresh(kb)
rag_service.create_collection(kb.id)
@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}
asyncio.create_task(_kb_from_repo_background(
kb.id, s.gitlab_url, s.gitlab_token,
repo.gitlab_project_id, branch, repo.name, repo.path_with_namespace,
))
return {"kb_id": kb.id, "name": kb_name, "status": "processing"}
# ═══════════════════════════════════════════════════
# Architecture Map
# ═══════════════════════════════════════════════════
async def _analyze_repo_background(repo_id: str, gitlab_url: str, gitlab_token: str, project_id: int, branch: str):
"""Background task: load all files and generate architecture map."""
async def _kb_from_repo_background(kb_id, gitlab_url, gitlab_token, project_id, branch, repo_name, repo_path):
"""Background task: load every code file from repo into a KB."""
from backend.database import SessionLocal as BgSession
db = BgSession()
try:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
if not repo:
return
repo.map_status = "analyzing"
db.commit()
result = await gitlab_service.load_project_files(
gitlab_url, gitlab_token, project_id, ref=branch,
)
files = result.get("files", [])
if not files:
repo.map_status = "failed"
repo.architecture_map = "[No files could be loaded for analysis]"
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first()
if kb:
kb.description = f"From {repo_path} ({branch}) — no files found"
db.commit()
return
architecture_map = code_analyzer.analyze_codebase(files)
total_docs = 0
total_chunks = 0
total_chars = 0
for f in files:
content = f.get("content", "")
path = f.get("path", "unknown")
if not content or content.startswith("["):
continue
chunks = _chunk_for_kb(content, chunk_size=3000, overlap=300)
if not chunks:
continue
repo.architecture_map = architecture_map
repo.map_status = "ready"
repo.map_generated_at = datetime.utcnow()
rag_service.add_documents(
collection_id=kb_id,
documents=chunks,
metadatas=[{"filename": path, "chunk_index": i} for i in range(len(chunks))],
)
doc = KnowledgeDocument(
knowledge_base_id=kb_id,
filename=path,
file_size=len(content),
chunk_count=len(chunks),
)
db.add(doc)
total_docs += 1
total_chunks += len(chunks)
total_chars += len(content)
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first()
if kb:
kb.document_count = total_docs
kb.chunk_count = total_chunks
kb.total_characters = total_chars
kb.description = f"From {repo_path} ({branch}) — {total_docs} files, {total_chunks} chunks"
db.commit()
print(f" ✅ Architecture map generated for {repo.name} ({len(architecture_map)} chars)")
print(f" ✅ KB from repo done: {repo_name} — {total_docs} files, {total_chunks} chunks, {total_chars:,} chars")
except Exception as e:
print(f" ❌ KB from repo failed for {repo_name}: {e}")
try:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
if repo:
repo.map_status = "failed"
repo.architecture_map = f"[Analysis failed: {str(e)[:200]}]"
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first()
if kb:
kb.description = f"From {repo_path} ({branch}) — ERROR: {str(e)[:200]}"
db.commit()
except Exception:
pass
print(f" ❌ Architecture analysis failed for repo {repo_id}: {e}")
finally:
db.close()
@router.post("/repos/{repo_id}/analyze")
async def reanalyze_repo(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
"""Re-generate the architecture map for a linked repo."""
s = _get_settings(db)
repo = _get_repo(repo_id, db)
repo.map_status = "analyzing"
db.commit()
asyncio.create_task(_analyze_repo_background(
repo.id, s.gitlab_url, s.gitlab_token,
repo.gitlab_project_id, repo.default_branch,
))
return {"ok": True, "status": "analyzing"}
@router.get("/repos/{repo_id}/map")
def get_repo_map(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
"""Get the architecture map for a linked repo."""
repo = _get_repo(repo_id, db)
return {
"map_status": repo.map_status or "none",
"map_generated_at": str(repo.map_generated_at) if repo.map_generated_at else None,
"architecture_map": repo.architecture_map or "",
"map_size": len(repo.architecture_map or ""),
}
# ═══════════════════════════════════════════════════
# 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)):
"""
Commit multiple files. Auto-detects create vs update per file.
"""
s = _get_settings(db)
repo = _get_repo(repo_id, db)
# Fetch tree to know which files exist
existing_paths = set()
try:
tree = await gitlab_service.get_tree(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
ref=body.branch, recursive=True,
)
existing_paths = {item["path"] for item in tree if item["type"] == "blob"}
except Exception:
pass
resolved_actions = []
for a in body.actions:
file_path = a.get("file_path", "")
content = a.get("content", "")
requested = a.get("action", "auto")
if not file_path:
continue
file_exists = file_path in existing_paths
if requested in ("auto", "upsert"):
actual = "update" if file_exists else "create"
elif requested == "update" and not file_exists:
actual = "create"
elif requested == "create" and file_exists:
actual = "update"
else:
actual = requested
resolved_actions.append({
"action": actual,
"file_path": file_path,
"content": content,
})
if not resolved_actions:
raise HTTPException(400, "No valid files to commit")
try:
result = await gitlab_service.commit_files(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.branch, body.commit_message, resolved_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),
):
"""
Commit a single file. Auto-detects create vs update.
"""
s = _get_settings(db)
repo = _get_repo(repo_id, db)
# Auto-detect whether file exists
action = body.action
if action in ("update", "create", "auto"):
try:
await gitlab_service.get_file_content(
s.gitlab_url, s.gitlab_token,
repo.gitlab_project_id, body.file_path, ref=body.branch,
)
file_exists = True
except gitlab_service.GitLabError:
file_exists = False
if action == "auto":
action = "update" if file_exists else "create"
elif action == "update" and not file_exists:
action = "create"
elif action == "create" and file_exists:
action = "update"
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, 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)
# ═══════════════════════════════════════════════════
# 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,
"map_status": r.map_status or "none",
"map_generated_at": str(r.map_generated_at) if r.map_generated_at else None,
"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
def _chunk_for_kb(text, chunk_size=3000, overlap=300):
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
if end < len(text):
for sep in ["\n\n", "\n", ". ", " "]:
pos = text.rfind(sep, start + chunk_size // 2, end)
if pos > start:
end = pos + len(sep)
break
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start = end - overlap if end < len(text) else end
return chunks
\ No newline at end of file
"""
Background generation manager — v4.1.0 with web search.
"""
import asyncio
import time
from datetime import datetime
from typing import Optional
from dataclasses import dataclass, field
from backend.database import SessionLocal
from backend.models import User, Chat, Message, ChatAttachment, GitLabSettings, LinkedRepo
from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service, gitlab_service
_tree_cache: dict[str, tuple[float, list[dict]]] = {}
TREE_CACHE_TTL = 600
_chat_file_history: dict[str, set[str]] = {}
def _get_tree_cache(repo_id, branch):
key = f"{repo_id}:{branch}"
if key in _tree_cache:
ts, tree = _tree_cache[key]
if time.time() - ts < TREE_CACHE_TTL:
return tree
return None
def _set_tree_cache(repo_id, branch, tree):
_tree_cache[f"{repo_id}:{branch}"] = (time.time(), tree)
@dataclass
class GenerationState:
events: list = field(default_factory=list)
done: asyncio.Event = field(default_factory=asyncio.Event)
message_id: str = ""
error: str = ""
class GenerationManager:
def __init__(self):
self._active: dict[str, GenerationState] = {}
def is_active(self, chat_id: str) -> bool:
state = self._active.get(chat_id)
return state is not None and not state.done.is_set()
def start(self, chat_id, user_id, content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False):
old = self._active.get(chat_id)
if old and not old.done.is_set():
old.done.set()
state = GenerationState()
self._active[chat_id] = state
asyncio.create_task(self._run(state, chat_id, user_id, content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search))
return state
async def stream_events(self, chat_id):
state = self._active.get(chat_id)
if not state: return
idx = 0
while True:
while idx < len(state.events):
yield state.events[idx]; idx += 1
if state.done.is_set():
while idx < len(state.events):
yield state.events[idx]; idx += 1
break
await asyncio.sleep(0.02)
def invalidate_repo_cache(self, repo_id):
for k in [k for k in _tree_cache if k.startswith(f"{repo_id}:")]:
_tree_cache.pop(k, None)
async def _build_repo_context(self, db, chat, user_query):
async def _build_repo_context(self, db, chat, user_query):
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: return None
branch = getattr(chat, 'linked_repo_branch', None) or repo.default_branch
try:
tree = _get_tree_cache(repo.id, repo.default_branch)
tree = _get_tree_cache(repo.id, branch)
if tree is None:
tree = await gitlab_service.get_tree(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, ref=repo.default_branch, recursive=True)
_set_tree_cache(repo.id, repo.default_branch, tree)
tree = await gitlab_service.get_tree(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, ref=branch, recursive=True)
_set_tree_cache(repo.id, branch, tree)
prev = _chat_file_history.get(chat.id, set())
result = await gitlab_service.load_smart_files(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, ref=repo.default_branch, tree=tree, user_query=user_query, previous_files=prev)
result = await gitlab_service.load_smart_files(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, ref=branch, tree=tree, user_query=user_query, previous_files=prev)
loaded = set()
for f in result["priority_files"]: loaded.add(f["path"])
for f in result["query_files"]: loaded.add(f["path"])
if chat.id not in _chat_file_history: _chat_file_history[chat.id] = set()
_chat_file_history[chat.id].update(loaded)
return self._format_smart_context(result, tree, repo, db)
return self._format_smart_context(result, tree, repo, db, branch)
except Exception as e:
return f"[Repository: {repo.name} — error: {str(e)[:200]}]"
def _format_smart_context(self, result, tree, repo, db):
def _format_smart_context(self, result, tree, repo, db, branch=None):
files_in_tree = sorted([i["path"] for i in tree if i["type"] == "blob"])
lines = [f"Repository: {repo.name}", f"Branch: {repo.default_branch}", f"Files loaded: {result['files_loaded']}", f"Characters: {result['total_characters']:,}"]
effective_branch = branch or repo.default_branch
lines = [f"Repository: {repo.name}", f"Branch: {effective_branch}", f"Files loaded: {result['files_loaded']}", f"Characters: {result['total_characters']:,}"]
if repo.architecture_map and repo.map_status == "ready":
lines.append(""); lines.append(repo.architecture_map); lines.append("")
lines.append("═" * 50); lines.append("FILE TREE:"); lines.append("═" * 50)
......@@ -107,117 +34,4 @@ class GenerationManager:
lines.append(f"\n━━━ {f['path']} ━━━"); lines.append(f["content"]); lines.append(f"━━━ end ━━━")
for f in result["query_files"]:
lines.append(f"\n━━━ {f['path']} ━━━"); lines.append(f["content"]); lines.append(f"━━━ end ━━━")
return "\n".join(lines)
async def _run(self, state, chat_id, user_id, content, model_id, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False):
db = SessionLocal()
try:
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user_id).first()
if not chat:
state.events.append({"type": "error", "message": "Chat not found"}); return
db_user = db.query(User).filter(User.id == user_id).first()
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
db_user.quota_reset_date = datetime(now.year + 1, 1, 1) if now.month == 12 else datetime(now.year, now.month + 1, 1)
db.commit()
if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly:
state.events.append({"type": "error", "message": "Monthly quota exceeded."}); return
attachments = []
if attachment_ids:
attachments = db.query(ChatAttachment).filter(ChatAttachment.id.in_(attachment_ids), ChatAttachment.chat_id == chat_id).all()
stored_content = content
if attachments:
labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
stored_content = "\n".join(notes) + "\n" + content
user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg); db.commit(); db.refresh(user_msg)
for att in attachments: att.message_id = user_msg.id
if attachments: db.commit()
kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
try: rag_context = rag_service.query(kb_id, content, n_results=8)
except Exception: pass
repo_context = await self._build_repo_context(db, chat, content)
attachment_context = memory_service.gather_attachment_context(chat_id, db)
# Web search
web_context = None
if web_search:
try:
from backend.services.web_search_service import search_web
state.events.append({"type": "status", "message": "Searching the web..."})
web_context = await search_web(content, num_results=8, fetch_pages=3)
except Exception as e:
web_context = f"[Web search failed: {str(e)[:100]}]"
system_prompt = build_full_prompt(rag_context=rag_context, repo_context=repo_context, attachment_context=attachment_context, web_search_context=web_context)
messages = memory_service.build_messages(chat, db)
if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": content})
messages[-1]["content"] = content_blocks
effective_max = max_tokens
thinking_config = None
if reasoning_budget > 0:
thinking_config = {"enabled": True, "budget_tokens": reasoning_budget}
effective_max = max_tokens + reasoning_budget
full_text = ""; full_thinking = ""; input_tokens = 0; output_tokens = 0; current_block_type = "text"
async for event in bedrock_service.stream_response(messages=messages, system_prompt=system_prompt, model_id=model_id, max_tokens=min(effective_max, 65536), thinking_config=thinking_config):
if state.done.is_set(): break
evt_type = event.get("type", "")
if evt_type == "message_start":
usage = event.get("message", {}).get("usage", {}); input_tokens = usage.get("input_tokens", 0)
elif evt_type == "content_block_start":
current_block_type = event.get("content_block", {}).get("type", "text")
if current_block_type == "thinking": state.events.append({"type": "thinking_start"})
elif evt_type == "content_block_delta":
delta = event.get("delta", {}); dt = delta.get("type", "")
if dt == "thinking_delta":
t = delta.get("thinking", ""); full_thinking += t; state.events.append({"type": "thinking_delta", "content": t})
elif dt == "text_delta":
t = delta.get("text", ""); full_text += t; state.events.append({"type": "text_delta", "content": t})
elif evt_type == "content_block_stop":
if current_block_type == "thinking": state.events.append({"type": "thinking_end"})
elif evt_type == "message_delta":
output_tokens = event.get("usage", {}).get("output_tokens", 0)
assistant_msg = Message(chat_id=chat_id, role="assistant", content=full_text, thinking_content=full_thinking or None, input_tokens=input_tokens, output_tokens=output_tokens)
db.add(assistant_msg)
db_user.tokens_used_this_month += input_tokens + output_tokens
chat.model = model_id; chat.max_tokens = max_tokens; chat.reasoning_budget = reasoning_budget
chat.knowledge_base_id = knowledge_base_id or None; chat.updated_at = datetime.utcnow()
db.commit()
state.message_id = assistant_msg.id
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
title = await self._generate_title(content, full_text[:300])
chat.title = title[:120]; db.commit()
state.events.append({"type": "title_update", "title": chat.title})
except Exception: pass
state.events.append({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
state.events.append({"type": "done", "message_id": assistant_msg.id})
except Exception as exc:
state.events.append({"type": "error", "message": str(exc)}); state.error = str(exc)
finally:
state.done.set(); db.close()
await asyncio.sleep(120); self._active.pop(chat_id, None)
async def _generate_title(self, user_msg, ai_msg):
from backend.config import FAST_MODEL
result = await bedrock_service.invoke_model_simple(model_id=FAST_MODEL, prompt=f"Generate a concise title (max 6 words):\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.", max_tokens=30)
return result.strip().strip('"').strip("'")
manager = GenerationManager()
\ No newline at end of file
return "\n".join(lines)
\ No newline at end of file
......@@ -5,117 +5,168 @@ function headers(token) {
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
function authHeader(token) { return token ? { Authorization: `Bearer ${token}` } : {}; }
function extractError(err, d) { let m = err.detail || err.message || d; if (Array.isArray(m)) return m.map(x => x.msg || JSON.stringify(x)).join(", "); if (typeof m === "object") return m.message || JSON.stringify(m); return String(m); }
function authHeader(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function request(method, path, token, body) {
const opts = { method, headers: headers(token) };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "Request failed")); }
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || err.message || "Request failed");
}
return res.json();
}
// Auth
export const login = (u, p) => request("POST", "/auth/login", null, { username: u, password: p });
export const register = (u, e, p) => request("POST", "/auth/register", null, { username: u, email: e, password: p });
export const getMe = (t) => request("GET", "/auth/me", t);
// Chats
export const listChats = (t) => request("GET", "/chats", t);
export const createChat = (t, d = {}) => request("POST", "/chats", t, d);
export const updateChat = (t, id, d) => request("PUT", `/chats/${id}`, t, d);
export const renameChat = (t, id, title) => updateChat(t, id, { title });
export const deleteChat = (t, id) => request("DELETE", `/chats/${id}`, t);
export const getMessages = (t, id) => request("GET", `/chats/${id}/messages`, t);
export const checkGenerating = (t, id) => request("GET", `/chats/${id}/generating`, t);
export const refreshRepoContext = (t, id) => request("POST", `/chats/${id}/refresh-repo`, t);
export const commitFromChat = (t, id, d) => request("POST", `/chats/${id}/commit`, t, d);
// Streaming
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 listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) =>
updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token);
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { method: "POST", headers: headers(token), body: JSON.stringify(body), signal });
if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "Stream failed")); }
const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split("\n\n"); buffer = parts.pop() || ""; for (const part of parts) { const line = part.trim(); if (line.startsWith("data: ")) { try { yield JSON.parse(line.slice(6)); } catch { } } } }
if (buffer.trim().startsWith("data: ")) { try { yield JSON.parse(buffer.trim().slice(6)); } catch { } }
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", headers: headers(token),
body: JSON.stringify(body), signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || "Stream failed");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
const line = part.trim();
if (line.startsWith("data: ")) {
try { yield JSON.parse(line.slice(6)); } catch { }
}
}
}
if (buffer.trim().startsWith("data: ")) {
try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
}
}
export async function uploadAttachments(token, chatId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json();
}
export function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`;
}
// Attachments
export async function uploadAttachments(t, chatId, files) { const form = new FormData(); for (const f of files) form.append("files", f); const res = await fetch(`${BASE}/chats/${chatId}/attachments`, { method: "POST", headers: authHeader(t), body: form }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); } return res.json(); }
export function getAttachmentUrl(id) { return `${BASE}/attachments/${id}/file`; }
export const deleteAttachment = (t, id) => request("DELETE", `/attachments/${id}`, t);
// Knowledge
export const listKnowledgeBases = (t) => request("GET", "/knowledge", t);
export const createKnowledgeBase = (t, n, d = "") => request("POST", "/knowledge", t, { name: n, description: d });
export const getKnowledgeBase = (t, id) => request("GET", `/knowledge/${id}`, t);
export const updateKnowledgeBase = (t, id, d) => request("PUT", `/knowledge/${id}`, t, d);
export const deleteKnowledgeBase = (t, id) => request("DELETE", `/knowledge/${id}`, t);
export const listKnowledgeDocuments = (t, id) => request("GET", `/knowledge/${id}/documents`, t);
export const deleteKnowledgeDocument = (t, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, t);
export async function uploadDocuments(t, kbId, files) { const form = new FormData(); for (const f of files) form.append("files", f); const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(t), body: form }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); } return res.json(); }
export const uploadDocument = (t, kbId, f) => uploadDocuments(t, kbId, [f]);
// Admin
export const adminStats = (t) => request("GET", "/admin/stats", t);
export const adminListUsers = (t) => request("GET", "/admin/users", t);
export const adminCreateUser = (t, d) => request("POST", "/admin/users", t, d);
export const adminUpdateUser = (t, id, d) => request("PUT", `/admin/users/${id}`, t, d);
export const adminDeleteUser = (t, id) => request("DELETE", `/admin/users/${id}`, t);
export const adminListChats = (t) => request("GET", "/admin/chats", t);
// Admin — Permissions
export const adminGetUserPermissions = (t, uid) => request("GET", `/admin/users/${uid}/permissions`, t);
export const adminUpdateUserPermissions = (t, uid, d) => request("PUT", `/admin/users/${uid}/permissions`, t, d);
export const adminGetDefaultPermissions = (t) => request("GET", "/admin/permissions/defaults", t);
export const adminUpdateDefaultPermissions = (t, d) => request("PUT", "/admin/permissions/defaults", t, d);
export const adminApplyDefaults = (t) => request("POST", "/admin/permissions/apply-defaults", t);
export const adminGetModels = (t) => request("GET", "/admin/models", t);
// Code Download
export async function downloadZip(t, md, title) { const res = await fetch(`${BASE}/files/download-zip`, { method: "POST", headers: headers(t), body: JSON.stringify({ markdown: md, title: title || null }) }); if (!res.ok) throw new Error("Download failed"); const ct = res.headers.get("content-type") || ""; if (ct.includes("application/zip")) { const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const raw = (title || "").trim(); a.download = `${raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code"}.zip`; a.click(); URL.revokeObjectURL(url); } else { const data = await res.json(); if (data.error) throw new Error(data.error); } }
// Export PPTX / DOCX
export async function exportPptx(token, markdown, title) {
const res = await fetch(`${BASE}/export/pptx`, { method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title }) });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "PPTX export failed")); }
const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url;
const safe = (title || "presentation").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 50) || "presentation";
a.download = `${safe}.pptx`; a.click(); URL.revokeObjectURL(url);
export const deleteAttachment = (token, attachmentId) =>
request("DELETE", `/attachments/${attachmentId}`, 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 deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocuments(token, kbId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json();
}
export async function exportDocx(token, markdown, title) {
const res = await fetch(`${BASE}/export/docx`, { method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title }) });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "DOCX export failed")); }
const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url;
const safe = (title || "document").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 50) || "document";
a.download = `${safe}.docx`; a.click(); URL.revokeObjectURL(url);
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) => request("GET", "/admin/chats", token);
export async function downloadZip(token, markdown) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown }),
});
if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/zip")) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "son-of-anton-code.zip";
a.click();
URL.revokeObjectURL(url);
} else {
const data = await res.json();
if (data.error) throw new Error(data.error);
}
}
// Utilities
const CODE_BLOCK_RE = /```(\S*?)(?::(\S+?))?\s*?\n([\s\S]*?)```/g;
export function extractCodeBlocks(md) { if (!md) return []; const blocks = []; let m; const re = new RegExp(CODE_BLOCK_RE.source, "g"); while ((m = re.exec(md)) !== null) { const lang = (m[1] || "text").toLowerCase(); const fn = m[2] || null; const code = (m[3] || "").trim(); if (code) blocks.push({ language: lang, filename: fn, code }); } return blocks; }
// GitLab
export const gitlabGetSettings = (t) => request("GET", "/gitlab/settings", t);
export const gitlabUpdateSettings = (t, d) => request("PUT", "/gitlab/settings", t, d);
export const gitlabTestConnection = (t) => request("POST", "/gitlab/test-connection", t);
export const gitlabSearchProjects = (t, s, o) => request("GET", `/gitlab/projects?search=${encodeURIComponent(s || "")}&owned=${o || false}`, t);
export const gitlabCreateProject = (t, d) => request("POST", "/gitlab/projects", t, d);
export const gitlabListRepos = (t) => request("GET", "/gitlab/repos", t);
export const gitlabLinkRepo = (t, pid) => request("POST", "/gitlab/repos", t, { gitlab_project_id: pid });
export const gitlabUnlinkRepo = (t, id) => request("DELETE", `/gitlab/repos/${id}`, t);
export const gitlabGetTree = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/tree?path=${encodeURIComponent(p || "")}&ref=${encodeURIComponent(r || "")}`, t);
export const gitlabGetFile = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/file?path=${encodeURIComponent(p)}&ref=${encodeURIComponent(r || "")}`, t);
export const gitlabGetBranches = (t, id) => request("GET", `/gitlab/repos/${id}/branches`, t);
export const gitlabCreateBranch = (t, id, d) => request("POST", `/gitlab/repos/${id}/branches`, t, d);
export const gitlabCommit = (t, id, d) => request("POST", `/gitlab/repos/${id}/commit`, t, d);
export const gitlabCommitSingle = (t, id, d) => request("POST", `/gitlab/repos/${id}/commit-single`, t, d);
export const gitlabCreateMR = (t, id, d) => request("POST", `/gitlab/repos/${id}/merge-request`, t, d);
export const gitlabReanalyzeRepo = (t, id) => request("POST", `/gitlab/repos/${id}/analyze`, t);
export const gitlabGetRepoMap = (t, id) => request("GET", `/gitlab/repos/${id}/map`, t);
export const gitlabListActions = (t, s) => request("GET", `/gitlab/actions?status=${s || "pending"}`, t);
export const gitlabCreateAction = (t, d) => request("POST", "/gitlab/actions", t, d);
export const gitlabApproveAction = (t, id) => request("POST", `/gitlab/actions/${id}/approve`, t);
export const gitlabRejectAction = (t, id) => request("POST", `/gitlab/actions/${id}/reject`, t);
\ No newline at end of file
// ═══════════════════════════════════════════════════
// GitLab — user-facing (permission-gated on backend)
// ═══════════════════════════════════════════════════
export const listLinkedRepos = (token) =>
request("GET", "/gitlab/user/repos", token);
export const listRepoBranches = (token, repoId) =>
request("GET", `/gitlab/user/repos/${repoId}/branches`, token);
export const createKBFromRepo = (token, repoId, data) =>
request("POST", `/gitlab/user/repos/${repoId}/create-kb`, token, data);
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useApp, usePermissions } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle, refreshRepoContext, exportPptx, exportDocx } from "../api";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, listLinkedRepos, listRepoBranches, createKBFromRepo } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode, GitBranch, RefreshCw, Globe, Presentation, FileOutput, Wand2, ChevronDown, Paintbrush } from "lucide-react";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, GitBranch, FolderGit2, Database } from "lucide-react";
const ALL_MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" },
const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
];
const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
const UI_DESIGN_PREFIX = `[UI DESIGN MODE] You are now a world-class UI/UX designer. Generate COMPLETE, SELF-CONTAINED HTML files that render beautifully in a browser preview.\n\nCRITICAL RULES:\n- Output a SINGLE HTML file using the format \`\`\`html:design.html with ALL CSS and JS embedded inline\n- Include <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n- Use modern CSS (flexbox, grid, custom properties, transitions, animations)\n- Make it fully responsive (mobile-first)\n- Use realistic, professional content (NOT \"Lorem ipsum\")\n- Include hover states, focus states, and micro-interactions\n- Use a cohesive color palette and typography (Google Fonts via CDN OK)\n- If the design uses Tailwind-like utilities, include the Tailwind CDN script\n- For multi-screen apps, generate separate HTML files per screen\n- The output MUST look production-quality and polished\n- Include subtle shadows, gradients, and modern design patterns\n\n`;
function classifyFile(f) { const ext = (f.name || "").split(".").pop().toLowerCase(); const mime = f.type || ""; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video"; if (mime === "application/pdf" || ext === "pdf") return "document"; return "text"; }
function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; }
function classifyFile(file) {
const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || "";
if (mime.startsWith("image/") || ["jpg","jpeg","png","gif","webp","bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4","mov","avi","mkv","webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document";
return "text";
}
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const perms = usePermissions();
const currentChat = state.chats.find(c => c.id === chatId);
const currentChat = state.chats.find((c) => c.id === chatId);
const messages = state.chatMessages[chatId] || [];
const isSuperadmin = state.user?.role === "superadmin";
// Filter models based on permissions
const MODELS = useMemo(() => {
const allowed = perms.allowed_models;
if (!allowed || allowed === "all") return ALL_MODELS;
const list = allowed.split(",").map(s => s.trim());
const filtered = ALL_MODELS.filter(m => list.includes(m.id));
return filtered.length > 0 ? filtered : ALL_MODELS;
}, [perms.allowed_models]);
const isStreamingGlobal = !!state.activeStreams[chatId];
const canGitlab = state.user?.permissions?.can_use_gitlab || state.user?.role === "superadmin";
const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false);
const [showTools, setShowTools] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0]?.id);
const [maxTokens, setMaxTokens] = useState(Math.min(currentChat?.max_tokens || 4096, perms.max_tokens_cap || 4096));
const [reasoningBudget, setReasoningBudget] = useState(Math.min(currentChat?.reasoning_budget ?? 0, perms.max_reasoning_budget || 0));
const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [webSearch, setWebSearch] = useState(false);
const [uiDesign, setUiDesign] = useState(false);
const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [exporting, setExporting] = useState("");
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null); const inputRef = useRef(null); const fileRef = useRef(null); const autoScroll = useRef(true); const rafRef = useRef(null);
// GitLab state
const [repos, setRepos] = useState([]);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [branches, setBranches] = useState([]);
const [selectedBranch, setSelectedBranch] = useState(currentChat?.linked_repo_branch || "");
const [creatingKB, setCreatingKB] = useState(false);
const scrollRef = useRef(null);
const inputRef = useRef(null);
const fileRef = useRef(null);
const autoScroll = useRef(true);
const rafRef = useRef(null);
const maxTokensCap = perms.max_tokens_cap || 4096;
const maxReasoningCap = perms.max_reasoning_budget || 0;
useEffect(() => {
setStreamData(streamManager.getStreamData(chatId));
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]);
useEffect(() => { setStreamData(streamManager.getStreamData(chatId)); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); }, [chatId]);
const scrollBottom = useCallback(() => { if (!autoScroll.current || rafRef.current) return; rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; }); }, []);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
rafRef.current = null;
});
}, []);
// Load messages, KBs, and repos on mount
useEffect(() => {
(async () => {
try {
const [msgs, kbData] = await Promise.all([
getMessages(state.token, chatId),
listKnowledgeBases(state.token),
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData);
} catch {}
if (canGitlab) {
try { setRepos(await listLinkedRepos(state.token)); } catch {}
}
})();
}, [chatId, state.token, dispatch, canGitlab]);
// Sync chat settings when chat changes
useEffect(() => {
if (currentChat) {
setModel(currentChat.model || MODELS[0].id);
setMaxTokens(currentChat.max_tokens || 4096);
setReasoningBudget(currentChat.reasoning_budget ?? 0);
setSelectedKbId(currentChat.knowledge_base_id || null);
setSelectedRepoId(currentChat.linked_repo_id || null);
setSelectedBranch(currentChat.linked_repo_branch || "");
}
}, [currentChat?.id]);
// Load branches when repo changes
useEffect(() => {
if (!selectedRepoId || !canGitlab) { setBranches([]); return; }
(async () => {
try {
const b = await listRepoBranches(state.token, selectedRepoId);
setBranches(b);
// If no branch selected, pick default
if (!selectedBranch) {
const repo = repos.find(r => r.id === selectedRepoId);
setSelectedBranch(repo?.default_branch || b[0]?.name || "main");
}
} catch { setBranches([]); }
})();
}, [selectedRepoId, canGitlab, state.token]);
useEffect(() => { (async () => { try { const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), perms.can_use_knowledge_base ? listKnowledgeBases(state.token) : []]); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); setKbs(kbData); if (isSuperadmin || perms.can_use_gitlab) { try { setRepos(await gitlabListRepos(state.token)); } catch { } } } catch { } })(); }, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => { if (currentChat) { const m = currentChat.model || MODELS[0]?.id; setModel(MODELS.find(x => x.id === m) ? m : MODELS[0]?.id); setMaxTokens(Math.min(currentChat.max_tokens || 4096, maxTokensCap)); setReasoningBudget(Math.min(currentChat.reasoning_budget ?? 0, maxReasoningCap)); setSelectedKbId(currentChat.knowledge_base_id || null); setSelectedRepoId(currentChat.linked_repo_id || null); } }, [chatId]);
const onScroll = useCallback(() => { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }, []);
const saveSettings = useCallback(async () => { try { await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "" }); const repoObj = selectedRepoId ? repos.find(r => r.id === selectedRepoId) || null : null; dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId, linked_repo: repoObj } }); } catch { } }, [state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, selectedRepoId, repos, dispatch]);
async function saveSettings() {
try {
await updateChat(state.token, chatId, {
model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId || "",
linked_repo_id: selectedRepoId || "",
linked_repo_branch: selectedBranch || "",
});
dispatch({ type: "UPDATE_CHAT", chat: {
id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId, linked_repo_branch: selectedBranch,
}});
} catch {}
}
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); setShowTools(false); }
function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); }
function toggleSettings() {
if (showSettings) saveSettings();
setShowSettings(!showSettings);
}
function handleFileSelect(e) {
const files = Array.from(e.target.files || []);
setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
e.target.value = "";
}
const handleSend = useCallback(async () => {
const raw = input.trim(); if ((!raw && !pendingFiles.length) || streamData.streaming) return;
const text = raw || "Please analyze the attached file(s).";
const content = uiDesign ? UI_DESIGN_PREFIX + text : text;
function removePending(i) {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
}
async function handleCreateKBFromRepo() {
if (!selectedRepoId || creatingKB) return;
setCreatingKB(true);
try {
const res = await createKBFromRepo(state.token, selectedRepoId, { branch: selectedBranch || undefined });
alert(`KB "${res.name}" created! It's being populated in the background. Check the Knowledge page.`);
// Refresh KB list
try { setKbs(await listKnowledgeBases(state.token)); } catch {}
} catch (err) {
alert(`Failed: ${err.message}`);
}
setCreatingKB(false);
}
async function handleSend() {
const content = input.trim();
if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = [];
if (pendingFiles.length) { setUploading(true); try { const res = await uploadAttachments(state.token, chatId, pendingFiles.map(p => p.file)); uploaded = (res.attachments || []).filter(a => !a.error); attIds = uploaded.map(a => a.id); } catch (e) { setUploading(false); alert(e.message); return; } setUploading(false); }
if (pendingFiles.length) {
setUploading(true);
try {
const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
uploaded = (res.attachments || []).filter((a) => !a.error);
attIds = uploaded.map((a) => a.id);
} catch (err) { console.error(err); setUploading(false); return; }
setUploading(false);
}
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
if (inputRef.current) inputRef.current.style.height = "auto";
streamManager.startStream({ token: state.token, chatId, body: { content, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds, web_search: webSearch } });
}, [input, pendingFiles, streamData.streaming, state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, webSearch, uiDesign, dispatch]);
setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]);
autoScroll.current = true;
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId, linked_repo_branch: selectedBranch } });
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
}
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []).filter(i => i.kind === "file"); if (!items.length) return; e.preventDefault(); addFiles(items.map(i => i.getAsFile()).filter(Boolean)); }
function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = Array.from(e.dataTransfer?.files || []); if (files.length) addFiles(files); }
const lastAssistantContent = messages.filter(m => m.role === "assistant").pop()?.content;
function handlePaste(e) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return;
e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
}
async function handleExport(type) {
if (!lastAssistantContent) return;
setExporting(type); setShowTools(false);
try {
if (type === "pptx") await exportPptx(state.token, lastAssistantContent, currentChat?.title);
else if (type === "docx") await exportDocx(state.token, lastAssistantContent, currentChat?.title);
} catch (e) { alert(`Export failed: ${e.message}`); }
setExporting("");
function handleDrop(e) {
e.preventDefault();
const files = Array.from(e.dataTransfer?.files || []);
if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
}
const streaming = streamData.streaming;
const linkedRepo = currentChat?.linked_repo;
const handleCommitFromChat = useCallback(async (filePath, code, action) => {
if (!linkedRepo) return;
const msg = prompt("Commit message:", `${action === "create" ? "Create" : "Update"} ${filePath} via Son of Anton`);
if (!msg) return;
try { await gitlabCommitSingle(state.token, linkedRepo.id, { branch: linkedRepo.default_branch, file_path: filePath, content: code, commit_message: msg, action }); try { await refreshRepoContext(state.token, chatId); } catch { } } catch (e) { alert(`❌ ${e.message}`); throw e; }
}, [linkedRepo, state.token, chatId]);
const canAttach = perms.can_use_attachments;
const canWebSearch = perms.can_use_web_search;
const canUiDesign = perms.can_use_ui_design;
const canExportPptx = perms.can_export_pptx;
const canExportDocx = perms.can_export_docx;
const canKB = perms.can_use_knowledge_base;
const canGitlab = perms.can_use_gitlab || isSuperadmin;
const hasAnyTool = canWebSearch || canUiDesign || canExportPptx || canExportDocx;
const selectedRepo = repos.find(r => r.id === selectedRepoId);
return (
<div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={e => { e.preventDefault(); setDragOver(true); }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}>
{dragOver && canAttach && <div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"><div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div></div>}
{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 flex-wrap">
<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>
</div>
)}
{uiDesign && (
<div className="px-3 py-1.5 bg-blue-500/10 border-b border-blue-500/20 flex items-center gap-2 text-xs">
<Paintbrush size={12} className="text-blue-400" /><span className="text-blue-300 font-medium">UI Design Mode</span><span className="text-blue-300/60">— Generating previewable HTML designs</span>
<button onClick={() => setUiDesign(false)} className="ml-auto text-blue-400/60 hover:text-blue-300 transition"><X size={12} /></button>
</div>
)}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3">
{messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} chatId={chatId} />)}
{streaming && (streamData.thinking || streamData.text) && <MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />}
<div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1">{[0, 150, 300].map(d => <span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}</div>
<span className="text-anton-muted text-sm">{uiDesign ? "Designing UI…" : webSearch ? "Searching web & thinking…" : linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span>
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<div className="flex gap-1">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
</div>
)}
</div>
<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 p-4">
{showSettings && (
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<div className="flex items-center justify-between"><h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3><button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button></div>
<div><label className="text-xs text-anton-muted mb-1 block">Model</label><select value={model} onChange={e => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">{MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}</select></div>
<div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}{maxTokensCap < 65536 && <span className="text-anton-muted"> / {maxTokensCap.toLocaleString()}</span>}</span></div><input type="range" min={256} max={maxTokensCap} step={256} value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} /></div>
{maxReasoningCap > 0 && (
<div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}{maxReasoningCap < 32000 && <span className="text-anton-muted"> / {maxReasoningCap.toLocaleString()}</span>}</span></div><input type="range" min={0} max={maxReasoningCap} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} /></div>
)}
{canKB && (
<div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label><select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"><option value="">None</option>{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}</select></div>
)}
<div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in max-h-[60vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div>
<label className="text-xs text-anton-muted mb-1 block">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
</div>
{/* ═══ GitLab Repo + Branch ═══ */}
{canGitlab && 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}</option>)}</select></div>
<>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><FolderGit2 size={12} className="text-orange-400" /> Repository</label>
<select value={selectedRepoId || ""} onChange={(e) => { setSelectedRepoId(e.target.value || null); setSelectedBranch(""); setBranches([]); }}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{repos.map((r) => <option key={r.id} value={r.id}>{r.name} ({r.path_with_namespace})</option>)}
</select>
</div>
{selectedRepoId && branches.length > 0 && (
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-green-400" /> Branch</label>
<select value={selectedBranch} onChange={(e) => setSelectedBranch(e.target.value)}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
{branches.map((b) => <option key={b.name} value={b.name}>{b.name}{b.default ? " (default)" : ""}</option>)}
</select>
</div>
)}
{selectedRepoId && (
<button onClick={handleCreateKBFromRepo} disabled={creatingKB}
className="w-full flex items-center justify-center gap-2 bg-orange-500/10 border border-orange-500/30 text-orange-400 rounded-lg px-3 py-2 text-xs font-medium hover:bg-orange-500/20 transition disabled:opacity-40">
{creatingKB ? <Loader2 size={14} className="animate-spin" /> : <Database size={14} />}
{creatingKB ? "Creating KB…" : "Create KB from Repo"}
</button>
)}
</>
)}
</div>
)}
{pendingFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => {
const Icon = TYPE_ICONS[pf.type] || FileText; return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" loading="lazy" /> : (<div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1"><Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} /><span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span></div>)}
<button onClick={() => removePending(i)} className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div>
</div>
);
})}
<div className="mb-3 flex flex-wrap gap-2 animate-fade-in">
{pendingFiles.map((pf, i) => (
<div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden">
{pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-16 h-16 object-cover" />
) : (
<div className="w-16 h-16 flex flex-col items-center justify-center px-1">
<FileText size={20} className="text-anton-muted mb-1" />
<span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
</div>
)}
<button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">{(pf.file.size / 1024).toFixed(0)}KB</div>
</div>
))}
</div>
)}
<div className="flex items-end gap-1.5">
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
{hasAnyTool && (
<div className="relative">
<button onClick={() => { setShowTools(!showTools); setShowSettings(false); }} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showTools ? "bg-blue-500/20 text-blue-400" : (webSearch || uiDesign) ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Tools"><Wand2 size={18} /></button>
{showTools && (
<div className="absolute bottom-full left-0 mb-2 w-56 bg-anton-card border border-anton-border rounded-xl shadow-2xl p-2 space-y-1 animate-fade-in z-30">
<p className="text-[10px] text-anton-muted px-2 py-1 uppercase tracking-wider font-semibold">Tools</p>
{canUiDesign && (
<button onClick={() => setUiDesign(!uiDesign)} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${uiDesign ? "bg-blue-500/15 text-blue-400" : "text-white hover:bg-anton-bg"}`}>
<span className="flex items-center gap-2"><Paintbrush size={15} /> UI Design</span>
<div className={`w-8 h-4.5 rounded-full transition-colors ${uiDesign ? "bg-blue-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${uiDesign ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
</button>
)}
{canWebSearch && (
<button onClick={() => setWebSearch(!webSearch)} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${webSearch ? "bg-green-500/15 text-green-400" : "text-white hover:bg-anton-bg"}`}>
<span className="flex items-center gap-2"><Globe size={15} /> Web Search</span>
<div className={`w-8 h-4.5 rounded-full transition-colors ${webSearch ? "bg-green-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${webSearch ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
</button>
)}
{(canExportPptx || canExportDocx) && <hr className="border-anton-border" />}
{canExportPptx && (
<button onClick={() => handleExport("pptx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
{exporting === "pptx" ? <Loader2 size={15} className="animate-spin" /> : <Presentation size={15} className="text-orange-400" />} Download PPTX
</button>
)}
{canExportDocx && (
<button onClick={() => handleExport("docx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
{exporting === "docx" ? <Loader2 size={15} className="animate-spin" /> : <FileOutput size={15} className="text-blue-400" />} Download DOCX
</button>
)}
</div>
)}
</div>
)}
{canAttach && (
<>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
</>
)}
<div className="flex-1 min-w-0">
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={uiDesign ? "Describe a UI design…" : webSearch ? "Search the web & ask…" : pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"} rows={1} style={{ maxHeight: "120px" }} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug" onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; }} />
<div className="flex items-end gap-2">
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.dart,.vue,.svelte,.log" onChange={handleFileSelect} />
<div className="flex-1 relative">
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"}
rows={1} style={{ maxHeight: "200px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
</div>
{streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || uploading} className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30">
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div>
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<span>{MODELS.find(m => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tok</span>
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted flex-wrap">
<span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tokens</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{uiDesign && <><span></span><span className="text-blue-400">🎨 UI Design</span></>}
{webSearch && <><span></span><span className="text-green-400">🌐 Web</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
{messages.some(m => m.role === "assistant") && <button onClick={async () => { const all = messages.filter(m => m.role === "assistant").map(m => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button>}
{selectedRepoId && <><span></span><span className="text-orange-400">🔗 {selectedRepo?.name || "Repo"}{selectedBranch ? `:${selectedBranch}` : ""}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span></>}
{messages.some((m) => m.role === "assistant") && (
<button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch {} }}
className="ml-auto hover:text-anton-accent transition">⬇ Download code</button>
)}
</div>
</div>
</div>
......
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