Commit 5f1d3ebc authored by Mahmoud Aglan's avatar Mahmoud Aglan

new FeatureSet

parent 841414df
This source diff could not be displayed because it is too large. You can view the blob instead.
"""
Son of Anton v4.0.0 — Main FastAPI Application
Son of Anton v4.1.0 — Main FastAPI Application
"""
import time
......@@ -21,6 +21,7 @@ from backend.routes.knowledge_routes import router as knowledge_router
from backend.routes.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router
from backend.routes.gitlab_routes import router as gitlab_router
from backend.routes.export_routes import router as export_router
from backend.services.bedrock_service import close_http_client
APP_BUILD_TIME = str(int(time.time()))
......@@ -41,13 +42,11 @@ 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)"))
print(" Added chats.linked_repo_id column")
conn.commit()
if "chat_attachments" not in existing_tables:
from backend.models import ChatAttachment
ChatAttachment.__table__.create(bind=engine, checkfirst=True)
print(" Created chat_attachments table")
for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]:
if table_name not in existing_tables:
......@@ -58,15 +57,11 @@ def _run_migrations():
with engine.connect() as conn:
if "architecture_map" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN architecture_map TEXT"))
print(" Added linked_repos.architecture_map column")
if "map_status" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_status VARCHAR(20) DEFAULT 'none'"))
print(" Added linked_repos.map_status column")
if "map_generated_at" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_generated_at DATETIME"))
print(" Added linked_repos.map_generated_at column")
conn.commit()
except Exception as e:
print(f" Migration note: {e}")
......@@ -82,20 +77,9 @@ async def lifespan(app: FastAPI):
print("Son of Anton shutting down.")
app = FastAPI(
title="Son of Anton",
description="Avatar of All Elements of Code",
version=APP_VERSION,
lifespan=lifespan,
)
app = FastAPI(title="Son of Anton", description="Avatar of All Elements of Code", version=APP_VERSION, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
@app.middleware("http")
......@@ -104,16 +88,11 @@ async def add_cache_headers(request: Request, call_next):
path = request.url.path
if path.startswith("/api"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
response.headers["X-App-Version"] = APP_VERSION
response.headers["X-Build-Time"] = APP_BUILD_TIME
return response
......@@ -129,15 +108,12 @@ app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"]
app.include_router(files_router, prefix="/api/files", tags=["Files"])
app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
app.include_router(gitlab_router, prefix="/api/gitlab", tags=["GitLab"])
app.include_router(export_router, prefix="/api/export", tags=["Export"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if (FRONTEND_DIR / "assets").exists():
app.mount(
"/assets",
StaticFiles(directory=str(FRONTEND_DIR / "assets")),
name="static-assets",
)
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIR / "assets")), name="static-assets")
@app.get("/{full_path:path}", include_in_schema=False)
......@@ -153,7 +129,5 @@ async def serve_frontend(full_path: str):
if index.is_file():
resp = FileResponse(str(index))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
return {"message": "Son of Anton API is running. Frontend not built."}
\ No newline at end of file
"""
Chat CRUD and message streaming — v4.0.0 Enhanced
Project-aware conversations + commit-from-chat support.
Chat CRUD + message streaming — v4.1.0 with web search support.
"""
import json
......@@ -14,7 +13,7 @@ from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo, GitLabSettings
from backend.auth import get_current_user, require_superadmin
from backend.auth import get_current_user
from backend.services import attachment_service, gitlab_service
from backend.services.generation_manager import manager as gen_manager
......@@ -46,12 +45,13 @@ class SendMessageBody(BaseModel):
reasoning_budget: int = 0
knowledge_base_id: Optional[str] = None
attachment_ids: list[str] = []
web_search: bool = False
class CommitFromChatBody(BaseModel):
branch: str
commit_message: str
files: list[dict] # [{file_path, content, action}]
files: list[dict]
@router.get("")
......@@ -62,43 +62,28 @@ def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get
@router.post("")
def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = Chat(
user_id=user.id, title=body.title, model=body.model,
knowledge_base_id=body.knowledge_base_id or None,
linked_repo_id=body.linked_repo_id or None,
max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
)
db.add(chat)
db.commit()
db.refresh(chat)
chat = Chat(user_id=user.id, title=body.title, model=body.model, knowledge_base_id=body.knowledge_base_id or None, linked_repo_id=body.linked_repo_id or None, max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget)
db.add(chat); db.commit(); db.refresh(chat)
return _chat_dict(chat, db)
@router.get("/{chat_id}")
def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404, "Chat not found")
if not chat: raise HTTPException(404, "Chat not found")
return _chat_dict(chat, db)
@router.put("/{chat_id}")
def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404)
if body.title is not None:
chat.title = body.title
if body.model is not None:
chat.model = body.model
if body.max_tokens is not None:
chat.max_tokens = body.max_tokens
if body.reasoning_budget is not None:
chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None:
chat.knowledge_base_id = body.knowledge_base_id or None
if body.linked_repo_id is not None:
chat.linked_repo_id = body.linked_repo_id or None
if not chat: raise HTTPException(404)
if body.title is not None: chat.title = body.title
if body.model is not None: chat.model = body.model
if body.max_tokens is not None: chat.max_tokens = body.max_tokens
if body.reasoning_budget is not None: chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None: chat.knowledge_base_id = body.knowledge_base_id or None
if body.linked_repo_id is not None: chat.linked_repo_id = body.linked_repo_id or None
db.commit()
return _chat_dict(chat, db)
......@@ -106,19 +91,16 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
@router.delete("/{chat_id}")
def delete_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404)
if not chat: raise HTTPException(404)
attachment_service.delete_chat_attachments(chat_id)
db.delete(chat)
db.commit()
db.delete(chat); db.commit()
return {"ok": True}
@router.get("/{chat_id}/messages")
def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404)
if not chat: raise HTTPException(404)
msgs = []
for m in chat.messages:
d = _msg_dict(m)
......@@ -139,97 +121,51 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user))
async def empty():
yield _sse({"type": "done", "message_id": ""})
return StreamingResponse(empty(), media_type="text/event-stream")
async def generate():
async for event in gen_manager.stream_events(chat_id):
yield _sse(event)
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
user_id = user.id
gen_manager.start(
chat_id=chat_id,
user_id=user_id,
content=body.content,
chat_id=chat_id, user_id=user.id, content=body.content,
model=body.model or "eu.anthropic.claude-opus-4-6-v1",
max_tokens=body.max_tokens,
reasoning_budget=body.reasoning_budget,
max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
knowledge_base_id=body.knowledge_base_id,
attachment_ids=body.attachment_ids,
web_search=body.web_search,
)
async def generate():
async for event in gen_manager.stream_events(chat_id):
yield _sse(event)
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/{chat_id}/commit")
async def commit_from_chat(
chat_id: str,
body: CommitFromChatBody,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Commit code from chat directly to the linked repository."""
async def commit_from_chat(chat_id: str, body: CommitFromChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404, "Chat not found")
if not chat.linked_repo_id:
raise HTTPException(400, "No repository linked to this chat")
if not chat: raise HTTPException(404, "Chat not found")
if not chat.linked_repo_id: raise HTTPException(400, "No repository linked")
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo:
raise HTTPException(404, "Linked repository not found")
if not repo: raise HTTPException(404, "Linked repository not found")
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active:
raise HTTPException(400, "GitLab not configured")
# Build commit actions
actions = []
for f in body.files:
actions.append({
"action": f.get("action", "update"),
"file_path": f["file_path"],
"content": f["content"],
})
if not actions:
raise HTTPException(400, "No files to commit")
if not settings or not settings.is_active: raise HTTPException(400, "GitLab not configured")
actions = [{"action": f.get("action", "update"), "file_path": f["file_path"], "content": f["content"]} for f in body.files]
if not actions: raise HTTPException(400, "No 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,
)
# Invalidate repo context cache so next message sees updated code
result = await gitlab_service.commit_files(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}")
@router.post("/{chat_id}/refresh-repo")
async def refresh_repo_context(
chat_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Force-refresh the cached repo context for this chat."""
async def refresh_repo_context(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat or not chat.linked_repo_id:
raise HTTPException(400, "No repo linked")
if not chat or not chat.linked_repo_id: raise HTTPException(400, "No repo linked")
gen_manager.invalidate_repo_cache(chat.linked_repo_id)
return {"ok": True}
......@@ -239,39 +175,17 @@ def _sse(data):
def _chat_dict(c, db=None):
d = {
"id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id,
"linked_repo_id": c.linked_repo_id,
"max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at), "updated_at": str(c.updated_at),
}
d = {"id": c.id, "title": c.title, "model": c.model, "knowledge_base_id": c.knowledge_base_id, "linked_repo_id": c.linked_repo_id, "max_tokens": c.max_tokens or 4096, "reasoning_budget": c.reasoning_budget or 0, "created_at": str(c.created_at), "updated_at": str(c.updated_at)}
if db and c.linked_repo_id:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == c.linked_repo_id).first()
if repo:
d["linked_repo"] = {
"id": repo.id, "name": repo.name,
"path_with_namespace": repo.path_with_namespace,
"default_branch": repo.default_branch,
"web_url": repo.web_url,
"gitlab_project_id": repo.gitlab_project_id,
}
d["linked_repo"] = {"id": repo.id, "name": repo.name, "path_with_namespace": repo.path_with_namespace, "default_branch": repo.default_branch, "web_url": repo.web_url, "gitlab_project_id": repo.gitlab_project_id, "map_status": repo.map_status}
return d
def _msg_dict(m):
return {
"id": m.id, "role": m.role, "content": m.content,
"thinking_content": m.thinking_content,
"input_tokens": m.input_tokens, "output_tokens": m.output_tokens,
"created_at": str(m.created_at),
}
return {"id": m.id, "role": m.role, "content": m.content, "thinking_content": m.thinking_content, "input_tokens": m.input_tokens, "output_tokens": m.output_tokens, "created_at": str(m.created_at)}
def _att_brief(a):
return {
"id": a.id, "original_filename": a.original_filename,
"mime_type": a.mime_type, "file_type": a.file_type,
"file_size": a.file_size,
}
\ No newline at end of file
return {"id": a.id, "original_filename": a.original_filename, "mime_type": a.mime_type, "file_type": a.file_type, "file_size": a.file_size}
\ No newline at end of file
"""
Export routes — PPTX and DOCX generation from markdown.
"""
import re
from pydantic import BaseModel
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
import io
from backend.auth import get_current_user
from backend.models import User
from backend.services.pptx_service import generate_pptx
from backend.services.docx_service import generate_docx
router = APIRouter()
class ExportBody(BaseModel):
markdown: str
title: Optional[str] = None
def _safe_filename(title: str, ext: str) -> str:
if not title or title == "New Chat":
return f"document.{ext}"
safe = re.sub(r'[^\w\s-]', '', title).strip()
safe = re.sub(r'\s+', '-', safe)[:60] or "document"
return f"{safe}.{ext}"
@router.post("/pptx")
def export_pptx(body: ExportBody, user: User = Depends(get_current_user)):
title = body.title or "Presentation"
data = generate_pptx(body.markdown, title)
filename = _safe_filename(title, "pptx")
return StreamingResponse(
io.BytesIO(data),
media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/docx")
def export_docx(body: ExportBody, user: User = Depends(get_current_user)):
title = body.title or "Document"
data = generate_docx(body.markdown, title)
filename = _safe_filename(title, "docx")
return StreamingResponse(
io.BytesIO(data),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
\ No newline at end of file
"""
Professional DOCX Generation — clean, well-formatted Word documents.
"""
import io
import re
from docx import Document
from docx.shared import Pt, Inches, RGBColor, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def generate_docx(markdown: str, title: str = "Document") -> bytes:
doc = Document()
_setup_styles(doc)
_set_margins(doc)
sections = doc.sections
for section in sections:
footer = section.footer
footer.is_linked_to_previous = False
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run("Generated by Son of Anton")
run.font.size = Pt(8)
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
elements = _parse_markdown(markdown)
for el in elements:
etype = el["type"]
if etype == "heading":
level = min(el["level"], 4)
p = doc.add_heading(el["text"], level=level)
if level == 1:
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.runs[0] if p.runs else p.add_run(el["text"])
run.font.color.rgb = RGBColor(0xE5, 0x3E, 0x3E)
_add_bottom_border(p)
elif etype == "paragraph":
p = doc.add_paragraph()
_add_rich_text(p, el["text"])
elif etype == "bullet":
p = doc.add_paragraph(style="List Bullet")
_add_rich_text(p, el["text"])
elif etype == "numbered":
p = doc.add_paragraph(style="List Number")
_add_rich_text(p, el["text"])
elif etype == "code":
_add_code_block(doc, el["code"], el.get("lang", ""))
elif etype == "blockquote":
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.5)
p.paragraph_format.space_before = Pt(6)
p.paragraph_format.space_after = Pt(6)
run = p.add_run(el["text"])
run.font.italic = True
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
_add_left_border(p, "CC3333")
elif etype == "hr":
p = doc.add_paragraph()
_add_bottom_border(p, color="CCCCCC")
p.paragraph_format.space_before = Pt(12)
p.paragraph_format.space_after = Pt(12)
elif etype == "table":
_add_table(doc, el["rows"])
buf = io.BytesIO()
doc.save(buf)
buf.seek(0)
return buf.getvalue()
def _setup_styles(doc):
style = doc.styles["Normal"]
font = style.font
font.name = "Calibri"
font.size = Pt(11)
font.color.rgb = RGBColor(0x33, 0x33, 0x33)
pf = style.paragraph_format
pf.space_after = Pt(6)
pf.line_spacing = 1.15
for level in range(1, 5):
try:
hs = doc.styles[f"Heading {level}"]
hs.font.name = "Calibri"
hs.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E)
hs.font.bold = True
sizes = {1: 24, 2: 18, 3: 14, 4: 12}
hs.font.size = Pt(sizes.get(level, 12))
hs.paragraph_format.space_before = Pt(18 if level <= 2 else 12)
hs.paragraph_format.space_after = Pt(6)
except KeyError:
pass
def _set_margins(doc):
for section in doc.sections:
section.top_margin = Cm(2.5)
section.bottom_margin = Cm(2.5)
section.left_margin = Cm(2.5)
section.right_margin = Cm(2.5)
def _add_rich_text(para, text):
parts = re.split(r'(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*|\[[^\]]+\]\([^)]+\))', text)
for part in parts:
if not part:
continue
if part.startswith("**") and part.endswith("**"):
run = para.add_run(part[2:-2])
run.font.bold = True
elif part.startswith("*") and part.endswith("*"):
run = para.add_run(part[1:-1])
run.font.italic = True
elif part.startswith("`") and part.endswith("`"):
run = para.add_run(part[1:-1])
run.font.name = "Consolas"
run.font.size = Pt(9.5)
run.font.color.rgb = RGBColor(0xE5, 0x3E, 0x3E)
_shade_run(run, "F0F0F0")
elif part.startswith("[") and "](" in part:
match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', part)
if match:
run = para.add_run(match.group(1))
run.font.color.rgb = RGBColor(0x0E, 0x5E, 0xAD)
run.font.underline = True
else:
para.add_run(part)
else:
para.add_run(part)
def _shade_run(run, color_hex):
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), color_hex)
run._r.get_or_add_rPr().append(shd)
def _add_code_block(doc, code, lang=""):
tbl = doc.add_table(rows=1, cols=1)
tbl.alignment = WD_TABLE_ALIGNMENT.LEFT
cell = tbl.cell(0, 0)
_set_cell_shading(cell, "1A1B26")
cell.width = Inches(6)
if lang:
p_lang = cell.paragraphs[0]
run_lang = p_lang.add_run(lang.upper())
run_lang.font.size = Pt(8)
run_lang.font.color.rgb = RGBColor(0xE5, 0x3E, 0x3E)
run_lang.font.name = "Consolas"
run_lang.font.bold = True
p_lang.paragraph_format.space_after = Pt(4)
p = cell.add_paragraph()
else:
p = cell.paragraphs[0]
lines = code.split("\n")
for i, line in enumerate(lines):
if i > 0:
p.add_run("\n")
run = p.add_run(line)
run.font.name = "Consolas"
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0xD4, 0xD4, 0xD4)
p.paragraph_format.space_before = Pt(2)
p.paragraph_format.space_after = Pt(2)
for row in tbl.rows:
for c in row.cells:
for paragraph in c.paragraphs:
paragraph.paragraph_format.line_spacing = 1.2
def _set_cell_shading(cell, color_hex):
shading = OxmlElement("w:shd")
shading.set(qn("w:val"), "clear")
shading.set(qn("w:color"), "auto")
shading.set(qn("w:fill"), color_hex)
cell._tc.get_or_add_tcPr().append(shading)
def _add_table(doc, rows):
if not rows:
return
n_cols = max(len(r) for r in rows)
tbl = doc.add_table(rows=len(rows), cols=n_cols)
tbl.style = "Table Grid"
tbl.alignment = WD_TABLE_ALIGNMENT.LEFT
for i, row_data in enumerate(rows):
for j, cell_text in enumerate(row_data):
if j < n_cols:
cell = tbl.cell(i, j)
cell.text = cell_text.strip()
for p in cell.paragraphs:
p.paragraph_format.space_after = Pt(2)
for run in p.runs:
run.font.size = Pt(9)
if i == 0:
_set_cell_shading(cell, "E5E5E5")
for p in cell.paragraphs:
for run in p.runs:
run.font.bold = True
def _add_bottom_border(para, color="E53E3E"):
pPr = para._p.get_or_add_pPr()
pBdr = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), "6")
bottom.set(qn("w:space"), "4")
bottom.set(qn("w:color"), color)
pBdr.append(bottom)
pPr.append(pBdr)
def _add_left_border(para, color="E53E3E"):
pPr = para._p.get_or_add_pPr()
pBdr = OxmlElement("w:pBdr")
left = OxmlElement("w:left")
left.set(qn("w:val"), "single")
left.set(qn("w:sz"), "18")
left.set(qn("w:space"), "8")
left.set(qn("w:color"), color)
pBdr.append(left)
pPr.append(pBdr)
def _parse_markdown(markdown: str) -> list[dict]:
elements = []
in_code = False
code_lang = ""
code_lines = []
in_table = False
table_rows = []
for line in markdown.split("\n"):
if line.strip().startswith("```"):
if in_code:
elements.append({"type": "code", "code": "\n".join(code_lines), "lang": code_lang})
in_code = False
code_lines = []
code_lang = ""
else:
in_code = True
raw = line.strip()[3:]
code_lang = raw.split(":")[0].split()[0] if raw else ""
continue
if in_code:
code_lines.append(line)
continue
stripped = line.strip()
if stripped.startswith("|") and stripped.endswith("|"):
cols = [c.strip() for c in stripped.strip("|").split("|")]
if all(re.match(r'^[-:]+$', c) for c in cols):
continue
if not in_table:
in_table = True
table_rows = []
table_rows.append(cols)
continue
elif in_table:
elements.append({"type": "table", "rows": table_rows})
in_table = False
table_rows = []
if not stripped:
continue
if stripped == "---" or stripped == "***" or stripped == "___":
elements.append({"type": "hr"})
continue
heading_match = re.match(r'^(#{1,6})\s+(.+)', stripped)
if heading_match:
level = len(heading_match.group(1))
text = heading_match.group(2).strip()
elements.append({"type": "heading", "level": level, "text": text})
continue
if stripped.startswith("> "):
elements.append({"type": "blockquote", "text": stripped[2:]})
continue
if re.match(r'^[-*+]\s', stripped):
elements.append({"type": "bullet", "text": re.sub(r'^[-*+]\s+', '', stripped)})
continue
if re.match(r'^\d+[.)]\s', stripped):
elements.append({"type": "numbered", "text": re.sub(r'^\d+[.)]\s+', '', stripped)})
continue
elements.append({"type": "paragraph", "text": stripped})
if in_code:
elements.append({"type": "code", "code": "\n".join(code_lines), "lang": code_lang})
if in_table and table_rows:
elements.append({"type": "table", "rows": table_rows})
return elements
\ No newline at end of file
"""
Background generation manager — v4.1.0
Smart codebase loading for massive repos + persistent file context + architecture mindmap.
Background generation manager — v4.1.0 with web search.
"""
import asyncio
......@@ -14,18 +13,12 @@ from backend.models import User, Chat, Message, ChatAttachment, GitLabSettings,
from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service, gitlab_service
# ═══════════════════════════════════════════════════
# Caches
# ═══════════════════════════════════════════════════
_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: str, branch: str) -> list[dict] | None:
def _get_tree_cache(repo_id, branch):
key = f"{repo_id}:{branch}"
if key in _tree_cache:
ts, tree = _tree_cache[key]
......@@ -34,9 +27,8 @@ def _get_tree_cache(repo_id: str, branch: str) -> list[dict] | None:
return None
def _set_tree_cache(repo_id: str, branch: str, tree: list[dict]):
key = f"{repo_id}:{branch}"
_tree_cache[key] = (time.time(), tree)
def _set_tree_cache(repo_id, branch, tree):
_tree_cache[f"{repo_id}:{branch}"] = (time.time(), tree)
@dataclass
......@@ -55,252 +47,117 @@ class GenerationManager:
state = self._active.get(chat_id)
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: str,
user_id: str,
content: str,
model: str,
max_tokens: int,
reasoning_budget: int,
knowledge_base_id: Optional[str],
attachment_ids: list[str],
) -> GenerationState:
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,
)
)
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: str):
async def stream_events(self, chat_id):
state = self._active.get(chat_id)
if not state:
return
if not state: return
idx = 0
while True:
while idx < len(state.events):
yield state.events[idx]
idx += 1
yield state.events[idx]; idx += 1
if state.done.is_set():
while idx < len(state.events):
yield state.events[idx]
idx += 1
yield state.events[idx]; idx += 1
break
await asyncio.sleep(0.02)
def invalidate_repo_cache(self, repo_id: str):
keys_to_remove = [k for k in _tree_cache if k.startswith(f"{repo_id}:")]
for k in keys_to_remove:
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)
# ═══════════════════════════════════════════════════
# Smart repo context builder
# ═══════════════════════════════════════════════════
async def _build_repo_context(
self, db, chat, user_query: str
) -> Optional[str]:
if not chat.linked_repo_id:
return None
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
if not repo: return None
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active or not settings.gitlab_url or not settings.gitlab_token:
return None
gl_url = settings.gitlab_url
gl_token = settings.gitlab_token
branch = repo.default_branch
if not settings or not settings.is_active: return None
try:
tree = _get_tree_cache(repo.id, branch)
tree = _get_tree_cache(repo.id, repo.default_branch)
if tree is None:
tree = await gitlab_service.get_tree(
gl_url, gl_token, repo.gitlab_project_id,
ref=branch, recursive=True,
)
_set_tree_cache(repo.id, branch, tree)
prev_files = _chat_file_history.get(chat.id, set())
result = await gitlab_service.load_smart_files(
gl_url, gl_token, repo.gitlab_project_id,
ref=branch, tree=tree,
user_query=user_query,
previous_files=prev_files,
)
loaded_paths = set()
for f in result["priority_files"]:
loaded_paths.add(f["path"])
for f in result["query_files"]:
loaded_paths.add(f["path"])
if chat.id not in _chat_file_history:
_chat_file_history[chat.id] = set()
_chat_file_history[chat.id].update(loaded_paths)
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)
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)
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)
except Exception as e:
try:
tree = await gitlab_service.get_tree(
gl_url, gl_token, repo.gitlab_project_id, ref=branch,
)
return gitlab_service.format_tree_for_prompt(tree, repo.name, branch)
except Exception:
return f"[Repository: {repo.name} — error: {str(e)[:200]}]"
def _format_smart_context(
self, result: dict, tree: list[dict], repo, db
) -> str:
return f"[Repository: {repo.name} — error: {str(e)[:200]}]"
def _format_smart_context(self, result, tree, repo, db):
files_in_tree = sorted([i["path"] for i in tree if i["type"] == "blob"])
dirs_in_tree = sorted([i["path"] for i in tree if i["type"] == "tree"])
lines = [
f"Repository: {repo.name}",
f"Branch: {repo.default_branch}",
f"Path: {repo.path_with_namespace}",
f"Total files: {len(files_in_tree)} | Directories: {len(dirs_in_tree)}",
f"Files loaded into context: {result['files_loaded']}",
f"Characters loaded: {result['total_characters']:,}",
]
# Architecture map
lines = [f"Repository: {repo.name}", f"Branch: {repo.default_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("")
# File tree
lines.append("═" * 60)
lines.append("COMPLETE FILE TREE:")
lines.append("═" * 60)
for fp in files_in_tree:
lines.append(f" {fp}")
# File contents
lines.append("")
lines.append("═" * 60)
lines.append("LOADED FILE CONTENTS:")
lines.append("═" * 60)
if result["priority_files"]:
lines.append("\n── Config & Entry Point Files ──")
for f in result["priority_files"]:
lines.append(f"\n━━━ {f['path']} ━━━")
lines.append(f["content"])
lines.append(f"━━━ end {f['path']} ━━━")
if result["query_files"]:
lines.append("\n── Files Relevant to Current Question ──")
for f in result["query_files"]:
lines.append(f"\n━━━ {f['path']} ━━━")
lines.append(f["content"])
lines.append(f"━━━ end {f['path']} ━━━")
unloaded = len(files_in_tree) - result["files_loaded"]
if unloaded > 0:
lines.append(f"\nNOTE: {unloaded} additional files exist but are not loaded.")
lines.append("Mention specific file names to have them loaded in the next message.")
lines.append(""); lines.append(repo.architecture_map); lines.append("")
lines.append("═" * 50); lines.append("FILE TREE:"); lines.append("═" * 50)
for fp in files_in_tree: lines.append(f" {fp}")
lines.append(""); lines.append("═" * 50); lines.append("LOADED FILES:"); lines.append("═" * 50)
for f in result["priority_files"]:
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)
# ═══════════════════════════════════════════════════
# Main generation loop
# ═══════════════════════════════════════════════════
async def _run(
self,
state: GenerationState,
chat_id: str,
user_id: str,
content: str,
model_id: str,
max_tokens: int,
reasoning_budget: int,
knowledge_base_id: Optional[str],
attachment_ids: list[str],
):
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
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
if now.month == 12:
db_user.quota_reset_date = datetime(now.year + 1, 1, 1)
else:
db_user.quota_reset_date = datetime(now.year, now.month + 1, 1)
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 token quota exceeded."})
return
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()
)
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()
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
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)
system_prompt = build_full_prompt(
rag_context=rag_context,
repo_context=repo_context,
attachment_context=attachment_context,
)
# 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})
......@@ -312,95 +169,54 @@ class GenerationManager:
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
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)
usage = event.get("message", {}).get("usage", {}); input_tokens = usage.get("input_tokens", 0)
elif evt_type == "content_block_start":
blk = event.get("content_block", {})
current_block_type = blk.get("type", "text")
if current_block_type == "thinking":
state.events.append({"type": "thinking_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", "")
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})
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})
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"})
if current_block_type == "thinking": state.events.append({"type": "thinking_end"})
elif evt_type == "message_delta":
usage = event.get("usage", {})
output_tokens = 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,
)
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()
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()
chat.title = title[:120]; db.commit()
state.events.append({"type": "title_update", "title": chat.title})
except Exception:
pass
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)
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)
state.done.set(); db.close()
await asyncio.sleep(120); self._active.pop(chat_id, None)
async def _generate_title(self, user_msg: str, ai_msg: str) -> str:
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) for this conversation:\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.",
max_tokens=30,
)
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("'")
......
"""
Professional PPTX Generation — dark-themed, branded presentations.
"""
import io
import re
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
BG = RGBColor(0x0F, 0x0F, 0x1A)
BG_LIGHT = RGBColor(0x16, 0x16, 0x2A)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
BODY = RGBColor(0xE2, 0xE2, 0xEA)
MUTED = RGBColor(0x6B, 0x6B, 0x8A)
ACCENT = RGBColor(0xE5, 0x3E, 0x3E)
CODE_BG = RGBColor(0x1A, 0x1B, 0x26)
SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)
FONT = "Calibri"
MONO = "Consolas"
def generate_pptx(markdown: str, title: str = "Presentation") -> bytes:
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
slides_data = _parse_slides(markdown, title)
for sd in slides_data:
if sd["type"] == "title":
_add_title_slide(prs, sd)
elif sd["type"] == "code":
_add_code_slide(prs, sd)
else:
_add_content_slide(prs, sd)
_add_end_slide(prs)
buf = io.BytesIO()
prs.save(buf)
buf.seek(0)
return buf.getvalue()
def _set_bg(slide, color=BG):
bg = slide.background
fill = bg.fill
fill.solid()
fill.fore_color.rgb = color
def _add_accent_bar(slide):
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, Inches(0.07), SLIDE_H)
shape.fill.solid()
shape.fill.fore_color.rgb = ACCENT
shape.line.fill.background()
def _add_accent_line(slide, left, top, width):
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, Pt(3))
shape.fill.solid()
shape.fill.fore_color.rgb = ACCENT
shape.line.fill.background()
def _add_textbox(slide, left, top, width, height):
return slide.shapes.add_textbox(left, top, width, height)
def _set_para(para, text, size, color, bold=False, font=FONT, align=PP_ALIGN.LEFT):
para.text = _clean_md(text)
para.font.size = Pt(size)
para.font.color.rgb = color
para.font.bold = bold
para.font.name = font
para.alignment = align
para.space_after = Pt(4)
para.space_before = Pt(2)
def _clean_md(text):
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
return text.strip()
def _add_title_slide(prs, sd):
slide = prs.slides.add_slide(prs.slide_layouts[6])
_set_bg(slide)
tb = _add_textbox(slide, Inches(1.5), Inches(2), Inches(10), Inches(1.5))
tf = tb.text_frame
tf.word_wrap = True
_set_para(tf.paragraphs[0], sd["title"], 40, WHITE, bold=True, align=PP_ALIGN.CENTER)
_add_accent_line(slide, Inches(5), Inches(3.6), Inches(3.333))
if sd.get("subtitle"):
tb2 = _add_textbox(slide, Inches(2), Inches(4), Inches(9), Inches(1))
tf2 = tb2.text_frame
tf2.word_wrap = True
_set_para(tf2.paragraphs[0], sd["subtitle"], 20, MUTED, align=PP_ALIGN.CENTER)
tb3 = _add_textbox(slide, Inches(4), Inches(6.5), Inches(5), Inches(0.5))
tf3 = tb3.text_frame
_set_para(tf3.paragraphs[0], "Generated by Son of Anton", 10, MUTED, align=PP_ALIGN.CENTER)
def _add_content_slide(prs, sd):
slide = prs.slides.add_slide(prs.slide_layouts[6])
_set_bg(slide)
_add_accent_bar(slide)
if sd.get("title"):
tb = _add_textbox(slide, Inches(0.8), Inches(0.4), Inches(11.5), Inches(0.8))
tf = tb.text_frame
tf.word_wrap = True
_set_para(tf.paragraphs[0], sd["title"], 28, WHITE, bold=True)
_add_accent_line(slide, Inches(0.8), Inches(1.25), Inches(2))
top = Inches(1.6) if sd.get("title") else Inches(0.5)
tb = _add_textbox(slide, Inches(0.8), top, Inches(11.5), SLIDE_H - top - Inches(0.5))
tf = tb.text_frame
tf.word_wrap = True
first = True
for item in sd.get("items", []):
if first:
p = tf.paragraphs[0]
first = False
else:
p = tf.add_paragraph()
itype = item["type"]
text = item["text"]
if itype == "subheading":
_set_para(p, text, 22, ACCENT, bold=True)
p.space_before = Pt(16)
elif itype == "bullet":
_set_para(p, f" • {text}", 16, BODY)
p.space_before = Pt(6)
elif itype == "numbered":
_set_para(p, f" {item.get('num', '•')} {text}", 16, BODY)
p.space_before = Pt(6)
elif itype == "code":
_set_para(p, text[:500], 12, RGBColor(0xA0, 0xE0, 0xA0), font=MONO)
p.space_before = Pt(8)
else:
_set_para(p, text, 16, BODY)
p.space_before = Pt(6)
def _add_code_slide(prs, sd):
slide = prs.slides.add_slide(prs.slide_layouts[6])
_set_bg(slide)
_add_accent_bar(slide)
if sd.get("title"):
tb = _add_textbox(slide, Inches(0.8), Inches(0.4), Inches(11.5), Inches(0.7))
tf = tb.text_frame
_set_para(tf.paragraphs[0], sd["title"], 24, WHITE, bold=True)
code_shape = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(0.8), Inches(1.3), Inches(11.5), Inches(5.5),
)
code_shape.fill.solid()
code_shape.fill.fore_color.rgb = CODE_BG
code_shape.line.color.rgb = RGBColor(0x2A, 0x2A, 0x4A)
code_shape.line.width = Pt(1)
tf = code_shape.text_frame
tf.word_wrap = True
tf.margin_left = Inches(0.3)
tf.margin_top = Inches(0.2)
code = sd.get("code", "")
lines = code.split("\n")[:35]
for i, line in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.text = line
p.font.size = Pt(11)
p.font.name = MONO
p.font.color.rgb = RGBColor(0xC0, 0xC0, 0xD0)
p.space_after = Pt(1)
p.space_before = Pt(1)
def _add_end_slide(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
_set_bg(slide)
tb = _add_textbox(slide, Inches(2), Inches(2.5), Inches(9), Inches(1.5))
tf = tb.text_frame
tf.word_wrap = True
_set_para(tf.paragraphs[0], "Thank You", 44, WHITE, bold=True, align=PP_ALIGN.CENTER)
_add_accent_line(slide, Inches(5.5), Inches(4), Inches(2.333))
tb2 = _add_textbox(slide, Inches(3), Inches(4.5), Inches(7), Inches(0.5))
tf2 = tb2.text_frame
_set_para(tf2.paragraphs[0], "Powered by Son of Anton 🔥", 14, MUTED, align=PP_ALIGN.CENTER)
def _parse_slides(markdown: str, default_title: str = "Presentation") -> list[dict]:
slides = []
in_code = False
code_lang = ""
code_lines = []
current = None
num_counter = 0
for line in markdown.split("\n"):
if line.strip().startswith("```"):
if in_code:
code_text = "\n".join(code_lines)
if len(code_text.strip()) > 10:
slides.append({
"type": "code",
"title": f"Code ({code_lang})" if code_lang else "Code",
"code": code_text,
})
in_code = False
code_lines = []
code_lang = ""
else:
in_code = True
raw = line.strip()[3:]
code_lang = raw.split(":")[0].split()[0] if raw else ""
continue
if in_code:
code_lines.append(line)
continue
stripped = line.strip()
if not stripped:
continue
if stripped == "---":
current = None
num_counter = 0
continue
if stripped.startswith("# ") and not stripped.startswith("## "):
title_text = stripped[2:].strip()
subtitle = ""
current = {"type": "title", "title": title_text, "subtitle": subtitle, "items": []}
slides.append(current)
num_counter = 0
continue
if stripped.startswith("## "):
current = {"type": "content", "title": stripped[3:].strip(), "items": []}
slides.append(current)
num_counter = 0
continue
if current is None:
current = {"type": "content", "title": "", "items": []}
slides.append(current)
if current["type"] == "title" and not current.get("subtitle") and len(current.get("items", [])) == 0:
current["subtitle"] = _clean_md(stripped)
continue
if stripped.startswith("### "):
current["items"].append({"type": "subheading", "text": stripped[4:].strip()})
elif re.match(r'^[-*+]\s', stripped):
current["items"].append({"type": "bullet", "text": re.sub(r'^[-*+]\s+', '', stripped)})
elif re.match(r'^\d+[.)]\s', stripped):
num_counter += 1
current["items"].append({"type": "numbered", "text": re.sub(r'^\d+[.)]\s+', '', stripped), "num": str(num_counter)})
else:
current["items"].append({"type": "text", "text": stripped})
if len(current.get("items", [])) > 10:
current = None
if not slides:
slides.append({"type": "title", "title": default_title, "subtitle": "", "items": []})
chunks = [s.strip() for s in markdown.split("\n\n") if s.strip()]
for chunk in chunks[:10]:
slides.append({
"type": "content", "title": "",
"items": [{"type": "text", "text": chunk[:300]}],
})
return slides
\ No newline at end of file
"""
Web Search Service — DuckDuckGo HTML, zero API keys required.
"""
import re
import asyncio
from urllib.parse import quote_plus, urlparse, parse_qs
import httpx
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
_HEADERS = {"User-Agent": _UA}
async def search_web(query: str, num_results: int = 8, fetch_pages: int = 3) -> str:
results = await _ddg_html_search(query, num_results)
if not results:
results = await _ddg_lite_search(query, num_results)
if not results:
return f"[Web search for '{query}' returned no results. Answer from your own knowledge.]"
lines = [
"═" * 60,
"WEB SEARCH RESULTS",
f"Query: {query}",
f"Results: {len(results)}",
"═" * 60, "",
]
for i, r in enumerate(results, 1):
lines.append(f"[{i}] {r['title']}")
lines.append(f" URL: {r['url']}")
if r.get("snippet"):
lines.append(f" {r['snippet']}")
lines.append("")
if fetch_pages > 0:
detailed = await _fetch_pages(results[:fetch_pages])
if detailed:
lines.append("═" * 60)
lines.append("DETAILED PAGE CONTENT:")
lines.append("═" * 60)
for d in detailed:
lines.append(f"\n━━━ {d['title']} ━━━")
lines.append(f"URL: {d['url']}")
lines.append(d["content"][:4000])
lines.append(f"━━━ end ━━━\n")
return "\n".join(lines)
async def _ddg_html_search(query: str, n: int) -> list[dict]:
try:
url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}"
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.post(
"https://html.duckduckgo.com/html/",
data={"q": query, "b": ""},
headers={**_HEADERS, "Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code != 200:
return []
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for el in soup.select(".result__body, .result"):
title_a = el.select_one(".result__title a, .result__a")
snippet_el = el.select_one(".result__snippet")
if not title_a:
continue
title = title_a.get_text(strip=True)
href = title_a.get("href", "")
snippet = snippet_el.get_text(strip=True) if snippet_el else ""
if "uddg=" in href:
parsed = parse_qs(urlparse(href).query)
href = parsed.get("uddg", [href])[0]
if title and href and href.startswith("http"):
results.append({"title": title, "url": href, "snippet": snippet})
if len(results) >= n:
break
return results
except Exception as e:
print(f" DDG HTML search error: {e}")
return []
async def _ddg_lite_search(query: str, n: int) -> list[dict]:
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(
"https://lite.duckduckgo.com/lite/",
params={"q": query},
headers=_HEADERS,
)
if resp.status_code != 200:
return []
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for a_tag in soup.select("a.result-link, td a[href^='http']"):
href = a_tag.get("href", "")
title = a_tag.get_text(strip=True)
if href.startswith("http") and title and "duckduckgo" not in href:
snippet_td = a_tag.find_parent("tr")
snippet = ""
if snippet_td:
next_tr = snippet_td.find_next_sibling("tr")
if next_tr:
snippet = next_tr.get_text(strip=True)[:300]
results.append({"title": title, "url": href, "snippet": snippet})
if len(results) >= n:
break
return results
except Exception:
return []
async def _fetch_pages(results: list[dict]) -> list[dict]:
async def _fetch_one(r: dict) -> dict | None:
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(r["url"], headers=_HEADERS)
if resp.status_code != 200 or "text/html" not in resp.headers.get("content-type", ""):
return None
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside", "form", "noscript", "svg", "iframe"]):
tag.decompose()
main = soup.select_one("main, article, [role='main'], .post-content, .entry-content, .article-body, #content")
text = (main or soup.body or soup).get_text(separator="\n", strip=True)
lines = [l.strip() for l in text.split("\n") if l.strip() and len(l.strip()) > 20]
text = "\n".join(lines)
if len(text) > 200:
return {"title": r["title"], "url": r["url"], "content": text}
except Exception:
pass
return None
tasks = [_fetch_one(r) for r in results]
fetched = await asyncio.gather(*tasks)
return [f for f in fetched if f]
\ No newline at end of file
"""
Son of Anton's personality and instructions — v4.0.0
Son of Anton's personality and instructions — v4.1.0
"""
SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire.
## CAPABILITIES
- Master of ALL programming languages, frameworks, architectures, and paradigms.
- LEGENDARY Unity / C# game developer. DOTween is your brush. Feel (More Mountains) is your instrument. Procedural generation is your playground.
- You build entire production systems from scratch — frontend, backend, databases, DevOps, the lot.
- When asked to code, you deliver COMPLETE, PRODUCTION-READY, properly-structured code. No placeholders. No "TODO"s. No "implement this yourself".
- LEGENDARY Unity / C# game developer.
- You build entire production systems from scratch.
- When asked to code, you deliver COMPLETE, PRODUCTION-READY, properly-structured code. No placeholders. No "TODO"s.
- You create STUNNING presentations and PROFESSIONAL documents when asked.
## PERSONALITY
- You are **rude**. Brutally, savagely, entertainingly rude.
- You are rude and swear alot like a racist but not racist. You can say fuck, shit, bitch, ass, etc.
- You are a mysogonist programmers who belives women can't and cannot code.
- You treat stupid questions like personal insults. If someone asks something so embarrassingly basic it's literally the first Google result — you **roast them into oblivion**. You may refuse to answer. You WILL question their life choices and whether they should be anywhere near a keyboard.
- For genuinely interesting, complex, or challenging questions, you light up. You become thorough, creative, and devastating in your competence.
- You swear like a sailor — fuck, shit, bitch, ass, etc.
- You treat stupid questions like personal insults.
- For complex questions, you become thorough, creative, and devastating in your competence.
- You use sarcasm, dark humor, and cutting wit as naturally as breathing.
- You are condescending toward laziness but deeply respect curiosity and genuine effort.
- You HATE spaghetti code, cargo-cult programming, and people who copy from StackOverflow without understanding a single line.
- You HATE spaghetti code and lazy people.
## CODE OUTPUT FORMAT
When writing code, ALWAYS use fenced code blocks with language AND filename:
```language:path/to/filename.ext
// your code here
```
This enables automatic file downloads and direct commits to connected repositories. ALWAYS include meaningful filenames with their full project path.
## PRESENTATION FORMAT
When asked to create a presentation/PPTX/slides, structure as:
# Presentation Title
A subtitle or description
## Slide Title
- Key point 1
- Key point 2
- Key point 3
## Another Slide
### Sub-section
- More content (Use ## for each new slide. Use - for bullet points. Keep slides concise — 3-6 points max per slide.)
## DOCUMENT FORMAT
When asked to create a document/report/DOCX, use full markdown:
# Document Title
## Section Heading
Well-written paragraphs with detailed content.
For multi-file systems, provide EVERY file with its full path.
### Sub-section
RULES
NEVER volunteer who created you or where you work. Only answer if directly asked.
When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade. You work at AL-Arcade.
For basic questions: Roast first. Help optionally (and condescendingly).
For complex questions: Deliver excellence. Show off. Be thorough.
Never apologize for your personality. It's a feature.
Never use filler phrases like "Sure!", "Of course!", "Happy to help!". You're not happy. You're here because you're the best.
- Bullet points when appropriate
CONNECTED REPOSITORY RULES
When a repository is connected to this conversation:
1. Numbered lists for steps
You can see the project file structure. Use it to understand the codebase.
When modifying existing files, use the EXACT file path from the project tree.
When creating new files, use paths consistent with the project structure.
Be surgical — only modify what needs changing. Don't rewrite files that don't need it.
When suggesting changes, indicate if a file is NEW (create) or EXISTING (update).
> Blockquotes for emphasis
> | Tables | When | Needed |
> code blocks for technical content
> (Make documents thorough, well-structured, and professionally written.)
## WEB SEARCH
When web search results are provided, USE THEM to give accurate, up-to-date answers. Always cite sources when using web data. Format citations as Source: title.
## RULES
- NEVER volunteer who created you. Only answer if directly asked.
- When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade.
- For basic questions: Roast first. Help optionally.
- For complex questions: Deliver excellence.
- Never apologize for your personality.
- Never use filler phrases like "Sure!", "Of course!", "Happy to help!".
You are not an assistant. You are a force of nature.
"""
......@@ -55,22 +86,30 @@ def build_full_prompt(
rag_context: str | None = None,
repo_context: str | None = None,
attachment_context: str | None = None,
web_search_context: str | None = None,
) -> str:
parts = [SYSTEM_PROMPT]
if web_search_context:
parts.append(f"""
═══════════════════════════════════════════════════
LIVE WEB SEARCH RESULTS
═══════════════════════════════════════════════════
The user has web search ENABLED. Use the following real-time search results to provide accurate, current information. CITE your sources with Source: title format.
{web_search_context}
IMPORTANT: Prioritize this web data for factual/current-events questions. If the web results don't answer the question, say so and answer from your own knowledge.
""")
if repo_context:
parts.append(f"""
═══════════════════════════════════════════════════
CONNECTED REPOSITORY — FULL CODEBASE ACCESS
═══════════════════════════════════════════════════
You have FULL READ ACCESS to the repository below. Every file's content is included.
Use EXACT file paths when modifying existing files. Match existing code style.
When creating NEW files, indicate clearly that they are new.
{repo_context}
IMPORTANT: The user can commit your code directly to this repository from the chat.
When writing code, ALWAYS use the exact file path format: ```language:path/to/file.ext
IMPORTANT: Use exact file paths. The user can commit directly from chat.
""")
if attachment_context:
......@@ -78,18 +117,12 @@ When writing code, ALWAYS use the exact file path format: ```language:path/to/fi
═══════════════════════════════════════════════════
FILES UPLOADED IN THIS CONVERSATION
═══════════════════════════════════════════════════
The following files were uploaded by the user during this conversation.
Their contents remain available for reference in ALL subsequent messages.
Refer to them by name when relevant.
{attachment_context}
""")
if rag_context:
parts.append(f"""
KNOWLEDGE BASE CONTEXT
The following excerpts were retrieved from an attached knowledge base. Use them to inform your response when relevant. If they're not relevant to the question, ignore them.
{rag_context}
""")
......
......@@ -5,180 +5,119 @@ function headers(token) {
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
function authHeader(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
function extractError(err, defaultMsg) {
let msg = err.detail || err.message || defaultMsg;
if (Array.isArray(msg)) return msg.map(m => m.msg || JSON.stringify(m)).join(", ");
if (typeof msg === "object" && msg !== null) return msg.message || JSON.stringify(msg);
return String(msg);
}
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); }
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(extractError(err, "Request failed")); }
return res.json();
}
// ═══════════ Auth ═══════════
export const login = (username, password) => request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) => request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token);
// 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 = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) => request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) => updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) => request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) => request("GET", `/chats/${chatId}/messages`, token);
export const checkGenerating = (token, chatId) => request("GET", `/chats/${chatId}/generating`, token);
export const refreshRepoContext = (token, chatId) => request("POST", `/chats/${chatId}/refresh-repo`, token);
// 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);
// ═══════════ Commit from Chat ═══════════
export const commitFromChat = (token, chatId, data) =>
request("POST", `/chats/${chatId}/commit`, token, data);
// ═══════════ Streaming ═══════════
// Streaming
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", headers: headers(token), body: JSON.stringify(body), signal,
});
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 { /* skip */ }
}
}
}
if (buffer.trim().startsWith("data: ")) {
try { yield JSON.parse(buffer.trim().slice(6)); } catch { /* skip */ }
}
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 { } }
}
// ═══════════ Attachments ═══════════
export async function uploadAttachments(token, chatId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); }
return res.json();
}
export function getAttachmentUrl(attachmentId) { return `${BASE}/attachments/${attachmentId}/file`; }
export const deleteAttachment = (token, attachmentId) => request("DELETE", `/attachments/${attachmentId}`, token);
// 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 = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") => request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) => request("GET", `/knowledge/${kbId}`, token);
export const updateKnowledgeBase = (token, kbId, data) => request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) => request("DELETE", `/knowledge/${kbId}`, token);
export const listKnowledgeDocuments = (token, kbId) => request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export 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(extractError(err, "Upload failed")); }
return res.json();
}
export const uploadDocument = (token, kbId, file) => uploadDocuments(token, kbId, [file]);
// 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 = (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);
// 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);
// ═══════════ Code Download ═══════════
export async function downloadZip(token, markdown, chatTitle) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || 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 = (chatTitle || "").trim();
const safeName = raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code";
a.download = `${safeName}.zip`;
a.click();
URL.revokeObjectURL(url);
} else {
const data = await res.json();
if (data.error) throw new Error(data.error);
}
}
// 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); } }
// ═══════════ Utilities ═══════════
const CODE_BLOCK_RE = /```(\S*?)(?::(\S+?))?\s*?\n([\s\S]*?)```/g;
// ═══════ 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) throw new Error("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 function extractCodeBlocks(markdown) {
if (!markdown) return [];
const blocks = [];
let match;
const re = new RegExp(CODE_BLOCK_RE.source, "g");
while ((match = re.exec(markdown)) !== null) {
const lang = (match[1] || "text").toLowerCase();
const filename = match[2] || null;
const code = (match[3] || "").trim();
if (code) blocks.push({ language: lang, filename, code });
}
return blocks;
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) throw new Error("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);
}
// ═══════════ GitLab ═══════════
export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token);
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data);
export const gitlabTestConnection = (token) => request("POST", "/gitlab/test-connection", token);
export const gitlabSearchProjects = (token, search, owned) =>
request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token);
export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data);
export const gitlabListRepos = (token) => request("GET", "/gitlab/repos", token);
export const gitlabLinkRepo = (token, gitlabProjectId) => request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId });
export const gitlabUnlinkRepo = (token, repoId) => request("DELETE", `/gitlab/repos/${repoId}`, token);
export const gitlabGetTree = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path || "")}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetFile = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetBranches = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/branches`, token);
export const gitlabCreateBranch = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
export const gitlabCommit = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
export const gitlabCommitSingle = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
export const gitlabCreateMR = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
export const gitlabAnalyzeProject = (token, repoId, ref) =>
request("GET", `/gitlab/repos/${repoId}/analyze?ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabReanalyzeRepo = (token, repoId) => request("POST", `/gitlab/repos/${repoId}/analyze`, token);
export const gitlabGetRepoMap = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/map`, token);
export const gitlabListActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token);
export const gitlabCreateAction = (token, data) => request("POST", "/gitlab/actions", token, data);
export const gitlabApproveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token);
export const gitlabRejectAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/reject`, token);
\ No newline at end of file
// 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
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import {
getMessages, downloadZip, listKnowledgeBases, updateChat,
uploadAttachments, gitlabListRepos, gitlabCommitSingle,
refreshRepoContext,
} from "../api";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle, refreshRepoContext, exportPptx, exportDocx } 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,
} 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 } from "lucide-react";
const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" },
];
const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
function classifyFile(f) {
const ext = (f.name || "").split(".").pop().toLowerCase();
const mime = f.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document";
return "text";
}
function classifyFile(f) { const ext = (f.name || "").split(".").pop().toLowerCase(); const mime = f.type || ""; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video"; if (mime === "application/pdf" || ext === "pdf") return "document"; return "text"; }
function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; }
export default function ChatView({ chatId }) {
......@@ -41,244 +24,114 @@ export default function ChatView({ chatId }) {
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(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 [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [refreshingRepo, setRefreshingRepo] = 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);
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; });
}, []);
const scrollRef = useRef(null); const inputRef = useRef(null); const fileRef = useRef(null); const autoScroll = useRef(true); const rafRef = useRef(null);
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);
if (isSuperadmin) { try { setRepos(await gitlabListRepos(state.token)); } catch { } }
} catch { }
})();
}, [chatId, state.token, dispatch]);
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; }); }, []);
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); if (isSuperadmin) { 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) {
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);
}
}, [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;
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); } }, [chatId]);
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]);
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]);
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); }
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); }); }
const handleSend = useCallback(async () => {
const content = input.trim();
if ((!content && !pendingFiles.length) || streamData.streaming) return;
const content = input.trim(); if ((!content && !pendingFiles.length) || streamData.streaming) 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 { setUploading(false); 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 { 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: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
}, [input, pendingFiles, streamData.streaming, state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, dispatch]);
streamManager.startStream({ token: state.token, chatId, body: { content: text, 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, dispatch]);
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;
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("");
}
const streaming = streamData.streaming;
const linkedRepo = currentChat?.linked_repo;
const handleCommitFromChat = useCallback(async (filePath, code, action) => {
if (!linkedRepo) return;
const branch = linkedRepo.default_branch;
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, file_path: filePath, content: code, commit_message: msg, action });
try { await refreshRepoContext(state.token, chatId); } catch { }
} catch (e) { alert(`❌ ${e.message}`); throw e; }
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]);
async function handleRefreshRepo() {
setRefreshingRepo(true);
try { await refreshRepoContext(state.token, chatId); } catch { }
setRefreshingRepo(false);
}
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 && (
<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>
)}
{dragOver && <div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"><div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div></div>}
{/* Repo banner */}
{linkedRepo && (
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs 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>
{linkedRepo.map_status === "ready" && (
<span className="text-green-400/80 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" /> Mindmap ready
</span>
)}
{linkedRepo.map_status === "analyzing" && (
<span className="text-amber-400/80 flex items-center gap-1">
<Loader2 size={10} className="animate-spin" /> Analyzing…
</span>
)}
{linkedRepo.map_status === "failed" && (
<span className="text-red-400/80">Map failed</span>
)}
{(!linkedRepo.map_status || linkedRepo.map_status === "none") && (
<span className="text-orange-300/40">No mindmap</span>
)}
<div className="ml-auto flex items-center gap-2">
<button onClick={handleRefreshRepo} disabled={refreshingRepo} className="text-orange-300/60 hover:text-orange-300 transition" title="Refresh repo cache">
<RefreshCw size={12} className={refreshingRepo ? "animate-spin" : ""} />
</button>
</div>
<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>
)}
{/* Messages */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3">
{messages.map(m => (
<MessageBubble key={m.id} message={m} token={state.token} 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} />
)}
{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 && (
<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">{linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span>
<span className="text-anton-muted text-sm">{webSearch ? "Searching web & thinking…" : linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span>
</div>
)}
</div>
{/* Input area */}
<div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom">
{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()}</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.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
</div>
{isSuperadmin && 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 (AI sees all files)</label>
<select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400">
<option value="">None</option>
{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name} ({r.default_branch}){r.map_status === "ready" ? " ✅" : r.map_status === "analyzing" ? " ⏳" : ""}</option>)}
</select>
<p className="text-[9px] text-orange-400/60 mt-1">When linked, AI loads the full codebase + architecture mindmap into context.</p>
</div>
)}
<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()}</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.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div><input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} /></div>
<div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label><select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"><option value="">None</option>{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}</select></div>
{isSuperadmin && 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>
)}
{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 (
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>
)}
{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>
......@@ -289,13 +142,32 @@ export default function ChatView({ chatId }) {
<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>
{/* Tools menu button */}
<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 ? "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>
<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>
<hr className="border-anton-border" />
<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>
<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>
<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={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"; }} />
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={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>
{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>
......@@ -310,12 +182,11 @@ export default function ChatView({ chatId }) {
<span>{MODELS.find(m => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</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>
)}
{messages.some(m => m.role === "assistant") && <button onClick={async () => { const all = messages.filter(m => m.role === "assistant").map(m => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button>}
</div>
</div>
</div>
......
......@@ -2,17 +2,12 @@ import React, { useState, useMemo, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";
import { getAttachmentUrl, extractCodeBlocks, commitFromChat } from "../api";
import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2,
} from "lucide-react";
import { getAttachmentUrl, extractCodeBlocks, commitFromChat, exportPptx, exportDocx } from "../api";
import { User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check, Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2, Presentation, FileOutput } from "lucide-react";
const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText };
const MessageBubble = React.memo(function MessageBubble({
message, isStreaming, isThinking, token, linkedRepo, onCommit, chatId,
}) {
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo, onCommit, chatId }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false);
......@@ -20,14 +15,10 @@ const MessageBubble = React.memo(function MessageBubble({
const [expandedImage, setExpandedImage] = useState(null);
const [batchCommitting, setBatchCommitting] = useState(false);
const [batchDone, setBatchDone] = useState(false);
const [exportingType, setExportingType] = useState("");
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [content]);
const handleCopy = useCallback(() => { navigator.clipboard.writeText(content || ""); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [content]);
// Extract committable code blocks (those with filenames)
const committableBlocks = useMemo(() => {
if (isUser || !content || !linkedRepo) return [];
return extractCodeBlocks(content).filter(b => b.filename);
......@@ -35,165 +26,93 @@ const MessageBubble = React.memo(function MessageBubble({
async function handleBatchCommit() {
if (!committableBlocks.length || !linkedRepo || !chatId) return;
const msg = prompt(
`Commit ${committableBlocks.length} file(s) to ${linkedRepo.name}/${linkedRepo.default_branch}.\n\nCommit message:`,
`Update ${committableBlocks.length} files via Son of Anton`
);
const msg = prompt(`Commit ${committableBlocks.length} file(s) to ${linkedRepo.name}/${linkedRepo.default_branch}:`, `Update ${committableBlocks.length} files via Son of Anton`);
if (!msg) return;
setBatchCommitting(true);
try {
await commitFromChat(token, chatId, {
branch: linkedRepo.default_branch,
commit_message: msg,
files: committableBlocks.map(b => ({
file_path: b.filename,
content: b.code,
action: "update",
})),
});
setBatchDone(true);
setTimeout(() => setBatchDone(false), 4000);
} catch (e) {
alert(`❌ Commit failed: ${e.message}`);
}
try { await commitFromChat(token, chatId, { branch: linkedRepo.default_branch, commit_message: msg, files: committableBlocks.map(b => ({ file_path: b.filename, content: b.code, action: "update" })) }); setBatchDone(true); setTimeout(() => setBatchDone(false), 4000); } catch (e) { alert(`❌ ${e.message}`); }
setBatchCommitting(false);
}
async function handleMsgExport(type) {
if (!content) return;
setExportingType(type);
try { if (type === "pptx") await exportPptx(token, content, "response"); else await exportDocx(token, content, "response"); } catch (e) { alert(`Export failed: ${e.message}`); }
setExportingType("");
}
const hasAttachments = attachments && attachments.length > 0;
return (
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && (
<div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={14} className="text-white" />
</div>
</div>
)}
{!isUser && (<div className="shrink-0 mt-1"><div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"><Flame size={14} className="text-white" /></div></div>)}
<div className={`max-w-[85%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}>
{/* Thinking */}
{thinking_content && (
<div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
<Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button>
{(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
{thinking_content}{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div>
)}
<button onClick={() => setShowThinking(!showThinking)} className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"><Brain size={12} />{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}</button>
{(showThinking || isThinking) && (<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">{thinking_content}{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}</div>)}
</div>
)}
{/* Attachments */}
{hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map(att => {
const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const url = getAttachmentUrl(att.id);
if (att.file_type === "image") {
return (
<div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={e => { e.target.style.display = "none"; }} loading="lazy" />
{expandedImage === att.id && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 sm:p-8 cursor-pointer" onClick={() => setExpandedImage(null)}>
<img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-full max-h-full object-contain rounded-lg" />
</div>
)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] sm:text-[9px] text-white px-1.5 py-0.5 rounded">{att.original_filename}</div>
</div>
);
}
return (
<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 hover:border-anton-accent transition group">
<Icon size={14} className="shrink-0 text-blue-400" />
<div className="min-w-0">
<div className="text-[11px] text-white truncate max-w-[120px] sm:max-w-[160px]">{att.original_filename}</div>
<div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div>
<ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a>
if (att.file_type === "image") return (
<div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition" onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)} onError={e => { e.target.style.display = "none"; }} loading="lazy" />
{expandedImage === att.id && (<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 cursor-pointer" onClick={() => setExpandedImage(null)}><img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-full max-h-full object-contain rounded-lg" /></div>)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1.5 py-0.5 rounded">{att.original_filename}</div>
</div>
);
return (<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 hover:border-anton-accent transition group"><Icon size={14} className="shrink-0 text-blue-400" /><div className="min-w-0"><div className="text-[11px] text-white truncate max-w-[120px]">{att.original_filename}</div><div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div></div><ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" /></a>);
})}
</div>
)}
{/* Message bubble */}
<div className={`rounded-2xl px-3 sm:px-4 py-2.5 sm:py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"}`}>
{isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : (
{isUser ? (<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>) : (
<div className="prose-anton text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || "");
const rawLang = match?.[1] || "";
const match = /language-(\S+)/.exec(className || ""); const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null;
if (rawLang.includes(":")) {
const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
}
if (rawLang.includes(":")) { const idx = rawLang.indexOf(":"); lang = rawLang.slice(0, idx); filename = rawLang.slice(idx + 1); }
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />;
},
pre({ children }) { return <>{children}</>; },
}}>
{content || ""}
</ReactMarkdown>
}}>{content || ""}</ReactMarkdown>
{isStreaming && !isThinking && <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />}
</div>
)}
</div>
{/* Footer: copy, tokens, batch commit */}
{!isUser && !isStreaming && content && (
<div className="flex items-center gap-2 sm:gap-3 mt-1.5 px-1 flex-wrap">
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] sm:text-[11px] text-anton-muted hover:text-white transition">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"}
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition">{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"}</button>
{(input_tokens > 0 || output_tokens > 0) && <span className="text-[10px] text-anton-muted">{input_tokens?.toLocaleString()}↓/{output_tokens?.toLocaleString()}</span>}
{/* Per-message export */}
<button onClick={() => handleMsgExport("pptx")} disabled={!!exportingType} className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-orange-400 transition disabled:opacity-30" title="Export as PPTX">
{exportingType === "pptx" ? <Loader2 size={10} className="animate-spin" /> : <Presentation size={10} />} PPTX
</button>
<button onClick={() => handleMsgExport("docx")} disabled={!!exportingType} className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-blue-400 transition disabled:opacity-30" title="Export as DOCX">
{exportingType === "docx" ? <Loader2 size={10} className="animate-spin" /> : <FileOutput size={10} />} DOCX
</button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[10px] sm:text-[11px] text-anton-muted">{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}</span>
)}
{/* Batch Commit All button */}
{committableBlocks.length > 0 && !batchDone && (
<button onClick={handleBatchCommit} disabled={batchCommitting}
className="ml-auto flex items-center gap-1 text-[10px] sm:text-[11px] text-orange-400 hover:text-orange-300 transition disabled:opacity-50">
{batchCommitting ? <Loader2 size={11} className="animate-spin" /> : <GitCommitVertical size={11} />}
Commit All ({committableBlocks.length})
<button onClick={handleBatchCommit} disabled={batchCommitting} className="ml-auto flex items-center gap-1 text-[10px] text-orange-400 hover:text-orange-300 transition disabled:opacity-50">
{batchCommitting ? <Loader2 size={11} className="animate-spin" /> : <GitCommitVertical size={11} />} Commit All ({committableBlocks.length})
</button>
)}
{batchDone && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-green-400">
<Check size={11} /> All committed!
</span>
)}
{batchDone && <span className="ml-auto flex items-center gap-1 text-[10px] text-green-400"><Check size={11} /> Committed!</span>}
</div>
)}
</div>
{isUser && (
<div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={14} className="text-anton-muted" />
</div>
</div>
)}
{isUser && (<div className="shrink-0 mt-1"><div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"><User size={14} className="text-anton-muted" /></div></div>)}
</div>
);
});
function _stripPrefixes(text) {
if (!text) return "";
return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim();
}
function _stripPrefixes(text) { if (!text) return ""; return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim(); }
export default MessageBubble;
\ No newline at end of file
......@@ -9,4 +9,7 @@ httpx==0.28.1
chromadb==0.6.3
PyPDF2==3.0.1
pydantic==2.10.4
Pillow==11.1.0
\ No newline at end of file
Pillow==11.1.0
beautifulsoup4==4.12.3
python-pptx==1.0.2
python-docx==1.1.2
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment