| import React, { useState } from 'react'; |
| import { Sparkles, Clock, Terminal, ChevronDown, ChevronUp, AlertCircle, Brain, Cpu } from 'lucide-react'; |
|
|
| |
| function RenderLatex({ text }) { |
| if (!text) return null; |
|
|
| |
| const blockParts = text.split(/(\$\$.*?\$\$)/g); |
| |
| return ( |
| <> |
| {blockParts.map((bp, bpIdx) => { |
| if (bp.startsWith('$$') && bp.endsWith('$$')) { |
| const formula = bp.slice(2, -2); |
| try { |
| if (window.katex) { |
| const html = window.katex.renderToString(formula, { displayMode: true, throwOnError: false }); |
| return <div key={bpIdx} dangerouslySetInnerHTML={{ __html: html }} style={{ margin: '0.8rem 0' }} />; |
| } |
| } catch (e) { |
| console.error(e); |
| } |
| return <div key={bpIdx} style={{ margin: '0.8rem 0', fontFamily: 'monospace' }}>{bp}</div>; |
| } |
| |
| // Inline math split |
| const inlineParts = bp.split(/(\$.*?\$)/g); |
| return ( |
| <React.Fragment key={bpIdx}> |
| {inlineParts.map((ip, ipIdx) => { |
| if (ip.startsWith('$') && ip.endsWith('$')) { |
| const formula = ip.slice(1, -1); |
| if (formula.trim()) { |
| try { |
| if (window.katex) { |
| const html = window.katex.renderToString(formula, { displayMode: false, throwOnError: false }); |
| return <span key={ipIdx} dangerouslySetInnerHTML={{ __html: html }} />; |
| } |
| } catch (e) { |
| console.error(e); |
| } |
| } |
| return <span key={ipIdx}>{ip}</span>; |
| } |
| |
| return <span key={ipIdx} dangerouslySetInnerHTML={{ __html: formatInline(ip) }} />; |
| })} |
| </React.Fragment> |
| ); |
| })} |
| </> |
| ); |
| } |
|
|
| |
| function SafeMarkdown({ content }) { |
| if (!content) return null; |
|
|
| const parts = content.split(/(```[\s\S]*?```)/g); |
|
|
| return ( |
| <div className="chat-response-content"> |
| {parts.map((part, index) => { |
| if (part.startsWith('```') && part.endsWith('```')) { |
| const code = part.slice(3, -3).replace(/^\w+\n/, ''); |
| return ( |
| <pre key={index}> |
| <code>{code}</code> |
| </pre> |
| ); |
| } |
| |
| const formatted = part |
| .split('\n\n') |
| .map((para, paraIdx) => { |
| if (!para.trim()) return null; |
| |
| // Handle bullet points |
| if (para.trim().startsWith('- ') || para.trim().startsWith('* ')) { |
| const items = para.split(/\n\s*[-*]\s+/); |
| return ( |
| <ul key={paraIdx} style={{ marginBottom: '1rem', paddingLeft: '1.5rem' }}> |
| {items.map((item, itemIdx) => { |
| let cleanItem = item; |
| if (itemIdx === 0) { |
| cleanItem = item.replace(/^\s*[-*]\s+/, ''); |
| } |
| if (!cleanItem.trim()) return null; |
| return ( |
| <li key={itemIdx}> |
| <RenderLatex text={cleanItem} /> |
| </li> |
| ); |
| })} |
| </ul> |
| ); |
| } |
| |
| return ( |
| <p key={paraIdx}> |
| <RenderLatex text={para} /> |
| </p> |
| ); |
| }); |
| |
| return <React.Fragment key={index}>{formatted}</React.Fragment>; |
| })} |
| </div> |
| ); |
| } |
|
|
| |
| function formatInline(text) { |
| return text |
| .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') |
| .replace(/\*([^*]+)\*/g, '<em>$1</em>') |
| .replace(/`([^`]+)`/g, '<code>$1</code>') |
| .replace(/\n/g, '<br />'); |
| } |
|
|
| export default function ChatWindow({ |
| history = [], |
| loading, |
| error, |
| onSubmitFollowUp |
| }) { |
| const [openInspectors, setOpenInspectors] = useState({}); |
|
|
| const toggleInspector = (idx) => { |
| setOpenInspectors(prev => ({ |
| ...prev, |
| [idx]: !prev[idx] |
| })); |
| }; |
|
|
| |
| if (loading && history.length === 0) { |
| return ( |
| <div className="single-column-chat"> |
| <div className="column-card"> |
| <div className="column-header"> |
| <div className="column-title-wrapper"> |
| <h2><Cpu size={18} color="var(--primary)" /> Socratic Tutor</h2> |
| <p>Analyzing Sentiment...</p> |
| </div> |
| <div className="latency-badge">--s</div> |
| </div> |
| <div className="column-body"> |
| <div className="skeleton-box" /> |
| <div className="skeleton-wrapper"> |
| <div className="skeleton-line long" /> |
| <div className="skeleton-line medium" /> |
| <div className="skeleton-line long" /> |
| <div className="skeleton-line short" /> |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| if (error) { |
| return ( |
| <div style={{ |
| background: 'rgba(244, 63, 94, 0.1)', |
| border: '1px solid var(--color-frustrated)', |
| borderRadius: '12px', |
| padding: '1.5rem', |
| display: 'flex', |
| alignItems: 'flex-start', |
| gap: '1rem', |
| color: 'var(--color-frustrated)', |
| marginBottom: '1rem' |
| }}> |
| <AlertCircle size={24} style={{ flexShrink: 0 }} /> |
| <div> |
| <h3 style={{ fontWeight: 700, marginBottom: '0.3rem' }}>Analysis Failed</h3> |
| <p style={{ color: 'var(--text-primary)', fontSize: '0.95rem' }}>{error}</p> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| if (history.length === 0) { |
| return ( |
| <div className="empty-state"> |
| <Brain size={48} className="empty-state-icon" style={{ color: 'var(--primary)' }} /> |
| <h3>Welcome to Socratic Sentiment Tutor</h3> |
| <p>Ask any question about math, science, or programming. The Socratic tutor will detect your mood and guide you towards the answers without giving them away directly.</p> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="single-column-chat" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}> |
| |
| {/* Scrollable Conversation Thread */} |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1.2rem', width: '100%' }}> |
| {history.map((msg, idx) => { |
| const isUser = msg.role === 'user'; |
| |
| if (isUser) { |
| return ( |
| <div |
| key={idx} |
| style={{ |
| alignSelf: 'flex-end', |
| background: 'linear-gradient(135deg, var(--primary-dark), var(--primary))', |
| border: '1px solid rgba(255, 255, 255, 0.1)', |
| borderRadius: '16px 16px 4px 16px', |
| padding: '0.9rem 1.25rem', |
| maxWidth: '85%', |
| boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)', |
| }} |
| > |
| <span style={{ |
| fontSize: '0.65rem', |
| fontWeight: 800, |
| textTransform: 'uppercase', |
| color: 'rgba(255, 255, 255, 0.7)', |
| display: 'block', |
| marginBottom: '0.25rem', |
| letterSpacing: '0.5px' |
| }}> |
| You |
| </span> |
| <p style={{ margin: 0, fontSize: '0.975rem', lineHeight: 1.5, color: '#fff' }}> |
| {msg.content} |
| </p> |
| </div> |
| ); |
| } |
| |
| // Assistant / Tutor Bubble |
| return ( |
| <div |
| key={idx} |
| className="column-card" |
| style={{ |
| alignSelf: 'flex-start', |
| width: '100%', |
| maxWidth: '90%', |
| margin: 0, |
| boxShadow: '0 8px 24px rgba(0, 0, 0, 0.12)', |
| }} |
| > |
| {/* Card Header with sentiment state & metrics */} |
| <div className="column-header" style={{ padding: '0.8rem 1.25rem', borderBottom: '1px solid var(--border-color)' }}> |
| <div className="column-title-wrapper"> |
| <h3 style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.95rem', fontWeight: 700, margin: 0 }}> |
| <Sparkles size={15} color="var(--secondary)" /> |
| Socratic Tutor |
| </h3> |
| </div> |
| |
| {msg.sentiment && ( |
| <div className="column-meta" style={{ gap: '0.6rem' }}> |
| <div className="latency-badge" title="Response Latency & Tokens"> |
| <Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} /> |
| {msg.latency}s |
| {msg.tokens !== undefined && ( |
| <> |
| <span style={{ margin: '0 4px', opacity: 0.3 }}>|</span> |
| <span>{msg.tokens}t</span> |
| </> |
| )} |
| {msg.cost !== undefined && ( |
| <> |
| <span style={{ margin: '0 4px', opacity: 0.3 }}>|</span> |
| <span>${msg.cost.toFixed(5)}</span> |
| </> |
| )} |
| </div> |
| </div> |
| )} |
| </div> |
| |
| {/* Chat Bubble Body */} |
| <div className="column-body" style={{ padding: '1.25rem', gap: '1rem' }}> |
| <SafeMarkdown content={msg.content} /> |
| </div> |
| |
| {/* Prompt Context Inspector Toggle */} |
| {msg.prompt_context && ( |
| <div className="inspector-section" style={{ borderTop: '1px solid var(--border-color)', borderRadius: '0 0 12px 12px' }}> |
| <div |
| className="inspector-header" |
| onClick={() => toggleInspector(idx)} |
| style={{ padding: '0.6rem 1.25rem', fontSize: '0.8rem', background: 'rgba(255,255,255,0.01)' }} |
| > |
| <span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}> |
| <Terminal size={12} /> |
| View Socratic Context Inspector |
| </span> |
| {openInspectors[idx] ? <ChevronUp size={14} /> : <ChevronDown size={14} />} |
| </div> |
| {openInspectors[idx] && ( |
| <div className="inspector-body" style={{ fontSize: '0.8rem', whiteSpace: 'pre-wrap', borderTop: '1px solid var(--border-color)', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> |
| <strong>Detected Student Sentiment:</strong> |
| <span className={`sentiment-badge ${msg.sentiment}`} style={{ fontSize: '0.75rem', padding: '0.2rem 0.5rem', borderRadius: '4px' }}> |
| {msg.sentiment.replace(/_/g, ' ')} |
| </span> |
| </div> |
| <div> |
| <strong>Prompt Context:</strong> |
| <div style={{ marginTop: '0.25rem', opacity: 0.8 }}> |
| {msg.prompt_context} |
| </div> |
| </div> |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| ); |
| })} |
|
|
| {} |
| {loading && ( |
| <div |
| className="column-card" |
| style={{ |
| alignSelf: 'flex-start', |
| width: '100%', |
| maxWidth: '90%', |
| margin: 0, |
| opacity: 0.7 |
| }} |
| > |
| <div className="column-header" style={{ padding: '0.8rem 1.25rem' }}> |
| <div className="column-title-wrapper"> |
| <h3 style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.95rem', fontWeight: 700, margin: 0 }}> |
| <Cpu size={15} color="var(--primary)" /> |
| Socratic Tutor thinking... |
| </h3> |
| </div> |
| </div> |
| <div className="column-body" style={{ padding: '1.25rem' }}> |
| <div className="skeleton-wrapper"> |
| <div className="skeleton-line long" /> |
| <div className="skeleton-line medium" /> |
| <div className="skeleton-line short" /> |
| </div> |
| </div> |
| </div> |
| )} |
| </div> |
|
|
| {} |
| {history.length > 0 && !loading && ( |
| <div |
| className="query-card" |
| style={{ |
| marginTop: '1.5rem', |
| background: 'rgba(99, 102, 241, 0.03)', |
| borderColor: 'var(--primary-glow)', |
| boxShadow: 'none', |
| padding: '1.25rem' |
| }} |
| > |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.8rem' }}> |
| <h3 style={{ fontSize: '0.95rem', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '0.4rem', margin: 0 }}> |
| <Brain size={16} color="var(--primary)" /> |
| Socratic Dialogue |
| </h3> |
| <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}> |
| Reply to the Socratic tutor's guide question to continue exploring the concept. |
| </p> |
| </div> |
| <form |
| onSubmit={(e) => { |
| e.preventDefault(); |
| const inputEl = e.target.elements.followUpText; |
| const val = inputEl.value.trim(); |
| if (val) { |
| onSubmitFollowUp(val); |
| inputEl.value = ''; |
| } |
| }} |
| className="query-input-wrapper" |
| > |
| <textarea |
| name="followUpText" |
| placeholder="Provide your Socratic response..." |
| className="query-textarea" |
| style={{ minHeight: '60px' }} |
| onKeyDown={(e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| e.target.form.requestSubmit(); |
| } |
| }} |
| /> |
| <button |
| type="submit" |
| className="send-button" |
| style={{ padding: '0.6rem 1.25rem' }} |
| > |
| Reply |
| </button> |
| </form> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|