Commit 34d5121e authored by Administrator's avatar Administrator

Update frontend/src/components/MessageBubble.jsx via Son of Anton

parent 9bdbdc7a
import React, { useState, useMemo, useCallback } from "react";
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";
import UIPreview, { buildPreviewHTML, isPreviewable } from "./UIPreview";
import { getAttachmentUrl, extractCodeBlocks, commitFromChat, exportPptx, exportDocx } from "../api";
import { getAttachmentUrl } from "../api";
import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2,
Presentation, FileOutput, Eye,
Image, Film, FileText, ExternalLink,
} 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, chatId }) {
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, onCommitFile }) {
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);
const [exportingType, setExportingType] = useState("");
const [showUIPreview, setShowUIPreview] = useState(false);
const handleCopy = useCallback(() => {
function handleCopy() {
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]);
const codeBlocks = useMemo(() => {
if (isUser || !content) return [];
return extractCodeBlocks(content);
}, [content, isUser]);
const previewable = useMemo(() => isPreviewable(codeBlocks), [codeBlocks]);
const previewHTML = useMemo(() => {
if (!previewable) return null;
return buildPreviewHTML(codeBlocks);
}, [previewable, codeBlocks]);
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`
);
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: "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}`);
}
setExportingType("");
}
const hasAttachments = attachments && attachments.length > 0;
return (
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
<div className={`flex 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 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>
</div>
)}
<div className={`max-w-[85%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}>
<div className={`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"
>
<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>}
......@@ -124,86 +63,67 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
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"
<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"; }}
loading="lazy"
/>
onError={(e) => { e.target.style.display = "none"; }} />
{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 className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
onClick={() => setExpandedImage(null)}>
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg" />
</div>
)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1.5 py-0.5 rounded">
<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>
);
}
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" />
<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" />
<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 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>
<ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
<ExternalLink size={12} 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"
}`}>
<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"
}`}>
{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}</>; },
}}
>
<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$/, "")}
onCommitFile={onCommitFile}
/>
);
},
pre({ children }) { return <>{children}</>; },
}}>
{content || ""}
</ReactMarkdown>
{isStreaming && !isThinking && (
......@@ -213,66 +133,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)}
</div>
{/* Preview UI Card */}
{!isUser && !isStreaming && previewable && previewHTML && (
<button
onClick={() => setShowUIPreview(true)}
className="mt-2 w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-blue-500/30 bg-blue-500/5 hover:bg-blue-500/10 hover:border-blue-500/50 transition group"
>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0 group-hover:bg-blue-500/30 transition">
<Eye size={18} className="text-blue-400" />
</div>
<div className="text-left min-w-0">
<p className="text-sm text-white font-medium">Preview UI Design</p>
<p className="text-[10px] text-blue-400/70">
{codeBlocks.length} code block{codeBlocks.length > 1 ? "s" : ""} • Click to open live preview
</p>
</div>
<Eye size={16} className="text-blue-400/50 group-hover:text-blue-400 transition ml-auto shrink-0" />
</button>
)}
{!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">
<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">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[10px] text-anton-muted">
{input_tokens?.toLocaleString()}↓/{output_tokens?.toLocaleString()}
</span>
)}
{/* Per-message export */}
<button
onClick={() => handleMsgExport("pptx")}
disabled={!!exportingType}
className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-orange-400 transition disabled:opacity-30"
title="Export as PPTX"
>
{exportingType === "pptx" ? <Loader2 size={10} className="animate-spin" /> : <Presentation size={10} />} PPTX
</button>
<button
onClick={() => handleMsgExport("docx")}
disabled={!!exportingType}
className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-blue-400 transition disabled:opacity-30"
title="Export as DOCX"
>
{exportingType === "docx" ? <Loader2 size={10} className="animate-spin" /> : <FileOutput size={10} />} DOCX
</button>
{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>
)}
{batchDone && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-green-400">
<Check size={11} /> Committed!
<span className="text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}↑ tokens
</span>
)}
</div>
......@@ -281,30 +150,18 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{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 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>
</div>
)}
{/* UI Preview Modal */}
{showUIPreview && previewHTML && (
<UIPreview
html={previewHTML}
title="UI Preview"
onClose={() => setShowUIPreview(false)}
/>
)}
</div>
);
});
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();
return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").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