'use client' import { memo, useState } from 'react' import { Button, cn, Duplicate, Tooltip } from '@sim/emcn' import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react' import { ChatFileDownload, ChatFileDownloadAll, } from '@/app/(interfaces)/chat/components/message/components/file-download' import MarkdownRenderer from '@/app/(interfaces)/chat/components/message/components/markdown-renderer' export interface ChatAttachment { id: string name: string type: string dataUrl: string size?: number } export interface ChatFile { id: string name: string url: string key: string size: number type: string context?: string } export interface ChatMessage { id: string content: string | Record type: 'user' | 'assistant' timestamp: Date isInitialMessage?: boolean isStreaming?: boolean attachments?: ChatAttachment[] files?: ChatFile[] } const HTML_ESCAPES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', } as const /** * Escapes HTML entities so untrusted strings are safe to interpolate into markup. */ export function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] || c) } /** * Opens an image attachment preview in a new tab via a blob URL, * escaping the user-controlled filename and data URL to prevent XSS. */ function openAttachmentPreview(name: string, dataUrl: string): void { const safeName = escapeHtml(name) const safeUrl = escapeHtml(dataUrl) const html = ` ${safeName} ${safeName} ` const blob = new Blob([html], { type: 'text/html' }) const blobUrl = URL.createObjectURL(blob) window.open(blobUrl, '_blank', 'noopener,noreferrer') setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000) } export const ClientChatMessage = memo( function ClientChatMessage({ message }: { message: ChatMessage }) { const [isCopied, setIsCopied] = useState(false) const isJsonObject = typeof message.content === 'object' && message.content !== null // Since tool calls are now handled via SSE events and stored in message.toolCalls, // we can use the content directly without parsing const cleanTextContent = message.content const content = message.type === 'user' ? (
{/* File attachments displayed above the message */} {message.attachments && message.attachments.length > 0 && (
{message.attachments.map((attachment) => { const isImage = attachment.type.startsWith('image/') const getFileIcon = (type: string) => { if (type.includes('pdf')) return if (type.startsWith('image/')) return if (type.includes('text') || type.includes('json')) return return } const formatFileSize = (bytes?: number) => { if (!bytes || bytes === 0) return '' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}` } const isInteractive = !!attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:') const handleOpenPreview = () => { const validDataUrl = attachment.dataUrl?.trim() if (!validDataUrl?.startsWith('data:')) return openAttachmentPreview(attachment.name, validDataUrl) } return (
{ if (!isInteractive) return e.preventDefault() e.stopPropagation() handleOpenPreview() }} onKeyDown={(e) => { if (!isInteractive) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() e.stopPropagation() handleOpenPreview() } }} > {isImage && attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:') ? ( {attachment.name} ) : ( <>
{getFileIcon(attachment.type)}
{attachment.name}
{attachment.size && (
{formatFileSize(attachment.size)}
)}
)}
) })}
)} {/* Only render message bubble if there's actual text content (not just file count message) */} {message.content && !String(message.content).startsWith('Sent') && (
{isJsonObject ? (
{JSON.stringify(message.content, null, 2)}
) : ( {message.content as string} )}
)}
) : (
{/* Direct content rendering - tool calls are now handled via SSE events */}
{isJsonObject ? (
                      {JSON.stringify(cleanTextContent, null, 2)}
                    
) : ( )}
{message.files && message.files.length > 0 && (
{message.files.map((file) => ( ))}
)} {message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
{/* Copy Button - Only show when not streaming */} {!message.isStreaming && ( {isCopied ? 'Copied!' : 'Copy to clipboard'} )} {/* Download All Button - Only show when there are files */} {!message.isStreaming && message.files && ( )}
)}
) return {content} }, (prevProps, nextProps) => { return ( prevProps.message.id === nextProps.message.id && prevProps.message.content === nextProps.message.content && prevProps.message.isStreaming === nextProps.message.isStreaming && prevProps.message.isInitialMessage === nextProps.message.isInitialMessage && prevProps.message.files?.length === nextProps.message.files?.length ) } )