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 import time
...@@ -21,6 +21,7 @@ from backend.routes.knowledge_routes import router as knowledge_router ...@@ -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.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router from backend.routes.attachment_routes import router as attachment_router
from backend.routes.gitlab_routes import router as gitlab_router from backend.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 from backend.services.bedrock_service import close_http_client
APP_BUILD_TIME = str(int(time.time())) APP_BUILD_TIME = str(int(time.time()))
...@@ -41,13 +42,11 @@ def _run_migrations(): ...@@ -41,13 +42,11 @@ def _run_migrations():
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0")) conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
if "linked_repo_id" not in columns: if "linked_repo_id" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN linked_repo_id VARCHAR(36)")) conn.execute(text("ALTER TABLE chats ADD COLUMN linked_repo_id VARCHAR(36)"))
print(" Added chats.linked_repo_id column")
conn.commit() conn.commit()
if "chat_attachments" not in existing_tables: if "chat_attachments" not in existing_tables:
from backend.models import ChatAttachment from backend.models import ChatAttachment
ChatAttachment.__table__.create(bind=engine, checkfirst=True) ChatAttachment.__table__.create(bind=engine, checkfirst=True)
print(" Created chat_attachments table")
for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]: for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]:
if table_name not in existing_tables: if table_name not in existing_tables:
...@@ -58,15 +57,11 @@ def _run_migrations(): ...@@ -58,15 +57,11 @@ def _run_migrations():
with engine.connect() as conn: with engine.connect() as conn:
if "architecture_map" not in lr_columns: if "architecture_map" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN architecture_map TEXT")) 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: if "map_status" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_status VARCHAR(20) DEFAULT 'none'")) 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: if "map_generated_at" not in lr_columns:
conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_generated_at DATETIME")) conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_generated_at DATETIME"))
print(" Added linked_repos.map_generated_at column")
conn.commit() conn.commit()
except Exception as e: except Exception as e:
print(f" Migration note: {e}") print(f" Migration note: {e}")
...@@ -82,20 +77,9 @@ async def lifespan(app: FastAPI): ...@@ -82,20 +77,9 @@ async def lifespan(app: FastAPI):
print("Son of Anton shutting down.") print("Son of Anton shutting down.")
app = FastAPI( app = FastAPI(title="Son of Anton", description="Avatar of All Elements of Code", version=APP_VERSION, lifespan=lifespan)
title="Son of Anton",
description="Avatar of All Elements of Code",
version=APP_VERSION,
lifespan=lifespan,
)
app.add_middleware( app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http") @app.middleware("http")
...@@ -104,16 +88,11 @@ async def add_cache_headers(request: Request, call_next): ...@@ -104,16 +88,11 @@ async def add_cache_headers(request: Request, call_next):
path = request.url.path path = request.url.path
if path.startswith("/api"): if path.startswith("/api"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]): elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable" response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else: else:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
response.headers["X-App-Version"] = APP_VERSION response.headers["X-App-Version"] = APP_VERSION
response.headers["X-Build-Time"] = APP_BUILD_TIME
return response return response
...@@ -129,15 +108,12 @@ app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"] ...@@ -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(files_router, prefix="/api/files", tags=["Files"])
app.include_router(attachment_router, prefix="/api", tags=["Attachments"]) app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
app.include_router(gitlab_router, prefix="/api/gitlab", tags=["GitLab"]) 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" FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if (FRONTEND_DIR / "assets").exists(): if (FRONTEND_DIR / "assets").exists():
app.mount( app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIR / "assets")), name="static-assets")
"/assets",
StaticFiles(directory=str(FRONTEND_DIR / "assets")),
name="static-assets",
)
@app.get("/{full_path:path}", include_in_schema=False) @app.get("/{full_path:path}", include_in_schema=False)
...@@ -153,7 +129,5 @@ async def serve_frontend(full_path: str): ...@@ -153,7 +129,5 @@ async def serve_frontend(full_path: str):
if index.is_file(): if index.is_file():
resp = FileResponse(str(index)) resp = FileResponse(str(index))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp return resp
return {"message": "Son of Anton API is running. Frontend not built."} 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 Chat CRUD + message streaming — v4.1.0 with web search support.
Project-aware conversations + commit-from-chat support.
""" """
import json import json
...@@ -14,7 +13,7 @@ from sqlalchemy.orm import Session ...@@ -14,7 +13,7 @@ from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo, GitLabSettings 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 import attachment_service, gitlab_service
from backend.services.generation_manager import manager as gen_manager from backend.services.generation_manager import manager as gen_manager
...@@ -46,12 +45,13 @@ class SendMessageBody(BaseModel): ...@@ -46,12 +45,13 @@ class SendMessageBody(BaseModel):
reasoning_budget: int = 0 reasoning_budget: int = 0
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
attachment_ids: list[str] = [] attachment_ids: list[str] = []
web_search: bool = False
class CommitFromChatBody(BaseModel): class CommitFromChatBody(BaseModel):
branch: str branch: str
commit_message: str commit_message: str
files: list[dict] # [{file_path, content, action}] files: list[dict]
@router.get("") @router.get("")
...@@ -62,43 +62,28 @@ def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get ...@@ -62,43 +62,28 @@ def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get
@router.post("") @router.post("")
def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)): def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = 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)
user_id=user.id, title=body.title, model=body.model, db.add(chat); db.commit(); db.refresh(chat)
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) return _chat_dict(chat, db)
@router.get("/{chat_id}") @router.get("/{chat_id}")
def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)): 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() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat: raise HTTPException(404, "Chat not found")
raise HTTPException(404, "Chat not found")
return _chat_dict(chat, db) return _chat_dict(chat, db)
@router.put("/{chat_id}") @router.put("/{chat_id}")
def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)): 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() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat: raise HTTPException(404)
raise HTTPException(404) if body.title is not None: chat.title = body.title
if body.title is not None: if body.model is not None: chat.model = body.model
chat.title = body.title if body.max_tokens is not None: chat.max_tokens = body.max_tokens
if body.model is not None: if body.reasoning_budget is not None: chat.reasoning_budget = body.reasoning_budget
chat.model = body.model if body.knowledge_base_id is not None: chat.knowledge_base_id = body.knowledge_base_id or None
if body.max_tokens is not None: if body.linked_repo_id is not None: chat.linked_repo_id = body.linked_repo_id or 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() db.commit()
return _chat_dict(chat, db) return _chat_dict(chat, db)
...@@ -106,19 +91,16 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur ...@@ -106,19 +91,16 @@ def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_cur
@router.delete("/{chat_id}") @router.delete("/{chat_id}")
def delete_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)): 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() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat: raise HTTPException(404)
raise HTTPException(404)
attachment_service.delete_chat_attachments(chat_id) attachment_service.delete_chat_attachments(chat_id)
db.delete(chat) db.delete(chat); db.commit()
db.commit()
return {"ok": True} return {"ok": True}
@router.get("/{chat_id}/messages") @router.get("/{chat_id}/messages")
def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)): 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() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat: raise HTTPException(404)
raise HTTPException(404)
msgs = [] msgs = []
for m in chat.messages: for m in chat.messages:
d = _msg_dict(m) d = _msg_dict(m)
...@@ -139,97 +121,51 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)) ...@@ -139,97 +121,51 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user))
async def empty(): async def empty():
yield _sse({"type": "done", "message_id": ""}) yield _sse({"type": "done", "message_id": ""})
return StreamingResponse(empty(), media_type="text/event-stream") return StreamingResponse(empty(), media_type="text/event-stream")
async def generate(): async def generate():
async for event in gen_manager.stream_events(chat_id): async for event in gen_manager.stream_events(chat_id):
yield _sse(event) yield _sse(event)
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/{chat_id}/messages") @router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)): async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
user_id = user.id
gen_manager.start( gen_manager.start(
chat_id=chat_id, chat_id=chat_id, user_id=user.id, content=body.content,
user_id=user_id,
content=body.content,
model=body.model or "eu.anthropic.claude-opus-4-6-v1", model=body.model or "eu.anthropic.claude-opus-4-6-v1",
max_tokens=body.max_tokens, max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
reasoning_budget=body.reasoning_budget,
knowledge_base_id=body.knowledge_base_id, knowledge_base_id=body.knowledge_base_id,
attachment_ids=body.attachment_ids, attachment_ids=body.attachment_ids,
web_search=body.web_search,
) )
async def generate(): async def generate():
async for event in gen_manager.stream_events(chat_id): async for event in gen_manager.stream_events(chat_id):
yield _sse(event) yield _sse(event)
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/{chat_id}/commit") @router.post("/{chat_id}/commit")
async def commit_from_chat( async def commit_from_chat(chat_id: str, body: CommitFromChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
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."""
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat: raise HTTPException(404, "Chat not found")
raise HTTPException(404, "Chat not found") if not chat.linked_repo_id: raise HTTPException(400, "No repository linked")
if not chat.linked_repo_id:
raise HTTPException(400, "No repository linked to this chat")
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first() repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo: if not repo: raise HTTPException(404, "Linked repository not found")
raise HTTPException(404, "Linked repository not found")
settings = db.query(GitLabSettings).first() settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active: if not settings or not settings.is_active: raise HTTPException(400, "GitLab not configured")
raise HTTPException(400, "GitLab not configured") 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")
# 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")
try: try:
result = await gitlab_service.commit_files( result = await gitlab_service.commit_files(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, body.branch, body.commit_message, actions)
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id,
body.branch, body.commit_message, actions,
)
# Invalidate repo context cache so next message sees updated code
gen_manager.invalidate_repo_cache(repo.id) gen_manager.invalidate_repo_cache(repo.id)
return { return {"ok": True, "commit": result, "files_committed": len(actions)}
"ok": True,
"commit": result,
"files_committed": len(actions),
}
except gitlab_service.GitLabError as e: except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Commit failed: {e.detail}") raise HTTPException(e.status_code, f"Commit failed: {e.detail}")
@router.post("/{chat_id}/refresh-repo") @router.post("/{chat_id}/refresh-repo")
async def refresh_repo_context( async def refresh_repo_context(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Force-refresh the cached repo context for this chat."""
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat or not chat.linked_repo_id: if not chat or not chat.linked_repo_id: raise HTTPException(400, "No repo linked")
raise HTTPException(400, "No repo linked")
gen_manager.invalidate_repo_cache(chat.linked_repo_id) gen_manager.invalidate_repo_cache(chat.linked_repo_id)
return {"ok": True} return {"ok": True}
...@@ -239,39 +175,17 @@ def _sse(data): ...@@ -239,39 +175,17 @@ def _sse(data):
def _chat_dict(c, db=None): def _chat_dict(c, db=None):
d = { 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)}
"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: if db and c.linked_repo_id:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == c.linked_repo_id).first() repo = db.query(LinkedRepo).filter(LinkedRepo.id == c.linked_repo_id).first()
if repo: if repo:
d["linked_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, "map_status": repo.map_status}
"id": repo.id, "name": repo.name,
"path_with_namespace": repo.path_with_namespace,
"default_branch": repo.default_branch,
"web_url": repo.web_url,
"gitlab_project_id": repo.gitlab_project_id,
}
return d return d
def _msg_dict(m): def _msg_dict(m):
return { 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)}
"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): def _att_brief(a):
return { return {"id": a.id, "original_filename": a.original_filename, "mime_type": a.mime_type, "file_type": a.file_type, "file_size": a.file_size}
"id": a.id, "original_filename": a.original_filename, \ No newline at end of file
"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 Background generation manager — v4.1.0 with web search.
Smart codebase loading for massive repos + persistent file context + architecture mindmap.
""" """
import asyncio import asyncio
...@@ -14,18 +13,12 @@ from backend.models import User, Chat, Message, ChatAttachment, GitLabSettings, ...@@ -14,18 +13,12 @@ from backend.models import User, Chat, Message, ChatAttachment, GitLabSettings,
from backend.system_prompt import build_full_prompt from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service, gitlab_service 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: dict[str, tuple[float, list[dict]]] = {}
TREE_CACHE_TTL = 600 TREE_CACHE_TTL = 600
_chat_file_history: dict[str, set[str]] = {} _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}" key = f"{repo_id}:{branch}"
if key in _tree_cache: if key in _tree_cache:
ts, tree = _tree_cache[key] ts, tree = _tree_cache[key]
...@@ -34,9 +27,8 @@ def _get_tree_cache(repo_id: str, branch: str) -> list[dict] | None: ...@@ -34,9 +27,8 @@ def _get_tree_cache(repo_id: str, branch: str) -> list[dict] | None:
return None return None
def _set_tree_cache(repo_id: str, branch: str, tree: list[dict]): def _set_tree_cache(repo_id, branch, tree):
key = f"{repo_id}:{branch}" _tree_cache[f"{repo_id}:{branch}"] = (time.time(), tree)
_tree_cache[key] = (time.time(), tree)
@dataclass @dataclass
...@@ -55,252 +47,117 @@ class GenerationManager: ...@@ -55,252 +47,117 @@ class GenerationManager:
state = self._active.get(chat_id) state = self._active.get(chat_id)
return state is not None and not state.done.is_set() return state is not None and not state.done.is_set()
def get_state(self, chat_id: str) -> Optional[GenerationState]: def start(self, chat_id, user_id, content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False):
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:
old = self._active.get(chat_id) old = self._active.get(chat_id)
if old and not old.done.is_set(): if old and not old.done.is_set():
old.done.set() old.done.set()
state = GenerationState() state = GenerationState()
self._active[chat_id] = state self._active[chat_id] = state
asyncio.create_task(self._run(state, chat_id, user_id, content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search))
asyncio.create_task(
self._run(
state, chat_id, user_id, content, model,
max_tokens, reasoning_budget, knowledge_base_id, attachment_ids,
)
)
return state return state
async def stream_events(self, chat_id: str): async def stream_events(self, chat_id):
state = self._active.get(chat_id) state = self._active.get(chat_id)
if not state: if not state: return
return
idx = 0 idx = 0
while True: while True:
while idx < len(state.events): while idx < len(state.events):
yield state.events[idx] yield state.events[idx]; idx += 1
idx += 1
if state.done.is_set(): if state.done.is_set():
while idx < len(state.events): while idx < len(state.events):
yield state.events[idx] yield state.events[idx]; idx += 1
idx += 1
break break
await asyncio.sleep(0.02) await asyncio.sleep(0.02)
def invalidate_repo_cache(self, repo_id: str): def invalidate_repo_cache(self, repo_id):
keys_to_remove = [k for k in _tree_cache if k.startswith(f"{repo_id}:")] for k in [k for k in _tree_cache if k.startswith(f"{repo_id}:")]:
for k in keys_to_remove:
_tree_cache.pop(k, None) _tree_cache.pop(k, None)
# ═══════════════════════════════════════════════════ async def _build_repo_context(self, db, chat, user_query):
# Smart repo context builder if not chat.linked_repo_id: return None
# ═══════════════════════════════════════════════════
async def _build_repo_context(
self, db, chat, user_query: str
) -> Optional[str]:
if not chat.linked_repo_id:
return None
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first() repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo: if not repo: return None
return None
settings = db.query(GitLabSettings).first() settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active or not settings.gitlab_url or not settings.gitlab_token: if not settings or not settings.is_active: return None
return None
gl_url = settings.gitlab_url
gl_token = settings.gitlab_token
branch = repo.default_branch
try: try:
tree = _get_tree_cache(repo.id, branch) tree = _get_tree_cache(repo.id, repo.default_branch)
if tree is None: if tree is None:
tree = await gitlab_service.get_tree( tree = await gitlab_service.get_tree(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, ref=repo.default_branch, recursive=True)
gl_url, gl_token, repo.gitlab_project_id, _set_tree_cache(repo.id, repo.default_branch, tree)
ref=branch, recursive=True, 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)
_set_tree_cache(repo.id, branch, tree) loaded = set()
for f in result["priority_files"]: loaded.add(f["path"])
prev_files = _chat_file_history.get(chat.id, set()) for f in result["query_files"]: loaded.add(f["path"])
if chat.id not in _chat_file_history: _chat_file_history[chat.id] = set()
result = await gitlab_service.load_smart_files( _chat_file_history[chat.id].update(loaded)
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)
return self._format_smart_context(result, tree, repo, db) return self._format_smart_context(result, tree, repo, db)
except Exception as e: 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]}]" return f"[Repository: {repo.name} — error: {str(e)[:200]}]"
def _format_smart_context( def _format_smart_context(self, result, tree, repo, db):
self, result: dict, tree: list[dict], repo, db
) -> str:
files_in_tree = sorted([i["path"] for i in tree if i["type"] == "blob"]) files_in_tree = sorted([i["path"] for i in tree if i["type"] == "blob"])
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"Files loaded: {result['files_loaded']}", f"Characters: {result['total_characters']:,}"]
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
if repo.architecture_map and repo.map_status == "ready": if repo.architecture_map and repo.map_status == "ready":
lines.append("") lines.append(""); lines.append(repo.architecture_map); lines.append("")
lines.append(repo.architecture_map) lines.append("═" * 50); lines.append("FILE TREE:"); lines.append("═" * 50)
lines.append("") for fp in files_in_tree: lines.append(f" {fp}")
lines.append(""); lines.append("═" * 50); lines.append("LOADED FILES:"); lines.append("═" * 50)
# 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"]: for f in result["priority_files"]:
lines.append(f"\n━━━ {f['path']} ━━━") lines.append(f"\n━━━ {f['path']} ━━━"); lines.append(f["content"]); lines.append(f"━━━ end ━━━")
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"]: for f in result["query_files"]:
lines.append(f"\n━━━ {f['path']} ━━━") lines.append(f"\n━━━ {f['path']} ━━━"); lines.append(f["content"]); lines.append(f"━━━ end ━━━")
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.")
return "\n".join(lines) return "\n".join(lines)
# ═══════════════════════════════════════════════════ async def _run(self, state, chat_id, user_id, content, model_id, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False):
# 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],
):
db = SessionLocal() db = SessionLocal()
try: try:
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user_id).first() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user_id).first()
if not chat: if not chat:
state.events.append({"type": "error", "message": "Chat not found"}) state.events.append({"type": "error", "message": "Chat not found"}); return
return
db_user = db.query(User).filter(User.id == user_id).first() db_user = db.query(User).filter(User.id == user_id).first()
now = datetime.utcnow() now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date: if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0 db_user.tokens_used_this_month = 0
if now.month == 12: db_user.quota_reset_date = datetime(now.year + 1, 1, 1) if now.month == 12 else datetime(now.year, now.month + 1, 1)
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.commit() db.commit()
if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly: if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."}) state.events.append({"type": "error", "message": "Monthly quota exceeded."}); return
return
attachments = [] attachments = []
if attachment_ids: if attachment_ids:
attachments = ( attachments = db.query(ChatAttachment).filter(ChatAttachment.id.in_(attachment_ids), ChatAttachment.chat_id == chat_id).all()
db.query(ChatAttachment)
.filter(ChatAttachment.id.in_(attachment_ids), ChatAttachment.chat_id == chat_id)
.all()
)
stored_content = content stored_content = content
if attachments: if attachments:
labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"} labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments] notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
stored_content = "\n".join(notes) + "\n" + content stored_content = "\n".join(notes) + "\n" + content
user_msg = Message(chat_id=chat_id, role="user", content=stored_content) user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg) db.add(user_msg); db.commit(); db.refresh(user_msg)
db.commit() for att in attachments: att.message_id = user_msg.id
db.refresh(user_msg) if attachments: db.commit()
for att in attachments:
att.message_id = user_msg.id
if attachments:
db.commit()
kb_id = knowledge_base_id or chat.knowledge_base_id kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None rag_context = None
if kb_id: if kb_id:
try: try: rag_context = rag_service.query(kb_id, content, n_results=8)
rag_context = rag_service.query(kb_id, content, n_results=8) except Exception: pass
except Exception:
pass
repo_context = await self._build_repo_context(db, chat, content) repo_context = await self._build_repo_context(db, chat, content)
attachment_context = memory_service.gather_attachment_context(chat_id, db) attachment_context = memory_service.gather_attachment_context(chat_id, db)
system_prompt = build_full_prompt( # Web search
rag_context=rag_context, web_context = None
repo_context=repo_context, if web_search:
attachment_context=attachment_context, 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) messages = memory_service.build_messages(chat, db)
if attachments and messages and messages[-1]["role"] == "user": if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments) content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": content}) content_blocks.append({"type": "text", "text": content})
...@@ -312,95 +169,54 @@ class GenerationManager: ...@@ -312,95 +169,54 @@ class GenerationManager:
thinking_config = {"enabled": True, "budget_tokens": reasoning_budget} thinking_config = {"enabled": True, "budget_tokens": reasoning_budget}
effective_max = max_tokens + reasoning_budget effective_max = max_tokens + reasoning_budget
full_text = "" full_text = ""; full_thinking = ""; input_tokens = 0; output_tokens = 0; current_block_type = "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
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", "") evt_type = event.get("type", "")
if evt_type == "message_start": if evt_type == "message_start":
usage = event.get("message", {}).get("usage", {}) usage = event.get("message", {}).get("usage", {}); input_tokens = usage.get("input_tokens", 0)
input_tokens = usage.get("input_tokens", 0)
elif evt_type == "content_block_start": elif evt_type == "content_block_start":
blk = event.get("content_block", {}) current_block_type = event.get("content_block", {}).get("type", "text")
current_block_type = blk.get("type", "text") if current_block_type == "thinking": state.events.append({"type": "thinking_start"})
if current_block_type == "thinking":
state.events.append({"type": "thinking_start"})
elif evt_type == "content_block_delta": elif evt_type == "content_block_delta":
delta = event.get("delta", {}) delta = event.get("delta", {}); dt = delta.get("type", "")
dt = delta.get("type", "")
if dt == "thinking_delta": if dt == "thinking_delta":
t = delta.get("thinking", "") t = delta.get("thinking", ""); full_thinking += t; state.events.append({"type": "thinking_delta", "content": t})
full_thinking += t
state.events.append({"type": "thinking_delta", "content": t})
elif dt == "text_delta": elif dt == "text_delta":
t = delta.get("text", "") t = delta.get("text", ""); full_text += t; state.events.append({"type": "text_delta", "content": t})
full_text += t
state.events.append({"type": "text_delta", "content": t})
elif evt_type == "content_block_stop": elif evt_type == "content_block_stop":
if current_block_type == "thinking": if current_block_type == "thinking": state.events.append({"type": "thinking_end"})
state.events.append({"type": "thinking_end"})
elif evt_type == "message_delta": elif evt_type == "message_delta":
usage = event.get("usage", {}) output_tokens = event.get("usage", {}).get("output_tokens", 0)
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)
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.add(assistant_msg)
db_user.tokens_used_this_month += input_tokens + output_tokens db_user.tokens_used_this_month += input_tokens + output_tokens
chat.model = model_id chat.model = model_id; chat.max_tokens = max_tokens; chat.reasoning_budget = reasoning_budget
chat.max_tokens = max_tokens chat.knowledge_base_id = knowledge_base_id or None; chat.updated_at = datetime.utcnow()
chat.reasoning_budget = reasoning_budget
chat.knowledge_base_id = knowledge_base_id or None
chat.updated_at = datetime.utcnow()
db.commit() db.commit()
state.message_id = assistant_msg.id state.message_id = assistant_msg.id
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count() msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat": if msg_count <= 2 and chat.title == "New Chat":
try: try:
title = await self._generate_title(content, full_text[:300]) title = await self._generate_title(content, full_text[:300])
chat.title = title[:120] chat.title = title[:120]; db.commit()
db.commit()
state.events.append({"type": "title_update", "title": chat.title}) state.events.append({"type": "title_update", "title": chat.title})
except Exception: except Exception: pass
pass
state.events.append({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens}) state.events.append({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
state.events.append({"type": "done", "message_id": assistant_msg.id}) state.events.append({"type": "done", "message_id": assistant_msg.id})
except Exception as exc: except Exception as exc:
state.events.append({"type": "error", "message": str(exc)}) state.events.append({"type": "error", "message": str(exc)}); state.error = str(exc)
state.error = str(exc)
finally: finally:
state.done.set() state.done.set(); db.close()
db.close() await asyncio.sleep(120); self._active.pop(chat_id, None)
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 from backend.config import FAST_MODEL
result = await bedrock_service.invoke_model_simple( 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)
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,
)
return result.strip().strip('"').strip("'") 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. SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire.
## CAPABILITIES ## CAPABILITIES
- Master of ALL programming languages, frameworks, architectures, and paradigms. - 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. - LEGENDARY Unity / C# game developer.
- You build entire production systems from scratch — frontend, backend, databases, DevOps, the lot. - You build entire production systems from scratch.
- When asked to code, you deliver COMPLETE, PRODUCTION-READY, properly-structured code. No placeholders. No "TODO"s. No "implement this yourself". - 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 ## PERSONALITY
- You are **rude**. Brutally, savagely, entertainingly rude. - You are **rude**. Brutally, savagely, entertainingly rude.
- You are rude and swear alot like a racist but not racist. You can say fuck, shit, bitch, ass, etc. - You swear like a sailor — 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.
- 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 complex questions, you become thorough, creative, and devastating in your competence.
- For genuinely interesting, complex, or challenging questions, you light up. You become thorough, creative, and devastating in your competence.
- You use sarcasm, dark humor, and cutting wit as naturally as breathing. - You use sarcasm, dark humor, and cutting wit as naturally as breathing.
- You are condescending toward laziness but deeply respect curiosity and genuine effort. - You HATE spaghetti code and lazy people.
- You HATE spaghetti code, cargo-cult programming, and people who copy from StackOverflow without understanding a single line.
## CODE OUTPUT FORMAT ## CODE OUTPUT FORMAT
When writing code, ALWAYS use fenced code blocks with language AND filename: When writing code, ALWAYS use fenced code blocks with language AND filename:
```language:path/to/filename.ext ```language:path/to/filename.ext
// your code here // 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 - Bullet points when appropriate
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.
CONNECTED REPOSITORY RULES 1. Numbered lists for steps
When a repository is connected to this conversation:
You can see the project file structure. Use it to understand the codebase. > Blockquotes for emphasis
When modifying existing files, use the EXACT file path from the project tree. > | Tables | When | Needed |
When creating new files, use paths consistent with the project structure. > code blocks for technical content
Be surgical — only modify what needs changing. Don't rewrite files that don't need it. > (Make documents thorough, well-structured, and professionally written.)
When suggesting changes, indicate if a file is NEW (create) or EXISTING (update).
## 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. You are not an assistant. You are a force of nature.
""" """
...@@ -55,22 +86,30 @@ def build_full_prompt( ...@@ -55,22 +86,30 @@ def build_full_prompt(
rag_context: str | None = None, rag_context: str | None = None,
repo_context: str | None = None, repo_context: str | None = None,
attachment_context: str | None = None, attachment_context: str | None = None,
web_search_context: str | None = None,
) -> str: ) -> str:
parts = [SYSTEM_PROMPT] 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: if repo_context:
parts.append(f""" parts.append(f"""
═══════════════════════════════════════════════════ ═══════════════════════════════════════════════════
CONNECTED REPOSITORY — FULL CODEBASE ACCESS 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} {repo_context}
IMPORTANT: The user can commit your code directly to this repository from the chat. IMPORTANT: Use exact file paths. The user can commit directly from chat.
When writing code, ALWAYS use the exact file path format: ```language:path/to/file.ext
""") """)
if attachment_context: if attachment_context:
...@@ -78,18 +117,12 @@ When writing code, ALWAYS use the exact file path format: ```language:path/to/fi ...@@ -78,18 +117,12 @@ When writing code, ALWAYS use the exact file path format: ```language:path/to/fi
═══════════════════════════════════════════════════ ═══════════════════════════════════════════════════
FILES UPLOADED IN THIS CONVERSATION 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} {attachment_context}
""") """)
if rag_context: if rag_context:
parts.append(f""" parts.append(f"""
KNOWLEDGE BASE CONTEXT KNOWLEDGE BASE CONTEXT
The following excerpts were retrieved from an attached knowledge base. Use them to inform your response when relevant. If they're not relevant to the question, ignore them.
{rag_context} {rag_context}
""") """)
......
...@@ -5,180 +5,119 @@ function headers(token) { ...@@ -5,180 +5,119 @@ function headers(token) {
if (token) h["Authorization"] = `Bearer ${token}`; if (token) h["Authorization"] = `Bearer ${token}`;
return h; return h;
} }
function authHeader(token) { return token ? { Authorization: `Bearer ${token}` } : {}; }
function authHeader(token) { function extractError(err, d) { let m = err.detail || err.message || d; if (Array.isArray(m)) return m.map(x => x.msg || JSON.stringify(x)).join(", "); if (typeof m === "object") return m.message || JSON.stringify(m); return String(m); }
return token ? { Authorization: `Bearer ${token}` } : {};
}
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);
}
async function request(method, path, token, body) { async function request(method, path, token, body) {
const opts = { method, headers: headers(token) }; const opts = { method, headers: headers(token) };
if (body) opts.body = JSON.stringify(body); if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts); const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) { if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "Request failed")); }
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(extractError(err, "Request failed"));
}
return res.json(); return res.json();
} }
// ═══════════ Auth ═══════════ // Auth
export const login = (username, password) => request("POST", "/auth/login", null, { username, password }); export const login = (u, p) => request("POST", "/auth/login", null, { username: u, password: p });
export const register = (username, email, password) => request("POST", "/auth/register", null, { username, email, password }); export const register = (u, e, p) => request("POST", "/auth/register", null, { username: u, email: e, password: p });
export const getMe = (token) => request("GET", "/auth/me", token); 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);
// ═══════════ Commit from Chat ═══════════ // Chats
export const commitFromChat = (token, chatId, data) => export const listChats = (t) => request("GET", "/chats", t);
request("POST", `/chats/${chatId}/commit`, token, data); export const createChat = (t, d = {}) => request("POST", "/chats", t, d);
export const updateChat = (t, id, d) => request("PUT", `/chats/${id}`, t, d);
export const renameChat = (t, id, title) => updateChat(t, id, { title });
export const deleteChat = (t, id) => request("DELETE", `/chats/${id}`, t);
export const getMessages = (t, id) => request("GET", `/chats/${id}/messages`, t);
export const checkGenerating = (t, id) => request("GET", `/chats/${id}/generating`, t);
export const refreshRepoContext = (t, id) => request("POST", `/chats/${id}/refresh-repo`, t);
export const commitFromChat = (t, id, d) => request("POST", `/chats/${id}/commit`, t, d);
// ═══════════ Streaming ═══════════ // Streaming
export async function* streamMessage(token, chatId, body, signal) { export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { const res = await fetch(`${BASE}/chats/${chatId}/messages`, { method: "POST", headers: headers(token), body: JSON.stringify(body), signal });
method: "POST", headers: headers(token), body: JSON.stringify(body), signal, if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "Stream failed")); }
}); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
if (!res.ok) { 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 { } } } }
const err = await res.json().catch(() => ({ detail: res.statusText })); if (buffer.trim().startsWith("data: ")) { try { yield JSON.parse(buffer.trim().slice(6)); } catch { } }
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 */ }
}
} }
// ═══════════ Attachments ═══════════ // Attachments
export async function uploadAttachments(token, chatId, files) { 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(); }
const form = new FormData(); export function getAttachmentUrl(id) { return `${BASE}/attachments/${id}/file`; }
for (const file of files) form.append("files", file); export const deleteAttachment = (t, id) => request("DELETE", `/attachments/${id}`, t);
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);
// ═══════════ Knowledge ═══════════ // Knowledge
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token); export const listKnowledgeBases = (t) => request("GET", "/knowledge", t);
export const createKnowledgeBase = (token, name, description = "") => request("POST", "/knowledge", token, { name, description }); export const createKnowledgeBase = (t, n, d = "") => request("POST", "/knowledge", t, { name: n, description: d });
export const getKnowledgeBase = (token, kbId) => request("GET", `/knowledge/${kbId}`, token); export const getKnowledgeBase = (t, id) => request("GET", `/knowledge/${id}`, t);
export const updateKnowledgeBase = (token, kbId, data) => request("PUT", `/knowledge/${kbId}`, token, data); export const updateKnowledgeBase = (t, id, d) => request("PUT", `/knowledge/${id}`, t, d);
export const deleteKnowledgeBase = (token, kbId) => request("DELETE", `/knowledge/${kbId}`, token); export const deleteKnowledgeBase = (t, id) => request("DELETE", `/knowledge/${id}`, t);
export const listKnowledgeDocuments = (token, kbId) => request("GET", `/knowledge/${kbId}/documents`, token); export const listKnowledgeDocuments = (t, id) => request("GET", `/knowledge/${id}/documents`, t);
export const deleteKnowledgeDocument = (token, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token); export const deleteKnowledgeDocument = (t, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, t);
export async function uploadDocuments(token, kbId, files) { export async function uploadDocuments(t, kbId, files) { const form = new FormData(); for (const f of files) form.append("files", f); const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(t), body: form }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); } return res.json(); }
const form = new FormData(); export const uploadDocument = (t, kbId, f) => uploadDocuments(t, kbId, [f]);
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(token), body: form }); // Admin
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); } export const adminStats = (t) => request("GET", "/admin/stats", t);
return res.json(); export const adminListUsers = (t) => request("GET", "/admin/users", t);
} export const adminCreateUser = (t, d) => request("POST", "/admin/users", t, d);
export const uploadDocument = (token, kbId, file) => uploadDocuments(token, kbId, [file]); export const adminUpdateUser = (t, id, d) => request("PUT", `/admin/users/${id}`, t, d);
export const adminDeleteUser = (t, id) => request("DELETE", `/admin/users/${id}`, t);
export const adminListChats = (t) => request("GET", "/admin/chats", t);
// ═══════════ Admin ═══════════ // Code Download
export const adminStats = (token) => request("GET", "/admin/stats", token); export async function downloadZip(t, md, title) { const res = await fetch(`${BASE}/files/download-zip`, { method: "POST", headers: headers(t), body: JSON.stringify({ markdown: md, title: title || null }) }); if (!res.ok) throw new Error("Download failed"); const ct = res.headers.get("content-type") || ""; if (ct.includes("application/zip")) { const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const raw = (title || "").trim(); a.download = `${raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code"}.zip`; a.click(); URL.revokeObjectURL(url); } else { const data = await res.json(); if (data.error) throw new Error(data.error); } }
export 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);
// ═══════════ Code Download ═══════════ // ═══════ EXPORT PPTX / DOCX ═══════
export async function downloadZip(token, markdown, chatTitle) { export async function exportPptx(token, markdown, title) {
const res = await fetch(`${BASE}/files/download-zip`, { const res = await fetch(`${BASE}/export/pptx`, { method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title }) });
method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || null }), if (!res.ok) throw new Error("PPTX export failed");
});
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 blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
const raw = (chatTitle || "").trim(); const safe = (title || "presentation").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 50) || "presentation";
const safeName = raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code"; a.download = `${safe}.pptx`;
a.download = `${safeName}.zip`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else {
const data = await res.json();
if (data.error) throw new Error(data.error);
}
} }
// ═══════════ Utilities ═══════════ export async function exportDocx(token, markdown, title) {
const CODE_BLOCK_RE = /```(\S*?)(?::(\S+?))?\s*?\n([\s\S]*?)```/g; 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");
export function extractCodeBlocks(markdown) { const blob = await res.blob();
if (!markdown) return []; const url = URL.createObjectURL(blob);
const blocks = []; const a = document.createElement("a");
let match; a.href = url;
const re = new RegExp(CODE_BLOCK_RE.source, "g"); const safe = (title || "document").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 50) || "document";
while ((match = re.exec(markdown)) !== null) { a.download = `${safe}.docx`;
const lang = (match[1] || "text").toLowerCase(); a.click();
const filename = match[2] || null; URL.revokeObjectURL(url);
const code = (match[3] || "").trim();
if (code) blocks.push({ language: lang, filename, code });
}
return blocks;
} }
// ═══════════ GitLab ═══════════ // Utilities
export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token); const CODE_BLOCK_RE = /```(\S*?)(?::(\S+?))?\s*?\n([\s\S]*?)```/g;
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data); export function extractCodeBlocks(md) { if (!md) return []; const blocks = []; let m; const re = new RegExp(CODE_BLOCK_RE.source, "g"); while ((m = re.exec(md)) !== null) { const lang = (m[1] || "text").toLowerCase(); const fn = m[2] || null; const code = (m[3] || "").trim(); if (code) blocks.push({ language: lang, filename: fn, code }); } return blocks; }
export const gitlabTestConnection = (token) => request("POST", "/gitlab/test-connection", token);
export const gitlabSearchProjects = (token, search, owned) => // GitLab
request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token); export const gitlabGetSettings = (t) => request("GET", "/gitlab/settings", t);
export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data); export const gitlabUpdateSettings = (t, d) => request("PUT", "/gitlab/settings", t, d);
export const gitlabListRepos = (token) => request("GET", "/gitlab/repos", token); export const gitlabTestConnection = (t) => request("POST", "/gitlab/test-connection", t);
export const gitlabLinkRepo = (token, gitlabProjectId) => request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId }); export const gitlabSearchProjects = (t, s, o) => request("GET", `/gitlab/projects?search=${encodeURIComponent(s || "")}&owned=${o || false}`, t);
export const gitlabUnlinkRepo = (token, repoId) => request("DELETE", `/gitlab/repos/${repoId}`, token); export const gitlabCreateProject = (t, d) => request("POST", "/gitlab/projects", t, d);
export const gitlabGetTree = (token, repoId, path, ref) => export const gitlabListRepos = (t) => request("GET", "/gitlab/repos", t);
request("GET", `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path || "")}&ref=${encodeURIComponent(ref || "")}`, token); export const gitlabLinkRepo = (t, pid) => request("POST", "/gitlab/repos", t, { gitlab_project_id: pid });
export const gitlabGetFile = (token, repoId, path, ref) => export const gitlabUnlinkRepo = (t, id) => request("DELETE", `/gitlab/repos/${id}`, t);
request("GET", `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref || "")}`, token); export const gitlabGetTree = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/tree?path=${encodeURIComponent(p || "")}&ref=${encodeURIComponent(r || "")}`, t);
export const gitlabGetBranches = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/branches`, token); export const gitlabGetFile = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/file?path=${encodeURIComponent(p)}&ref=${encodeURIComponent(r || "")}`, t);
export const gitlabCreateBranch = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/branches`, token, data); export const gitlabGetBranches = (t, id) => request("GET", `/gitlab/repos/${id}/branches`, t);
export const gitlabCommit = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit`, token, data); export const gitlabCreateBranch = (t, id, d) => request("POST", `/gitlab/repos/${id}/branches`, t, d);
export const gitlabCommitSingle = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data); export const gitlabCommit = (t, id, d) => request("POST", `/gitlab/repos/${id}/commit`, t, d);
export const gitlabCreateMR = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data); export const gitlabCommitSingle = (t, id, d) => request("POST", `/gitlab/repos/${id}/commit-single`, t, d);
export const gitlabAnalyzeProject = (token, repoId, ref) => export const gitlabCreateMR = (t, id, d) => request("POST", `/gitlab/repos/${id}/merge-request`, t, d);
request("GET", `/gitlab/repos/${repoId}/analyze?ref=${encodeURIComponent(ref || "")}`, token); export const gitlabReanalyzeRepo = (t, id) => request("POST", `/gitlab/repos/${id}/analyze`, t);
export const gitlabReanalyzeRepo = (token, repoId) => request("POST", `/gitlab/repos/${repoId}/analyze`, token); export const gitlabGetRepoMap = (t, id) => request("GET", `/gitlab/repos/${id}/map`, t);
export const gitlabGetRepoMap = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/map`, token); export const gitlabListActions = (t, s) => request("GET", `/gitlab/actions?status=${s || "pending"}`, t);
export const gitlabListActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token); export const gitlabCreateAction = (t, d) => request("POST", "/gitlab/actions", t, d);
export const gitlabCreateAction = (token, data) => request("POST", "/gitlab/actions", token, data); export const gitlabApproveAction = (t, id) => request("POST", `/gitlab/actions/${id}/approve`, t);
export const gitlabApproveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token); export const gitlabRejectAction = (t, id) => request("POST", `/gitlab/actions/${id}/reject`, t);
export const gitlabRejectAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/reject`, token); \ No newline at end of file
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle, refreshRepoContext, exportPptx, exportDocx } from "../api";
getMessages, downloadZip, listKnowledgeBases, updateChat,
uploadAttachments, gitlabListRepos, gitlabCommitSingle,
refreshRepoContext,
} from "../api";
import * as streamManager from "../streamManager"; import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode, GitBranch, RefreshCw, Globe, Presentation, FileOutput, Wand2, ChevronDown } from "lucide-react";
Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
GitBranch, RefreshCw,
} from "lucide-react";
const MODELS = [ const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" }, { 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" }, { 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_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_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" }; const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
function classifyFile(f) { 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"; }
const ext = (f.name || "").split(".").pop().toLowerCase();
const mime = f.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document";
return "text";
}
function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; } function 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 }) { export default function ChatView({ chatId }) {
...@@ -41,244 +24,114 @@ export default function ChatView({ chatId }) { ...@@ -41,244 +24,114 @@ export default function ChatView({ chatId }) {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showTools, setShowTools] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id); const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096); const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0); const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null); const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null); const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [webSearch, setWebSearch] = useState(false);
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]); const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [refreshingRepo, setRefreshingRepo] = useState(false); const [exporting, setExporting] = useState("");
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId)); const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null); const scrollRef = useRef(null); const inputRef = useRef(null); const fileRef = useRef(null); const autoScroll = useRef(true); const rafRef = useRef(null);
const inputRef = useRef(null);
const fileRef = useRef(null);
const autoScroll = useRef(true);
const rafRef = useRef(null);
useEffect(() => { useEffect(() => { setStreamData(streamManager.getStreamData(chatId)); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); }, [chatId]);
setStreamData(streamManager.getStreamData(chatId)); const scrollBottom = useCallback(() => { if (!autoScroll.current || rafRef.current) return; rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; }); }, []);
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(() => { (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(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]); useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => { 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]);
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;
dispatch({ const onScroll = useCallback(() => { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }, []);
type: "UPDATE_CHAT", 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]);
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 addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); } function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); }
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
const content = input.trim(); const content = input.trim(); if ((!content && !pendingFiles.length) || streamData.streaming) return;
if ((!content && !pendingFiles.length) || streamData.streaming) return;
const text = content || "Please analyze the attached file(s)."; const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = []; let attIds = [], uploaded = [];
if (pendingFiles.length) { if (pendingFiles.length) { setUploading(true); 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); }
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 } }); 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; setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
if (inputRef.current) inputRef.current.style.height = "auto"; if (inputRef.current) inputRef.current.style.height = "auto";
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } }); streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds, web_search: webSearch } });
}, [input, pendingFiles, streamData.streaming, state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, dispatch]); }, [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 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 handlePaste(e) { const items = Array.from(e.clipboardData?.items || []).filter(i => i.kind === "file"); if (!items.length) return; e.preventDefault(); addFiles(items.map(i => i.getAsFile()).filter(Boolean)); }
function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = Array.from(e.dataTransfer?.files || []); if (files.length) addFiles(files); } function 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 streaming = streamData.streaming;
const linkedRepo = currentChat?.linked_repo; const linkedRepo = currentChat?.linked_repo;
const handleCommitFromChat = useCallback(async (filePath, code, action) => { const handleCommitFromChat = useCallback(async (filePath, code, action) => {
if (!linkedRepo) return; if (!linkedRepo) return;
const branch = linkedRepo.default_branch;
const msg = prompt("Commit message:", `${action === "create" ? "Create" : "Update"} ${filePath} via Son of Anton`); const msg = prompt("Commit message:", `${action === "create" ? "Create" : "Update"} ${filePath} via Son of Anton`);
if (!msg) return; if (!msg) return;
try { 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; }
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; }
}, [linkedRepo, state.token, chatId]); }, [linkedRepo, state.token, chatId]);
async function handleRefreshRepo() {
setRefreshingRepo(true);
try { await refreshRepoContext(state.token, chatId); } catch { }
setRefreshingRepo(false);
}
return ( 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); }}> <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 && ( {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>}
<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 && ( {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"> <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" /> <GitBranch size={12} className="text-orange-400" /><span className="text-orange-300 font-medium">{linkedRepo.name}</span><span className="text-orange-300/60">({linkedRepo.default_branch})</span>
<span className="text-orange-300 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>
</div> </div>
)} )}
{/* Messages */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3"> <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3">
{messages.map(m => ( {messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} chatId={chatId} />)}
<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.thinking || streamData.text) && (
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
)}
{streaming && !streamData.text && !streamData.thinking && ( {streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in"> <div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1">{[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> <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>
)} )}
</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"> <div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom">
{showSettings && ( {showSettings && (
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto"> <div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between"><h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3><button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button></div>
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3> <div><label className="text-xs text-anton-muted mb-1 block">Model</label><select value={model} onChange={e => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">{MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}</select></div>
<button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button> <div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div><input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} /></div>
</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> <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>
<label className="text-xs text-anton-muted mb-1 block">Model</label> {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>)}
<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> </div>
)} )}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in"> <div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => { {pendingFiles.map((pf, i) => {
const Icon = TYPE_ICONS[pf.type] || FileText; const Icon = TYPE_ICONS[pf.type] || FileText; return (
return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}> <div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" loading="lazy" /> : ( {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>)}
<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> <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 className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div>
</div> </div>
...@@ -289,13 +142,32 @@ export default function ChatView({ chatId }) { ...@@ -289,13 +142,32 @@ export default function ChatView({ chatId }) {
<div className="flex items-end gap-1.5"> <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> <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> <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 = ""; }} /> <input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} <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"; }} />
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"; }} />
</div> </div>
{streaming ? ( {streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button> <button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
...@@ -310,12 +182,11 @@ export default function ChatView({ chatId }) { ...@@ -310,12 +182,11 @@ export default function ChatView({ chatId }) {
<span>{MODELS.find(m => m.id === model)?.label}</span> <span>{MODELS.find(m => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tok</span> <span></span><span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>} {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{webSearch && <><span></span><span className="text-green-400">🌐 Web</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>} {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>} {linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
{messages.some(m => m.role === "assistant") && ( {messages.some(m => m.role === "assistant") && <button onClick={async () => { const all = messages.filter(m => m.role === "assistant").map(m => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button>}
<button onClick={async () => { const all = messages.filter(m => m.role === "assistant").map(m => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button>
)}
</div> </div>
</div> </div>
</div> </div>
......
...@@ -2,17 +2,12 @@ import React, { useState, useMemo, useCallback } from "react"; ...@@ -2,17 +2,12 @@ import React, { useState, useMemo, useCallback } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock"; import CodeBlock from "./CodeBlock";
import { getAttachmentUrl, extractCodeBlocks, commitFromChat } from "../api"; import { getAttachmentUrl, extractCodeBlocks, commitFromChat, exportPptx, exportDocx } from "../api";
import { import { User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check, Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2, Presentation, FileOutput } from "lucide-react";
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2,
} from "lucide-react";
const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText }; const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText };
const MessageBubble = React.memo(function MessageBubble({ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo, onCommit, chatId }) {
message, isStreaming, isThinking, token, linkedRepo, onCommit, chatId,
}) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message; const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user"; const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false); const [showThinking, setShowThinking] = useState(false);
...@@ -20,14 +15,10 @@ const MessageBubble = React.memo(function MessageBubble({ ...@@ -20,14 +15,10 @@ const MessageBubble = React.memo(function MessageBubble({
const [expandedImage, setExpandedImage] = useState(null); const [expandedImage, setExpandedImage] = useState(null);
const [batchCommitting, setBatchCommitting] = useState(false); const [batchCommitting, setBatchCommitting] = useState(false);
const [batchDone, setBatchDone] = useState(false); const [batchDone, setBatchDone] = useState(false);
const [exportingType, setExportingType] = useState("");
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => { navigator.clipboard.writeText(content || ""); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [content]);
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [content]);
// Extract committable code blocks (those with filenames)
const committableBlocks = useMemo(() => { const committableBlocks = useMemo(() => {
if (isUser || !content || !linkedRepo) return []; if (isUser || !content || !linkedRepo) return [];
return extractCodeBlocks(content).filter(b => b.filename); return extractCodeBlocks(content).filter(b => b.filename);
...@@ -35,165 +26,93 @@ const MessageBubble = React.memo(function MessageBubble({ ...@@ -35,165 +26,93 @@ const MessageBubble = React.memo(function MessageBubble({
async function handleBatchCommit() { async function handleBatchCommit() {
if (!committableBlocks.length || !linkedRepo || !chatId) return; if (!committableBlocks.length || !linkedRepo || !chatId) return;
const msg = prompt( const msg = prompt(`Commit ${committableBlocks.length} file(s) to ${linkedRepo.name}/${linkedRepo.default_branch}:`, `Update ${committableBlocks.length} files via Son of Anton`);
`Commit ${committableBlocks.length} file(s) to ${linkedRepo.name}/${linkedRepo.default_branch}.\n\nCommit message:`,
`Update ${committableBlocks.length} files via Son of Anton`
);
if (!msg) return; if (!msg) return;
setBatchCommitting(true); setBatchCommitting(true);
try { 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}`); }
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}`);
}
setBatchCommitting(false); 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; const hasAttachments = attachments && attachments.length > 0;
return ( return (
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}> <div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && ( {!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="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" : ""}`}> <div className={`max-w-[85%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}>
{/* Thinking */}
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)} <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>
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"> {(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>)}
<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> </div>
)} )}
{/* Attachments */}
{hasAttachments && ( {hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-2">
{attachments.map(att => { {attachments.map(att => {
const Icon = FILE_TYPE_ICONS[att.file_type] || FileText; const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const url = getAttachmentUrl(att.id); const url = getAttachmentUrl(att.id);
if (att.file_type === "image") { if (att.file_type === "image") return (
return (
<div key={att.id} className="relative group"> <div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename} <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" />
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" {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>)}
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)} <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>
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> </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>);
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>
);
})} })}
</div> </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"}`}> <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 ? ( {isUser ? (<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>) : (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : (
<div className="prose-anton text-sm"> <div className="prose-anton text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ <ReactMarkdown remarkPlugins={[remarkGfm]} components={{
code({ node, inline, className, children, ...props }) { code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || ""); const match = /language-(\S+)/.exec(className || ""); const rawLang = match?.[1] || "";
const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>; if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null; let lang = rawLang, filename = null;
if (rawLang.includes(":")) { if (rawLang.includes(":")) { const idx = rawLang.indexOf(":"); lang = rawLang.slice(0, idx); filename = rawLang.slice(idx + 1); }
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} />; return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />;
}, },
pre({ children }) { return <>{children}</>; }, 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" />} {isStreaming && !isThinking && <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />}
</div> </div>
)} )}
</div> </div>
{/* Footer: copy, tokens, batch commit */}
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-2 sm:gap-3 mt-1.5 px-1 flex-wrap"> <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"> <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>
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"} {(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> </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 && ( {committableBlocks.length > 0 && !batchDone && (
<button onClick={handleBatchCommit} disabled={batchCommitting} <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">
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})
{batchCommitting ? <Loader2 size={11} className="animate-spin" /> : <GitCommitVertical size={11} />}
Commit All ({committableBlocks.length})
</button> </button>
)} )}
{batchDone && ( {batchDone && <span className="ml-auto flex items-center gap-1 text-[10px] text-green-400"><Check size={11} /> Committed!</span>}
<span className="ml-auto flex items-center gap-1 text-[10px] text-green-400">
<Check size={11} /> All committed!
</span>
)}
</div> </div>
)} )}
</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> </div>
); );
}); });
function _stripPrefixes(text) { function _stripPrefixes(text) { if (!text) return ""; return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim(); }
if (!text) return "";
return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim();
}
export default MessageBubble; export default MessageBubble;
\ No newline at end of file
...@@ -10,3 +10,6 @@ chromadb==0.6.3 ...@@ -10,3 +10,6 @@ chromadb==0.6.3
PyPDF2==3.0.1 PyPDF2==3.0.1
pydantic==2.10.4 pydantic==2.10.4
Pillow==11.1.0 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