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:
1 parent
a6780ce1b2
commit
c78604de02
22 files changed
+2097
-8
No files matched your search
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user