import { motion } from "framer-motion"; import { User, Bot, Copy, Check, ChevronDown, ChevronUp, FileText, Eye, Code2 } from "lucide-react"; import { useState, type ReactNode } from "react"; import type { AgentScopeMessage } from "@/hooks/use-agentscope"; interface ChatMessageProps { message: AgentScopeMessage; isStreaming?: boolean; isLast?: boolean; } export function ChatMessage({ message, isStreaming, isLast }: ChatMessageProps) { const [copied, setCopied] = useState(false); const [showRaw, setShowRaw] = useState(false); const isUser = message.role === "user"; const isAssistant = message.role === "assistant"; const text = message.content .filter((c) => c.type === "text") .map((c) => c.text) .join("\n"); const images = message.content.filter((c) => c.type === "image" && c.image_url); const files = message.content.filter((c) => c.type === "file"); const codeBlocks = extractCodeBlocks(text); const hasCode = codeBlocks.length > 0; const handleCopy = async (textToCopy: string) => { try { await navigator.clipboard.writeText(textToCopy); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { const textarea = document.createElement("textarea"); textarea.value = textToCopy; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; return ( {/* Avatar */}
{isUser ? ( ) : ( )}
{/* Content */}
{/* Role label */}
{isUser ? "You" : "Agent"} {!isStreaming && (text || files.length > 0) && ( ~{estimateTokens(text, files)} tok )}
{isAssistant && text && !isStreaming && ( )}
{/* Attached images */} {images.length > 0 && (
{images.map((img, i) => ( {img.file_name ))}
)} {/* Attached files */} {files.length > 0 && (
{files.map((f, i) => ( ))}
)} {/* Text content */} {text ? (
{renderFormattedText(text)}
) : isStreaming ? (
) : null} {/* Code blocks */} {hasCode && !isStreaming && (
{codeBlocks.map((block, i) => ( ))}
)} {/* Toggle raw view */} {isAssistant && hasCode && !isStreaming && ( )} {showRaw && (
            {text}
          
)} {/* Streaming cursor */} {isStreaming && isLast && ( )}
); } /* ─── Token estimation (rough, provider-agnostic) ─── No provider here reliably surfaces real usage through AgentScope's abstraction, so we estimate using the common "~4 chars per token" rule of thumb for English text. It's not exact, but it's good enough for a lightweight visual indicator of relative message size/cost. */ function estimateTokens(text: string, files: { text?: string }[] = []): number { const fileChars = files.reduce((sum, f) => sum + (f.text?.length || 0), 0); const totalChars = text.length + fileChars; return Math.max(1, Math.ceil(totalChars / 4)); } /* ─── Code block: syntax highlighting + live preview for HTML ─── */ const PREVIEWABLE_LANGS = new Set(["html", "htm"]); function looksLikeHtmlDoc(code: string): boolean { return /|]/i.test(code); } function CodeBlock({ language, code, onCopy, copied, }: { language: string; code: string; onCopy: (text: string) => void; copied: boolean; }) { const [showPreview, setShowPreview] = useState(false); const lang = (language || "").toLowerCase(); const canPreview = PREVIEWABLE_LANGS.has(lang) || (lang === "code" && looksLikeHtmlDoc(code)) || looksLikeHtmlDoc(code); return (
{language || "code"}
{canPreview && ( )}
{showPreview && canPreview ? (