Spaces:
Paused
Paused
| 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 ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.25, ease: "easeOut" }} | |
| className={`flex w-full gap-3 px-4 py-5 ${ | |
| isLast && isStreaming ? "border-l-2 border-foreground/20" : "" | |
| }`} | |
| > | |
| {/* Avatar */} | |
| <div | |
| className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${ | |
| isUser ? "bg-foreground text-background" : "bg-secondary text-secondary-foreground" | |
| }`} | |
| > | |
| {isUser ? ( | |
| <User className="w-4 h-4" /> | |
| ) : ( | |
| <Bot className="w-4 h-4" /> | |
| )} | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 min-w-0 space-y-3"> | |
| {/* Role label */} | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> | |
| {isUser ? "You" : "Agent"} | |
| </span> | |
| {!isStreaming && (text || files.length > 0) && ( | |
| <span | |
| className="text-[10px] text-muted-foreground/40 font-mono" | |
| title="Estimated token count (~4 chars/token, not exact)" | |
| > | |
| ~{estimateTokens(text, files)} tok | |
| </span> | |
| )} | |
| </div> | |
| {isAssistant && text && !isStreaming && ( | |
| <button | |
| onClick={() => handleCopy(text)} | |
| className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors" | |
| title="Copy response" | |
| > | |
| {copied ? ( | |
| <Check className="w-3.5 h-3.5 text-green-500" /> | |
| ) : ( | |
| <Copy className="w-3.5 h-3.5" /> | |
| )} | |
| </button> | |
| )} | |
| </div> | |
| {/* Attached images */} | |
| {images.length > 0 && ( | |
| <div className="flex flex-wrap gap-2"> | |
| {images.map((img, i) => ( | |
| <img | |
| key={i} | |
| src={img.image_url} | |
| alt={img.file_name || "Attached image"} | |
| className="max-w-[200px] max-h-[200px] rounded-lg border border-border object-cover" | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| {/* Attached files */} | |
| {files.length > 0 && ( | |
| <div className="flex flex-wrap gap-2"> | |
| {files.map((f, i) => ( | |
| <FileChip key={i} name={f.file_name || "Attached file"} text={f.text} /> | |
| ))} | |
| </div> | |
| )} | |
| {/* Text content */} | |
| {text ? ( | |
| <div className="text-sm leading-relaxed text-foreground/90 space-y-2"> | |
| {renderFormattedText(text)} | |
| </div> | |
| ) : isStreaming ? ( | |
| <div className="flex gap-1.5 py-2"> | |
| <span className="w-1.5 h-1.5 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> | |
| <span className="w-1.5 h-1.5 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} /> | |
| <span className="w-1.5 h-1.5 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} /> | |
| </div> | |
| ) : null} | |
| {/* Code blocks */} | |
| {hasCode && !isStreaming && ( | |
| <div className="space-y-3"> | |
| {codeBlocks.map((block, i) => ( | |
| <CodeBlock key={i} language={block.language} code={block.code} onCopy={handleCopy} copied={copied} /> | |
| ))} | |
| </div> | |
| )} | |
| {/* Toggle raw view */} | |
| {isAssistant && hasCode && !isStreaming && ( | |
| <button | |
| onClick={() => setShowRaw(!showRaw)} | |
| className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" | |
| > | |
| {showRaw ? ( | |
| <ChevronUp className="w-3 h-3" /> | |
| ) : ( | |
| <ChevronDown className="w-3 h-3" /> | |
| )} | |
| {showRaw ? "Hide raw" : "Show raw"} | |
| </button> | |
| )} | |
| {showRaw && ( | |
| <pre className="p-3 rounded-lg bg-background border border-border text-xs text-muted-foreground overflow-x-auto"> | |
| {text} | |
| </pre> | |
| )} | |
| {/* Streaming cursor */} | |
| {isStreaming && isLast && ( | |
| <span className="inline-block w-2 h-4 bg-foreground/60 animate-pulse rounded-sm" /> | |
| )} | |
| </div> | |
| </motion.div> | |
| ); | |
| } | |
| /* ─── 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 /<!DOCTYPE html>|<html[\s>]/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 ( | |
| <div className="rounded-lg overflow-hidden border border-border bg-background/50"> | |
| <div className="flex items-center justify-between px-3 py-1.5 bg-secondary/50 border-b border-border"> | |
| <span className="text-xs text-muted-foreground font-mono"> | |
| {language || "code"} | |
| </span> | |
| <div className="flex items-center gap-1"> | |
| {canPreview && ( | |
| <button | |
| onClick={() => setShowPreview((p) => !p)} | |
| className="flex items-center gap-1 px-2 py-1 rounded text-[10px] text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors" | |
| title={showPreview ? "Show code" : "Preview"} | |
| > | |
| {showPreview ? <Code2 className="w-3 h-3" /> : <Eye className="w-3 h-3" />} | |
| {showPreview ? "Code" : "Preview"} | |
| </button> | |
| )} | |
| <button | |
| onClick={() => onCopy(code)} | |
| className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors" | |
| title="Copy code" | |
| > | |
| {copied ? ( | |
| <Check className="w-3 h-3 text-green-500" /> | |
| ) : ( | |
| <Copy className="w-3 h-3" /> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| {showPreview && canPreview ? ( | |
| <iframe | |
| title="Code preview" | |
| srcDoc={code} | |
| sandbox="allow-scripts" | |
| className="w-full h-80 bg-white" | |
| /> | |
| ) : ( | |
| <pre className="p-3 overflow-x-auto text-xs leading-relaxed text-foreground/80 font-mono"> | |
| <code>{highlightCode(code, lang)}</code> | |
| </pre> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /* ─── Minimal dependency-free syntax highlighter ─── | |
| Not a full tokenizer — just enough regex-based coloring (comments, | |
| strings, keywords, tags, numbers) to make code blocks readable without | |
| pulling in a highlighting library. */ | |
| const KEYWORDS_BY_LANG: Record<string, string[]> = { | |
| js: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "class", "extends", "import", "export", "default", "from", "async", "await", "new", "this", "try", "catch", "finally", "throw", "typeof", "instanceof", "null", "undefined", "true", "false"], | |
| jsx: [], | |
| ts: [], | |
| tsx: [], | |
| python: ["def", "return", "if", "elif", "else", "for", "while", "class", "import", "from", "as", "try", "except", "finally", "raise", "with", "lambda", "None", "True", "False", "and", "or", "not", "in", "is", "yield", "async", "await"], | |
| py: [], | |
| css: [], | |
| json: [], | |
| }; | |
| KEYWORDS_BY_LANG.jsx = KEYWORDS_BY_LANG.js; | |
| KEYWORDS_BY_LANG.ts = KEYWORDS_BY_LANG.js; | |
| KEYWORDS_BY_LANG.tsx = KEYWORDS_BY_LANG.js; | |
| KEYWORDS_BY_LANG.py = KEYWORDS_BY_LANG.python; | |
| function highlightCode(code: string, lang: string): ReactNode { | |
| if (lang === "html" || lang === "htm" || (lang === "code" && looksLikeHtmlDoc(code))) { | |
| return highlightHtml(code); | |
| } | |
| if (lang === "css") return highlightGeneric(code, [], /(".*?"|'.*?')/g, /(\/\*[\s\S]*?\*\/)/g); | |
| const keywords = KEYWORDS_BY_LANG[lang] || []; | |
| return highlightGeneric(code, keywords, /(".*?"|'.*?'|`[\s\S]*?`)/g, /(\/\/.*$|#.*$)/gm); | |
| } | |
| function highlightGeneric( | |
| code: string, | |
| keywords: string[], | |
| stringRe: RegExp, | |
| commentRe: RegExp, | |
| ): ReactNode { | |
| // Split into lines first so comments (which run to end-of-line) work. | |
| const lines = code.split("\n"); | |
| return ( | |
| <> | |
| {lines.map((line, li) => ( | |
| <div key={li}>{highlightLine(line, keywords, stringRe, commentRe)}</div> | |
| ))} | |
| </> | |
| ); | |
| } | |
| function highlightLine( | |
| line: string, | |
| keywords: string[], | |
| stringRe: RegExp, | |
| commentRe: RegExp, | |
| ): ReactNode { | |
| // Comment takes priority: everything after a comment marker is dimmed. | |
| commentRe.lastIndex = 0; | |
| const commentMatch = commentRe.exec(line); | |
| const codePart = commentMatch ? line.slice(0, commentMatch.index) : line; | |
| const commentPart = commentMatch ? line.slice(commentMatch.index) : ""; | |
| const parts: ReactNode[] = []; | |
| let lastIndex = 0; | |
| stringRe.lastIndex = 0; | |
| let m: RegExpExecArray | null; | |
| while ((m = stringRe.exec(codePart)) !== null) { | |
| if (m.index > lastIndex) { | |
| parts.push(...highlightKeywords(codePart.slice(lastIndex, m.index), keywords)); | |
| } | |
| parts.push( | |
| <span key={`str-${m.index}`} className="text-emerald-400/90"> | |
| {m[0]} | |
| </span>, | |
| ); | |
| lastIndex = m.index + m[0].length; | |
| } | |
| if (lastIndex < codePart.length) { | |
| parts.push(...highlightKeywords(codePart.slice(lastIndex), keywords)); | |
| } | |
| if (commentPart) { | |
| parts.push( | |
| <span key="comment" className="text-muted-foreground/50 italic"> | |
| {commentPart} | |
| </span>, | |
| ); | |
| } | |
| return <>{parts}</>; | |
| } | |
| function highlightKeywords(text: string, keywords: string[]): ReactNode[] { | |
| if (keywords.length === 0) return [text]; | |
| const re = new RegExp(`\\b(${keywords.join("|")})\\b`, "g"); | |
| const parts: ReactNode[] = []; | |
| let lastIndex = 0; | |
| let m: RegExpExecArray | null; | |
| while ((m = re.exec(text)) !== null) { | |
| if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index)); | |
| parts.push( | |
| <span key={`kw-${m.index}`} className="text-sky-400 font-medium"> | |
| {m[0]} | |
| </span>, | |
| ); | |
| lastIndex = m.index + m[0].length; | |
| } | |
| if (lastIndex < text.length) parts.push(text.slice(lastIndex)); | |
| return parts; | |
| } | |
| function highlightHtml(code: string): ReactNode { | |
| const lines = code.split("\n"); | |
| const tagRe = /(<\/?[a-zA-Z][\w-]*|\/?>|<\/?[a-zA-Z][\w-]*|\/?>)/g; | |
| const attrRe = /([\w-]+)(=)("[^"]*"|'[^']*')/g; | |
| return ( | |
| <> | |
| {lines.map((line, li) => { | |
| const attrParts: ReactNode[] = []; | |
| let lastIndex = 0; | |
| let m: RegExpExecArray | null; | |
| attrRe.lastIndex = 0; | |
| while ((m = attrRe.exec(line)) !== null) { | |
| if (m.index > lastIndex) attrParts.push(line.slice(lastIndex, m.index)); | |
| attrParts.push( | |
| <span key={`a-${m.index}`}> | |
| <span className="text-amber-400/90">{m[1]}</span> | |
| <span className="text-foreground/60">{m[2]}</span> | |
| <span className="text-emerald-400/90">{m[3]}</span> | |
| </span>, | |
| ); | |
| lastIndex = m.index + m[0].length; | |
| } | |
| if (lastIndex < line.length) attrParts.push(line.slice(lastIndex)); | |
| return ( | |
| <div key={li}> | |
| {attrParts.map((part, pi) => | |
| typeof part === "string" ? ( | |
| <span key={pi}> | |
| {part.split(tagRe).map((seg, si) => | |
| /^(<\/?[a-zA-Z][\w-]*|\/?>)$/.test(seg) ? ( | |
| <span key={si} className="text-sky-400 font-medium"> | |
| {seg} | |
| </span> | |
| ) : ( | |
| <span key={si}>{seg}</span> | |
| ), | |
| )} | |
| </span> | |
| ) : ( | |
| part | |
| ), | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </> | |
| ); | |
| } | |
| function FileChip({ name, text }: { name: string; text?: string }) { | |
| const [open, setOpen] = useState(false); | |
| return ( | |
| <div className="rounded-lg border border-border bg-secondary/40 overflow-hidden max-w-full"> | |
| <button | |
| onClick={() => text && setOpen(!open)} | |
| className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-foreground/80 hover:bg-secondary/60 transition-colors" | |
| title={name} | |
| > | |
| <FileText className="w-3.5 h-3.5 flex-shrink-0" /> | |
| <span className="truncate max-w-[180px]">{name}</span> | |
| {text && (open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)} | |
| </button> | |
| {open && text && ( | |
| <pre className="px-2.5 pb-2.5 text-[10px] text-muted-foreground max-h-40 overflow-y-auto whitespace-pre-wrap"> | |
| {text} | |
| </pre> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /* ─── Markdown-like rendering (no dangerouslySetInnerHTML) ─── */ | |
| function renderFormattedText(text: string): ReactNode[] { | |
| const parts: ReactNode[] = []; | |
| const codeBlockRegex = /```(\w*)\n?([\s\S]*?)```/g; | |
| let lastIndex = 0; | |
| let match: RegExpExecArray | null; | |
| while ((match = codeBlockRegex.exec(text)) !== null) { | |
| if (match.index > lastIndex) { | |
| parts.push(renderInlineText(text.slice(lastIndex, match.index))); | |
| } | |
| lastIndex = match.index + match[0].length; | |
| } | |
| if (lastIndex < text.length) { | |
| parts.push(renderInlineText(text.slice(lastIndex))); | |
| } | |
| return parts.length > 0 ? parts : [renderInlineText(text)]; | |
| } | |
| type InlineToken = | |
| | { type: "text"; value: string } | |
| | { type: "bold"; value: string } | |
| | { type: "italic"; value: string } | |
| | { type: "code"; value: string }; | |
| function tokenizeInline(line: string): InlineToken[] { | |
| const tokens: InlineToken[] = []; | |
| const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`([^`]+)`)/g; | |
| let lastIndex = 0; | |
| let m: RegExpExecArray | null; | |
| while ((m = regex.exec(line)) !== null) { | |
| if (m.index > lastIndex) { | |
| tokens.push({ type: "text", value: line.slice(lastIndex, m.index) }); | |
| } | |
| if (m[1]) tokens.push({ type: "bold", value: m[2] }); | |
| else if (m[3]) tokens.push({ type: "italic", value: m[4] }); | |
| else if (m[5]) tokens.push({ type: "code", value: m[6] }); | |
| lastIndex = m.index + m[0].length; | |
| } | |
| if (lastIndex < line.length) { | |
| tokens.push({ type: "text", value: line.slice(lastIndex) }); | |
| } | |
| return tokens; | |
| } | |
| function renderInlineTokens(tokens: InlineToken[]): ReactNode { | |
| return ( | |
| <> | |
| {tokens.map((t, i) => { | |
| switch (t.type) { | |
| case "bold": | |
| return <strong key={i}>{t.value}</strong>; | |
| case "italic": | |
| return <em key={i}>{t.value}</em>; | |
| case "code": | |
| return ( | |
| <code | |
| key={i} | |
| className="bg-secondary px-1 py-0.5 rounded text-xs font-mono text-foreground/80" | |
| > | |
| {t.value} | |
| </code> | |
| ); | |
| default: | |
| return <span key={i}>{t.value}</span>; | |
| } | |
| })} | |
| </> | |
| ); | |
| } | |
| function renderInlineText(text: string): ReactNode { | |
| const lines = text.split("\n").map((line, i) => { | |
| const trimmed = line.trim(); | |
| if (trimmed === "") { | |
| return <div key={i} className="h-3" />; | |
| } | |
| // Headers | |
| if (line.startsWith("### ")) { | |
| return ( | |
| <p key={i} className="text-sm font-semibold text-foreground pt-2 first:pt-0"> | |
| {line.slice(4)} | |
| </p> | |
| ); | |
| } | |
| if (line.startsWith("## ")) { | |
| return ( | |
| <p key={i} className="text-base font-bold text-foreground pt-3 first:pt-0"> | |
| {line.slice(3)} | |
| </p> | |
| ); | |
| } | |
| if (line.startsWith("# ")) { | |
| return ( | |
| <p key={i} className="text-lg font-bold text-foreground pt-3 first:pt-0"> | |
| {line.slice(2)} | |
| </p> | |
| ); | |
| } | |
| // Unordered list items | |
| if (line.startsWith("- ") || line.startsWith("* ")) { | |
| const content = line.slice(2); | |
| return ( | |
| <li key={i} className="text-sm text-foreground/80 ml-4 list-disc"> | |
| {renderInlineTokens(tokenizeInline(content))} | |
| </li> | |
| ); | |
| } | |
| // Ordered list items | |
| if (/^\d+\. /.test(line)) { | |
| const content = line.replace(/^\d+\. /, ""); | |
| return ( | |
| <li key={i} className="text-sm text-foreground/80 ml-4 list-decimal"> | |
| {renderInlineTokens(tokenizeInline(content))} | |
| </li> | |
| ); | |
| } | |
| // Regular paragraph with inline formatting | |
| const tokens = tokenizeInline(line); | |
| return ( | |
| <p key={i} className="text-sm text-foreground/80"> | |
| {renderInlineTokens(tokens)} | |
| </p> | |
| ); | |
| }); | |
| return <>{lines}</>; | |
| } | |
| function extractCodeBlocks(text: string): { language: string; code: string }[] { | |
| const blocks: { language: string; code: string }[] = []; | |
| const regex = /```(\w*)\n?([\s\S]*?)```/g; | |
| let match: RegExpExecArray | null; | |
| while ((match = regex.exec(text)) !== null) { | |
| blocks.push({ | |
| language: match[1] || "text", | |
| code: match[2].trim(), | |
| }); | |
| } | |
| return blocks; | |
| } | |