Commit b02ad490 authored by Mahmoud Aglan's avatar Mahmoud Aglan

new fix

parent 3102b2dd
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -66,19 +66,16 @@ def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get
@router.post("")
def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
perms = get_user_permissions(user.id, db)
# Enforce max chats
max_chats = perms.get("max_chats", 0)
if max_chats > 0:
current_count = count_user_chats(user.id, db)
if current_count >= max_chats:
raise HTTPException(403, f"Chat limit reached ({max_chats}). Delete old chats or contact admin.")
# Validate KB permission
if body.knowledge_base_id:
if not perms.get("can_use_knowledge_base"):
raise HTTPException(403, "Knowledge base access not enabled for your account.")
# Validate GitLab permission
if body.linked_repo_id:
if not perms.get("can_use_gitlab"):
raise HTTPException(403, "GitLab access not enabled for your account.")
......@@ -166,19 +163,16 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user))
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
perms = get_user_permissions(user.id, db)
# Enforce daily message limit
max_per_day = perms.get("max_messages_per_day", 0)
if max_per_day > 0:
today_count = count_user_messages_today(user.id, db)
if today_count >= max_per_day:
raise HTTPException(429, f"Daily message limit reached ({max_per_day}). Try again tomorrow.")
# Enforce web search permission
web_search = body.web_search
if web_search and not perms.get("can_use_web_search"):
web_search = False
# Enforce attachment permission
if body.attachment_ids and not perms.get("can_use_attachments"):
raise HTTPException(403, "File attachments not enabled for your account.")
......@@ -187,7 +181,6 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
if len(body.attachment_ids) > max_att:
raise HTTPException(400, f"Too many attachments. Max {max_att} per message.")
# Enforce model & limits
model = check_model_allowed(user.id, body.model or "eu.anthropic.claude-opus-4-6-v1", db)
max_tokens = min(body.max_tokens, perms.get("max_tokens_cap", 4096))
reasoning_budget = min(body.reasoning_budget, perms.get("max_reasoning_budget", 0))
......@@ -206,21 +199,100 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
@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)):
async def commit_from_chat(
chat_id: str,
body: CommitFromChatBody,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Commit files from chat to linked GitLab repo.
Auto-detects whether each file should be 'create' or 'update'
by checking the repo tree, so it never fails on wrong action type.
"""
check_feature(user.id, "use_gitlab", db)
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")
if not chat:
raise HTTPException(404, "Chat not found")
if not chat.linked_repo_id:
raise HTTPException(400, "No repository linked")
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo: raise HTTPException(404, "Linked repository not found")
if not repo:
raise HTTPException(404, "Linked repository not found")
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active: raise HTTPException(400, "GitLab not configured")
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")
if not settings or not settings.is_active:
raise HTTPException(400, "GitLab not configured")
# ── Fetch repo tree to know which files already exist ──
existing_paths = set()
try:
tree = await gitlab_service.get_tree(
settings.gitlab_url,
settings.gitlab_token,
repo.gitlab_project_id,
ref=body.branch,
recursive=True,
)
existing_paths = {
item["path"] for item in tree if item["type"] == "blob"
}
except Exception:
# If tree fetch fails (empty repo, network issue, etc.),
# we'll try all as "create" since we can't know what exists
pass
# ── Build actions with auto-detected create/update ──
actions = []
for f in body.files:
file_path = f.get("file_path", "")
content = f.get("content", "")
requested_action = f.get("action", "auto")
if not file_path or not content:
continue
file_exists = file_path in existing_paths
# Smart action resolution
if requested_action in ("auto", "upsert"):
# Auto-detect: update if exists, create if not
actual_action = "update" if file_exists else "create"
elif requested_action == "update" and not file_exists:
# User said update but file doesn't exist → create instead
actual_action = "create"
elif requested_action == "create" and file_exists:
# User said create but file already exists → update instead
actual_action = "update"
else:
actual_action = requested_action
actions.append({
"action": actual_action,
"file_path": file_path,
"content": content,
})
if not actions:
raise HTTPException(400, "No valid 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)
result = await gitlab_service.commit_files(
settings.gitlab_url,
settings.gitlab_token,
repo.gitlab_project_id,
body.branch,
body.commit_message,
actions,
)
gen_manager.invalidate_repo_cache(repo.id)
return {"ok": True, "commit": result, "files_committed": len(actions)}
return {
"ok": True,
"commit": result,
"files_committed": len(actions),
}
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Commit failed: {e.detail}")
......@@ -238,17 +310,41 @@ def _sse(data):
def _chat_dict(c, db=None):
d = {"id": c.id, "title": c.title, "model": c.model, "knowledge_base_id": c.knowledge_base_id, "linked_repo_id": c.linked_repo_id, "max_tokens": c.max_tokens or 4096, "reasoning_budget": c.reasoning_budget or 0, "created_at": str(c.created_at), "updated_at": str(c.updated_at)}
d = {
"id": c.id, "title": c.title, "model": c.model,
"knowledge_base_id": c.knowledge_base_id,
"linked_repo_id": c.linked_repo_id,
"max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at),
"updated_at": str(c.updated_at),
}
if db and c.linked_repo_id:
repo = db.query(LinkedRepo).filter(LinkedRepo.id == c.linked_repo_id).first()
if repo:
d["linked_repo"] = {"id": repo.id, "name": repo.name, "path_with_namespace": repo.path_with_namespace, "default_branch": repo.default_branch, "web_url": repo.web_url, "gitlab_project_id": repo.gitlab_project_id, "map_status": repo.map_status}
d["linked_repo"] = {
"id": repo.id, "name": repo.name,
"path_with_namespace": repo.path_with_namespace,
"default_branch": repo.default_branch,
"web_url": repo.web_url,
"gitlab_project_id": repo.gitlab_project_id,
"map_status": repo.map_status,
}
return d
def _msg_dict(m):
return {"id": m.id, "role": m.role, "content": m.content, "thinking_content": m.thinking_content, "input_tokens": m.input_tokens, "output_tokens": m.output_tokens, "created_at": str(m.created_at)}
return {
"id": m.id, "role": m.role, "content": m.content,
"thinking_content": m.thinking_content,
"input_tokens": m.input_tokens, "output_tokens": m.output_tokens,
"created_at": str(m.created_at),
}
def _att_brief(a):
return {"id": a.id, "original_filename": a.original_filename, "mime_type": a.mime_type, "file_type": a.file_type, "file_size": a.file_size}
\ No newline at end of file
return {
"id": a.id, "original_filename": a.original_filename,
"mime_type": a.mime_type, "file_type": a.file_type,
"file_size": a.file_size,
}
\ No newline at end of file
"""
GitLab CE integration routes — superadmin only.
Son of Anton v4.0.0
Son of Anton v4.2.0
"""
import asyncio
......@@ -46,7 +46,7 @@ class SingleCommitBody(BaseModel):
file_path: str
content: str
commit_message: str
action: str = "update"
action: str = "auto"
class BranchBody(BaseModel):
branch_name: str
......@@ -188,7 +188,6 @@ async def link_repo(body: LinkRepoBody, admin: User = Depends(require_superadmin
db.commit()
db.refresh(repo)
# Start background analysis
asyncio.create_task(_analyze_repo_background(
repo.id, s.gitlab_url, s.gitlab_token,
project["id"], project.get("default_branch", "main"),
......@@ -347,12 +346,56 @@ async def create_branch(repo_id: str, body: BranchBody, admin: User = Depends(re
@router.post("/repos/{repo_id}/commit")
async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
"""
Commit multiple files. Auto-detects create vs update per file.
"""
s = _get_settings(db)
repo = _get_repo(repo_id, db)
# Fetch tree to know which files exist
existing_paths = set()
try:
tree = await gitlab_service.get_tree(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
ref=body.branch, recursive=True,
)
existing_paths = {item["path"] for item in tree if item["type"] == "blob"}
except Exception:
pass
resolved_actions = []
for a in body.actions:
file_path = a.get("file_path", "")
content = a.get("content", "")
requested = a.get("action", "auto")
if not file_path:
continue
file_exists = file_path in existing_paths
if requested in ("auto", "upsert"):
actual = "update" if file_exists else "create"
elif requested == "update" and not file_exists:
actual = "create"
elif requested == "create" and file_exists:
actual = "update"
else:
actual = requested
resolved_actions.append({
"action": actual,
"file_path": file_path,
"content": content,
})
if not resolved_actions:
raise HTTPException(400, "No valid files to commit")
try:
result = await gitlab_service.commit_files(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.branch, body.commit_message, body.actions,
body.branch, body.commit_message, resolved_actions,
)
return result
except gitlab_service.GitLabError as e:
......@@ -360,13 +403,41 @@ async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(requ
@router.post("/repos/{repo_id}/commit-single")
async def commit_single(repo_id: str, body: SingleCommitBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
async def commit_single(
repo_id: str,
body: SingleCommitBody,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""
Commit a single file. Auto-detects create vs update.
"""
s = _get_settings(db)
repo = _get_repo(repo_id, db)
# Auto-detect whether file exists
action = body.action
if action in ("update", "create", "auto"):
try:
await gitlab_service.get_file_content(
s.gitlab_url, s.gitlab_token,
repo.gitlab_project_id, body.file_path, ref=body.branch,
)
file_exists = True
except gitlab_service.GitLabError:
file_exists = False
if action == "auto":
action = "update" if file_exists else "create"
elif action == "update" and not file_exists:
action = "create"
elif action == "create" and file_exists:
action = "update"
try:
result = await gitlab_service.commit_single_file(
s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
body.branch, body.file_path, body.content, body.commit_message, body.action,
body.branch, body.file_path, body.content, body.commit_message, action,
)
return result
except gitlab_service.GitLabError as e:
......
......@@ -4,7 +4,11 @@ import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";
import UIPreview, { buildPreviewHTML, isPreviewable } from "./UIPreview";
import { getAttachmentUrl, extractCodeBlocks, commitFromChat, exportPptx, exportDocx } from "../api";
import { User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check, Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2, Presentation, FileOutput, Eye } from "lucide-react";
import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2,
Presentation, FileOutput, Eye,
} from "lucide-react";
const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText };
......@@ -19,14 +23,17 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const [exportingType, setExportingType] = useState("");
const [showUIPreview, setShowUIPreview] = useState(false);
const handleCopy = useCallback(() => { navigator.clipboard.writeText(content || ""); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [content]);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [content]);
const committableBlocks = useMemo(() => {
if (isUser || !content || !linkedRepo) return [];
return extractCodeBlocks(content).filter(b => b.filename);
}, [content, isUser, linkedRepo]);
// Check if this message has previewable UI code
const codeBlocks = useMemo(() => {
if (isUser || !content) return [];
return extractCodeBlocks(content);
......@@ -41,17 +48,39 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
async function handleBatchCommit() {
if (!committableBlocks.length || !linkedRepo || !chatId) return;
const msg = prompt(`Commit ${committableBlocks.length} file(s) to ${linkedRepo.name}/${linkedRepo.default_branch}:`, `Update ${committableBlocks.length} files via Son of Anton`);
const msg = prompt(
`Commit ${committableBlocks.length} file(s) to ${linkedRepo.name}/${linkedRepo.default_branch}:`,
`Update ${committableBlocks.length} files via Son of Anton`
);
if (!msg) return;
setBatchCommitting(true);
try { await commitFromChat(token, chatId, { branch: linkedRepo.default_branch, commit_message: msg, files: committableBlocks.map(b => ({ file_path: b.filename, content: b.code, action: "update" })) }); setBatchDone(true); setTimeout(() => setBatchDone(false), 4000); } catch (e) { alert(`❌ ${e.message}`); }
try {
await commitFromChat(token, chatId, {
branch: linkedRepo.default_branch,
commit_message: msg,
files: committableBlocks.map(b => ({
file_path: b.filename,
content: b.code,
action: "auto",
})),
});
setBatchDone(true);
setTimeout(() => setBatchDone(false), 4000);
} catch (e) {
alert(`❌ ${e.message}`);
}
setBatchCommitting(false);
}
async function handleMsgExport(type) {
if (!content) return;
setExportingType(type);
try { if (type === "pptx") await exportPptx(token, content, "response"); else await exportDocx(token, content, "response"); } catch (e) { alert(`Export failed: ${e.message}`); }
try {
if (type === "pptx") await exportPptx(token, content, "response");
else await exportDocx(token, content, "response");
} catch (e) {
alert(`Export failed: ${e.message}`);
}
setExportingType("");
}
......@@ -59,51 +88,132 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
return (
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && (<div className="shrink-0 mt-1"><div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"><Flame size={14} className="text-white" /></div></div>)}
{!isUser && (
<div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={14} className="text-white" />
</div>
</div>
)}
<div className={`max-w-[85%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}>
{thinking_content && (
<div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)} className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"><Brain size={12} />{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}</button>
{(showThinking || isThinking) && (<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">{thinking_content}{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}</div>)}
<button
onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
>
<Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button>
{(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
{thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div>
)}
</div>
)}
{hasAttachments && (
<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 url = getAttachmentUrl(att.id);
if (att.file_type === "image") return (
<div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition" onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)} onError={e => { e.target.style.display = "none"; }} loading="lazy" />
{expandedImage === att.id && (<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 cursor-pointer" onClick={() => setExpandedImage(null)}><img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-full max-h-full object-contain rounded-lg" /></div>)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1.5 py-0.5 rounded">{att.original_filename}</div>
</div>
if (att.file_type === "image") {
return (
<div key={att.id} className="relative group">
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
/>
{expandedImage === att.id && (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 cursor-pointer"
onClick={() => setExpandedImage(null)}
>
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg"
/>
</div>
)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1.5 py-0.5 rounded">
{att.original_filename}
</div>
</div>
);
}
return (
<a
key={att.id}
href={`${url}?token=${token}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 hover:border-anton-accent transition group"
>
<Icon size={14} className="shrink-0 text-blue-400" />
<div className="min-w-0">
<div className="text-[11px] text-white truncate max-w-[120px]">{att.original_filename}</div>
<div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div>
<ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a>
);
return (<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 hover:border-anton-accent transition group"><Icon size={14} className="shrink-0 text-blue-400" /><div className="min-w-0"><div className="text-[11px] text-white truncate max-w-[120px]">{att.original_filename}</div><div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div></div><ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" /></a>);
})}
</div>
)}
<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>) : (
<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>
) : (
<div className="prose-anton text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || ""); const rawLang = match?.[1] || "";
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); }
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />;
},
pre({ children }) { return <>{children}</>; },
}}>{content || ""}</ReactMarkdown>
{isStreaming && !isThinking && <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || "");
const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null;
if (rawLang.includes(":")) {
const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
}
return (
<CodeBlock
language={lang}
filename={filename}
code={String(children).replace(/\n$/, "")}
linkedRepo={linkedRepo}
onCommit={onCommit}
/>
);
},
pre({ children }) { return <>{children}</>; },
}}
>
{content || ""}
</ReactMarkdown>
{isStreaming && !isThinking && (
<span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />
)}
</div>
)}
</div>
{/* Preview UI Card — shown when message has previewable code */}
{/* Preview UI Card */}
{!isUser && !isStreaming && previewable && previewHTML && (
<button
onClick={() => setShowUIPreview(true)}
......@@ -124,31 +234,64 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{!isUser && !isStreaming && content && (
<div className="flex items-center gap-2 sm:gap-3 mt-1.5 px-1 flex-wrap">
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition">{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"}</button>
{(input_tokens > 0 || output_tokens > 0) && <span className="text-[10px] text-anton-muted">{input_tokens?.toLocaleString()}↓/{output_tokens?.toLocaleString()}</span>}
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[10px] text-anton-muted">
{input_tokens?.toLocaleString()}↓/{output_tokens?.toLocaleString()}
</span>
)}
{/* Per-message export */}
<button onClick={() => handleMsgExport("pptx")} disabled={!!exportingType} className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-orange-400 transition disabled:opacity-30" title="Export as PPTX">
<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">
<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>
{committableBlocks.length > 0 && !batchDone && (
<button onClick={handleBatchCommit} disabled={batchCommitting} className="ml-auto flex items-center gap-1 text-[10px] text-orange-400 hover:text-orange-300 transition disabled:opacity-50">
{batchCommitting ? <Loader2 size={11} className="animate-spin" /> : <GitCommitVertical size={11} />} Commit All ({committableBlocks.length})
<button
onClick={handleBatchCommit}
disabled={batchCommitting}
className="ml-auto flex items-center gap-1 text-[10px] text-orange-400 hover:text-orange-300 transition disabled:opacity-50"
>
{batchCommitting ? <Loader2 size={11} className="animate-spin" /> : <GitCommitVertical size={11} />}
Commit All ({committableBlocks.length})
</button>
)}
{batchDone && <span className="ml-auto flex items-center gap-1 text-[10px] text-green-400"><Check size={11} /> Committed!</span>}
{batchDone && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-green-400">
<Check size={11} /> Committed!
</span>
)}
</div>
)}
</div>
{isUser && (<div className="shrink-0 mt-1"><div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"><User size={14} className="text-anton-muted" /></div></div>)}
{isUser && (
<div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={14} className="text-anton-muted" />
</div>
</div>
)}
{/* UI Preview Modal */}
{showUIPreview && previewHTML && (
<UIPreview
html={previewHTML}
title={`UI Preview`}
title="UI Preview"
onClose={() => setShowUIPreview(false)}
/>
)}
......@@ -156,6 +299,12 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
);
});
function _stripPrefixes(text) { if (!text) return ""; return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").replace(/^\[UI DESIGN MODE\][\s\S]*?\n\n/m, "").trim(); }
function _stripPrefixes(text) {
if (!text) return "";
return text
.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "")
.replace(/^\[UI DESIGN MODE\][\s\S]*?\n\n/m, "")
.trim();
}
export default MessageBubble;
\ 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