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 ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock"; import CodeBlock from "./CodeBlock";
import UIPreview, { buildPreviewHTML, isPreviewable } from "./UIPreview"; import { getAttachmentUrl } from "../api";
import { getAttachmentUrl, extractCodeBlocks, commitFromChat, exportPptx, exportDocx } from "../api";
import { import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check, User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2, Image, Film, FileText, ExternalLink,
Presentation, FileOutput, Eye,
} 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, chatId }) { const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, onCommitFile }) {
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);
const [exportingType, setExportingType] = useState("");
const [showUIPreview, setShowUIPreview] = useState(false);
const handleCopy = useCallback(() => { function handleCopy() {
navigator.clipboard.writeText(content || ""); navigator.clipboard.writeText(content || "");
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); 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; const hasAttachments = attachments && attachments.length > 0;
return ( return (
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}> <div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && ( {!isUser && (
<div className="shrink-0 mt-1"> <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"> <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={14} className="text-white" /> <Flame size={16} className="text-white" />
</div> </div>
</div> </div>
)} )}
<div className={`max-w-[85%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}> <div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
<button <button onClick={() => setShowThinking(!showThinking)}
onClick={() => setShowThinking(!showThinking)} className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
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>}
...@@ -124,63 +63,46 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -124,63 +63,46 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
if (att.file_type === "image") { if (att.file_type === "image") {
return ( return (
<div key={att.id} className="relative group"> <div key={att.id} className="relative group">
<img <img src={`${url}?token=${token}`} alt={att.original_filename}
src={`${url}?token=${token}`} className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
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)} onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }} onError={(e) => { e.target.style.display = "none"; }} />
loading="lazy"
/>
{expandedImage === att.id && ( {expandedImage === att.id && (
<div <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 cursor-pointer" onClick={() => setExpandedImage(null)}>
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-[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} {att.original_filename}
</div> </div>
</div> </div>
); );
} }
return ( return (
<a <a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer"
key={att.id} 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">
href={`${url}?token=${token}`} <Icon size={16} className="shrink-0 text-blue-400" />
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="min-w-0">
<div className="text-[11px] text-white truncate max-w-[120px]">{att.original_filename}</div> <div className="text-xs text-white truncate max-w-[160px]">{att.original_filename}</div>
<div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div> <div className="text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</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> </a>
); );
})} })}
</div> </div>
)} )}
<div className={`rounded-2xl px-3 sm:px-4 py-2.5 sm:py-3 ${isUser <div className={`rounded-2xl px-4 py-3 ${
? "bg-anton-accent text-white rounded-br-md" isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`}> }`}>
{isUser ? ( {isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div> <div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : ( ) : (
<div className="prose-anton text-sm"> <div className="prose-anton text-sm">
<ReactMarkdown <ReactMarkdown remarkPlugins={[remarkGfm]} components={{
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) { code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || ""); const match = /language-(\S+)/.exec(className || "");
const rawLang = match?.[1] || ""; const rawLang = match?.[1] || "";
...@@ -196,14 +118,12 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -196,14 +118,12 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
language={lang} language={lang}
filename={filename} filename={filename}
code={String(children).replace(/\n$/, "")} code={String(children).replace(/\n$/, "")}
linkedRepo={linkedRepo} onCommitFile={onCommitFile}
onCommit={onCommit}
/> />
); );
}, },
pre({ children }) { return <>{children}</>; }, pre({ children }) { return <>{children}</>; },
}} }}>
>
{content || ""} {content || ""}
</ReactMarkdown> </ReactMarkdown>
{isStreaming && !isThinking && ( {isStreaming && !isThinking && (
...@@ -213,66 +133,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -213,66 +133,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)} )}
</div> </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 && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-2 sm:gap-3 mt-1.5 px-1 flex-wrap"> <div className="flex items-center gap-3 mt-1.5 px-1">
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition"> <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 ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</button> </button>
{(input_tokens > 0 || output_tokens > 0) && ( {(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[10px] text-anton-muted"> <span className="text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓/{output_tokens?.toLocaleString()} {input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}↑ tokens
</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> </span>
)} )}
</div> </div>
...@@ -281,30 +150,18 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -281,30 +150,18 @@ 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-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={14} className="text-anton-muted" /> <User size={16} className="text-anton-muted" />
</div> </div>
</div> </div>
)} )}
{/* UI Preview Modal */}
{showUIPreview && previewHTML && (
<UIPreview
html={previewHTML}
title="UI Preview"
onClose={() => setShowUIPreview(false)}
/>
)}
</div> </div>
); );
}); });
function _stripPrefixes(text) { function _stripPrefixes(text) {
if (!text) return ""; if (!text) return "";
return text return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim();
.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "")
.replace(/^\[UI DESIGN MODE\][\s\S]*?\n\n/m, "")
.trim();
} }
export default MessageBubble; 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