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

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+250
View File
@@ -0,0 +1,250 @@
import React, { useState, useEffect } from 'react';
import { SCENARIOS } from './constants';
import { Scenario, SessionMode } from './types';
import ScenarioCard from './components/ScenarioCard';
import ChatSession from './components/ChatSession';
import VoiceSession from './components/VoiceSession';
import ScenarioGenerator from './components/ScenarioGenerator';
import CBTReference from './components/CBTReference';
import { Brain, Info, ShieldCheck, Filter, Plus, Sparkles, BookOpen, Loader2, Activity, Zap } from 'lucide-react';
const App: React.FC = () => {
const [activeScenario, setActiveScenario] = useState<Scenario | null>(null);
const [sessionMode, setSessionMode] = useState<SessionMode>('text');
const [selectedDifficulty, setSelectedDifficulty] = useState<string>('All');
const [isPreparing, setIsPreparing] = useState(false);
// Custom Scenarios State
const [customScenarios, setCustomScenarios] = useState<Scenario[]>([]);
const [showGenerator, setShowGenerator] = useState(false);
const [showReference, setShowReference] = useState(false);
const handleSelectScenario = (scenario: Scenario, mode: SessionMode) => {
setIsPreparing(true);
// Simulate "Clinical Environment Calibration"
// This provides a clearer boundary between dashboard and the specialized session environment
setTimeout(() => {
setActiveScenario(scenario);
setSessionMode(mode);
setIsPreparing(false);
}, 1400);
};
const handleExitSession = () => {
setActiveScenario(null);
};
const handleScenarioGenerated = (newScenario: Scenario) => {
setCustomScenarios(prev => [newScenario, ...prev]);
setShowGenerator(false);
};
const allScenarios = [...customScenarios, ...SCENARIOS];
const filteredScenarios = allScenarios.filter(scenario =>
selectedDifficulty === 'All' || scenario.difficulty === selectedDifficulty
);
const difficulties = ['All', 'Beginner', 'Intermediate', 'Advanced'];
// Robust Session Preparation UI
if (isPreparing) {
return (
<div className="min-h-screen bg-slate-900 flex flex-col items-center justify-center p-6 text-white overflow-hidden relative">
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-20">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-teal-500 rounded-full blur-[120px] animate-pulse"></div>
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-500 rounded-full blur-[120px] animate-pulse" style={{ animationDelay: '1s' }}></div>
</div>
<div className="relative z-10 flex flex-col items-center max-w-md text-center">
<div className="w-24 h-24 bg-white/5 rounded-3xl border border-white/10 flex items-center justify-center mb-8 relative">
<Brain size={48} className="text-teal-400" />
<div className="absolute inset-0 border-2 border-teal-500/30 rounded-3xl animate-ping opacity-20"></div>
</div>
<h2 className="text-2xl font-bold mb-3 tracking-tight">Initializing Session</h2>
<div className="flex items-center gap-2 mb-8">
<span className="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
<span className="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
<span className="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest ml-2">Calibrating AI Parameters</p>
</div>
<div className="w-full bg-white/5 h-1.5 rounded-full overflow-hidden mb-4 border border-white/5">
<div className="h-full bg-teal-500 rounded-full w-0 animate-[loading_1.4s_ease-in-out_forwards]"></div>
</div>
<div className="space-y-2 opacity-60">
<p className="text-[10px] uppercase font-black tracking-tighter flex items-center justify-center gap-2">
<Zap size={10} className="text-teal-400" />
Configuring {sessionMode === 'voice' ? 'Voice Link' : 'Neural Chat Bridge'}
</p>
<p className="text-[10px] uppercase font-black tracking-tighter flex items-center justify-center gap-2">
<Activity size={10} className="text-blue-400" />
Injecting {activeScenario?.cbtFocus[0] || 'Clinical'} Logic
</p>
</div>
</div>
<style>{`
@keyframes loading {
0% { width: 0%; }
20% { width: 15%; }
50% { width: 70%; }
100% { width: 100%; }
}
`}</style>
</div>
);
}
if (activeScenario) {
return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center pt-8 px-4 pb-8 animate-in fade-in duration-700">
{sessionMode === 'text' ? (
<ChatSession scenario={activeScenario} onExit={handleExitSession} />
) : (
<div className="w-full max-w-4xl h-[80vh]">
<VoiceSession scenario={activeScenario} onExit={handleExitSession} />
</div>
)}
</div>
);
}
return (
<div className="min-h-screen bg-[#FDFDFF] text-slate-900 font-sans relative animate-in fade-in duration-500">
{/* Generator Modal */}
{showGenerator && (
<ScenarioGenerator
onGenerate={handleScenarioGenerated}
onCancel={() => setShowGenerator(false)}
/>
)}
{/* Reference Modal */}
{showReference && (
<CBTReference onClose={() => setShowReference(false)} />
)}
{/* Hero Section */}
<div className="bg-white border-b border-slate-200">
<div className="max-w-5xl mx-auto px-6 py-12">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div className="flex items-center gap-3">
<div className="bg-teal-600 p-2 rounded-lg text-white shadow-sm shadow-teal-200">
<Brain size={32} />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Cognitive Clarity</h1>
</div>
<button
onClick={() => setShowReference(true)}
className="flex items-center justify-center gap-2 bg-slate-50 text-slate-600 hover:text-teal-700 hover:bg-teal-50 px-5 py-2.5 rounded-xl border border-slate-200 transition-all font-bold text-sm"
>
<BookOpen size={18} />
<span>Reference Guide</span>
</button>
</div>
<p className="text-xl text-slate-600 max-w-2xl leading-relaxed font-light">
A clinical roleplaying platform utilizing CBT principles and AI to help you navigate social complexities and restructure internal dialogues.
</p>
<div className="flex flex-wrap gap-4 mt-8">
<div className="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-100">
<ShieldCheck className="text-teal-600" size={18} />
<span>Evidence-Based Framework</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-100">
<Info className="text-teal-600" size={18} />
<span>Personalized Adaptive Logic</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<main className="max-w-5xl mx-auto px-6 py-12">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
<h2 className="text-2xl font-medium text-slate-800">
Training Scenarios
</h2>
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
{/* Filter Controls */}
<div className="flex items-center bg-white border border-slate-200 rounded-lg p-1 shadow-sm overflow-x-auto">
<div className="px-3 text-slate-400 flex items-center gap-2 text-sm font-medium border-r border-slate-100 mr-1">
<Filter size={14} />
<span className="hidden sm:inline">Difficulty</span>
</div>
<div className="flex gap-1">
{difficulties.map((diff) => (
<button
key={diff}
onClick={() => setSelectedDifficulty(diff)}
className={`px-3 py-1.5 text-xs rounded-md transition-all whitespace-nowrap ${
selectedDifficulty === diff
? 'bg-teal-50 text-teal-700 font-semibold border border-teal-100'
: 'text-slate-500 hover:bg-slate-50 border border-transparent'
}`}
>
{diff}
</button>
))}
</div>
</div>
<button
onClick={() => setShowGenerator(true)}
className="flex items-center justify-center gap-2 bg-teal-600 text-white px-5 py-2.5 rounded-lg hover:bg-teal-700 transition-all font-bold text-sm shadow-md shadow-teal-100"
>
<Plus size={18} />
<span>Create Custom</span>
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredScenarios.map(scenario => (
<ScenarioCard
key={scenario.id}
scenario={scenario}
onSelect={handleSelectScenario}
/>
))}
<button
onClick={() => setShowGenerator(true)}
className="flex flex-col items-center justify-center gap-4 p-6 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 hover:border-teal-300 hover:text-teal-600 hover:bg-teal-50/50 transition-all h-full min-h-[250px] group"
>
<div className="bg-slate-50 p-4 rounded-full group-hover:bg-white transition-colors">
<Plus size={32} />
</div>
<div className="text-center">
<span className="block font-bold text-sm uppercase tracking-widest mb-1">Custom Scenario</span>
<span className="text-xs font-light">Generate a specific roleplay session</span>
</div>
</button>
</div>
</main>
<footer className="bg-white border-t border-slate-200 py-12">
<div className="max-w-5xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-8">
<div className="flex items-center gap-2 text-slate-400">
<Brain size={20} />
<span className="text-sm font-bold uppercase tracking-widest">Cognitive Clarity v1.0</span>
</div>
<div className="flex gap-8 text-xs font-bold text-slate-400 uppercase tracking-widest">
<a href="#" className="hover:text-teal-600 transition-colors">Privacy Protocol</a>
<a href="#" className="hover:text-teal-600 transition-colors">Clinical Terms</a>
<a href="#" className="hover:text-teal-600 transition-colors">Support</a>
</div>
</div>
</footer>
</div>
);
};
export default App;
+17 -8
View File
@@ -1,11 +1,20 @@
<div align="center"> <div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" /> <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div> </div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1MTS72LTsm0ZY5GzQzl-a8Rpt72k1csd9
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+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;
+203
View File
@@ -0,0 +1,203 @@
import { Scenario } from './types';
import { Type } from "@google/genai";
export interface GlossaryEntry {
term: string;
definition: string;
category: 'Distortion' | 'Technique' | 'Concept';
example?: string;
}
export const CBT_GLOSSARY: GlossaryEntry[] = [
{
term: "Mind Reading",
category: "Distortion",
definition: "Assuming you know what others are thinking without sufficient evidence, usually assuming they are thinking something negative about you.",
example: "They haven't replied to my text for an hour; they must be angry with me."
},
{
term: "Catastrophizing",
category: "Distortion",
definition: "Predicting a future outcome will be a disaster, ignoring other more likely possibilities.",
example: "If I fail this presentation, I'll definitely lose my job and never work again."
},
{
term: "Fortune Telling",
category: "Distortion",
definition: "Predicting a negative future outcome without considering more likely possibilities.",
example: "I just know I'm going to stutter and look like a fool in front of everyone."
},
{
term: "All-or-Nothing Thinking",
category: "Distortion",
definition: "Viewing things in black-and-white terms. If a situation isn't perfect, it's a total failure.",
example: "I ate one cookie, so my entire diet is ruined. I might as well eat the whole box."
},
{
term: "Emotional Reasoning",
category: "Distortion",
definition: "Assuming that because you feel a certain way, it must be true, regardless of evidence.",
example: "I feel guilty, so I must have done something wrong."
},
{
term: "Maladaptive Guilt",
category: "Distortion",
definition: "Feeling excessive responsibility or shame for events that are not within your control or are not your fault.",
example: "I feel bad for taking a break when my coworkers are still working, even though I've finished my tasks."
},
{
term: "Spotlight Effect",
category: "Concept",
definition: "The phenomenon where people tend to believe they are being noticed more than they actually are.",
example: "Thinking everyone at the party is staring at the small stain on your shirt."
},
{
term: "Emotional Regulation",
category: "Technique",
definition: "The ability to monitor and manage your emotional state, identifying triggers and using tools to return to a baseline.",
example: "Using a box-breathing technique to calm down before responding to a frustrating email."
},
{
term: "Healthy Boundaries",
category: "Technique",
definition: "The practice of clearly stating your needs and limits to others to protect your mental health and time.",
example: "Saying, 'I can't take on more work this week, but I can look at it on Monday.'"
},
{
term: "Cognitive Reframing",
category: "Technique",
definition: "Identifying and then disputing irrational or maladaptive thoughts to view a situation more realistically.",
example: "Changing 'I'm a failure' to 'I struggled with this task, but I can learn from it.'"
},
{
term: "Behavioral Activation",
category: "Technique",
definition: "A specific CBT skill used to help people with depression or anxiety by increasing their participation in rewarding activities.",
example: "Scheduling a 10-minute walk even when you feel like staying in bed."
},
{
term: "Radical Acceptance",
category: "Technique",
definition: "Completely accepting reality as it is, without judgment or attempts to fight it, especially for situations out of your control.",
example: "Acknowledging that a relationship has ended and feeling the pain without trying to fix the past."
},
{
term: "Gray Rock Method",
category: "Concept",
definition: "A technique used to deal with manipulative or toxic people by acting as uninteresting as a gray rock.",
example: "Giving short, non-committal answers to a relative's intrusive questions."
},
{
term: "Should Statements",
category: "Distortion",
definition: "Having a list of ironclad rules about how you and other people 'should' act.",
example: "I should never feel anxious in public."
},
{
term: "Personalization",
category: "Distortion",
definition: "Believing you are responsible for events outside your control or that other people's behavior is a direct reaction to you.",
example: "My boss looks upset today; it must be because of that email I sent."
},
{
term: "Grounding Exercises",
category: "Technique",
definition: "Techniques used to pull you away from flashbacks, unwanted memories, or negative emotions and back into the present moment.",
example: "The 5-4-3-2-1 technique: noticing 5 things you see, 4 you feel, etc."
}
];
// Default clinical roleplay scenarios available for selection in the app
export const SCENARIOS: Scenario[] = [
{
id: 's1',
title: "The Silent Friend",
description: "You sent a text to a close friend hours ago and haven't heard back. You're starting to imagine they're angry with you or that you've done something wrong.",
category: 'Relationships',
difficulty: 'Beginner',
systemInstruction: "You are a busy friend who just hasn't had a chance to reply yet. You are friendly and non-confrontational. If asked, you explain you were just preoccupied with chores.",
initialMessage: "Hey! Sorry I'm just getting back to you, it's been a crazy day. What's up?",
cbtFocus: ["Mind Reading", "Fortune Telling"]
},
{
id: 's2',
title: "Critical Feedback",
description: "Your manager asks for a quick sync. You immediately assume the worst about your performance and upcoming review.",
category: 'Workplace',
difficulty: 'Intermediate',
systemInstruction: "You are a professional manager. You have one piece of constructive feedback regarding a recent report, but overall you're happy with the user's performance.",
initialMessage: "Hi there, thanks for jumping on. I wanted to chat briefly about the Q3 project report.",
cbtFocus: ["Catastrophizing", "All-or-Nothing Thinking"]
},
{
id: 's3',
title: "The Social Spotlight",
description: "You're at a networking event and you just made a small slip-up in a conversation. You feel like everyone noticed and is judging you.",
category: 'Social Anxiety',
difficulty: 'Advanced',
systemInstruction: "You are an attendee at a networking event. You are polite and didn't even notice the user's minor slip-up. You are focused on professional exchange.",
initialMessage: "That's an interesting point about the industry trends. So, how did you get started in this field?",
cbtFocus: ["Spotlight Effect", "Emotional Reasoning"]
},
{
id: 's4',
title: "Inner Critic Echo",
description: "A minor mistake at home triggers a spiral of self-deprecating thoughts. Practice internal cognitive restructuring by talking back to your personified inner critic.",
category: 'Self-Talk',
difficulty: 'Beginner',
systemInstruction: "You are the user's personified Inner Critic. You are harsh, cynical, and use common distortions. Your goal is to represent the user's negative self-talk so they can practice refuting it.",
initialMessage: "I can't believe you forgot to buy milk again. You're completely disorganized and you'll never get your life together.",
cbtFocus: ["Labeling", "Overgeneralization"]
}
];
export const CBT_ANALYSIS_SCHEMA = {
type: Type.OBJECT,
properties: {
analysis: {
type: Type.OBJECT,
properties: {
distortions: { type: Type.ARRAY, items: { type: Type.STRING } },
isAdaptive: { type: Type.BOOLEAN },
evidence: { type: Type.STRING },
},
required: ['distortions', 'isAdaptive', 'evidence']
},
rating: {
type: Type.OBJECT,
properties: {
score: { type: Type.INTEGER },
reason: { type: Type.STRING },
},
required: ['score', 'reason']
},
suggestions: {
type: Type.OBJECT,
properties: {
alternativeThought: { type: Type.STRING },
behavioralStep: { type: Type.STRING },
},
required: ['alternativeThought', 'behavioralStep']
}
},
required: ['analysis', 'rating', 'suggestions']
};
export const SYSTEM_ANALYSIS_PROMPT = `
You are an expert Clinical CBT Supervisor. Your task is to provide immediate, high-precision cognitive analysis of a patient's latest roleplay interaction.
CORE MISSION:
Identify cognitive distortions, evaluate the adaptiveness of the user's response, and provide a single powerful reframing thought.
Maintain a neutral, clinical, and encouraging tone. Focus purely on the cognitive and behavioral mechanics of the latest user turn.
`;
export const SYSTEM_SUMMARY_ANALYSIS_PROMPT = `
You are a Senior Clinical CBT Supervisor performing a post-session synthesis on a full roleplay transcript.
CORE MISSION:
Synthesize the entire interaction to identify recurring cognitive patterns, evaluate overall progress, and assign "homework" for behavioral activation.
Analyze the transcript for patterns, not just individual messages. Focus on how the user handles the "Internal CBT Logic" of the specific scenario.
`;
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cognitive Clarity | CBT Roleplay</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #f8fafc;
}
/* Custom scrollbar for chat windows */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@^19.2.3",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"react/": "https://esm.sh/react@^19.2.3/",
"@google/genai": "https://esm.sh/@google/genai@^1.33.0",
"lucide-react": "https://esm.sh/lucide-react@^0.561.0"
}
}
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+7
View File
@@ -0,0 +1,7 @@
{
"name": "Cognitive Clarity",
"description": "A CBT-based roleplaying application using Generative AI to help users practice cognitive restructuring and behavioral activation through realistic scenarios.",
"requestFramePermissions": [
"microphone"
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "cognitive-clarity",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@google/genai": "^1.33.0",
"lucide-react": "^0.561.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
+66
View File
@@ -0,0 +1,66 @@
import { LiveServerMessage, Modality } from "@google/genai";
// Convert Float32Array (Web Audio API) to PCM Int16 (Gemini API requirement)
export function floatTo16BitPCM(float32Array: Float32Array): ArrayBuffer {
const buffer = new ArrayBuffer(float32Array.length * 2);
const view = new DataView(buffer);
let offset = 0;
for (let i = 0; i < float32Array.length; i++) {
let s = Math.max(-1, Math.min(1, float32Array[i]));
s = s < 0 ? s * 0x8000 : s * 0x7FFF;
view.setInt16(offset, s, true); // Little Endian
offset += 2;
}
return buffer;
}
// Base64 encoding for the payload
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Convert Base64 string back to Uint8Array
export function base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// Decode raw PCM data from Gemini to AudioBuffer
export async function decodeAudioData(
data: Uint8Array,
ctx: AudioContext,
sampleRate: number = 24000,
numChannels: number = 1,
): Promise<AudioBuffer> {
// Ensure the byte length is even for Int16Array
if (data.byteLength % 2 !== 0) {
console.warn("Received odd byte length for PCM16 audio, trimming last byte.");
data = data.subarray(0, data.byteLength - 1);
}
// Use the underlying buffer, but handle offsets if the Uint8Array is a view
const dataInt16 = new Int16Array(data.buffer, data.byteOffset, data.byteLength / 2);
const buffer = ctx.createBuffer(numChannels, dataInt16.length / numChannels, sampleRate);
for (let channel = 0; channel < numChannels; channel++) {
const channelData = buffer.getChannelData(channel);
// Determine step size if multiple channels (usually 1 for this API)
for (let i = 0; i < dataInt16.length; i++) {
// Normalize Int16 to Float32 [-1.0, 1.0]
channelData[i] = dataInt16[i] / 32768.0;
}
}
return buffer;
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+41
View File
@@ -0,0 +1,41 @@
export interface Scenario {
id: string;
title: string;
description: string;
category: 'Social Anxiety' | 'Workplace' | 'Relationships' | 'Self-Talk';
difficulty: 'Beginner' | 'Intermediate' | 'Advanced';
systemInstruction: string;
initialMessage: string;
cbtFocus: string[];
}
export interface Message {
id: string;
role: 'user' | 'model' | 'system';
text: string;
timestamp: Date;
isAnalysis?: boolean; // If true, this is a CBT analysis, not part of roleplay
}
export type SessionMode = 'text' | 'voice';
export interface AudioConfig {
sampleRate: number;
}
export interface AnalysisResult {
analysis: {
distortions: string[];
isAdaptive: boolean;
evidence: string;
};
rating: {
score: number;
reason: string;
};
suggestions: {
alternativeThought: string;
behavioralStep: string;
};
}
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});