Commit 3c51ab07 authored by Mahmoud Aglan's avatar Mahmoud Aglan

lol

parent 1cd1b7d6
...@@ -42,14 +42,13 @@ def _run_migrations(): ...@@ -42,14 +42,13 @@ def _run_migrations():
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0")) conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
if "linked_repo_id" not in columns: if "linked_repo_id" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN linked_repo_id VARCHAR(36)")) 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() conn.commit()
if "chat_attachments" not in existing_tables: if "chat_attachments" not in existing_tables:
from backend.models import ChatAttachment from backend.models import ChatAttachment
ChatAttachment.__table__.create(bind=engine, checkfirst=True) ChatAttachment.__table__.create(bind=engine, checkfirst=True)
# Create user_permissions table if missing
if "user_permissions" not in existing_tables: if "user_permissions" not in existing_tables:
from backend.models import UserPermissions from backend.models import UserPermissions
UserPermissions.__table__.create(bind=engine, checkfirst=True) UserPermissions.__table__.create(bind=engine, checkfirst=True)
......
...@@ -54,6 +54,7 @@ class UserPermissions(Base): ...@@ -54,6 +54,7 @@ class UserPermissions(Base):
unique=True, nullable=False, index=True, unique=True, nullable=False, index=True,
) )
# Feature access
can_use_web_search = Column(Boolean, default=False) can_use_web_search = Column(Boolean, default=False)
can_use_ui_design = Column(Boolean, default=False) can_use_ui_design = Column(Boolean, default=False)
can_use_knowledge_base = Column(Boolean, default=True) can_use_knowledge_base = Column(Boolean, default=True)
...@@ -62,8 +63,10 @@ class UserPermissions(Base): ...@@ -62,8 +63,10 @@ class UserPermissions(Base):
can_export_pptx = Column(Boolean, default=True) can_export_pptx = Column(Boolean, default=True)
can_export_docx = 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") 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_tokens_cap = Column(Integer, default=4096)
max_reasoning_budget = Column(Integer, default=0) max_reasoning_budget = Column(Integer, default=0)
max_chats = Column(Integer, default=50) max_chats = Column(Integer, default=50)
...@@ -87,7 +90,6 @@ class Chat(Base): ...@@ -87,7 +90,6 @@ class Chat(Base):
model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1") model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
knowledge_base_id = Column(String(36), nullable=True) knowledge_base_id = Column(String(36), nullable=True)
linked_repo_id = Column(String(36), nullable=True) linked_repo_id = Column(String(36), nullable=True)
linked_repo_branch = Column(String(100), nullable=True)
max_tokens = Column(Integer, default=4096) max_tokens = Column(Integer, default=4096)
reasoning_budget = Column(Integer, default=0) reasoning_budget = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
......
...@@ -28,7 +28,6 @@ class CreateChatBody(BaseModel): ...@@ -28,7 +28,6 @@ class CreateChatBody(BaseModel):
model: str = "eu.anthropic.claude-opus-4-6-v1" model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None linked_repo_id: Optional[str] = None
linked_repo_branch: Optional[str] = None
max_tokens: int = 4096 max_tokens: int = 4096
reasoning_budget: int = 0 reasoning_budget: int = 0
...@@ -40,7 +39,6 @@ class UpdateChatBody(BaseModel): ...@@ -40,7 +39,6 @@ class UpdateChatBody(BaseModel):
reasoning_budget: Optional[int] = None reasoning_budget: Optional[int] = None
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
linked_repo_id: Optional[str] = None linked_repo_id: Optional[str] = None
linked_repo_branch: Optional[str] = None
class SendMessageBody(BaseModel): class SendMessageBody(BaseModel):
...@@ -87,7 +85,6 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db ...@@ -87,7 +85,6 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db
model=check_model_allowed(user.id, body.model, db), model=check_model_allowed(user.id, body.model, db),
knowledge_base_id=body.knowledge_base_id or None, knowledge_base_id=body.knowledge_base_id or None,
linked_repo_id=body.linked_repo_id or None, 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)), max_tokens=min(body.max_tokens, perms.get("max_tokens_cap", 4096)),
reasoning_budget=min(body.reasoning_budget, perms.get("max_reasoning_budget", 0)), reasoning_budget=min(body.reasoning_budget, perms.get("max_reasoning_budget", 0)),
) )
...@@ -119,8 +116,6 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur ...@@ -119,8 +116,6 @@ 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"): if body.linked_repo_id and not perms.get("can_use_gitlab"):
raise HTTPException(403, "GitLab not enabled.") raise HTTPException(403, "GitLab not enabled.")
chat.linked_repo_id = body.linked_repo_id or None 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() db.commit()
return _chat_dict(chat, db) return _chat_dict(chat, db)
...@@ -210,6 +205,11 @@ async def commit_from_chat( ...@@ -210,6 +205,11 @@ async def commit_from_chat(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), 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) check_feature(user.id, "use_gitlab", db)
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
...@@ -226,44 +226,73 @@ async def commit_from_chat( ...@@ -226,44 +226,73 @@ async def commit_from_chat(
if not settings or not settings.is_active: if not settings or not settings.is_active:
raise HTTPException(400, "GitLab not configured") raise HTTPException(400, "GitLab not configured")
# ── Fetch repo tree to know which files already exist ──
existing_paths = set() existing_paths = set()
try: try:
tree = await gitlab_service.get_tree( tree = await gitlab_service.get_tree(
settings.gitlab_url, settings.gitlab_token, settings.gitlab_url,
repo.gitlab_project_id, ref=body.branch, recursive=True, 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: 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 pass
# ── Build actions with auto-detected create/update ──
actions = [] actions = []
for f in body.files: for f in body.files:
file_path = f.get("file_path", "") file_path = f.get("file_path", "")
content = f.get("content", "") content = f.get("content", "")
requested_action = f.get("action", "auto") requested_action = f.get("action", "auto")
if not file_path or not content: if not file_path or not content:
continue continue
file_exists = file_path in existing_paths file_exists = file_path in existing_paths
# Smart action resolution
if requested_action in ("auto", "upsert"): if requested_action in ("auto", "upsert"):
# Auto-detect: update if exists, create if not
actual_action = "update" if file_exists else "create" actual_action = "update" if file_exists else "create"
elif requested_action == "update" and not file_exists: elif requested_action == "update" and not file_exists:
# User said update but file doesn't exist → create instead
actual_action = "create" actual_action = "create"
elif requested_action == "create" and file_exists: elif requested_action == "create" and file_exists:
# User said create but file already exists → update instead
actual_action = "update" actual_action = "update"
else: else:
actual_action = requested_action 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: if not actions:
raise HTTPException(400, "No valid files to commit") raise HTTPException(400, "No valid files to commit")
try: try:
result = await gitlab_service.commit_files( result = await gitlab_service.commit_files(
settings.gitlab_url, settings.gitlab_token, settings.gitlab_url,
repo.gitlab_project_id, body.branch, body.commit_message, actions, settings.gitlab_token,
repo.gitlab_project_id,
body.branch,
body.commit_message,
actions,
) )
gen_manager.invalidate_repo_cache(repo.id) 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: except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Commit failed: {e.detail}") raise HTTPException(e.status_code, f"Commit failed: {e.detail}")
...@@ -285,7 +314,6 @@ def _chat_dict(c, db=None): ...@@ -285,7 +314,6 @@ def _chat_dict(c, db=None):
"id": c.id, "title": c.title, "model": c.model, "id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id, "knowledge_base_id": c.knowledge_base_id,
"linked_repo_id": c.linked_repo_id, "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, "max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0, "reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at), "created_at": str(c.created_at),
......
""" """
GitLab CE integration routes — superadmin + user-facing. GitLab CE integration routes — superadmin only.
Son of Anton v4.2.0 Son of Anton v4.2.0
""" """
...@@ -13,9 +13,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query ...@@ -13,9 +13,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import User, GitLabSettings, LinkedRepo, PendingAction, KnowledgeBase, KnowledgeDocument from backend.models import User, GitLabSettings, LinkedRepo, PendingAction
from backend.auth import require_superadmin, get_current_user, check_feature from backend.auth import require_superadmin
from backend.services import gitlab_service, code_analyzer, rag_service from backend.services import gitlab_service, code_analyzer
router = APIRouter() router = APIRouter()
...@@ -64,10 +64,6 @@ class ActionBody(BaseModel): ...@@ -64,10 +64,6 @@ class ActionBody(BaseModel):
title: str = "" title: str = ""
payload: str payload: str
class CreateKBFromRepoBody(BaseModel):
branch: Optional[str] = None
name: Optional[str] = None
def _get_settings(db: Session) -> GitLabSettings: def _get_settings(db: Session) -> GitLabSettings:
s = db.query(GitLabSettings).first() s = db.query(GitLabSettings).first()
...@@ -350,10 +346,13 @@ async def create_branch(repo_id: str, body: BranchBody, admin: User = Depends(re ...@@ -350,10 +346,13 @@ async def create_branch(repo_id: str, body: BranchBody, admin: User = Depends(re
@router.post("/repos/{repo_id}/commit") @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)): 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.""" """
Commit multiple files. Auto-detects create vs update per file.
"""
s = _get_settings(db) s = _get_settings(db)
repo = _get_repo(repo_id, db) repo = _get_repo(repo_id, db)
# Fetch tree to know which files exist
existing_paths = set() existing_paths = set()
try: try:
tree = await gitlab_service.get_tree( tree = await gitlab_service.get_tree(
...@@ -369,9 +368,12 @@ async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(requ ...@@ -369,9 +368,12 @@ async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(requ
file_path = a.get("file_path", "") file_path = a.get("file_path", "")
content = a.get("content", "") content = a.get("content", "")
requested = a.get("action", "auto") requested = a.get("action", "auto")
if not file_path: if not file_path:
continue continue
file_exists = file_path in existing_paths file_exists = file_path in existing_paths
if requested in ("auto", "upsert"): if requested in ("auto", "upsert"):
actual = "update" if file_exists else "create" actual = "update" if file_exists else "create"
elif requested == "update" and not file_exists: elif requested == "update" and not file_exists:
...@@ -380,7 +382,12 @@ async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(requ ...@@ -380,7 +382,12 @@ async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(requ
actual = "update" actual = "update"
else: else:
actual = requested actual = requested
resolved_actions.append({"action": actual, "file_path": file_path, "content": content})
resolved_actions.append({
"action": actual,
"file_path": file_path,
"content": content,
})
if not resolved_actions: if not resolved_actions:
raise HTTPException(400, "No valid files to commit") raise HTTPException(400, "No valid files to commit")
...@@ -402,10 +409,13 @@ async def commit_single( ...@@ -402,10 +409,13 @@ async def commit_single(
admin: User = Depends(require_superadmin), admin: User = Depends(require_superadmin),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Commit a single file. Auto-detects create vs update.""" """
Commit a single file. Auto-detects create vs update.
"""
s = _get_settings(db) s = _get_settings(db)
repo = _get_repo(repo_id, db) repo = _get_repo(repo_id, db)
# Auto-detect whether file exists
action = body.action action = body.action
if action in ("update", "create", "auto"): if action in ("update", "create", "auto"):
try: try:
...@@ -537,153 +547,6 @@ def reject_action(action_id: str, admin: User = Depends(require_superadmin), db: ...@@ -537,153 +547,6 @@ def reject_action(action_id: str, admin: User = Depends(require_superadmin), db:
return {"ok": True} return {"ok": True}
# ═══════════════════════════════════════════════════
# USER-FACING ENDPOINTS (permission-gated, not superadmin-only)
# ═══════════════════════════════════════════════════
@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.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:
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("/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),
):
"""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)
repo = _get_repo(repo_id, db)
s = _get_settings(db)
branch = body.branch or repo.default_branch
kb_name = body.name or f"Repo: {repo.name} ({branch})"
kb = KnowledgeBase(
user_id=user.id,
name=kb_name,
description=f"Auto-generated from {repo.path_with_namespace} branch:{branch} — processing...",
)
db.add(kb)
db.commit()
db.refresh(kb)
rag_service.create_collection(kb.id)
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"}
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:
result = await gitlab_service.load_project_files(
gitlab_url, gitlab_token, project_id, ref=branch,
)
files = result.get("files", [])
if not files:
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
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
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" ✅ 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:
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
finally:
db.close()
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
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
# Helpers # Helpers
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
......
""" """
Background generation manager — v4.2.0 with web search + branch-aware repo context. Background generation manager — v4.1.0 with web search.
""" """
import asyncio import asyncio
...@@ -47,9 +47,6 @@ class GenerationManager: ...@@ -47,9 +47,6 @@ class GenerationManager:
state = self._active.get(chat_id) state = self._active.get(chat_id)
return state is not None and not state.done.is_set() return state is not None and not state.done.is_set()
def get_state(self, chat_id: str) -> Optional[GenerationState]:
return self._active.get(chat_id)
def start(self, chat_id, user_id, content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False): 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) old = self._active.get(chat_id)
if old and not old.done.is_set(): if old and not old.done.is_set():
...@@ -82,27 +79,25 @@ class GenerationManager: ...@@ -82,27 +79,25 @@ class GenerationManager:
if not repo: return None if not repo: return None
settings = db.query(GitLabSettings).first() settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active: return None if not settings or not settings.is_active: return None
branch = getattr(chat, 'linked_repo_branch', None) or repo.default_branch
try: try:
tree = _get_tree_cache(repo.id, branch) tree = _get_tree_cache(repo.id, repo.default_branch)
if tree is None: if tree is None:
tree = await gitlab_service.get_tree(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, ref=branch, recursive=True) 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, branch, tree) _set_tree_cache(repo.id, repo.default_branch, tree)
prev = _chat_file_history.get(chat.id, set()) 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=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=repo.default_branch, tree=tree, user_query=user_query, previous_files=prev)
loaded = set() loaded = set()
for f in result["priority_files"]: loaded.add(f["path"]) for f in result["priority_files"]: loaded.add(f["path"])
for f in result["query_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() if chat.id not in _chat_file_history: _chat_file_history[chat.id] = set()
_chat_file_history[chat.id].update(loaded) _chat_file_history[chat.id].update(loaded)
return self._format_smart_context(result, tree, repo, db, branch) return self._format_smart_context(result, tree, repo, db)
except Exception as e: except Exception as e:
return f"[Repository: {repo.name} — error: {str(e)[:200]}]" return f"[Repository: {repo.name} — error: {str(e)[:200]}]"
def _format_smart_context(self, result, tree, repo, db, branch=None): def _format_smart_context(self, result, tree, repo, db):
files_in_tree = sorted([i["path"] for i in tree if i["type"] == "blob"]) files_in_tree = sorted([i["path"] for i in tree if i["type"] == "blob"])
effective_branch = branch or repo.default_branch lines = [f"Repository: {repo.name}", f"Branch: {repo.default_branch}", f"Files loaded: {result['files_loaded']}", f"Characters: {result['total_characters']:,}"]
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": if repo.architecture_map and repo.map_status == "ready":
lines.append(""); lines.append(repo.architecture_map); lines.append("") lines.append(""); lines.append(repo.architecture_map); lines.append("")
lines.append("═" * 50); lines.append("FILE TREE:"); lines.append("═" * 50) lines.append("═" * 50); lines.append("FILE TREE:"); lines.append("═" * 50)
......
{ {
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": { "dependencies": {
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
......
const BASE = "/api"; const BASE = "/api";
function headers(token) { function headers(token) {
const h = { "Content-Type": "application/json" }; const h = { "Content-Type": "application/json" };
if (token) h["Authorization"] = `Bearer ${token}`; if (token) h["Authorization"] = `Bearer ${token}`;
return h; return h;
} }
function authHeader(token) { return token ? { Authorization: `Bearer ${token}` } : {}; }
function authHeader(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); }
return token ? { Authorization: `Bearer ${token}` } : {};
} async function request(method, path, token, body) {
const opts = { method, headers: headers(token) };
async function request(method, path, token, body) { if (body) opts.body = JSON.stringify(body);
const opts = { method, headers: headers(token) }; const res = await fetch(`${BASE}${path}`, opts);
if (body) opts.body = JSON.stringify(body); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "Request failed")); }
const res = await fetch(`${BASE}${path}`, opts); return res.json();
if (!res.ok) { }
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || err.message || "Request failed"); // Auth
} export const login = (u, p) => request("POST", "/auth/login", null, { username: u, password: p });
return res.json(); 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);
export const login = (username, password) => // Chats
request("POST", "/auth/login", null, { username, password }); export const listChats = (t) => request("GET", "/chats", t);
export const createChat = (t, d = {}) => request("POST", "/chats", t, d);
export const register = (username, email, password) => export const updateChat = (t, id, d) => request("PUT", `/chats/${id}`, t, d);
request("POST", "/auth/register", null, { username, email, password }); export const renameChat = (t, id, title) => updateChat(t, id, { title });
export const deleteChat = (t, id) => request("DELETE", `/chats/${id}`, t);
export const getMe = (token) => request("GET", "/auth/me", token); export const getMessages = (t, id) => request("GET", `/chats/${id}/messages`, t);
export const checkGenerating = (t, id) => request("GET", `/chats/${id}/generating`, t);
export const listChats = (token) => request("GET", "/chats", token); 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);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
// Streaming
export const updateChat = (token, chatId, data) => export async function* streamMessage(token, chatId, body, signal) {
request("PUT", `/chats/${chatId}`, token, data); 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")); }
export const renameChat = (token, chatId, title) => const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
updateChat(token, chatId, { title }); 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 const deleteChat = (token, chatId) => }
request("DELETE", `/chats/${chatId}`, token);
// Attachments
export const getMessages = (token, chatId) => 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(); }
request("GET", `/chats/${chatId}/messages`, token); export function getAttachmentUrl(id) { return `${BASE}/attachments/${id}/file`; }
export const deleteAttachment = (t, id) => request("DELETE", `/attachments/${id}`, t);
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { // Knowledge
method: "POST", headers: headers(token), export const listKnowledgeBases = (t) => request("GET", "/knowledge", t);
body: JSON.stringify(body), signal, export const createKnowledgeBase = (t, n, d = "") => request("POST", "/knowledge", t, { name: n, description: d });
}); export const getKnowledgeBase = (t, id) => request("GET", `/knowledge/${id}`, t);
if (!res.ok) { export const updateKnowledgeBase = (t, id, d) => request("PUT", `/knowledge/${id}`, t, d);
const err = await res.json().catch(() => ({ detail: res.statusText })); export const deleteKnowledgeBase = (t, id) => request("DELETE", `/knowledge/${id}`, t);
throw new Error(err.detail || "Stream failed"); export const listKnowledgeDocuments = (t, id) => request("GET", `/knowledge/${id}/documents`, t);
} export const deleteKnowledgeDocument = (t, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, t);
const reader = res.body.getReader(); 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(); }
const decoder = new TextDecoder(); export const uploadDocument = (t, kbId, f) => uploadDocuments(t, kbId, [f]);
let buffer = "";
while (true) { // Admin
const { done, value } = await reader.read(); export const adminStats = (t) => request("GET", "/admin/stats", t);
if (done) break; export const adminListUsers = (t) => request("GET", "/admin/users", t);
buffer += decoder.decode(value, { stream: true }); export const adminCreateUser = (t, d) => request("POST", "/admin/users", t, d);
const parts = buffer.split("\n\n"); export const adminUpdateUser = (t, id, d) => request("PUT", `/admin/users/${id}`, t, d);
buffer = parts.pop() || ""; export const adminDeleteUser = (t, id) => request("DELETE", `/admin/users/${id}`, t);
for (const part of parts) { export const adminListChats = (t) => request("GET", "/admin/chats", t);
const line = part.trim();
if (line.startsWith("data: ")) { // Admin — Permissions
try { yield JSON.parse(line.slice(6)); } catch { } 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);
if (buffer.trim().startsWith("data: ")) { export const adminApplyDefaults = (t) => request("POST", "/admin/permissions/apply-defaults", t);
try { yield JSON.parse(buffer.trim().slice(6)); } catch { } 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 async function uploadAttachments(token, chatId, files) {
const form = new FormData(); // Export PPTX / DOCX
for (const file of files) form.append("files", file); export async function exportPptx(token, markdown, title) {
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, { const res = await fetch(`${BASE}/export/pptx`, { method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title }) });
method: "POST", headers: authHeader(token), body: form, 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;
if (!res.ok) { const safe = (title || "presentation").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 50) || "presentation";
const err = await res.json().catch(() => ({})); a.download = `${safe}.pptx`; a.click(); URL.revokeObjectURL(url);
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")); }
export function getAttachmentUrl(attachmentId) { const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url;
return `${BASE}/attachments/${attachmentId}/file`; 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 deleteAttachment = (token, attachmentId) =>
request("DELETE", `/attachments/${attachmentId}`, token); // Utilities
const CODE_BLOCK_RE = /```(\S*?)(?::(\S+?))?\s*?\n([\s\S]*?)```/g;
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token); 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; }
export const createKnowledgeBase = (token, name, description = "") => // GitLab
request("POST", "/knowledge", token, { name, description }); export const gitlabGetSettings = (t) => request("GET", "/gitlab/settings", t);
export const gitlabUpdateSettings = (t, d) => request("PUT", "/gitlab/settings", t, d);
export const getKnowledgeBase = (token, kbId) => export const gitlabTestConnection = (t) => request("POST", "/gitlab/test-connection", t);
request("GET", `/knowledge/${kbId}`, token); 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 deleteKnowledgeBase = (token, kbId) => export const gitlabListRepos = (t) => request("GET", "/gitlab/repos", t);
request("DELETE", `/knowledge/${kbId}`, token); 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 async function uploadDocuments(token, kbId, files) { export const gitlabGetTree = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/tree?path=${encodeURIComponent(p || "")}&ref=${encodeURIComponent(r || "")}`, t);
const form = new FormData(); export const gitlabGetFile = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/file?path=${encodeURIComponent(p)}&ref=${encodeURIComponent(r || "")}`, t);
for (const file of files) form.append("files", file); export const gitlabGetBranches = (t, id) => request("GET", `/gitlab/repos/${id}/branches`, t);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { export const gitlabCreateBranch = (t, id, d) => request("POST", `/gitlab/repos/${id}/branches`, t, d);
method: "POST", headers: authHeader(token), body: form, 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);
if (!res.ok) { export const gitlabCreateMR = (t, id, d) => request("POST", `/gitlab/repos/${id}/merge-request`, t, d);
const err = await res.json().catch(() => ({})); export const gitlabReanalyzeRepo = (t, id) => request("POST", `/gitlab/repos/${id}/analyze`, t);
throw new Error(err.detail || "Upload failed"); 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);
return res.json(); 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);
export const uploadDocument = (token, kbId, file) => \ No newline at end of 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);
}
}
// ═══════════════════════════════════════════════════
// 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);
// ═══════════════════════════════════════════════════
// Utilities & New Exports
// ═══════════════════════════════════════════════════
export function extractCodeBlocks(text) {
if (!text) return [];
const blocks = [];
const regex = /```([\w:.-]+)?\n([\s\S]*?)```/g;
let match;
while ((match = regex.exec(text)) !== null) {
const rawLang = match[1] || "";
let language = rawLang;
let filename = null;
if (rawLang.includes(":")) {
const parts = rawLang.split(":");
language = parts[0];
filename = parts.slice(1).join(":");
}
blocks.push({ language, filename, code: match[2].trim() });
}
return blocks;
}
export const commitFromChat = (token, chatId, data) =>
request("POST", `/chats/${chatId}/commit`, token, data);
export const exportPptx = async (token, markdown, title) => {
const res = await fetch(`${BASE}/export/pptx`, {
method: "POST",
headers: headers(token),
body: JSON.stringify({ markdown, title })
});
if (!res.ok) throw new Error("PPTX export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${title || "Presentation"}.pptx`;
a.click();
URL.revokeObjectURL(url);
};
export const exportDocx = async (token, markdown, title) => {
const res = await fetch(`${BASE}/export/docx`, {
method: "POST",
headers: headers(token),
body: JSON.stringify({ markdown, title })
});
if (!res.ok) throw new Error("DOCX export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${title || "Document"}.docx`;
a.click();
URL.revokeObjectURL(url);
};
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useApp } from "../store"; import { useApp, usePermissions } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, listLinkedRepos, listRepoBranches, createKBFromRepo } from "../api"; import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle, refreshRepoContext, exportPptx, exportDocx } from "../api";
import * as streamManager from "../streamManager"; import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, GitBranch, FolderGit2, Database } from "lucide-react"; 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";
const MODELS = [ const ALL_MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" }, { id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" }, { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" },
]; ];
const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
function classifyFile(file) { 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`;
const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || ""; 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"; }
if (mime.startsWith("image/") || ["jpg","jpeg","png","gif","webp","bmp"].includes(ext)) return "image"; function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; }
if (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 }) { export default function ChatView({ chatId }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const currentChat = state.chats.find((c) => c.id === chatId); const perms = usePermissions();
const currentChat = state.chats.find(c => c.id === chatId);
const messages = state.chatMessages[chatId] || []; const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId]; const isSuperadmin = state.user?.role === "superadmin";
const canGitlab = state.user?.permissions?.can_use_gitlab || 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 [input, setInput] = useState(""); const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id); const [showTools, setShowTools] = useState(false);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096); const [model, setModel] = useState(currentChat?.model || MODELS[0]?.id);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0); 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 [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null); const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [webSearch, setWebSearch] = useState(false);
const [uiDesign, setUiDesign] = useState(false);
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [exporting, setExporting] = useState("");
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId)); const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
// GitLab state const scrollRef = useRef(null); const inputRef = useRef(null); const fileRef = useRef(null); const autoScroll = useRef(true); const rafRef = useRef(null);
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);
useEffect(() => { const maxTokensCap = perms.max_tokens_cap || 4096;
setStreamData(streamManager.getStreamData(chatId)); const maxReasoningCap = perms.max_reasoning_budget || 0;
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]);
function onScroll() { useEffect(() => { setStreamData(streamManager.getStreamData(chatId)); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); }, [chatId]);
const el = scrollRef.current; const scrollBottom = useCallback(() => { if (!autoScroll.current || rafRef.current) return; rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; }); }, []);
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(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]); 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]);
async function saveSettings() { const onScroll = useCallback(() => { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }, []);
try { 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]);
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() { function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); setShowTools(false); }
if (showSettings) saveSettings(); function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
setShowSettings(!showSettings); function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); }
}
function handleFileSelect(e) {
const files = Array.from(e.target.files || []);
setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
e.target.value = "";
}
function removePending(i) { const handleSend = useCallback(async () => {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); 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;
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 = []; let attIds = [], uploaded = [];
if (pendingFiles.length) { 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); }
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 } }); dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
setInput(""); setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); }); if (inputRef.current) inputRef.current.style.height = "auto";
setPendingFiles([]); 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 } });
autoScroll.current = true; }, [input, pendingFiles, streamData.streaming, state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, webSearch, uiDesign, dispatch]);
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 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); }
function handlePaste(e) { const lastAssistantContent = messages.filter(m => m.role === "assistant").pop()?.content;
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) }; })]);
}
function handleDrop(e) { async function handleExport(type) {
e.preventDefault(); if (!lastAssistantContent) return;
const files = Array.from(e.dataTransfer?.files || []); setExporting(type); setShowTools(false);
if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); 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("");
} }
const streaming = streamData.streaming; const streaming = streamData.streaming;
const selectedRepo = repos.find(r => r.id === selectedRepoId); 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;
return ( return (
<div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}> <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); }}>
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4"> {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>}
{messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
{streaming && (streamData.thinking || streamData.text) && ( {linkedRepo && (
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} /> <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} />}
{streaming && !streamData.text && !streamData.thinking && ( {streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in"> <div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1"> <div className="flex gap-1">{[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="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> <span className="text-anton-muted text-sm">{uiDesign ? "Designing UI…" : webSearch ? "Searching web & thinking…" : linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span>
<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> </div>
<div className="border-t border-anton-border bg-anton-surface p-4"> <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">
{showSettings && ( {showSettings && (
<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="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between"><h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3><button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button></div>
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3> <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>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button> <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>
</div> {maxReasoningCap > 0 && (
<div> <div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}{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>
<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"> {canKB && (
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)} <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>
</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 && ( {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> </div>
)} )}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 animate-fade-in"> <div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => ( {pendingFiles.map((pf, i) => {
<div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden"> const Icon = TYPE_ICONS[pf.type] || FileText; return (
{pf.type === "image" && pf.preview ? ( <div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
<img src={pf.preview} alt="" className="w-16 h-16 object-cover" /> {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="w-16 h-16 flex flex-col items-center justify-center px-1"> <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>
<FileText size={20} className="text-anton-muted mb-1" /> </div>
<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>
)} )}
<div className="flex items-end gap-2"> <div className="flex items-end gap-1.5">
<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={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>
<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} /> {hasAnyTool && (
<div className="flex-1 relative"> <div className="relative">
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} <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>
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"} {showTools && (
rows={1} style={{ maxHeight: "200px" }} <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">
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" <p className="text-[10px] text-anton-muted px-2 py-1 uppercase tracking-wider font-semibold">Tools</p>
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} /> {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> </div>
{streaming ? ( {streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button> <button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
) : ( ) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading} <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">
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} />} {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted flex-wrap"> <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<span>{MODELS.find((m) => m.id === model)?.label}</span> <span>{MODELS.find(m => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tokens</span> <span></span><span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>} {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{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></>} {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{selectedRepoId && <><span></span><span className="text-orange-400">🔗 {selectedRepo?.name || "Repo"}{selectedBranch ? `:${selectedBranch}` : ""}</span></>} {linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span></>} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
{messages.some((m) => m.role === "assistant") && ( {messages.some(m => m.role === "assistant") && <button 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>}
<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> </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