/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * ChatMessage — renders a single user or assistant message. * Assistant messages have executable code blocks inline. * Streaming messages show a blinking cursor at the end. */ import { memo, useMemo } from 'react'; import { User, Bot, Paperclip } from 'lucide-react'; import { cn } from '@/lib/utils'; import { ExecutableCodeBlock } from './ExecutableCodeBlock'; import type { ChatMessage as ChatMessageType } from '@/lib/llm/types'; import { renderTextContent } from './renderTextContent'; interface ChatMessageProps { message: ChatMessageType; /** Whether this is a live-streaming message */ isStreaming?: boolean; /** Callback for "Fix this" error feedback */ onFixError?: (code: string, error: string) => void; } /** * Split assistant content into text segments and code block placeholders. * This allows us to render text normally and code blocks as ExecutableCodeBlock. */ function splitContent(content: string): Array<{ type: 'text'; text: string } | { type: 'code'; index: number }> { const parts: Array<{ type: 'text'; text: string } | { type: 'code'; index: number }> = []; const regex = /```\w*\n[\s\S]*?```/g; let lastIndex = 0; let match: RegExpExecArray | null; let codeIndex = 0; while ((match = regex.exec(content)) !== null) { if (match.index > lastIndex) { const text = content.slice(lastIndex, match.index).trim(); if (text) parts.push({ type: 'text', text }); } const lang = match[0].match(/```(\w*)/)?.[1] ?? ''; const isExecutable = ['js', 'javascript', 'ts', 'typescript', ''].includes(lang.toLowerCase()) || match[0].includes('bim.'); if (isExecutable) { parts.push({ type: 'code', index: codeIndex }); codeIndex++; } else { parts.push({ type: 'text', text: match[0] }); } lastIndex = match.index + match[0].length; } if (lastIndex < content.length) { const text = content.slice(lastIndex).trim(); if (text) parts.push({ type: 'text', text }); } return parts; } export const ChatMessageComponent = memo(function ChatMessageComponent({ message, isStreaming, onFixError, }: ChatMessageProps) { const isUser = message.role === 'user'; const contentParts = useMemo( () => isUser ? null : splitContent(message.content), [message.content, isUser], ); return (
{/* Avatar */}
{isUser ? : }
{/* Content */}
{/* User message — plain text */} {isUser && ( <>

{message.content}

{message.attachments && message.attachments.length > 0 && (
{message.attachments.map((a) => ( a.isImage && a.imageBase64 ? ( {a.name} ) : ( {a.name} {a.csvData && ({a.csvData.length} rows)} ) ))}
)} )} {/* Assistant message — rich content with code blocks */} {!isUser && contentParts && contentParts.map((part, i) => { if (part.type === 'text') { return (
); } const block = message.codeBlocks?.find((b) => b.index === part.index); if (!block) return null; const execResult = message.execResults?.get(part.index); return ( ); })} {/* Streaming cursor */} {isStreaming && ( )}
); });