'use client';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github-dark.css';
import { Check, ChevronDown, ChevronRight, Copy, Search, Calculator, Sparkles } from 'lucide-react';
import clsx from 'clsx';
import type { Message } from '@/types/chat';
import SteamTyping from '@/components/svg/SteamTyping';
// ---- Content parser: split into text / think / tool_call / tool_result segments ----
type Segment =
| { kind: 'text'; content: string }
| { kind: 'think'; content: string; closed: boolean }
| { kind: 'tool_call'; content: string; closed: boolean }
| { kind: 'tool_result'; content: string; closed: boolean };
function parseSegments(raw: string): Segment[] {
const segs: Segment[] = [];
let i = 0;
// marker -> [openTag, closeTag, kind]
const markers: Array<[string, string, Segment['kind']]> = [
['', '', 'think'],
['<|python_start|>', '<|python_end|>', 'tool_call'],
['<|output_start|>', '<|output_end|>', 'tool_result'],
];
while (i < raw.length) {
// find the nearest opening marker
let bestOpen = -1;
let bestMarker: [string, string, Segment['kind']] | null = null;
for (const m of markers) {
const p = raw.indexOf(m[0], i);
if (p !== -1 && (bestOpen === -1 || p < bestOpen)) { bestOpen = p; bestMarker = m; }
}
if (bestOpen === -1) {
if (i < raw.length) segs.push({ kind: 'text', content: raw.slice(i) });
break;
}
if (bestOpen > i) segs.push({ kind: 'text', content: raw.slice(i, bestOpen) });
const [openTag, closeTag, kind] = bestMarker!;
const afterOpen = bestOpen + openTag.length;
const closeIdx = raw.indexOf(closeTag, afterOpen);
if (closeIdx === -1) {
segs.push({ kind, content: raw.slice(afterOpen), closed: false });
i = raw.length;
} else {
segs.push({ kind, content: raw.slice(afterOpen, closeIdx), closed: true });
i = closeIdx + closeTag.length;
}
}
return segs;
}
function ThinkBlock({ content, closed }: { content: string; closed: boolean }) {
const [open, setOpen] = useState(true);
return (
{open && (
{content}
)}
);
}
function ToolCallBlock({ content, closed }: { content: string; closed: boolean }) {
let parsed: { tool?: string; arguments?: Record } | null = null;
try { parsed = JSON.parse(content); } catch { /* streaming — partial JSON */ }
const toolName = parsed?.tool ?? 'tool';
const icon = toolName === 'web_search' ? : toolName === 'calculator' ? : ;
const query = parsed?.arguments ? JSON.stringify(parsed.arguments) : content;
return (
{icon}
Calling {toolName}{closed ? '' : '…'}
{query}
);
}
function ToolResultBlock({ content, closed }: { content: string; closed: boolean }) {
const [open, setOpen] = useState(false);
let summary = content;
try {
const j = JSON.parse(content);
if (j?.output?.answer) summary = String(j.output.answer).slice(0, 220);
else if (j?.output?.results?.[0]?.snippet) summary = String(j.output.results[0].snippet).slice(0, 160);
else if (j?.output?.value !== undefined) summary = `= ${j.output.value}`;
else if (j?.error) summary = `error: ${j.error}`;
} catch { /* partial */ }
return (
{open && (
{content}
)}
);
}
interface Props {
message: Message;
isStreaming?: boolean;
}
function CodeBlock({ inline, className, children, ...props }: {
inline?: boolean;
className?: string;
children?: React.ReactNode;
} & React.HTMLAttributes) {
const [copied, setCopied] = useState(false);
const content = String(children ?? '').replace(/\n$/, '');
if (inline) {
return (
{children}
);
}
const copy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
return (
);
}
export default function MessageBubble({ message, isStreaming }: Props) {
const isUser = message.role === 'user';
const isConsole = message.role === 'console';
if (isConsole) {
return (
);
}
return (
{!isUser && isStreaming && message.content.length === 0 ? (
) : isUser ? (
{message.content}
) : (
{parseSegments(message.content).map((seg, idx) => {
if (seg.kind === 'think') return ;
if (seg.kind === 'tool_call') return ;
if (seg.kind === 'tool_result') return ;
return (
{seg.content}
);
})}
)}
);
}