Spaces:
Sleeping
Sleeping
Download src/app/interview/session/page.tsx from viraj-421/InterviewAI: direct link, hf CLI and curl.
- Browser
- Download file 22.8 kB
-
https://huggingface.co/spaces/viraj-421/InterviewAI/resolve/main/src/app/interview/session/page.tsx
- Command line
-
hf download hf://spaces/viraj-421/InterviewAI/src/app/interview/session/page.tsx
-
curl -L -o page.tsx https://huggingface.co/spaces/viraj-421/InterviewAI/resolve/main/src/app/interview/session/page.tsx
22.8 kB
| 'use client'; | |
| import { useState, useEffect, useRef, useCallback } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { useRouter } from 'next/navigation'; | |
| import { useInterview } from '@/context/InterviewContext'; | |
| import LoadingScreen from '@/components/LoadingScreen'; | |
| import ScoreRing from '@/components/ScoreRing'; | |
| import { Question, ChatMessage, Evaluation } from '@/types/interview'; | |
| import { | |
| Send, | |
| Mic, | |
| MicOff, | |
| Clock, | |
| ChevronRight, | |
| CheckCircle2, | |
| AlertCircle, | |
| Lightbulb, | |
| XCircle, | |
| SkipForward, | |
| Trophy, | |
| MessageSquare, | |
| Volume2, | |
| } from 'lucide-react'; | |
| export default function SessionPage() { | |
| const router = useRouter(); | |
| const { | |
| session, | |
| setQuestions, | |
| addMessage, | |
| submitAnswer, | |
| addEvaluation, | |
| nextQuestion, | |
| setStatus, | |
| completeSession, | |
| } = useInterview(); | |
| const [input, setInput] = useState(''); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isEvaluating, setIsEvaluating] = useState(false); | |
| const [isGeneratingSummary, setIsGeneratingSummary] = useState(false); | |
| const [showEvaluation, setShowEvaluation] = useState<Evaluation | null>(null); | |
| const [elapsedTime, setElapsedTime] = useState(0); | |
| const [isListening, setIsListening] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const inputRef = useRef<HTMLTextAreaElement>(null); | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const recognitionRef = useRef<any>(null); | |
| // Redirect if no session | |
| useEffect(() => { | |
| if (!session) { | |
| router.push('/interview'); | |
| } | |
| }, [session, router]); | |
| // Timer | |
| useEffect(() => { | |
| if (session?.status === 'in-progress') { | |
| const interval = setInterval(() => { | |
| const elapsed = Math.floor((Date.now() - session.startedAt) / 1000); | |
| setElapsedTime(elapsed); | |
| // Auto-end if time limit is set and exceeded | |
| if (session.config.timeLimit > 0) { | |
| const limitSeconds = session.config.timeLimit * 60; | |
| if (elapsed >= limitSeconds) { | |
| clearInterval(interval); | |
| handleEndInterview(); | |
| } | |
| } | |
| }, 1000); | |
| return () => clearInterval(interval); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [session?.status, session?.startedAt]); | |
| // Auto scroll to bottom | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }, [session?.messages]); | |
| // Generate questions on mount | |
| useEffect(() => { | |
| if (session && session.questions.length === 0 && isLoading) { | |
| generateQuestions(); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [session]); | |
| const generateQuestions = async () => { | |
| if (!session) return; | |
| setError(null); | |
| try { | |
| const res = await fetch('/api/generate-questions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'x-gemini-api-key': localStorage.getItem('gemini-api-key') || '' | |
| }, | |
| body: JSON.stringify({ | |
| role: session.config.role, | |
| difficulty: session.config.difficulty, | |
| questionCount: session.config.questionCount, | |
| }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.error); | |
| const questions: Question[] = data.questions; | |
| setQuestions(questions); | |
| // Add welcome message | |
| addMessage({ | |
| id: 'welcome', | |
| role: 'ai', | |
| content: `Welcome to your **${session.config.role.title}** mock interview! π―\n\nI'll be asking you **${questions.length} questions** at **${session.config.difficulty}** difficulty.\n\nTake your time to think before answering. Let's begin!`, | |
| timestamp: Date.now(), | |
| }); | |
| // Add first question | |
| setTimeout(() => { | |
| addMessage({ | |
| id: `q-${questions[0].id}`, | |
| role: 'ai', | |
| content: `**Question 1/${questions.length}** β’ _${questions[0].category}_\n\n${questions[0].text}`, | |
| timestamp: Date.now(), | |
| questionId: questions[0].id, | |
| }); | |
| setIsLoading(false); | |
| }, 800); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : 'Failed to generate questions'); | |
| setIsLoading(false); | |
| } | |
| }; | |
| const handleSubmitAnswer = async () => { | |
| if (!session || !input.trim() || isEvaluating) return; | |
| const currentQ = session.questions[session.currentQuestionIndex]; | |
| if (!currentQ) return; | |
| const answerText = input.trim(); | |
| setInput(''); | |
| // Add user message | |
| addMessage({ | |
| id: `a-${currentQ.id}`, | |
| role: 'user', | |
| content: answerText, | |
| timestamp: Date.now(), | |
| questionId: currentQ.id, | |
| }); | |
| // Submit answer | |
| submitAnswer({ | |
| questionId: currentQ.id, | |
| text: answerText, | |
| timestamp: Date.now(), | |
| }); | |
| // Evaluate | |
| setIsEvaluating(true); | |
| try { | |
| const res = await fetch('/api/evaluate-answer', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'x-gemini-api-key': localStorage.getItem('gemini-api-key') || '' | |
| }, | |
| body: JSON.stringify({ | |
| question: currentQ.text, | |
| answer: answerText, | |
| role: session.config.role.title, | |
| difficulty: session.config.difficulty, | |
| }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.error); | |
| const evaluation: Evaluation = { | |
| ...data.evaluation, | |
| questionId: currentQ.id, | |
| }; | |
| addEvaluation(evaluation); | |
| setShowEvaluation(evaluation); | |
| // Add AI feedback message | |
| addMessage({ | |
| id: `eval-${currentQ.id}`, | |
| role: 'ai', | |
| content: buildEvaluationMessage(evaluation), | |
| timestamp: Date.now(), | |
| questionId: currentQ.id, | |
| evaluation, | |
| }); | |
| } catch (err) { | |
| addMessage({ | |
| id: `err-${currentQ.id}`, | |
| role: 'ai', | |
| content: `β οΈ I couldn't evaluate that response. ${err instanceof Error ? err.message : 'Please try again.'}`, | |
| timestamp: Date.now(), | |
| }); | |
| } | |
| setIsEvaluating(false); | |
| }; | |
| const handleNextQuestion = useCallback(() => { | |
| if (!session) return; | |
| setShowEvaluation(null); | |
| const nextIndex = session.currentQuestionIndex + 1; | |
| if (nextIndex >= session.questions.length) { | |
| // End of interview | |
| handleEndInterview(); | |
| return; | |
| } | |
| nextQuestion(); | |
| const nextQ = session.questions[nextIndex]; | |
| addMessage({ | |
| id: `q-${nextQ.id}`, | |
| role: 'ai', | |
| content: `**Question ${nextIndex + 1}/${session.questions.length}** β’ _${nextQ.category}_\n\n${nextQ.text}`, | |
| timestamp: Date.now(), | |
| questionId: nextQ.id, | |
| }); | |
| inputRef.current?.focus(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [session, nextQuestion, addMessage]); | |
| const handleEndInterview = async () => { | |
| if (!session) return; | |
| setIsGeneratingSummary(true); | |
| try { | |
| const res = await fetch('/api/session-summary', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'x-gemini-api-key': localStorage.getItem('gemini-api-key') || '' | |
| }, | |
| body: JSON.stringify({ | |
| role: session.config.role.title, | |
| difficulty: session.config.difficulty, | |
| evaluations: session.evaluations, | |
| questions: session.questions, | |
| answers: session.answers, | |
| }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.error); | |
| completeSession(data.summary); | |
| router.push('/dashboard'); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : 'Failed to generate summary'); | |
| setIsGeneratingSummary(false); | |
| } | |
| }; | |
| const handleSkipQuestion = () => { | |
| if (!session) return; | |
| const currentQ = session.questions[session.currentQuestionIndex]; | |
| addMessage({ | |
| id: `skip-${currentQ.id}`, | |
| role: 'user', | |
| content: '_Skipped this question_', | |
| timestamp: Date.now(), | |
| questionId: currentQ.id, | |
| }); | |
| submitAnswer({ | |
| questionId: currentQ.id, | |
| text: '[SKIPPED]', | |
| timestamp: Date.now(), | |
| }); | |
| handleNextQuestion(); | |
| }; | |
| // Voice input | |
| const toggleVoiceInput = () => { | |
| if (isListening) { | |
| recognitionRef.current?.stop(); | |
| setIsListening(false); | |
| return; | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const win = window as any; | |
| const SpeechRecognitionAPI = win.SpeechRecognition || win.webkitSpeechRecognition; | |
| if (!SpeechRecognitionAPI) { | |
| setError('Voice input is not supported in your browser.'); | |
| return; | |
| } | |
| const recognition = new SpeechRecognitionAPI(); | |
| recognition.continuous = true; | |
| recognition.interimResults = true; | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| recognition.onresult = (event: any) => { | |
| let transcript = ''; | |
| for (let i = 0; i < event.results.length; i++) { | |
| transcript += event.results[i][0].transcript; | |
| } | |
| setInput(transcript); | |
| }; | |
| recognition.onerror = () => { | |
| setIsListening(false); | |
| }; | |
| recognition.onend = () => { | |
| setIsListening(false); | |
| }; | |
| recognition.start(); | |
| recognitionRef.current = recognition; | |
| setIsListening(true); | |
| }; | |
| const formatTime = (seconds: number) => { | |
| const m = Math.floor(seconds / 60); | |
| const s = seconds % 60; | |
| return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; | |
| }; | |
| if (!session) return null; | |
| if (isLoading) { | |
| return <LoadingScreen message="AI is preparing your interview questions..." />; | |
| } | |
| if (isGeneratingSummary) { | |
| return <LoadingScreen message="Generating your performance summary..." />; | |
| } | |
| const progress = session.questions.length > 0 | |
| ? ((session.currentQuestionIndex + (session.evaluations.length > session.currentQuestionIndex ? 1 : 0)) / session.questions.length) * 100 | |
| : 0; | |
| const timeLimitSeconds = session.config.timeLimit * 60; | |
| const remainingSeconds = session.config.timeLimit > 0 ? Math.max(0, timeLimitSeconds - elapsedTime) : 0; | |
| const isRunningOut = session.config.timeLimit > 0 && remainingSeconds <= 120; | |
| const displayTime = session.config.timeLimit > 0 ? remainingSeconds : elapsedTime; | |
| return ( | |
| <div className="min-h-screen flex flex-col"> | |
| {/* Top Bar */} | |
| <div className="glass sticky top-16 z-40" style={{ borderBottom: '1px solid var(--border)' }}> | |
| <div className="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between"> | |
| <div className="flex items-center gap-4"> | |
| <span className="text-xl">{session.config.role.icon}</span> | |
| <div> | |
| <h2 className="font-semibold text-sm">{session.config.role.title}</h2> | |
| <p className="text-xs capitalize" style={{ color: 'var(--muted)' }}> | |
| {session.config.difficulty} β’ Q{session.currentQuestionIndex + 1}/{session.questions.length} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| {/* Progress bar */} | |
| <div className="hidden sm:block w-32"> | |
| <div className="progress-bar"> | |
| <div className="progress-bar-fill" style={{ width: `${progress}%` }} /> | |
| </div> | |
| </div> | |
| {/* Timer */} | |
| <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors duration-300" style={{ | |
| background: isRunningOut ? 'rgba(255, 107, 107, 0.1)' : 'var(--card)', | |
| border: `1px solid ${isRunningOut ? 'rgba(255, 107, 107, 0.3)' : 'var(--border)'}` | |
| }}> | |
| <Clock className="w-3.5 h-3.5" style={{ color: isRunningOut ? 'var(--danger)' : 'var(--muted)' }} /> | |
| <span className="text-sm font-mono" style={{ color: isRunningOut ? 'var(--danger)' : 'var(--foreground)' }}> | |
| {formatTime(displayTime)} | |
| </span> | |
| </div> | |
| {/* End button */} | |
| <button | |
| onClick={handleEndInterview} | |
| className="px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-300 cursor-pointer" | |
| style={{ | |
| background: 'rgba(255, 107, 107, 0.1)', | |
| border: '1px solid rgba(255, 107, 107, 0.2)', | |
| color: 'var(--danger)', | |
| }} | |
| > | |
| End | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Error bar */} | |
| {error && ( | |
| <div className="max-w-5xl mx-auto px-4 mt-4"> | |
| <div className="flex items-center gap-3 p-4 rounded-xl" style={{ background: 'rgba(255, 107, 107, 0.1)', border: '1px solid rgba(255, 107, 107, 0.2)' }}> | |
| <AlertCircle className="w-5 h-5 shrink-0" style={{ color: 'var(--danger)' }} /> | |
| <p className="text-sm" style={{ color: 'var(--danger)' }}>{error}</p> | |
| <button onClick={() => setError(null)} className="ml-auto cursor-pointer"> | |
| <XCircle className="w-4 h-4" style={{ color: 'var(--danger)' }} /> | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto py-6 px-4"> | |
| <div className="max-w-3xl mx-auto space-y-4"> | |
| <AnimatePresence initial={false}> | |
| {session.messages.map((msg) => ( | |
| <ChatBubble key={msg.id} message={msg} roleColor={session.config.role.color} /> | |
| ))} | |
| </AnimatePresence> | |
| {/* Evaluating indicator */} | |
| {isEvaluating && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="flex items-start gap-3" | |
| > | |
| <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0" style={{ background: 'var(--gradient-primary)' }}> | |
| <span className="text-sm">π€</span> | |
| </div> | |
| <div className="glass-light px-4 py-3 rounded-2xl rounded-tl-md"> | |
| <div className="typing-dots flex gap-1"> | |
| <span></span> | |
| <span></span> | |
| <span></span> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| </div> | |
| {/* Evaluation Drawer */} | |
| <AnimatePresence> | |
| {showEvaluation && ( | |
| <motion.div | |
| initial={{ y: 100, opacity: 0 }} | |
| animate={{ y: 0, opacity: 1 }} | |
| exit={{ y: 100, opacity: 0 }} | |
| className="glass sticky bottom-0 z-30" | |
| style={{ borderTop: '1px solid var(--border)' }} | |
| > | |
| <div className="max-w-3xl mx-auto p-4"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center gap-3"> | |
| <ScoreRing score={showEvaluation.score} maxScore={10} size={56} strokeWidth={5} showPercentage={false} /> | |
| <div> | |
| <p className="font-semibold text-sm">Score: {showEvaluation.score}/10</p> | |
| <div className="flex gap-3 mt-1"> | |
| {[ | |
| { label: 'Relevance', value: showEvaluation.relevance }, | |
| { label: 'Clarity', value: showEvaluation.clarity }, | |
| { label: 'Accuracy', value: showEvaluation.technicalAccuracy }, | |
| ].map((m) => ( | |
| <span key={m.label} className="text-xs" style={{ color: 'var(--muted)' }}> | |
| {m.label}: <span style={{ color: m.value >= 7 ? 'var(--success)' : m.value >= 4 ? 'var(--warning)' : 'var(--danger)' }}>{m.value}/10</span> | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <button | |
| onClick={handleNextQuestion} | |
| className="btn-primary flex items-center gap-2 text-sm cursor-pointer" | |
| > | |
| {session.currentQuestionIndex + 1 >= session.questions.length ? ( | |
| <> | |
| <Trophy className="w-4 h-4" /> | |
| Finish | |
| </> | |
| ) : ( | |
| <> | |
| Next Question | |
| <ChevronRight className="w-4 h-4" /> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Input Area */} | |
| {!showEvaluation && session.status !== 'completed' && ( | |
| <div className="glass sticky bottom-0 z-20" style={{ borderTop: '1px solid var(--border)' }}> | |
| <div className="max-w-3xl mx-auto p-4"> | |
| <div className="flex items-end gap-3"> | |
| <div className="flex-1 relative"> | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmitAnswer(); | |
| } | |
| }} | |
| placeholder="Type your answer... (Shift+Enter for new line)" | |
| rows={2} | |
| className="w-full resize-none rounded-xl px-4 py-3 text-sm focus:outline-none transition-all duration-300" | |
| style={{ | |
| background: 'var(--card)', | |
| border: '1px solid var(--border)', | |
| color: 'var(--foreground)', | |
| }} | |
| onFocus={(e) => { | |
| e.currentTarget.style.borderColor = 'var(--primary)'; | |
| e.currentTarget.style.boxShadow = '0 0 20px rgba(108, 92, 231, 0.1)'; | |
| }} | |
| onBlur={(e) => { | |
| e.currentTarget.style.borderColor = 'var(--border)'; | |
| e.currentTarget.style.boxShadow = 'none'; | |
| }} | |
| disabled={isEvaluating} | |
| /> | |
| </div> | |
| {/* Voice Button */} | |
| <button | |
| onClick={toggleVoiceInput} | |
| className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 shrink-0 cursor-pointer" | |
| style={{ | |
| background: isListening ? 'var(--danger)' : 'var(--card)', | |
| border: `1px solid ${isListening ? 'var(--danger)' : 'var(--border)'}`, | |
| color: isListening ? 'white' : 'var(--muted)', | |
| }} | |
| title={isListening ? 'Stop listening' : 'Voice input'} | |
| > | |
| {isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />} | |
| </button> | |
| {/* Skip Button */} | |
| <button | |
| onClick={handleSkipQuestion} | |
| className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 shrink-0 cursor-pointer" | |
| style={{ | |
| background: 'var(--card)', | |
| border: '1px solid var(--border)', | |
| color: 'var(--muted)', | |
| }} | |
| title="Skip question" | |
| > | |
| <SkipForward className="w-4 h-4" /> | |
| </button> | |
| {/* Send Button */} | |
| <button | |
| onClick={handleSubmitAnswer} | |
| disabled={!input.trim() || isEvaluating} | |
| className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 shrink-0 cursor-pointer disabled:opacity-40" | |
| style={{ | |
| background: input.trim() ? 'var(--gradient-primary)' : 'var(--card)', | |
| border: `1px solid ${input.trim() ? 'transparent' : 'var(--border)'}`, | |
| color: input.trim() ? 'white' : 'var(--muted)', | |
| }} | |
| > | |
| <Send className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function buildEvaluationMessage(evaluation: Evaluation): string { | |
| const scoreEmoji = evaluation.score >= 8 ? 'π' : evaluation.score >= 6 ? 'π' : evaluation.score >= 4 ? 'π‘' : 'π'; | |
| let msg = `${scoreEmoji} **Score: ${evaluation.score}/10**\n\n`; | |
| msg += `π Relevance: ${evaluation.relevance}/10 β’ Clarity: ${evaluation.clarity}/10 β’ Accuracy: ${evaluation.technicalAccuracy}/10 β’ Confidence: ${evaluation.confidence}/10\n\n`; | |
| if (evaluation.strengths.length > 0) { | |
| msg += `β **Strengths:**\n${evaluation.strengths.map(s => `β’ ${s}`).join('\n')}\n\n`; | |
| } | |
| if (evaluation.weaknesses.length > 0) { | |
| msg += `β οΈ **Areas to Improve:**\n${evaluation.weaknesses.map(w => `β’ ${w}`).join('\n')}\n\n`; | |
| } | |
| if (evaluation.suggestions.length > 0) { | |
| msg += `π‘ **Suggestions:**\n${evaluation.suggestions.map(s => `β’ ${s}`).join('\n')}\n\n`; | |
| } | |
| if (evaluation.sampleAnswer) { | |
| msg += `π **Sample Answer:**\n_${evaluation.sampleAnswer}_`; | |
| } | |
| return msg; | |
| } | |
| function ChatBubble({ message, roleColor }: { message: ChatMessage; roleColor: string }) { | |
| const isAI = message.role === 'ai'; | |
| return ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 10, scale: 0.98 }} | |
| animate={{ opacity: 1, y: 0, scale: 1 }} | |
| transition={{ duration: 0.3 }} | |
| className={`flex items-start gap-3 ${isAI ? '' : 'flex-row-reverse'}`} | |
| > | |
| {/* Avatar */} | |
| <div | |
| className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 text-sm" | |
| style={{ | |
| background: isAI ? 'var(--gradient-primary)' : roleColor, | |
| }} | |
| > | |
| {isAI ? 'π€' : 'π€'} | |
| </div> | |
| {/* Bubble */} | |
| <div | |
| className={`max-w-[80%] px-4 py-3 rounded-2xl text-sm leading-relaxed ${isAI ? 'rounded-tl-md' : 'rounded-tr-md'}`} | |
| style={{ | |
| background: isAI ? 'var(--card)' : `${roleColor}15`, | |
| border: `1px solid ${isAI ? 'var(--border)' : `${roleColor}30`}`, | |
| }} | |
| > | |
| <div | |
| dangerouslySetInnerHTML={{ | |
| __html: formatMessage(message.content), | |
| }} | |
| /> | |
| </div> | |
| </motion.div> | |
| ); | |
| } | |
| function formatMessage(text: string): string { | |
| return text | |
| .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/_(.*?)_/g, '<em>$1</em>') | |
| .replace(/\n/g, '<br/>'); | |
| } | |