feat: Initialize project structure

Sets up the basic project configuration for Cognitive Clarity. This includes:
- Initializing `package.json` with necessary dependencies (React, Gemini API, Lucide React) and build scripts for Vite.
- Creating a basic `index.html` with Tailwind CSS and Google Fonts integration.
- Setting up `index.tsx` as the React application entry point.
- Configuring `tsconfig.json` for TypeScript compilation with Vite.
- Establishing `vite.config.ts` for development server setup and environment variable handling.
- Adding a standard `.gitignore` file.
- Updating `README.md` with local run instructions and AI Studio links.
- Defining `metadata.json` for application details.
- Creating core type definitions in `types.ts`.
- Populating `constants.ts` with initial CBT glossary terms.
- Adding utility functions for audio processing in `services/audioUtils.ts`.
This commit is contained in:
John Remillard committed 2025-12-17 11:52:32 -05:00
1 parent a6780ce1b2
commit c78604de02
22 files changed
+2097 -8

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
interface AudioVisualizerProps {
level: number;
active: boolean;
}
const AudioVisualizer: React.FC<AudioVisualizerProps> = ({ level, active }) => {
const barCount = 19;
return (
<div className="flex items-center justify-center gap-1.5 h-24 w-full px-4">
{Array.from({ length: barCount }).map((_, i) => {
const center = (barCount - 1) / 2;
const dist = Math.abs(i - center);
const multiplier = Math.pow(0.88, dist);
const jitter = 0.8 + (Math.sin(i * 1.5) * 0.2);
const height = active ? Math.max(8, level * multiplier * jitter) : 6;
return (
<div
key={i}
className={`w-1.5 rounded-full transition-all duration-100 ease-out border-b border-white/5 ${
active
? 'bg-teal-400 shadow-[0_0_15px_rgba(45,212,191,0.6)]'
: 'bg-slate-700/50'
}`}
style={{
height: `${height}%`,
opacity: active ? 1 - (dist / barCount) : 0.3
}}
/>
);
})}
</div>
);
};
export default React.memo(AudioVisualizer);
+129
View File
@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { CBT_GLOSSARY, GlossaryEntry } from '../constants';
import { X, Search, BookOpen, Brain, Activity, HelpCircle } from 'lucide-react';
interface CBTReferenceProps {
onClose: () => void;
}
const CBTReference: React.FC<CBTReferenceProps> = ({ onClose }) => {
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState<'All' | 'Distortion' | 'Technique' | 'Concept'>('All');
const filtered = CBT_GLOSSARY.filter(entry => {
const matchesSearch = entry.term.toLowerCase().includes(search.toLowerCase()) ||
entry.definition.toLowerCase().includes(search.toLowerCase());
const matchesCategory = activeCategory === 'All' || entry.category === activeCategory;
return matchesSearch && matchesCategory;
});
const categories: ('All' | 'Distortion' | 'Technique' | 'Concept')[] = ['All', 'Distortion', 'Technique', 'Concept'];
const getCategoryIcon = (cat: string) => {
switch (cat) {
case 'Distortion': return <Brain size={16} className="text-rose-500" />;
case 'Technique': return <Activity size={16} className="text-teal-500" />;
case 'Concept': return <HelpCircle size={16} className="text-amber-500" />;
default: return <BookOpen size={16} />;
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl h-[85vh] overflow-hidden flex flex-col animate-in fade-in zoom-in duration-300">
{/* Header */}
<div className="p-6 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="bg-teal-600 p-2 rounded-lg text-white">
<BookOpen size={24} />
</div>
<div>
<h2 className="text-xl font-bold text-slate-800">CBT Clinical Reference</h2>
<p className="text-xs text-slate-500 font-medium uppercase tracking-widest">Evidence-Based Concepts & Frameworks</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-xl transition-colors text-slate-400">
<X size={20} />
</button>
</div>
{/* Filters */}
<div className="p-6 space-y-4 border-b border-slate-100 bg-white">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Search concepts, distortions, or definitions..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-12 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-4 focus:ring-teal-500/10 focus:border-teal-500 focus:outline-none transition-all text-sm"
/>
</div>
<div className="flex flex-wrap gap-2">
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
activeCategory === cat
? 'bg-teal-600 text-white border-teal-600 shadow-md shadow-teal-100'
: 'bg-white text-slate-500 border-slate-200 hover:border-teal-200 hover:text-teal-600'
}`}
>
{cat}
</button>
))}
</div>
</div>
{/* Content */}
<div className="flex-grow overflow-y-auto p-6 scrollbar-hide bg-slate-50/30">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((entry, idx) => (
<div
key={idx}
className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm hover:border-teal-200 transition-all group"
>
<div className="flex justify-between items-start mb-3">
<h3 className="font-bold text-slate-800 group-hover:text-teal-700 transition-colors">{entry.term}</h3>
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-slate-50 border border-slate-100 rounded text-[9px] font-black uppercase tracking-tighter text-slate-500">
{getCategoryIcon(entry.category)}
{entry.category}
</div>
</div>
<p className="text-sm text-slate-600 leading-relaxed mb-4 font-light">
{entry.definition}
</p>
{entry.example && (
<div className="bg-teal-50/50 p-3 rounded-xl border border-teal-100">
<span className="text-[9px] font-black uppercase text-teal-600 block mb-1">Clinical Example</span>
<p className="text-xs text-teal-800 italic leading-relaxed">"{entry.example}"</p>
</div>
)}
</div>
))}
{filtered.length === 0 && (
<div className="col-span-full py-20 text-center space-y-4">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto text-slate-400">
<Search size={32} />
</div>
<p className="text-slate-400 text-sm font-medium">No concepts matching your search parameters.</p>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-4 bg-white border-t border-slate-100 text-center">
<p className="text-[10px] text-slate-400 font-medium uppercase tracking-widest">
References compiled from clinical CBT standard frameworks.
</p>
</div>
</div>
</div>
);
};
export default CBTReference;
+43
View File
@@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { Send } from 'lucide-react';
interface ChatInputProps {
onSend: (text: string) => void;
disabled?: boolean;
}
const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {
const [input, setInput] = useState('');
const handleSend = () => {
if (!input.trim() || disabled) return;
onSend(input);
setInput('');
};
return (
<div className="p-4 border-t border-slate-100 bg-[#FCFDFF]">
<div className="flex gap-2 max-w-3xl mx-auto">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Type your response in character..."
className="flex-grow px-5 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white text-slate-900 placeholder:text-slate-400 text-sm shadow-inner"
disabled={disabled}
/>
<button
onClick={handleSend}
disabled={disabled || !input.trim()}
className="bg-teal-600 text-white p-3 rounded-xl hover:bg-teal-700 disabled:opacity-50 transition-all shadow-md shadow-teal-100 active:scale-95"
>
<Send size={20} />
</button>
</div>
</div>
);
};
export default React.memo(ChatInput);
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import { User, Bot } from 'lucide-react';
import { Message } from '../types';
interface ChatMessageProps {
message: Message;
isLoading?: boolean;
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isLoading }) => {
const isModel = message.role === 'model';
return (
<div className={`flex ${!isModel ? 'justify-end' : 'justify-start'}`}>
<div className={`flex max-w-[85%] gap-3 ${!isModel ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm border ${
!isModel ? 'bg-teal-50 text-teal-600 border-teal-100' : 'bg-slate-50 text-slate-600 border-slate-100'
}`}>
{!isModel ? <User size={18} /> : <Bot size={18} />}
</div>
<div className={`p-4 rounded-2xl text-sm leading-relaxed shadow-sm ${
!isModel
? 'bg-teal-600 text-white rounded-tr-none'
: 'bg-[#F9FAFB] text-slate-800 rounded-tl-none border border-slate-100'
}`}>
{message.text || (isModel && isLoading ? (
<div className="flex gap-1 items-center">
<span className="w-1 h-1 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
) : null)}
</div>
</div>
</div>
);
};
export default React.memo(ChatMessage);
+244
View File
@@ -0,0 +1,244 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Scenario, Message, AnalysisResult } from '../types';
import { GoogleGenAI, Chat, GenerateContentResponse } from "@google/genai";
import { BrainCircuit, ChevronLeft, Flag, Loader2 } from 'lucide-react';
import { SYSTEM_ANALYSIS_PROMPT, SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
// Sub-components
import ChatMessage from './ChatMessage';
import ChatInput from './ChatInput';
import LiveInsightPanel from './LiveInsightPanel';
import SessionReport from './SessionReport';
interface ChatSessionProps {
scenario: Scenario;
onExit: () => void;
}
const ChatSession: React.FC<ChatSessionProps> = ({ scenario, onExit }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState<'active' | 'analyzing' | 'finished'>('active');
// Clinical Feedback State
const [liveInsight, setLiveInsight] = useState<AnalysisResult | null>(null);
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
const [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
const chatRef = useRef<Chat | null>(null);
const analysisRef = useRef<Chat | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Session Initialization
useEffect(() => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Roleplay Chat Setup
const roleplaySystemInstruction = `
${scenario.systemInstruction}
CRITICAL ROLEPLAY PROTOCOL:
1. Stay strictly in character as the persona defined in the scenario.
2. NEVER provide clinical feedback, advice, or meta-commentary about the user's progress.
3. NEVER use phrases like "Great job holding your boundary" or "Feedback:".
4. Do not congratulate the user on using CBT techniques; simply react as the character would.
5. If the user successfully concludes the scenario, end the dialogue naturally.
`;
chatRef.current = ai.chats.create({
model: 'gemini-3-flash-preview',
config: { systemInstruction: roleplaySystemInstruction },
});
// Live Analysis Chat Setup
const analysisSystemInstruction = `${SYSTEM_ANALYSIS_PROMPT}
SCENARIO CONTEXT:
Title: ${scenario.title}
Description: ${scenario.description}
CBT Focus Areas: ${scenario.cbtFocus.join(', ')}`;
analysisRef.current = ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: analysisSystemInstruction,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
}
});
// Initial Message
setMessages([{
id: 'init',
role: 'model',
text: scenario.initialMessage,
timestamp: new Date()
}]);
setLiveInsight(null);
}, [scenario]);
const handleSend = useCallback(async (text: string) => {
if (!chatRef.current || isLoading) return;
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
text: text,
timestamp: new Date()
};
setMessages(prev => [...prev, userMsg]);
setIsLoading(true);
try {
const botMsgId = (Date.now() + 1).toString();
const botMsg: Message = {
id: botMsgId,
role: 'model',
text: '',
timestamp: new Date()
};
setMessages(prev => [...prev, botMsg]);
const streamResponse = await chatRef.current.sendMessageStream({ message: text });
let fullResponseText = '';
for await (const chunk of streamResponse) {
const c = chunk as GenerateContentResponse;
const textChunk = c.text || "";
fullResponseText += textChunk;
setMessages(prev => prev.map(m =>
m.id === botMsgId ? { ...m, text: fullResponseText } : m
));
}
// Live Turn Analysis
if (analysisRef.current) {
setIsAnalyzingTurn(true);
const turnContext = `User said: "${userMsg.text}". Roleplay partner said: "${fullResponseText}".`;
analysisRef.current.sendMessage({ message: turnContext }).then(res => {
try {
if (res.text) setLiveInsight(JSON.parse(res.text));
} catch(e) { console.error("Live analysis parse error", e); } finally {
setIsAnalyzingTurn(false);
}
});
}
} catch (error) {
console.error("Chat transmission failed", error);
setMessages(prev => prev.filter(m => m.text !== ''));
} finally {
setIsLoading(false);
}
}, [isLoading]);
const handleFinishSession = async () => {
if (messages.length < 2) return onExit();
setStatus('analyzing');
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const transcript = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n');
const summaryPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}
SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`;
const result = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: summaryPrompt,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
},
contents: [{ role: 'user', parts: [{ text: `SESSION TRANSCRIPT:\n${transcript}` }] }]
});
if (result.text) {
setFinalReport(JSON.parse(result.text));
setStatus('finished');
} else {
throw new Error("Final synthesis failed");
}
} catch (err) {
console.error("Post-session analysis failure", err);
onExit();
}
};
if (status === 'analyzing') {
return (
<div className="flex flex-col items-center justify-center min-h-[500px] h-full bg-slate-50 rounded-3xl p-8 border border-slate-200 w-full max-w-2xl mx-auto animate-in fade-in duration-500">
<div className="bg-white p-12 rounded-full shadow-lg border border-teal-100 mb-8 relative">
<BrainCircuit size={64} className="text-teal-600 animate-pulse" />
<div className="absolute inset-0 border-4 border-teal-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<h2 className="text-2xl font-bold text-slate-800 mb-2">Compiling Report</h2>
<p className="text-slate-500 text-center max-w-sm">Generating evidence-based synthesis of your cognitive and behavioral patterns during this session...</p>
</div>
);
}
if (status === 'finished' && finalReport) {
return <SessionReport analysis={finalReport} onExit={onExit} />;
}
return (
<div className="flex flex-col h-[calc(100vh-100px)] max-w-6xl mx-auto w-full gap-4 px-2">
{/* Interaction Header */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-slate-200 flex justify-between items-center">
<div className="flex items-center gap-3">
<button onClick={onExit} className="p-2 hover:bg-slate-50 rounded-lg text-slate-400 hover:text-teal-600 transition-colors">
<ChevronLeft size={20} />
</button>
<div>
<h2 className="font-bold text-slate-800 text-lg leading-tight">{scenario.title}</h2>
<div className="flex gap-2">
{scenario.cbtFocus.slice(0, 3).map(f => (
<span key={f} className="text-[10px] font-bold text-teal-600 uppercase tracking-tight">{f}</span>
))}
</div>
</div>
</div>
<button
onClick={handleFinishSession}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-teal-50 text-teal-700 hover:bg-teal-600 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all border border-teal-100"
>
<Flag size={14} />
Complete Interaction
</button>
</div>
<div className="flex flex-col lg:flex-row gap-4 h-full overflow-hidden">
{/* Core Chat Module */}
<div className="flex-grow flex flex-col bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="flex-grow overflow-y-auto p-6 space-y-6 scrollbar-hide">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} isLoading={isLoading && msg.id === messages[messages.length-1].id} />
))}
<div ref={messagesEndRef} />
</div>
<ChatInput onSend={handleSend} disabled={isLoading} />
</div>
{/* Real-time Insights Module */}
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} />
</div>
</div>
);
};
export default React.memo(ChatSession);
+97
View File
@@ -0,0 +1,97 @@
import React from 'react';
import { BrainCircuit, Loader2, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp } from 'lucide-react';
import { AnalysisResult } from '../types';
interface LiveInsightPanelProps {
analysis: AnalysisResult | null;
isAnalyzing: boolean;
}
const LiveInsightPanel: React.FC<LiveInsightPanelProps> = ({ analysis, isAnalyzing }) => {
return (
<div className="lg:w-[35%] w-full bg-white rounded-3xl border border-slate-200 overflow-y-auto flex flex-col animate-in slide-in-from-right-8 duration-500 shadow-xl lg:h-full h-[400px]">
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-[#F8FAFC]">
<div className="flex items-center gap-2 text-teal-600">
<BrainCircuit size={20} />
<h3 className="font-bold text-xs uppercase tracking-widest text-slate-800">Live Clinical Insight</h3>
</div>
{isAnalyzing && <Loader2 size={16} className="animate-spin text-teal-500" />}
</div>
<div className="p-6 space-y-6">
{analysis ? (
<div className="animate-in fade-in duration-500 space-y-5">
<div className={`p-5 rounded-2xl border-l-4 ${analysis.analysis.isAdaptive ? 'bg-teal-50/50 border-teal-500' : 'bg-rose-50/50 border-rose-500'}`}>
<div className="flex items-center gap-2 mb-3">
{analysis.analysis.isAdaptive ? (
<CheckCircle2 size={16} className="text-teal-600" />
) : (
<AlertTriangle size={16} className="text-rose-600" />
)}
<span className={`font-bold text-[10px] uppercase tracking-wider ${analysis.analysis.isAdaptive ? 'text-teal-800' : 'text-rose-800'}`}>
{analysis.analysis.isAdaptive ? 'Optimal Thinking' : 'Distortion Identified'}
</span>
</div>
{!analysis.analysis.isAdaptive && analysis.analysis.distortions.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{analysis.analysis.distortions.map((d, i) => (
<span key={i} className="text-[9px] font-black uppercase tracking-widest text-white bg-rose-500 px-2 py-0.5 rounded shadow-sm">
{d}
</span>
))}
</div>
)}
<p className={`text-xs leading-relaxed font-medium ${analysis.analysis.isAdaptive ? 'text-teal-800' : 'text-rose-800'}`}>
{analysis.analysis.evidence}
</p>
</div>
<div className="bg-slate-50 p-5 rounded-2xl border border-slate-100 shadow-inner">
<div className="flex justify-between items-center mb-3">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Efficiency</span>
<div className="flex gap-1">
{[1,2,3,4,5].map(star => (
<div key={star} className={`h-1.5 w-4 rounded-full ${star <= analysis.rating.score ? 'bg-teal-500' : 'bg-slate-200'}`} />
))}
</div>
</div>
<p className="text-xs text-slate-600 font-medium leading-relaxed">{analysis.rating.reason}</p>
</div>
<div className="space-y-3">
<div className="bg-white p-5 rounded-2xl border border-teal-100 shadow-sm relative overflow-hidden group">
<div className="flex items-center gap-2 text-teal-600 mb-2">
<Lightbulb size={16} />
<span className="text-[10px] font-black uppercase tracking-widest">Master Reframe</span>
</div>
<p className="text-sm text-slate-800 italic font-medium leading-relaxed relative z-10">"{analysis.suggestions.alternativeThought}"</p>
</div>
<div className="bg-white p-5 rounded-2xl border border-teal-100 shadow-sm">
<div className="flex items-center gap-2 text-teal-600 mb-2">
<TrendingUp size={16} />
<span className="text-[10px] font-black uppercase tracking-widest">Tactical Pivot</span>
</div>
<p className="text-xs text-slate-800 leading-relaxed font-light">{analysis.suggestions.behavioralStep}</p>
</div>
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-center p-8 space-y-4 pt-20">
<div className="w-16 h-16 rounded-3xl bg-slate-50 flex items-center justify-center border border-slate-100 animate-pulse">
<BrainCircuit size={32} className="text-slate-300" />
</div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest leading-loose">
Monitoring Interaction...<br/>Turn analysis pending
</p>
</div>
)}
</div>
</div>
);
};
export default React.memo(LiveInsightPanel);
+64
View File
@@ -0,0 +1,64 @@
import React from 'react';
import { Scenario } from '../types';
import { Mic, MessageSquare } from 'lucide-react';
interface ScenarioCardProps {
scenario: Scenario;
onSelect: (scenario: Scenario, mode: 'text' | 'voice') => void;
}
const ScenarioCard: React.FC<ScenarioCardProps> = ({ scenario, onSelect }) => {
const getDifficultyColor = (diff: string) => {
switch (diff) {
case 'Beginner': return 'bg-teal-50 text-teal-800 border-teal-100';
case 'Intermediate': return 'bg-amber-50 text-amber-800 border-amber-100';
case 'Advanced': return 'bg-rose-50 text-rose-800 border-rose-100';
default: return 'bg-slate-50 text-slate-800 border-slate-100';
}
};
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-slate-200 hover:border-teal-200 hover:shadow-md transition-all flex flex-col h-full group">
<div className="flex justify-between items-start mb-4">
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border ${getDifficultyColor(scenario.difficulty)}`}>
{scenario.difficulty}
</span>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
{scenario.category}
</span>
</div>
<h3 className="text-lg font-bold text-slate-800 mb-2 group-hover:text-teal-700 transition-colors">{scenario.title}</h3>
<p className="text-slate-600 text-sm mb-5 flex-grow leading-relaxed font-light">{scenario.description}</p>
<div className="mb-6">
<div className="flex flex-wrap gap-1.5">
{scenario.cbtFocus.map(focus => (
<span key={focus} className="text-[10px] font-medium bg-slate-50 text-slate-500 px-2 py-0.5 rounded border border-slate-100">
{focus}
</span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-auto">
<button
onClick={() => onSelect(scenario, 'text')}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-slate-50 text-slate-600 hover:bg-slate-100 transition-all font-semibold text-xs border border-slate-200"
>
<MessageSquare size={14} />
Chat
</button>
<button
onClick={() => onSelect(scenario, 'voice')}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-teal-600 text-white hover:bg-teal-700 transition-all font-semibold text-xs shadow-sm shadow-teal-100"
>
<Mic size={14} />
Voice
</button>
</div>
</div>
);
};
export default ScenarioCard;
+232
View File
@@ -0,0 +1,232 @@
import React, { useState } from 'react';
import { GoogleGenAI, Type } from "@google/genai";
import { Scenario } from '../types';
import { CBT_GLOSSARY } from '../constants';
import { Sparkles, X, Loader2, AlertCircle, Info, Target, UserCircle2, BrainCircuit, Check } from 'lucide-react';
interface ScenarioGeneratorProps {
onGenerate: (scenario: Scenario) => void;
onCancel: () => void;
}
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel }) => {
const [prompt, setPrompt] = useState('');
const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggleConcept = (term: string) => {
setSelectedConcepts(prev =>
prev.includes(term) ? prev.filter(t => t !== term) : [...prev, term]
);
};
const handleGenerate = async () => {
if (!prompt.trim() && selectedConcepts.length === 0) return;
setIsLoading(true);
setError(null);
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const schema = {
type: Type.OBJECT,
properties: {
id: { type: Type.STRING },
title: { type: Type.STRING },
description: { type: Type.STRING },
category: {
type: Type.STRING,
enum: ['Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk']
},
difficulty: {
type: Type.STRING,
enum: ['Beginner', 'Intermediate', 'Advanced']
},
systemInstruction: { type: Type.STRING },
initialMessage: { type: Type.STRING },
cbtFocus: {
type: Type.ARRAY,
items: { type: Type.STRING }
}
},
required: ['id', 'title', 'description', 'category', 'difficulty', 'systemInstruction', 'initialMessage', 'cbtFocus']
};
const selectedContext = selectedConcepts.length > 0
? `Focus specifically on these CBT concepts: ${selectedConcepts.join(', ')}.`
: '';
const result = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
config: {
responseMimeType: 'application/json',
responseSchema: schema,
systemInstruction: `
You are a Clinical CBT Scenario Architect.
Your goal is to transform a user's prompt into a structured clinical simulation.
CORE INSTRUCTIONS:
1. CLINICAL FOCUS: ${selectedContext}
2. CATEGORIZATION: You MUST assign the scenario to exactly one of the following categories based on its primary context:
- 'Social Anxiety': Situations involving social performance, fear of judgment, or networking.
- 'Workplace': Scenarios involving colleagues, supervisors, career growth, or professional conflict.
- 'Relationships': Dynamics with family, friends, or romantic partners; boundary setting.
- 'Self-Talk': Purely internal cognitive restructuring, rumination, or self-compassion work.
3. DIFFICULTY CALIBRATION: Assign a level based on cognitive load:
- 'Beginner': Low stakes, high support, clear-cut distortions.
- 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions.
- 'Advanced': High emotional triggers, persistent clinical resistance from AI character, deep-seated patterns.
4. INTERNAL LOGIC: The 'systemInstruction' must include a detailed "INTERNAL CBT LOGIC" section. This tells the AI how to react to the user's progress or cognitive lapses.
5. CONSISTENCY: Ensure 'cbtFocus' includes the selected concepts.
`
},
contents: [
{ role: 'user', parts: [{ text: `Generate a clinical scenario for: ${prompt || 'A scenario exploring the chosen CBT concepts.'}` }] }
]
});
if (result.text) {
const scenario = JSON.parse(result.text) as Scenario;
scenario.id = `custom-${Date.now()}-${scenario.id}`;
onGenerate(scenario);
} else {
throw new Error("No content generated");
}
} catch (err: any) {
console.error("Generation error:", err);
setError("Clinical scenario synthesis failed. Please refine your parameters.");
} finally {
setIsLoading(false);
}
};
const getConceptColor = (category: string, isSelected: boolean) => {
if (!isSelected) return 'bg-white border-slate-200 text-slate-500 hover:border-slate-300';
switch (category) {
case 'Distortion': return 'bg-rose-600 border-rose-600 text-white shadow-md shadow-rose-100';
case 'Technique': return 'bg-teal-600 border-teal-600 text-white shadow-md shadow-teal-100';
case 'Concept': return 'bg-amber-600 border-amber-600 text-white shadow-md shadow-amber-100';
default: return 'bg-slate-800 border-slate-800 text-white';
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md overflow-y-auto">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-300 border border-slate-100 my-8">
{/* Header */}
<div className="p-6 bg-teal-600 text-white flex justify-between items-start relative overflow-hidden">
<div className="relative z-10">
<h2 className="text-2xl font-bold flex items-center gap-3">
<Sparkles size={28} className="text-teal-200" />
Scenario Synthesis
</h2>
<p className="text-teal-50 text-sm mt-1 font-light opacity-90">
Construct an evidence-based clinical environment.
</p>
</div>
<button onClick={onCancel} className="relative z-10 text-white/70 hover:text-white transition-colors bg-white/10 p-2 rounded-xl border border-white/10">
<X size={20} />
</button>
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-10 -mt-10 blur-2xl"></div>
</div>
<div className="p-8 space-y-6 bg-[#FCFDFF]">
{/* Prompting Guide */}
<div className="bg-teal-50/50 border border-teal-100 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-teal-700">
<Info size={16} />
<h3 className="text-xs font-bold uppercase tracking-wider">Clinical Blueprint</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
<UserCircle2 size={12} /> Character Role
</div>
<p className="text-[10px] text-slate-500 leading-tight">Define who the AI plays.</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
<Target size={12} /> The Challenge
</div>
<p className="text-[10px] text-slate-500 leading-tight">Describe the social friction.</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
<BrainCircuit size={12} /> CBT Focus
</div>
<p className="text-[10px] text-slate-500 leading-tight">Target specific distortions.</p>
</div>
</div>
</div>
{/* Clinical Targets (Selection) */}
<div className="space-y-3">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
Clinical Targets (Optional)
</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto p-1 scrollbar-hide">
{CBT_GLOSSARY.map(concept => {
const isSelected = selectedConcepts.includes(concept.term);
return (
<button
key={concept.term}
onClick={() => toggleConcept(concept.term)}
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold border transition-all flex items-center gap-1.5 ${getConceptColor(concept.category, isSelected)}`}
>
{isSelected && <Check size={10} />}
{concept.term}
</button>
);
})}
</div>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">
Simulation Context / Custom Instructions
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. Practicing assertiveness with a pushy salesperson who won't take 'no' for an answer."
className="w-full h-28 p-5 border border-slate-200 rounded-2xl focus:ring-4 focus:ring-teal-500/10 focus:border-teal-500 focus:outline-none resize-none text-slate-800 placeholder:text-slate-400 text-sm leading-relaxed shadow-inner bg-white"
disabled={isLoading}
/>
</div>
{error && (
<div className="bg-rose-50 text-rose-600 p-4 rounded-xl text-xs flex items-center gap-3 border border-rose-100 font-medium">
<AlertCircle size={18} />
{error}
</div>
)}
<div className="flex justify-end pt-2">
<button
onClick={handleGenerate}
disabled={(!prompt.trim() && selectedConcepts.length === 0) || isLoading}
className="bg-teal-600 text-white px-8 py-3.5 rounded-2xl font-bold hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-3 transition-all shadow-lg shadow-teal-100 text-sm uppercase tracking-widest"
>
{isLoading ? (
<>
<Loader2 size={20} className="animate-spin" />
Synthesizing...
</>
) : (
<>
<Sparkles size={20} />
Initiate Build
</>
)}
</button>
</div>
</div>
</div>
</div>
);
};
export default ScenarioGenerator;
+87
View File
@@ -0,0 +1,87 @@
import React from 'react';
import { AnalysisResult } from '../types';
import { ClipboardCheck, X, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp } from 'lucide-react';
interface SessionReportProps {
analysis: AnalysisResult;
onExit: () => void;
}
const SessionReport: React.FC<SessionReportProps> = ({ analysis, onExit }) => {
return (
<div className="flex flex-col h-full bg-[#FDFDFF] rounded-3xl border border-slate-200 overflow-hidden shadow-2xl animate-in zoom-in-95 duration-500 w-full max-w-4xl mx-auto">
<div className="bg-teal-600 p-6 flex justify-between items-center text-white">
<div className="flex items-center gap-3">
<ClipboardCheck size={28} />
<div>
<h2 className="text-xl font-bold leading-none">Session Summary Report</h2>
<span className="text-[10px] font-bold uppercase tracking-widest text-teal-100 opacity-80">Clinical Analysis Complete</span>
</div>
</div>
<button onClick={onExit} className="bg-white/10 hover:bg-white/20 p-2 rounded-xl border border-white/10 transition-colors">
<X size={20} />
</button>
</div>
<div className="flex-grow overflow-y-auto p-8 flex flex-col gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className={`p-6 rounded-2xl border-l-8 ${analysis.analysis.isAdaptive ? 'bg-teal-50/50 border-teal-500' : 'bg-rose-50/50 border-rose-500'}`}>
<div className="flex items-center gap-2 mb-4">
{analysis.analysis.isAdaptive ? <CheckCircle2 className="text-teal-600" /> : <AlertTriangle className="text-rose-600" />}
<span className="font-black text-xs uppercase tracking-widest">{analysis.analysis.isAdaptive ? 'Adaptive Growth Observed' : 'Clinical Patterns Detected'}</span>
</div>
{analysis.analysis.distortions.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{analysis.analysis.distortions.map(d => (
<span key={d} className="bg-white border border-slate-200 px-3 py-1 rounded-lg text-[10px] font-bold text-slate-600 shadow-sm">{d}</span>
))}
</div>
)}
<p className="text-sm leading-relaxed text-slate-700 font-medium italic">"{analysis.analysis.evidence}"</p>
</div>
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm flex flex-col justify-center">
<div className="flex justify-between items-center mb-4">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Mastery Level</span>
<div className="flex gap-1.5">
{[1,2,3,4,5].map(s => (
<div key={s} className={`h-2 w-8 rounded-full ${s <= analysis.rating.score ? 'bg-teal-500' : 'bg-slate-100'}`} />
))}
</div>
</div>
<p className="text-sm text-slate-600 leading-relaxed font-light">{analysis.rating.reason}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-2xl border border-teal-100 shadow-sm">
<div className="flex items-center gap-2 text-teal-600 mb-3">
<Lightbulb size={20} />
<span className="text-[10px] font-black uppercase tracking-widest">Master Reframe</span>
</div>
<p className="text-base text-slate-800 italic font-medium leading-relaxed">"{analysis.suggestions.alternativeThought}"</p>
</div>
<div className="bg-white p-6 rounded-2xl border border-teal-100 shadow-sm">
<div className="flex items-center gap-2 text-teal-600 mb-3">
<TrendingUp size={20} />
<span className="text-[10px] font-black uppercase tracking-widest">Homework & Next Steps</span>
</div>
<p className="text-sm text-slate-700 leading-relaxed">{analysis.suggestions.behavioralStep}</p>
</div>
</div>
</div>
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-center">
<button
onClick={onExit}
className="bg-teal-600 text-white px-12 py-3 rounded-xl font-bold hover:bg-teal-700 shadow-lg shadow-teal-100 transition-all uppercase tracking-widest text-xs"
>
Close Report & Return
</button>
</div>
</div>
);
};
export default React.memo(SessionReport);
+387
View File
@@ -0,0 +1,387 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
// Corrected import: AnalysisResult is defined in types.ts, not LiveInsightPanel.tsx
import { Scenario, AnalysisResult } from '../types';
import { GoogleGenAI, LiveServerMessage, Modality, Chat } from "@google/genai";
import { Mic, MicOff, X, Radio, AlertCircle, Headphones, BrainCircuit, LayoutPanelLeft } from 'lucide-react';
import { arrayBufferToBase64, base64ToUint8Array, decodeAudioData, floatTo16BitPCM } from '../services/audioUtils';
import { CBT_ANALYSIS_SCHEMA, SYSTEM_SUMMARY_ANALYSIS_PROMPT, SYSTEM_ANALYSIS_PROMPT } from '../constants';
// Sub-components
import AudioVisualizer from './AudioVisualizer';
// Corrected import: LiveInsightPanel is a default export, AnalysisResult is already imported from types
import LiveInsightPanel from './LiveInsightPanel';
import SessionReport from './SessionReport';
interface VoiceSessionProps {
scenario: Scenario;
onExit: () => void;
}
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit }) => {
const [status, setStatus] = useState<'disconnected' | 'connecting' | 'connected' | 'error' | 'analyzing' | 'finished'>('disconnected');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [finalAnalysis, setFinalAnalysis] = useState<AnalysisResult | null>(null);
// Live Analysis State
const [liveAnalysis, setLiveAnalysis] = useState<AnalysisResult | null>(null);
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
const [showLiveInsights, setShowLiveInsights] = useState(false);
// Audio Refs
const inputContextRef = useRef<AudioContext | null>(null);
const outputContextRef = useRef<AudioContext | null>(null);
const nextStartTimeRef = useRef<number>(0);
const sourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
// Transcript accumulation
const transcriptRef = useRef<string>("");
const currentTurnUserText = useRef<string>("");
const currentTurnPartnerText = useRef<string>("");
// AI Refs
const sessionRef = useRef<any>(null);
const analysisChatRef = useRef<Chat | null>(null);
// Visualization
const [volumeLevel, setVolumeLevel] = useState(0);
useEffect(() => {
return () => {
sourcesRef.current.forEach(source => { try { source.stop(); } catch(e) {} });
};
}, []);
const disconnectSession = useCallback(async () => {
sourcesRef.current.forEach(source => {
try { source.stop(); } catch(e) {}
});
sourcesRef.current.clear();
try {
if (inputContextRef.current && inputContextRef.current.state !== 'closed') {
await inputContextRef.current.close();
}
if (outputContextRef.current && outputContextRef.current.state !== 'closed') {
await outputContextRef.current.close();
}
} catch (e) {
console.error("Error closing contexts", e);
}
inputContextRef.current = null;
outputContextRef.current = null;
if (sessionRef.current) {
try {
const session = await sessionRef.current;
if (session && typeof session.close === 'function') {
session.close();
}
} catch (e) {
console.error("Error closing session:", e);
}
sessionRef.current = null;
}
}, []);
const handleTerminateAndAnalyze = async () => {
await disconnectSession();
if (transcriptRef.current.length < 10) {
setStatus('disconnected');
onExit();
return;
}
setStatus('analyzing');
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const systemPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}
SCENARIO CONTEXT:
Title: ${scenario.title}
Description: ${scenario.description}
CBT Focus: ${scenario.cbtFocus.join(', ')}`;
const result = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: systemPrompt,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
},
contents: [{ role: 'user', parts: [{ text: `SESSION TRANSCRIPT:\n${transcriptRef.current}` }] }]
});
if (result.text) {
setFinalAnalysis(JSON.parse(result.text));
setStatus('finished');
} else {
throw new Error("Analysis synthesis failed.");
}
} catch (err) {
console.error("Post-session analysis error", err);
setStatus('disconnected');
onExit();
}
};
const startSession = async () => {
try {
setStatus('connecting');
setErrorMessage(null);
transcriptRef.current = "";
currentTurnUserText.current = "";
currentTurnPartnerText.current = "";
setLiveAnalysis(null);
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
outputContextRef.current = new AudioContextClass({ sampleRate: 24000 });
inputContextRef.current = new AudioContextClass({ sampleRate: 16000 });
if (outputContextRef.current.state === 'suspended') await outputContextRef.current.resume();
if (inputContextRef.current.state === 'suspended') await inputContextRef.current.resume();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
analysisChatRef.current = ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\n\nSCENARIO CONTEXT:\nTitle: ${scenario.title}\nDescription: ${scenario.description}\nCBT Focus: ${scenario.cbtFocus.join(', ')}`,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
}
});
const effectiveSystemInstruction = `
${scenario.systemInstruction}
IMPORTANT: This is a voice conversation. Keep responses concise (1-2 sentences). You are strictly in character.
START THE CONVERSATION IMMEDIATELY by saying: "${scenario.initialMessage}"
`;
const sessionPromise = ai.live.connect({
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
config: {
responseModalities: [Modality.AUDIO],
systemInstruction: effectiveSystemInstruction,
outputAudioTranscription: {},
inputAudioTranscription: {},
speechConfig: {
voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Puck' } },
},
},
callbacks: {
onopen: () => {
setStatus('connected');
if (!inputContextRef.current) return;
const source = inputContextRef.current.createMediaStreamSource(stream);
const processor = inputContextRef.current.createScriptProcessor(4096, 1, 1);
const gainNode = inputContextRef.current.createGain();
gainNode.gain.value = 0;
processor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
let sum = 0;
for(let i=0; i<inputData.length; i+=10) sum += Math.abs(inputData[i]);
setVolumeLevel(Math.min((sum / (inputData.length/10)) * 500, 100));
const pcmData = floatTo16BitPCM(inputData);
const b64Data = arrayBufferToBase64(pcmData);
sessionPromise.then(session => {
session.sendRealtimeInput({
media: { mimeType: 'audio/pcm;rate=16000', data: b64Data }
});
}).catch(err => console.error("Error sending audio:", err));
};
source.connect(processor);
processor.connect(gainNode);
gainNode.connect(inputContextRef.current.destination);
},
onmessage: async (msg: LiveServerMessage) => {
if (msg.serverContent?.inputTranscription) {
const text = msg.serverContent.inputTranscription.text;
currentTurnUserText.current += text;
transcriptRef.current += `[USER]: ${text}\n`;
}
if (msg.serverContent?.outputTranscription) {
const text = msg.serverContent.outputTranscription.text;
currentTurnPartnerText.current += text;
transcriptRef.current += `[PARTNER]: ${text}\n`;
}
const audioData = msg.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
if (audioData && outputContextRef.current) {
const ctx = outputContextRef.current;
const bytes = base64ToUint8Array(audioData);
try {
const audioBuffer = await decodeAudioData(bytes, ctx, 24000, 1);
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
const now = ctx.currentTime;
if (nextStartTimeRef.current < now) nextStartTimeRef.current = now;
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += audioBuffer.duration;
sourcesRef.current.add(source);
source.addEventListener('ended', () => sourcesRef.current.delete(source));
} catch (decodingErr) { console.error("Audio decoding error", decodingErr); }
}
if (msg.serverContent?.interrupted) {
sourcesRef.current.forEach(src => { try { src.stop(); } catch(e){} });
sourcesRef.current.clear();
if (outputContextRef.current) nextStartTimeRef.current = outputContextRef.current.currentTime;
}
if (msg.serverContent?.turnComplete) {
if (analysisChatRef.current && (currentTurnUserText.current || currentTurnPartnerText.current)) {
setIsAnalyzingTurn(true);
const turnContext = `User said: "${currentTurnUserText.current}". Roleplay partner said: "${currentTurnPartnerText.current}".`;
analysisChatRef.current.sendMessage({ message: turnContext }).then(res => {
try {
if (res.text) {
setLiveAnalysis(JSON.parse(res.text));
if (!showLiveInsights) setShowLiveInsights(true);
}
} catch (e) { console.error("Failed to parse live analysis", e); } finally {
setIsAnalyzingTurn(false);
currentTurnUserText.current = "";
currentTurnPartnerText.current = "";
}
});
}
}
},
onclose: () => { if (status !== 'error' && status !== 'analyzing' && status !== 'finished') setStatus('disconnected'); },
onerror: (err) => {
console.error("Session error", err);
setErrorMessage("Clinical bridge interrupted. Resetting connection.");
setStatus('error');
disconnectSession();
}
}
});
sessionRef.current = sessionPromise;
} catch (err: any) {
console.error("Failed to start session", err);
setErrorMessage(err.message || "Microphone access denied or link failure.");
setStatus('error');
}
};
if (status === 'analyzing') {
return (
<div className="flex flex-col items-center justify-center min-h-[500px] h-full bg-slate-50 rounded-3xl p-8 border border-slate-200">
<div className="bg-white p-12 rounded-full shadow-lg border border-teal-100 mb-8 relative">
<BrainCircuit size={64} className="text-teal-600 animate-pulse" />
<div className="absolute inset-0 border-4 border-teal-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<h2 className="text-2xl font-bold text-slate-800 mb-2">Synthesizing Clinical Insights</h2>
<p className="text-slate-500 text-center max-w-sm">Please wait while the engine analyzes your vocal patterns and cognitive restructuring efficiency.</p>
</div>
);
}
if (status === 'finished' && finalAnalysis) {
return <SessionReport analysis={finalAnalysis} onExit={onExit} />;
}
return (
<div className={`flex flex-col lg:flex-row h-full max-h-[85vh] gap-4 transition-all duration-500`}>
{/* Main Voice Hub */}
<div className={`flex flex-col items-center justify-center bg-slate-900 rounded-3xl relative overflow-hidden text-white p-8 shadow-2xl border border-slate-800 transition-all duration-500 ${showLiveInsights ? 'lg:w-[65%]' : 'w-full'}`}>
{status === 'connected' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20">
<div className="w-80 h-80 bg-teal-500 rounded-full blur-[100px] transition-transform duration-150 ease-out" style={{ transform: `scale(${1 + volumeLevel/30})` }}></div>
</div>
)}
{/* Header */}
<div className="absolute top-8 left-8 right-8 flex justify-between items-start z-10">
<div className="flex items-center gap-4">
<div className="bg-teal-600/20 p-2.5 rounded-xl border border-teal-500/30">
<Headphones size={24} className="text-teal-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white tracking-tight">{scenario.title}</h2>
<div className="flex items-center gap-2 mt-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${status === 'connected' ? 'bg-teal-400 animate-pulse' : status === 'connecting' ? 'bg-amber-400 animate-bounce' : 'bg-rose-400'}`} />
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{status}</span>
</div>
</div>
</div>
<div className="flex gap-2">
{status === 'connected' && (
<button
onClick={() => setShowLiveInsights(!showLiveInsights)}
className={`p-2.5 rounded-xl transition-all backdrop-blur-md border ${showLiveInsights ? 'bg-teal-500 border-teal-400 text-white' : 'bg-white/5 border-white/10 text-white/60 hover:text-white'}`}
title="Toggle Live Insights"
>
<LayoutPanelLeft size={20} />
</button>
)}
<button
onClick={onExit}
className="bg-white/5 hover:bg-white/10 p-2.5 rounded-xl transition-all backdrop-blur-md border border-white/10 text-white/60 hover:text-white"
>
<X size={20} />
</button>
</div>
</div>
<div className="relative z-10 flex flex-col items-center gap-10 w-full">
<div className={`w-full max-w-lg mx-auto flex flex-col items-center gap-8 transition-all duration-700`}>
<AudioVisualizer level={volumeLevel} active={status === 'connected'} />
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-500 ${status === 'connected' ? 'bg-teal-500/10 ring-8 ring-teal-500/20' : 'bg-slate-800/50 ring-8 ring-slate-800'}`}>
<div className={`w-20 h-20 rounded-full flex items-center justify-center ${status === 'connected' ? 'bg-teal-500 shadow-[0_0_50px_rgba(20,184,166,0.3)]' : 'bg-slate-700'}`}>
<Radio size={32} className={`text-white ${status === 'connected' ? 'animate-pulse' : 'opacity-20'}`} />
</div>
</div>
</div>
<div className="text-center max-w-sm mt-4 min-h-[60px]">
{status === 'disconnected' && <p className="text-slate-400 text-sm font-light leading-relaxed">System ready for audio link.<br/>Please proceed when in a quiet environment.</p>}
{status === 'connecting' && <p className="text-teal-400 text-sm font-bold uppercase tracking-widest animate-pulse">Establishing Bi-Directional Bridge...</p>}
{status === 'connected' && <p className="text-white font-medium text-lg">Active Session. Listening...</p>}
{status === 'error' && (
<div className="flex items-center gap-2 text-rose-300 bg-rose-950/40 border border-rose-500/30 px-5 py-3 rounded-xl">
<AlertCircle size={18} />
<p className="text-sm font-medium">{errorMessage}</p>
</div>
)}
</div>
</div>
<div className="absolute bottom-12 z-10">
{status !== 'connected' ? (
<button
onClick={startSession}
disabled={status === 'connecting'}
className="flex items-center gap-3 bg-teal-600 hover:bg-teal-500 disabled:bg-teal-800 text-white px-10 py-4 rounded-2xl font-bold shadow-xl transition-all transform hover:scale-105 active:scale-95 border border-teal-400/20"
>
<Mic size={24} />
{status === 'connecting' ? 'SYNCING...' : 'INITIALIZE LINK'}
</button>
) : (
<button
onClick={handleTerminateAndAnalyze}
className="flex items-center gap-3 bg-slate-100 hover:bg-white text-slate-900 px-10 py-4 rounded-2xl font-bold shadow-xl transition-all active:scale-95"
>
<MicOff size={24} />
TERMINATE SESSION
</button>
)}
</div>
</div>
{showLiveInsights && <LiveInsightPanel analysis={liveAnalysis} isAnalyzing={isAnalyzingTurn} />}
</div>
);
};
export default VoiceSession;