Spaces:
Running
Running
| import React, { useRef, useEffect } from 'react'; | |
| import CodeBlock from "./CodeBlock"; | |
| import { Box, Paper, CircularProgress, Typography } from "@mui/material"; | |
| import "./ChatBox.css"; // Add dot animation CSS | |
| export default function ChatBox({ messages, loading }) { | |
| const bottomRef = useRef(null); | |
| useEffect(() => { | |
| if (!bottomRef.current) return; | |
| if (loading) { | |
| bottomRef.current.scrollIntoView({ behavior: 'auto' }); | |
| } else { | |
| // Smooth scroll after streaming ends with slight delay | |
| setTimeout(() => { | |
| bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }, 80); // tweak timing based on UX | |
| } | |
| }, [messages, loading]); | |
| return ( | |
| <Box className="chat-box"> | |
| {messages.map((msg, i) => | |
| msg.content?.trim() ? ( | |
| <Box | |
| key={i} | |
| className={`message ${msg.role}`} | |
| // sx={{ | |
| // display: "flex", | |
| // justifyContent: msg.role === "user" ? "flex-end" : "flex-start", | |
| // mb: 1.5, | |
| // }} | |
| > | |
| <Paper | |
| elevation={3} | |
| sx={{ | |
| p: 1.5, | |
| bgcolor: msg.role === "user" ? "#1e1e2f" : "#2c2c3e", | |
| color: "#fff", | |
| maxWidth: "100%", | |
| borderRadius: 2, | |
| // overflowX: "auto", | |
| }} | |
| > | |
| <div className="bubble"> | |
| <Message content={msg.content} /> | |
| </div> | |
| </Paper> | |
| </Box> | |
| ) : null | |
| )} | |
| {loading && ( | |
| <Box | |
| className="message assistant" | |
| sx={{ | |
| display: "flex", | |
| justifyContent: "flex-start", | |
| pl: 1, | |
| mt: 1, | |
| }} | |
| > | |
| <Box className="typing-indicator"> | |
| <span className="dot" /> | |
| <span className="dot" /> | |
| <span className="dot" /> | |
| </Box> | |
| </Box> | |
| )} | |
| <Box ref={bottomRef} /> | |
| </Box> | |
| ); | |
| } | |
| const formatText = (text) => { | |
| // β Handle base64 images | |
| const imageRegex = /\[IMAGE_START\](.*?)\[IMAGE_END\]/gs; | |
| text = text.replace(imageRegex, (match, base64) => { | |
| const src = `data:image/png;base64,${base64.trim()}`; | |
| return `<img src="${src}" alt="Generated Image" class="chat-image"/>`; | |
| }); | |
| // β Normalize line endings and remove excessive blank lines | |
| text = text.replace(/\r\n|\r/g, '\n'); | |
| text = text.replace(/\n{3,}/g, '\n\n'); | |
| // β Parse fenced code blocks (```code```) | |
| text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { | |
| const language = lang ? ` class="language-${lang}"` : ''; | |
| return `<pre><code${language}>${code.trim().replace(/</g, '<').replace(/>/g, '>')}</code></pre>`; | |
| }); | |
| // β Parse blockquotes | |
| text = text.replace(/^> (.*)$/gm, '<blockquote>$1</blockquote>'); | |
| // β Headings | |
| text = text.replace(/^### (.*)$/gm, '<h3>$1</h3>'); | |
| // β Horizontal rules | |
| text = text.replace(/^---$/gm, '<hr>'); | |
| // β Bold (**text**) and italic (*text*) | |
| text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); | |
| text = text.replace(/\*(.*?)\*/g, '<em>$1</em>'); | |
| // β Emoji rendering using colon syntax (:smile:) | |
| const emojiMap = { | |
| smile: "π", | |
| sad: "π’", | |
| heart: "β€οΈ", | |
| thumbs_up: "π", | |
| fire: "π₯", | |
| check: "β ", | |
| x: "β", | |
| star: "β", | |
| rocket: "π", | |
| warning: "β οΈ", | |
| }; | |
| text = text.replace(/:([a-z0-9_+-]+):/g, (match, name) => emojiMap[name] || match); | |
| // β Unordered list (bullets) | |
| const listify = (lines, tag) => | |
| `<${tag}>` + | |
| lines.map(item => `<li>${item.replace(/^(\-|\d+\.)\s*/, '').trim()}</li>`).join('') + | |
| `</${tag}>`; | |
| text = text.replace( | |
| /((?:^[-*] .+(?:\n|$))+)/gm, | |
| (match) => listify(match.trim().split('\n'), 'ul') | |
| ); | |
| // β Ordered list (fix separate `1.` items issue) | |
| text = text.replace(/^(\d+\. .+)$/gm, '__ORDERED__START__$1__ORDERED__END__'); | |
| text = text.replace( | |
| /__ORDERED__START__(\d+\. .+?)__ORDERED__END__/gs, | |
| (_, line) => `<ol><li>${line.replace(/^\d+\.\s*/, '')}</li></ol>` | |
| ); | |
| text = text.replace(/<\/ol>\s*<ol>/g, ''); | |
| // β Markdown-style tables | |
| text = text.replace( | |
| /^\|(.+?)\|\n\|([-:| ]+)\|\n((?:\|.*\|\n?)*)/gm, | |
| (_, headerRow, dividerRow, bodyRows) => { | |
| const headers = headerRow.split('|').map(h => `<th>${h.trim()}</th>`).join(''); | |
| const rows = bodyRows.trim().split('\n').map(r => | |
| '<tr>' + r.split('|').map(cell => `<td>${cell.trim()}</td>`).join('') + '</tr>' | |
| ).join(''); | |
| return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`; | |
| } | |
| ); | |
| // β Paragraphs and line breaks inside paragraphs | |
| const blocks = text.split(/\n{2,}/).map(block => { | |
| if ( | |
| block.startsWith('<h3>') || | |
| block.startsWith('<hr>') || | |
| block.startsWith('<ul>') || | |
| block.startsWith('<ol>') || | |
| block.startsWith('<table>') || | |
| block.startsWith('<pre>') || | |
| block.startsWith('<blockquote>') || | |
| block.startsWith('<img') | |
| ) { | |
| return block; | |
| } else { | |
| return `<p>${block.trim().replace(/\n/g, '<br>')}</p>`; | |
| } | |
| }); | |
| return blocks.join('\n'); | |
| }; | |
| function Message({ content }) { | |
| const parts = content.split(/```(?:[a-z]*)\n([\s\S]*?)```/g); | |
| return ( | |
| <> | |
| {parts.map((part, i) => | |
| i % 2 === 1 ? ( | |
| <CodeBlock key={i} code={part.trim()} /> | |
| ) : ( | |
| <div | |
| key={i} | |
| className="formatted-text" | |
| dangerouslySetInnerHTML={{ __html: formatText(part) }} | |
| /> | |
| ) | |
| )} | |
| </> | |
| ); | |
| } | |