Agentscoope / src /components /ChatMessage.tsx
Nerdur's picture
Upload 2 files
49653a8 verified
Raw
History Blame Contribute Delete
19.4 kB
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 = /(&lt;\/?[a-zA-Z][\w-]*|\/?&gt;|<\/?[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;
}