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 Chat CRUD and message streaming — v4.0.0 Enhanced
Now with linked_repo_id for project-aware conversations. Project-aware conversations + commit-from-chat support.
""" """
import json import json
...@@ -13,9 +13,9 @@ from fastapi.responses import StreamingResponse ...@@ -13,9 +13,9 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo, GitLabSettings
from backend.auth import get_current_user from backend.auth import get_current_user, require_superadmin
from backend.services import attachment_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
router = APIRouter() router = APIRouter()
...@@ -48,6 +48,12 @@ class SendMessageBody(BaseModel): ...@@ -48,6 +48,12 @@ class SendMessageBody(BaseModel):
attachment_ids: list[str] = [] attachment_ids: list[str] = []
class CommitFromChatBody(BaseModel):
branch: str
commit_message: str
files: list[dict] # [{file_path, content, action}]
@router.get("") @router.get("")
def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)): 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() 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 ...@@ -163,6 +169,71 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
return StreamingResponse(generate(), media_type="text/event-stream") 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): def _sse(data):
return f"data: {json.dumps(data)}\n\n" 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. 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 asyncio
import json import time
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
...@@ -17,6 +16,13 @@ from backend.system_prompt import build_full_prompt ...@@ -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 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 @dataclass
class GenerationState: class GenerationState:
events: list = field(default_factory=list) events: list = field(default_factory=list)
...@@ -78,24 +84,84 @@ class GenerationManager: ...@@ -78,24 +84,84 @@ class GenerationManager:
break break
await asyncio.sleep(0.02) await asyncio.sleep(0.02)
# ═══════════════════════════════════════════════════
# Build FULL repo context with file contents + cache
# ═══════════════════════════════════════════════════
async def _build_repo_context(self, db, chat) -> Optional[str]: 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: if not chat.linked_repo_id:
return None 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 or not settings.gitlab_url or not settings.gitlab_token:
return None 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: 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, settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id, ref=repo.default_branch, repo.gitlab_project_id, ref=repo.default_branch,
) )
return gitlab_service.format_tree_for_prompt(tree, repo.name, repo.default_branch)
except Exception: context = self._format_full_repo_context(result, repo)
return f"[Repository: {repo.name} — could not load file tree]" _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( async def _run(
self, self,
...@@ -118,6 +184,7 @@ class GenerationManager: ...@@ -118,6 +184,7 @@ class GenerationManager:
db_user = db.query(User).filter(User.id == user_id).first() db_user = db.query(User).filter(User.id == user_id).first()
# Quota check + reset
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
...@@ -131,6 +198,7 @@ class GenerationManager: ...@@ -131,6 +198,7 @@ class GenerationManager:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."}) state.events.append({"type": "error", "message": "Monthly token quota exceeded."})
return return
# Process current message attachments
attachments = [] attachments = []
if attachment_ids: if attachment_ids:
attachments = ( attachments = (
...@@ -155,6 +223,7 @@ class GenerationManager: ...@@ -155,6 +223,7 @@ class GenerationManager:
if attachments: if attachments:
db.commit() db.commit()
# RAG context
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:
...@@ -163,17 +232,29 @@ class GenerationManager: ...@@ -163,17 +232,29 @@ class GenerationManager:
except Exception: except Exception:
pass pass
# Build repo context for project-aware conversations # ── FULL REPO CONTEXT (loads all file contents) ──
repo_context = await self._build_repo_context(db, chat) 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) messages = memory_service.build_messages(chat, db)
# Inject current message's multimodal content blocks
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})
messages[-1]["content"] = content_blocks messages[-1]["content"] = content_blocks
# Thinking / reasoning config
effective_max = max_tokens effective_max = max_tokens
thinking_config = None thinking_config = None
if reasoning_budget > 0: if reasoning_budget > 0:
...@@ -224,6 +305,7 @@ class GenerationManager: ...@@ -224,6 +305,7 @@ class GenerationManager:
usage = event.get("usage", {}) usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0) output_tokens = usage.get("output_tokens", 0)
# Save assistant message
assistant_msg = Message( assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text, chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None, thinking_content=full_thinking or None,
...@@ -240,6 +322,7 @@ class GenerationManager: ...@@ -240,6 +322,7 @@ class GenerationManager:
state.message_id = assistant_msg.id state.message_id = assistant_msg.id
# Auto-generate title for first message
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:
......
""" """
Build the messages list for the Bedrock/Anthropic API from chat history. 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 sqlalchemy.orm import Session
from backend.models import Chat, Message from backend.models import Chat, Message, ChatAttachment
MAX_CONTEXT_CHARS = 400_000 MAX_CONTEXT_CHARS = 400_000
MAX_MESSAGES = 80 MAX_MESSAGES = 80
MAX_ATTACHMENT_CONTEXT_CHARS = 200_000
def build_messages(chat: Chat, db: Session) -> list[dict]: def build_messages(chat: Chat, db: Session) -> list[dict]:
...@@ -47,3 +49,52 @@ 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}) result.append({"role": role, "content": content})
return result 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, { ...@@ -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 deleteChat = (token, chatId) => request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) => request("GET", `/chats/${chatId}/messages`, 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 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 ═══════════ // ═══════════ Streaming ═══════════
export async function* streamMessage(token, chatId, body, signal) { export async function* streamMessage(token, chatId, body, signal) {
...@@ -133,10 +138,24 @@ export async function downloadZip(token, markdown, chatTitle) { ...@@ -133,10 +138,24 @@ export async function downloadZip(token, markdown, chatTitle) {
} }
} }
// ═══════════════════════════════════════════════════ // ═══════════ Utilities ═══════════
// GitLab CE Integration — v4.0.0 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 gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token);
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data); export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data);
export const gitlabTestConnection = (token) => request("POST", "/gitlab/test-connection", token); 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 { 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 * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip, Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode, FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
GitBranch, GitBranch, RefreshCw,
} from "lucide-react"; } from "lucide-react";
const MODELS = [ const MODELS = [
...@@ -47,6 +51,7 @@ export default function ChatView({ chatId }) { ...@@ -47,6 +51,7 @@ export default function ChatView({ chatId }) {
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 [streamData, setStreamData] = useState(streamManager.getStreamData(chatId)); const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null); const scrollRef = useRef(null);
...@@ -88,20 +93,23 @@ export default function ChatView({ chatId }) { ...@@ -88,20 +93,23 @@ export default function ChatView({ chatId }) {
} }
}, [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 { try {
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "" }); 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 } }); dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId } });
} catch { } } catch { }
} }, [state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, selectedRepoId, dispatch]);
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); } 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 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); }); }
async function handleSend() { 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).";
...@@ -115,7 +123,7 @@ export default function ChatView({ chatId }) { ...@@ -115,7 +123,7 @@ export default function ChatView({ chatId }) {
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 } });
} }, [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 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)); }
...@@ -124,15 +132,22 @@ export default function ChatView({ chatId }) { ...@@ -124,15 +132,22 @@ export default function ChatView({ chatId }) {
const streaming = streamData.streaming; const streaming = streamData.streaming;
const linkedRepo = currentChat?.linked_repo; const linkedRepo = currentChat?.linked_repo;
async function handleCommitFromChat(filePath, code, action) { const handleCommitFromChat = useCallback(async (filePath, code, action) => {
if (!linkedRepo) return; if (!linkedRepo) return;
const branch = linkedRepo.default_branch; 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; if (!msg) return;
try { try {
await gitlabCommitSingle(state.token, linkedRepo.id, { branch, file_path: filePath, content: code, commit_message: msg, action }); await gitlabCommitSingle(state.token, linkedRepo.id, { branch, file_path: filePath, content: code, commit_message: msg, action });
alert(`✅ Committed to ${branch}`); // Refresh repo cache so AI sees updated code
} catch (e) { alert(`❌ ${e.message}`); } 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 ( return (
...@@ -149,20 +164,25 @@ export default function ChatView({ chatId }) { ...@@ -149,20 +164,25 @@ export default function ChatView({ chatId }) {
<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 font-medium">{linkedRepo.name}</span>
<span className="text-orange-300/60">({linkedRepo.default_branch})</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> </div>
)} )}
{/* Messages */} {/* 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 => <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) && ( {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} /> <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">Thinking…</span> <span className="text-anton-muted text-sm">{linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span>
</div> </div>
)} )}
</div> </div>
...@@ -198,11 +218,12 @@ export default function ChatView({ chatId }) { ...@@ -198,11 +218,12 @@ export default function ChatView({ chatId }) {
</div> </div>
{isSuperadmin && repos.length > 0 && ( {isSuperadmin && repos.length > 0 && (
<div> <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"> <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> <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> </select>
<p className="text-[9px] text-orange-400/60 mt-1">When linked, AI loads the full codebase into context.</p>
</div> </div>
)} )}
</div> </div>
...@@ -214,7 +235,7 @@ export default function ChatView({ chatId }) { ...@@ -214,7 +235,7 @@ export default function ChatView({ chatId }) {
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" /> : ( {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"> <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`} /> <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> <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 { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; 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 [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); navigator.clipboard.writeText(code);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} }, [code]);
function handleDownload() { const handleDownload = useCallback(() => {
const blob = new Blob([code], { type: "text/plain" }); const blob = new Blob([code], { type: "text/plain" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
...@@ -20,27 +23,64 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm ...@@ -20,27 +23,64 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
a.download = filename || `code.${language || "txt"}`; a.download = filename || `code.${language || "txt"}`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }, [code, filename, language]);
function handleCommit() { async function handleCommit(action) {
if (!onCommit || !filename) return; 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 ( return (
<div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]"> <div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]">
{/* Header */} {/* 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"> <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>} {filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>}
</div> </div>
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
{linkedRepo && filename && ( {/* Git commit buttons */}
<button onClick={handleCommit} title={`Commit to ${linkedRepo.name}`} {showGit && !commitDone && (
className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition"> <div className="relative">
<GitCommitVertical size={11} /> Commit {committing ? (
</button> <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"> <button onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download">
<Download size={12} /> <Download size={12} />
...@@ -56,7 +96,7 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm ...@@ -56,7 +96,7 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
language={language || "text"} language={language || "text"}
style={oneDark} style={oneDark}
customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }} 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" }} lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }}
wrapLongLines wrapLongLines
> >
...@@ -64,4 +104,4 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm ...@@ -64,4 +104,4 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </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 ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock"; import CodeBlock from "./CodeBlock";
import { getAttachmentUrl } from "../api"; import { getAttachmentUrl, extractCodeBlocks, commitFromChat } from "../api";
import { import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check, User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink, Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2,
} from "lucide-react"; } 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({ 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 { 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);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [expandedImage, setExpandedImage] = useState(null); 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; const hasAttachments = attachments && attachments.length > 0;
return ( 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 && ( {!isUser && (
<div className="shrink-0 mt-1"> <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"> <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={16} className="text-white" /> <Flame size={14} className="text-white" />
</div> </div>
</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 && ( {thinking_content && (
<div className="mb-2"> <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} /> <Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>} {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
...@@ -47,6 +89,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -47,6 +89,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</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 => {
...@@ -56,33 +99,35 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -56,33 +99,35 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
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-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition" 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"; }} /> onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={e => { e.target.style.display = "none"; }} loading="lazy" />
{expandedImage === att.id && ( {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" /> <img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-full max-h-full object-contain rounded-lg" />
</div> </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> </div>
); );
} }
return ( return (
<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer" <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"> 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={16} className="shrink-0 text-blue-400" /> <Icon size={14} className="shrink-0 text-blue-400" />
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-white truncate max-w-[160px]">{att.original_filename}</div> <div className="text-[11px] text-white truncate max-w-[120px] sm: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-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</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> </a>
); );
})} })}
</div> </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 ? ( {isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div> <div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : ( ) : (
...@@ -93,7 +138,11 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -93,7 +138,11 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
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(":")) { 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} />; return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />;
}, },
pre({ children }) { return <>{children}</>; }, pre({ children }) { return <>{children}</>; },
...@@ -105,13 +154,27 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -105,13 +154,27 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)} )}
</div> </div>
{/* Footer: copy, tokens, batch commit */}
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1"> <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-[11px] text-anton-muted hover:text-white transition"> <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"} {copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"}
</button> </button>
{(input_tokens > 0 || output_tokens > 0) && ( {(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> </div>
)} )}
...@@ -119,8 +182,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -119,8 +182,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && ( {isUser && (
<div className="shrink-0 mt-1"> <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"> <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={16} className="text-anton-muted" /> <User size={14} className="text-anton-muted" />
</div> </div>
</div> </div>
)} )}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment