3v324v23's picture
Migrate chatbot to Socratic sentiment tutor with Gemini Live Voice Session capability
c622774
Raw
History Blame Contribute Delete
15 kB
import React, { useState } from 'react';
import { Sparkles, Clock, Terminal, ChevronDown, ChevronUp, AlertCircle, Brain, Cpu } from 'lucide-react';
// Render LaTeX and text mixed together
function RenderLatex({ text }) {
if (!text) return null;
// Split text by $$ (block math) first, then by $ (inline math)
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>
);
})}
</>
);
}
// A simple local Markdown parser that converts basic markdown elements to safe HTML
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>
);
}
// Format bold (**), italics (*), and inline code (`)
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]
}));
};
// Render loading skeleton
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>
);
}
// Render error message
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>
);
}
// Render empty state
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 Bubble when generating */}
{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>
{/* Follow-up / Reply Area */}
{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>
);
}