Commit 1a6de5e2 authored by Mahmoud Aglan's avatar Mahmoud Aglan

try gitlabfix

parent 17ddd732
This source diff could not be displayed because it is too large. You can view the blob instead.
"""
Chat CRUD and message streaming — v4.0.0
Now with linked_repo_id for project-aware conversations.
Chat CRUD and message streaming — v4.0.0 Enhanced
Project-aware conversations + commit-from-chat support.
"""
import json
......@@ -13,9 +13,9 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo
from backend.auth import get_current_user
from backend.services import attachment_service
from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo, GitLabSettings
from backend.auth import get_current_user, require_superadmin
from backend.services import attachment_service, gitlab_service
from backend.services.generation_manager import manager as gen_manager
router = APIRouter()
......@@ -48,6 +48,12 @@ class SendMessageBody(BaseModel):
attachment_ids: list[str] = []
class CommitFromChatBody(BaseModel):
branch: str
commit_message: str
files: list[dict] # [{file_path, content, action}]
@router.get("")
def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all()
......@@ -163,6 +169,71 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/{chat_id}/commit")
async def commit_from_chat(
chat_id: str,
body: CommitFromChatBody,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Commit code from chat directly to the linked repository."""
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404, "Chat not found")
if not chat.linked_repo_id:
raise HTTPException(400, "No repository linked to this chat")
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo:
raise HTTPException(404, "Linked repository not found")
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active:
raise HTTPException(400, "GitLab not configured")
# Build commit actions
actions = []
for f in body.files:
actions.append({
"action": f.get("action", "update"),
"file_path": f["file_path"],
"content": f["content"],
})
if not actions:
raise HTTPException(400, "No files to commit")
try:
result = await gitlab_service.commit_files(
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id,
body.branch, body.commit_message, actions,
)
# Invalidate repo context cache so next message sees updated code
gen_manager.invalidate_repo_cache(repo.id)
return {
"ok": True,
"commit": result,
"files_committed": len(actions),
}
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Commit failed: {e.detail}")
@router.post("/{chat_id}/refresh-repo")
async def refresh_repo_context(
chat_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Force-refresh the cached repo context for this chat."""
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat or not chat.linked_repo_id:
raise HTTPException(400, "No repo linked")
gen_manager.invalidate_repo_cache(chat.linked_repo_id)
return {"ok": True}
def _sse(data):
return f"data: {json.dumps(data)}\n\n"
......
"""
Background generation manager — v4.0.0
Background generation manager — v4.0.0 Enhanced
Decouples AI generation from the SSE HTTP connection.
Now includes repository context for project-aware conversations.
Full repository awareness + persistent attachment context.
"""
import asyncio
import json
import time
from datetime import datetime
from typing import Optional
from dataclasses import dataclass, field
......@@ -17,6 +16,13 @@ from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service, gitlab_service
# ═══════════════════════════════════════════════════
# Repo context cache — avoids re-fetching every message
# ═══════════════════════════════════════════════════
_repo_cache: dict[str, tuple[float, str]] = {}
REPO_CACHE_TTL = 300 # 5 minutes
@dataclass
class GenerationState:
events: list = field(default_factory=list)
......@@ -78,24 +84,84 @@ class GenerationManager:
break
await asyncio.sleep(0.02)
# ═══════════════════════════════════════════════════
# Build FULL repo context with file contents + cache
# ═══════════════════════════════════════════════════
async def _build_repo_context(self, db, chat) -> Optional[str]:
"""Build repository context string if a repo is linked to this chat."""
"""Load full repository file contents for project-aware conversations."""
if not chat.linked_repo_id:
return None
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo:
return None
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active or not settings.gitlab_url or not settings.gitlab_token:
return None
# Check cache
cache_key = f"{repo.id}:{repo.default_branch}"
now = time.time()
if cache_key in _repo_cache:
ts, ctx = _repo_cache[cache_key]
if now - ts < REPO_CACHE_TTL:
return ctx
try:
tree = await gitlab_service.get_tree(
# Load full file contents (up to 80 files, 300K chars)
result = await gitlab_service.load_project_files(
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id, ref=repo.default_branch,
)
return gitlab_service.format_tree_for_prompt(tree, repo.name, repo.default_branch)
except Exception:
return f"[Repository: {repo.name} — could not load file tree]"
context = self._format_full_repo_context(result, repo)
_repo_cache[cache_key] = (now, context)
return context
except Exception as e:
# Fallback: try just the tree
try:
tree = await gitlab_service.get_tree(
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id, ref=repo.default_branch,
)
return gitlab_service.format_tree_for_prompt(tree, repo.name, repo.default_branch)
except Exception:
return f"[Repository: {repo.name} — could not load: {str(e)[:100]}]"
def _format_full_repo_context(self, result: dict, repo) -> str:
"""Format loaded project files into a prompt-friendly string."""
lines = [
f"Repository: {repo.name}",
f"Branch: {repo.default_branch}",
f"Path: {repo.path_with_namespace}",
f"Total files in tree: {result.get('total_files_in_tree', '?')}",
f"Files loaded: {result.get('files_loaded', '?')}",
f"Total characters: {result.get('total_characters', '?')}",
"",
"FILE CONTENTS:",
"=" * 60,
]
for f in result.get("files", []):
path = f.get("path", "?")
content = f.get("content", "")
lines.append(f"\n━━━ {path} ━━━")
lines.append(content)
lines.append(f"━━━ end {path} ━━━")
return "\n".join(lines)
def invalidate_repo_cache(self, repo_id: str):
"""Call when a commit is made to refresh the cache."""
keys_to_remove = [k for k in _repo_cache if k.startswith(f"{repo_id}:")]
for k in keys_to_remove:
_repo_cache.pop(k, None)
# ═══════════════════════════════════════════════════
# Main generation loop
# ═══════════════════════════════════════════════════
async def _run(
self,
......@@ -118,6 +184,7 @@ class GenerationManager:
db_user = db.query(User).filter(User.id == user_id).first()
# Quota check + reset
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
......@@ -131,6 +198,7 @@ class GenerationManager:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."})
return
# Process current message attachments
attachments = []
if attachment_ids:
attachments = (
......@@ -155,6 +223,7 @@ class GenerationManager:
if attachments:
db.commit()
# RAG context
kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
......@@ -163,17 +232,29 @@ class GenerationManager:
except Exception:
pass
# Build repo context for project-aware conversations
# ── FULL REPO CONTEXT (loads all file contents) ──
repo_context = await self._build_repo_context(db, chat)
system_prompt = build_full_prompt(rag_context, repo_context)
# ── PERSISTENT ATTACHMENT CONTEXT (all files in chat) ──
attachment_context = memory_service.gather_attachment_context(chat_id, db)
# Build system prompt with ALL context
system_prompt = build_full_prompt(
rag_context=rag_context,
repo_context=repo_context,
attachment_context=attachment_context,
)
# Build conversation messages
messages = memory_service.build_messages(chat, db)
# Inject current message's multimodal content blocks
if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": content})
messages[-1]["content"] = content_blocks
# Thinking / reasoning config
effective_max = max_tokens
thinking_config = None
if reasoning_budget > 0:
......@@ -224,6 +305,7 @@ class GenerationManager:
usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0)
# Save assistant message
assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
......@@ -240,6 +322,7 @@ class GenerationManager:
state.message_id = assistant_msg.id
# Auto-generate title for first message
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
......
"""
Build the messages list for the Bedrock/Anthropic API from chat history.
Also gathers attachment context for persistent file awareness.
"""
from sqlalchemy.orm import Session
from backend.models import Chat, Message
from backend.models import Chat, Message, ChatAttachment
MAX_CONTEXT_CHARS = 400_000
MAX_MESSAGES = 80
MAX_ATTACHMENT_CONTEXT_CHARS = 200_000
def build_messages(chat: Chat, db: Session) -> list[dict]:
......@@ -47,3 +49,52 @@ def build_messages(chat: Chat, db: Session) -> list[dict]:
result.append({"role": role, "content": content})
return result
def gather_attachment_context(chat_id: str, db: Session) -> str | None:
"""
Collect text extracts from ALL attachments in a chat.
This ensures uploaded files remain "visible" to the AI
throughout the entire conversation, not just in the message
where they were uploaded.
"""
attachments = (
db.query(ChatAttachment)
.filter(ChatAttachment.chat_id == chat_id)
.order_by(ChatAttachment.created_at)
.all()
)
if not attachments:
return None
parts = []
total_chars = 0
for att in attachments:
if att.file_type in ("image",):
# Images: just note they exist (can't include binary in text)
entry = f"[Uploaded Image: {att.original_filename} ({att.file_size} bytes)]"
elif att.file_type in ("video",):
entry = f"[Uploaded Video: {att.original_filename} ({att.file_size} bytes)]"
elif att.text_extract:
# Text files and PDFs: include full content
text = att.text_extract
remaining = MAX_ATTACHMENT_CONTEXT_CHARS - total_chars
if remaining <= 0:
parts.append(f"[{att.original_filename}: content truncated — context limit reached]")
continue
if len(text) > remaining:
text = text[:remaining] + "\n... [truncated]"
entry = (
f"━━━ {att.original_filename} ({att.file_type}, {att.file_size} bytes) ━━━\n"
f"{text}\n"
f"━━━ end of {att.original_filename} ━━━"
)
else:
entry = f"[Uploaded {att.file_type}: {att.original_filename} ({att.file_size} bytes) — no text extracted]"
parts.append(entry)
total_chars += len(entry)
return "\n\n".join(parts) if parts else None
\ No newline at end of file
......@@ -41,6 +41,11 @@ export const renameChat = (token, chatId, title) => updateChat(token, chatId, {
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 ═══════════
export const commitFromChat = (token, chatId, data) =>
request("POST", `/chats/${chatId}/commit`, token, data);
// ═══════════ Streaming ═══════════
export async function* streamMessage(token, chatId, body, signal) {
......@@ -133,10 +138,24 @@ export async function downloadZip(token, markdown, chatTitle) {
}
}
// ═══════════════════════════════════════════════════
// GitLab CE Integration — v4.0.0
// ═══════════════════════════════════════════════════
// ═══════════ Utilities ═══════════
const CODE_BLOCK_RE = /```(\S*?)(?::(\S+?))?\s*?\n([\s\S]*?)```/g;
export function extractCodeBlocks(markdown) {
if (!markdown) return [];
const blocks = [];
let match;
const re = new RegExp(CODE_BLOCK_RE.source, "g");
while ((match = re.exec(markdown)) !== null) {
const lang = (match[1] || "text").toLowerCase();
const filename = match[2] || null;
const code = (match[3] || "").trim();
if (code) blocks.push({ language: lang, filename, code });
}
return blocks;
}
// ═══════════ GitLab ═══════════
export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token);
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data);
export const gitlabTestConnection = (token) => request("POST", "/gitlab/test-connection", token);
......
import React, { useState, useEffect, useRef, useCallback } from "react";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle } from "../api";
import {
getMessages, downloadZip, listKnowledgeBases, updateChat,
uploadAttachments, gitlabListRepos, gitlabCommitSingle,
refreshRepoContext,
} from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
GitBranch,
GitBranch, RefreshCw,
} from "lucide-react";
const MODELS = [
......@@ -47,6 +51,7 @@ export default function ChatView({ chatId }) {
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [refreshingRepo, setRefreshingRepo] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null);
......@@ -88,20 +93,23 @@ export default function ChatView({ chatId }) {
}
}, [chatId]);
function onScroll() { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }
const onScroll = useCallback(() => {
const el = scrollRef.current;
if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}, []);
async function saveSettings() {
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 || "" });
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId } });
} catch { }
}
}, [state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, selectedRepoId, dispatch]);
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); }
function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); }
async function handleSend() {
const handleSend = useCallback(async () => {
const content = input.trim();
if ((!content && !pendingFiles.length) || streamData.streaming) return;
const text = content || "Please analyze the attached file(s).";
......@@ -115,7 +123,7 @@ export default function ChatView({ chatId }) {
setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
if (inputRef.current) inputRef.current.style.height = "auto";
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
}
}, [input, pendingFiles, streamData.streaming, state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, dispatch]);
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)); }
......@@ -124,15 +132,22 @@ export default function ChatView({ chatId }) {
const streaming = streamData.streaming;
const linkedRepo = currentChat?.linked_repo;
async function handleCommitFromChat(filePath, code, action) {
const handleCommitFromChat = useCallback(async (filePath, code, action) => {
if (!linkedRepo) return;
const branch = linkedRepo.default_branch;
const msg = prompt("Commit message:", `Update ${filePath} via Son of Anton`);
const msg = prompt("Commit message:", `${action === "create" ? "Create" : "Update"} ${filePath} via Son of Anton`);
if (!msg) return;
try {
await gitlabCommitSingle(state.token, linkedRepo.id, { branch, file_path: filePath, content: code, commit_message: msg, action });
alert(`✅ Committed to ${branch}`);
} catch (e) { alert(`❌ ${e.message}`); }
// Refresh repo cache so AI sees updated code
try { await refreshRepoContext(state.token, chatId); } catch { }
} catch (e) { alert(`❌ ${e.message}`); throw e; }
}, [linkedRepo, state.token, chatId]);
async function handleRefreshRepo() {
setRefreshingRepo(true);
try { await refreshRepoContext(state.token, chatId); } catch { }
setRefreshingRepo(false);
}
return (
......@@ -149,20 +164,25 @@ export default function ChatView({ chatId }) {
<GitBranch size={12} className="text-orange-400" />
<span className="text-orange-300 font-medium">{linkedRepo.name}</span>
<span className="text-orange-300/60">({linkedRepo.default_branch})</span>
<span className="text-orange-300/40 ml-auto">Project-aware mode</span>
<span className="text-orange-300/40">Full codebase loaded</span>
<button onClick={handleRefreshRepo} disabled={refreshingRepo} className="ml-auto text-orange-300/60 hover:text-orange-300 transition" title="Refresh repo context">
<RefreshCw size={12} className={refreshingRepo ? "animate-spin" : ""} />
</button>
</div>
)}
{/* Messages */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3">
{messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} />)}
{messages.map(m => (
<MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} chatId={chatId} />
))}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1">{[0, 150, 300].map(d => <span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}</div>
<span className="text-anton-muted text-sm">Thinking…</span>
<span className="text-anton-muted text-sm">{linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span>
</div>
)}
</div>
......@@ -198,11 +218,12 @@ export default function ChatView({ chatId }) {
</div>
{isSuperadmin && repos.length > 0 && (
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label>
<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})</option>)}
{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name} ({r.default_branch})</option>)}
</select>
<p className="text-[9px] text-orange-400/60 mt-1">When linked, AI loads the full codebase into context.</p>
</div>
)}
</div>
......@@ -214,7 +235,7 @@ export default function ChatView({ chatId }) {
const Icon = TYPE_ICONS[pf.type] || FileText;
return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" /> : (
{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>
......
import React, { useState } from "react";
import React, { useState, useCallback } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, GitCommitVertical } from "lucide-react";
import { Copy, Check, Download, GitCommitVertical, Loader2, Plus, Pencil } from "lucide-react";
export default function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
export default React.memo(function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
const [copied, setCopied] = useState(false);
const [committing, setCommitting] = useState(false);
const [commitDone, setCommitDone] = useState(false);
const [showCommitOptions, setShowCommitOptions] = useState(false);
function handleCopy() {
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [code]);
function handleDownload() {
const handleDownload = useCallback(() => {
const blob = new Blob([code], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
......@@ -20,27 +23,64 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
a.download = filename || `code.${language || "txt"}`;
a.click();
URL.revokeObjectURL(url);
}
}, [code, filename, language]);
function handleCommit() {
async function handleCommit(action) {
if (!onCommit || !filename) return;
onCommit(filename, code, "update");
setCommitting(true);
setShowCommitOptions(false);
try {
await onCommit(filename, code, action);
setCommitDone(true);
setTimeout(() => setCommitDone(false), 3000);
} catch { /* error handled in parent */ }
setCommitting(false);
}
const showGit = linkedRepo && filename;
const lineCount = code.split("\n").length;
return (
<div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]">
{/* Header */}
<div className="flex items-center justify-between px-3 py-1.5 bg-anton-surface border-b border-anton-border">
<div className="flex items-center justify-between px-3 py-1.5 bg-anton-surface border-b border-anton-border gap-2">
<div className="flex items-center gap-2 min-w-0">
{language && <span className="text-[10px] text-anton-accent font-mono uppercase">{language}</span>}
{language && <span className="text-[10px] text-anton-accent font-mono uppercase shrink-0">{language}</span>}
{filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>}
</div>
<div className="flex items-center gap-0.5 shrink-0">
{linkedRepo && filename && (
<button onClick={handleCommit} title={`Commit to ${linkedRepo.name}`}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition">
<GitCommitVertical size={11} /> Commit
</button>
{/* Git commit buttons */}
{showGit && !commitDone && (
<div className="relative">
{committing ? (
<span className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400">
<Loader2 size={11} className="animate-spin" /> Committing…
</span>
) : (
<button onClick={() => setShowCommitOptions(!showCommitOptions)}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition"
title={`Commit to ${linkedRepo.name}`}>
<GitCommitVertical size={11} /> Commit
</button>
)}
{showCommitOptions && (
<div className="absolute right-0 top-full mt-1 z-20 bg-anton-card border border-anton-border rounded-lg shadow-xl p-1.5 min-w-[140px] animate-fade-in">
<button onClick={() => handleCommit("update")}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-white hover:bg-anton-accent/10 rounded transition">
<Pencil size={11} className="text-blue-400" /> Update file
</button>
<button onClick={() => handleCommit("create")}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-white hover:bg-anton-accent/10 rounded transition">
<Plus size={11} className="text-green-400" /> Create new
</button>
</div>
)}
</div>
)}
{commitDone && (
<span className="flex items-center gap-1 px-2 py-1 text-[10px] text-green-400">
<Check size={11} /> Committed!
</span>
)}
<button onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download">
<Download size={12} />
......@@ -56,7 +96,7 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
language={language || "text"}
style={oneDark}
customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }}
showLineNumbers={code.split("\n").length > 3}
showLineNumbers={lineCount > 3}
lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }}
wrapLongLines
>
......@@ -64,4 +104,4 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
</SyntaxHighlighter>
</div>
);
}
\ No newline at end of file
});
\ No newline at end of file
import React, { useState } from "react";
import React, { useState, useMemo, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";
import { getAttachmentUrl } from "../api";
import { getAttachmentUrl, extractCodeBlocks, commitFromChat } from "../api";
import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink,
Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2,
} from "lucide-react";
const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText };
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo, onCommit }) {
const MessageBubble = React.memo(function MessageBubble({
message, isStreaming, isThinking, token, linkedRepo, onCommit, chatId,
}) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false);
const [copied, setCopied] = useState(false);
const [expandedImage, setExpandedImage] = useState(null);
const [batchCommitting, setBatchCommitting] = useState(false);
const [batchDone, setBatchDone] = useState(false);
function handleCopy() { navigator.clipboard.writeText(content || ""); setCopied(true); setTimeout(() => setCopied(false), 2000); }
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [content]);
// Extract committable code blocks (those with filenames)
const committableBlocks = useMemo(() => {
if (isUser || !content || !linkedRepo) return [];
return extractCodeBlocks(content).filter(b => b.filename);
}, [content, isUser, linkedRepo]);
async function handleBatchCommit() {
if (!committableBlocks.length || !linkedRepo || !chatId) return;
const msg = prompt(
`Commit ${committableBlocks.length} file(s) to ${linkedRepo.name}/${linkedRepo.default_branch}.\n\nCommit message:`,
`Update ${committableBlocks.length} files via Son of Anton`
);
if (!msg) return;
setBatchCommitting(true);
try {
await commitFromChat(token, chatId, {
branch: linkedRepo.default_branch,
commit_message: msg,
files: committableBlocks.map(b => ({
file_path: b.filename,
content: b.code,
action: "update",
})),
});
setBatchDone(true);
setTimeout(() => setBatchDone(false), 4000);
} catch (e) {
alert(`❌ Commit failed: ${e.message}`);
}
setBatchCommitting(false);
}
const hasAttachments = attachments && attachments.length > 0;
return (
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && (
<div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={16} className="text-white" />
<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-[80%] ${isUser ? "order-first" : ""}`}>
<div className={`max-w-[85%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}>
{/* Thinking */}
{thinking_content && (
<div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)} className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
<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>}
......@@ -47,6 +89,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div>
)}
{/* Attachments */}
{hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map(att => {
......@@ -56,33 +99,35 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
return (
<div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-[240px] 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"; }} />
className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={e => { e.target.style.display = "none"; }} loading="lazy" />
{expandedImage === att.id && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer" onClick={() => setExpandedImage(null)}>
<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-[9px] text-white px-1.5 py-0.5 rounded">{att.original_filename}</div>
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] sm:text-[9px] text-white px-1.5 py-0.5 rounded">{att.original_filename}</div>
</div>
);
}
return (
<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2 hover:border-anton-accent transition group">
<Icon size={16} className="shrink-0 text-blue-400" />
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-xs text-white truncate max-w-[160px]">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
<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={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
<ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a>
);
})}
</div>
)}
<div className={`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"}`}>
{/* Message bubble */}
<div className={`rounded-2xl px-3 sm:px-4 py-2.5 sm:py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"}`}>
{isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : (
......@@ -93,7 +138,11 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null;
if (rawLang.includes(":")) { const idx = rawLang.indexOf(":"); lang = rawLang.slice(0, idx); filename = rawLang.slice(idx + 1); }
if (rawLang.includes(":")) {
const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
}
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />;
},
pre({ children }) { return <>{children}</>; },
......@@ -105,13 +154,27 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)}
</div>
{/* Footer: copy, tokens, batch commit */}
{!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1">
<button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition">
<div className="flex items-center gap-2 sm:gap-3 mt-1.5 px-1 flex-wrap">
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] sm:text-[11px] text-anton-muted hover:text-white transition">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"}
</button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted">{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}</span>
<span className="text-[10px] sm:text-[11px] text-anton-muted">{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}</span>
)}
{/* Batch Commit All button */}
{committableBlocks.length > 0 && !batchDone && (
<button onClick={handleBatchCommit} disabled={batchCommitting}
className="ml-auto flex items-center gap-1 text-[10px] sm:text-[11px] text-orange-400 hover:text-orange-300 transition disabled:opacity-50">
{batchCommitting ? <Loader2 size={11} className="animate-spin" /> : <GitCommitVertical size={11} />}
Commit All ({committableBlocks.length})
</button>
)}
{batchDone && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-green-400">
<Check size={11} /> All committed!
</span>
)}
</div>
)}
......@@ -119,8 +182,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && (
<div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={16} className="text-anton-muted" />
<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>
)}
......
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