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) => (

))}
)}
{/* 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 ? (
) : (
{highlightCode(code, lang)}
)}
);
}
/* ─── 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 = {
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) => (
{highlightLine(line, keywords, stringRe, commentRe)}
))}
>
);
}
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(
{m[0]}
,
);
lastIndex = m.index + m[0].length;
}
if (lastIndex < codePart.length) {
parts.push(...highlightKeywords(codePart.slice(lastIndex), keywords));
}
if (commentPart) {
parts.push(
{commentPart}
,
);
}
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(
{m[0]}
,
);
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(
{m[1]}
{m[2]}
{m[3]}
,
);
lastIndex = m.index + m[0].length;
}
if (lastIndex < line.length) attrParts.push(line.slice(lastIndex));
return (
{attrParts.map((part, pi) =>
typeof part === "string" ? (
{part.split(tagRe).map((seg, si) =>
/^(<\/?[a-zA-Z][\w-]*|\/?>)$/.test(seg) ? (
{seg}
) : (
{seg}
),
)}
) : (
part
),
)}
);
})}
>
);
}
function FileChip({ name, text }: { name: string; text?: string }) {
const [open, setOpen] = useState(false);
return (
{open && text && (
{text}
)}
);
}
/* ─── 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 {t.value};
case "italic":
return {t.value};
case "code":
return (
{t.value}
);
default:
return {t.value};
}
})}
>
);
}
function renderInlineText(text: string): ReactNode {
const lines = text.split("\n").map((line, i) => {
const trimmed = line.trim();
if (trimmed === "") {
return ;
}
// Headers
if (line.startsWith("### ")) {
return (
{line.slice(4)}
);
}
if (line.startsWith("## ")) {
return (
{line.slice(3)}
);
}
if (line.startsWith("# ")) {
return (
{line.slice(2)}
);
}
// Unordered list items
if (line.startsWith("- ") || line.startsWith("* ")) {
const content = line.slice(2);
return (
{renderInlineTokens(tokenizeInline(content))}
);
}
// Ordered list items
if (/^\d+\. /.test(line)) {
const content = line.replace(/^\d+\. /, "");
return (
{renderInlineTokens(tokenizeInline(content))}
);
}
// Regular paragraph with inline formatting
const tokens = tokenizeInline(line);
return (
{renderInlineTokens(tokens)}
);
});
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;
}