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
+24
@@ -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?
|
||||||
@@ -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;
|
||||||
@@ -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`
|
||||||
@@ -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;
|
||||||
+203
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in new issue
Block a user