'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 sanitizeModelOutput(s: string): string { // Strip training-artifact leaks the small model sometimes emits: // - HTML bold/italic tags from /// that were never meant to render // - Stray leading "<" (a dangling tool marker that was never closed — cosmetic only) // - Leading "Answer:" / "Response:" labels that are training-data prefixes // - Markdown image references whose payload is clearly placeholder text (e.g. [something image]) // - Duplicate paragraphs the model sometimes emits verbatim after <|output_end|> s = s.replace(/<\/?(?:b|i|strong|em|u|small|big|code)\s*>/gi, ''); // stray standalone "<" on its own line s = s.replace(/^\s*<\s*$/gm, ''); // leading "Answer:" / "Response:" labels at start of a line s = s.replace(/^\s*(?:Answer|Response|Final answer|Reply)\s*:\s*/gim, ''); // placeholder-image markdown e.g. ![Diary entry for samosa history] s = s.replace(/!\[[^\]]*?\](?!\()/g, ''); // normalize double newlines s = s.replace(/\n{3,}/g, '\n\n'); return s.trim(); } function parseSegments(raw: string): Segment[] { // Sanitize first — remove HTML tag leaks / stray Answer: / orphan "<" raw = sanitizeModelOutput(raw); // Then strip orphan tool markers (end without open) that the model sometimes // emits as loop artifacts. raw = stripOrphanMarkers(raw); const segs: Segment[] = []; let i = 0; const markers: Array<[string, string, Segment['kind']]> = [ ['', '', 'think'], ['<|python_start|>', '<|python_end|>', 'tool_call'], ['<|output_start|>', '<|output_end|>', 'tool_result'], ]; while (i < raw.length) { 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 dedupeAndClean(segs); } function stripOrphanMarkers(s: string): string { // Walk the string left-to-right. For each opening marker we encounter, keep // it only if its matching close exists somewhere after it. For each close // marker encountered without a preceding open, drop it. const pairs: Array<[string, string]> = [ ['', ''], ['<|python_start|>', '<|python_end|>'], ['<|output_start|>', '<|output_end|>'], ]; for (const [open, close] of pairs) { // Remove any close-tag that has no preceding open-tag const openPositions: number[] = []; let idx = 0; while (true) { const p = s.indexOf(open, idx); if (p === -1) break; openPositions.push(p); idx = p + open.length; } const closePositions: number[] = []; idx = 0; while (true) { const p = s.indexOf(close, idx); if (p === -1) break; closePositions.push(p); idx = p + close.length; } // drop close tags that appear before any open tag const firstOpen = openPositions[0] ?? Infinity; const orphanCloses = closePositions.filter((c) => c < firstOpen); if (orphanCloses.length) { // remove each orphan close (work in reverse so indices stay valid) for (const c of orphanCloses.reverse()) { s = s.slice(0, c) + s.slice(c + close.length); } } } return s; } function dedupeAndClean(segs: Segment[]): Segment[] { const out: Segment[] = []; let lastResultKey: string | null = null; for (const seg of segs) { // collapse consecutive duplicate tool_result segments (model re-emits the // same block as a training artifact) if (seg.kind === 'tool_result') { const key = seg.content.replace(/\s+/g, ' ').trim(); if (key === lastResultKey) continue; lastResultKey = key; } else { lastResultKey = null; } // drop plain-text segments that are just leftover tool-marker fragments if (seg.kind === 'text') { const t = seg.content.replace(/<\|?(?:python|output)_(?:start|end)\|?>/g, '').trim(); if (!t) continue; out.push({ kind: 'text', content: seg.content.replace(/<\|?(?:python|output)_(?:start|end)\|?>/g, '') }); continue; } out.push(seg); } return out; } 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 (
        
          {children}
        
      
); } export default function MessageBubble({ message, isStreaming }: Props) { const isUser = message.role === 'user'; const isConsole = message.role === 'console'; if (isConsole) { return (
{message.content}
); } 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} ); })}
)}
); }