feat: Add user settings for voice and persona
Introduces a new `UserSettings` interface and default settings. This allows users to customize voice preferences (name, style, gender) and persona characteristics within the application. This enhances personalization and user experience by tailoring interactions to individual needs and preferences.
This commit is contained in:
1 parent
c78604de02
commit
a7a8c661b7
9 files changed
+668
-358
No files matched your search
@@ -1,13 +1,21 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SCENARIOS } from './constants';
|
||||
import { Scenario, SessionMode } from './types';
|
||||
import { Scenario, SessionMode, UserSettings } 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';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import Tooltip from './components/Tooltip';
|
||||
import { Brain, Info, ShieldCheck, Filter, Plus, BookOpen, Activity, Zap, Settings } from 'lucide-react';
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
voiceName: 'Zephyr',
|
||||
vocalStyle: 'Helpful',
|
||||
genderPreference: 'Neutral'
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [activeScenario, setActiveScenario] = useState<Scenario | null>(null);
|
||||
@@ -19,11 +27,20 @@ const App: React.FC = () => {
|
||||
const [customScenarios, setCustomScenarios] = useState<Scenario[]>([]);
|
||||
const [showGenerator, setShowGenerator] = useState(false);
|
||||
const [showReference, setShowReference] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
// User Settings State
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>(() => {
|
||||
const saved = localStorage.getItem('cognitive-clarity-settings');
|
||||
return saved ? JSON.parse(saved) : DEFAULT_SETTINGS;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('cognitive-clarity-settings', JSON.stringify(userSettings));
|
||||
}, [userSettings]);
|
||||
|
||||
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);
|
||||
@@ -41,14 +58,12 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -82,7 +97,7 @@ const App: React.FC = () => {
|
||||
</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
|
||||
Applying {userSettings.voiceName} Vocal Profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,7 +121,11 @@ const App: React.FC = () => {
|
||||
<ChatSession scenario={activeScenario} onExit={handleExitSession} />
|
||||
) : (
|
||||
<div className="w-full max-w-4xl h-[80vh]">
|
||||
<VoiceSession scenario={activeScenario} onExit={handleExitSession} />
|
||||
<VoiceSession
|
||||
scenario={activeScenario}
|
||||
onExit={handleExitSession}
|
||||
voiceSettings={userSettings}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -115,18 +134,10 @@ const App: React.FC = () => {
|
||||
|
||||
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)} />
|
||||
)}
|
||||
{/* Modals */}
|
||||
{showGenerator && <ScenarioGenerator onGenerate={handleScenarioGenerated} onCancel={() => setShowGenerator(false)} />}
|
||||
{showReference && <CBTReference onClose={() => setShowReference(false)} />}
|
||||
{showSettings && <SettingsModal settings={userSettings} onUpdate={setUserSettings} onClose={() => setShowSettings(false)} />}
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
@@ -139,41 +150,53 @@ const App: React.FC = () => {
|
||||
<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 className="flex items-center gap-2">
|
||||
<Tooltip content="Adjust voice settings for audio sessions.">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2.5 rounded-xl bg-slate-50 border border-slate-200 text-slate-400 hover:text-slate-800 transition-all"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Explore the glossary of CBT distortions, techniques, and concepts.">
|
||||
<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>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</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.
|
||||
An adaptive clinical training environment designed to strengthen cognitive flexibility and navigate social friction.
|
||||
</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>
|
||||
<Tooltip content="Our scenarios are built on the foundations of Cognitive Behavioral Therapy.">
|
||||
<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 cursor-help">
|
||||
<ShieldCheck className="text-teal-600" size={18} />
|
||||
<span>Evidence-Based Framework</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip content="The AI character responds dynamically to your level of cognitive restructuring.">
|
||||
<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 cursor-help">
|
||||
<Info className="text-teal-600" size={18} />
|
||||
<span>Vocal Partner: {userSettings.voiceName}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</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 justify-between items-start sm:items-center gap-4 mb-10">
|
||||
<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} />
|
||||
@@ -196,32 +219,27 @@ const App: React.FC = () => {
|
||||
</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>
|
||||
<Tooltip content="Generate a unique roleplay situation based on your specific needs using AI." position="bottom">
|
||||
<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>
|
||||
</Tooltip>
|
||||
</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}
|
||||
/>
|
||||
<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="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>
|
||||
@@ -233,13 +251,7 @@ const App: React.FC = () => {
|
||||
<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>
|
||||
<Brain size={20} /><span className="text-sm font-bold uppercase tracking-widest">Cognitive Clarity v1.2</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import { BrainCircuit, Loader2, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp } from 'lucide-react';
|
||||
import { BrainCircuit, Loader2, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp, Info } from 'lucide-react';
|
||||
import { AnalysisResult } from '../types';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
interface LiveInsightPanelProps {
|
||||
analysis: AnalysisResult | null;
|
||||
@@ -22,61 +23,69 @@ const LiveInsightPanel: React.FC<LiveInsightPanelProps> = ({ analysis, isAnalyzi
|
||||
<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" />
|
||||
<Tooltip content="Identifies whether your last statement followed adaptive, evidence-based thinking patterns or contained cognitive distortions.">
|
||||
<div className={`p-5 rounded-2xl border-l-4 w-full ${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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<p className={`text-xs leading-relaxed font-medium ${analysis.analysis.isAdaptive ? 'text-teal-800' : 'text-rose-800'}`}>
|
||||
{analysis.analysis.evidence}
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{!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'}`} />
|
||||
))}
|
||||
<Tooltip content="A score representing your effectiveness in applying CBT techniques to this specific turn.">
|
||||
<div className="bg-slate-50 p-5 rounded-2xl border border-slate-100 shadow-inner w-full">
|
||||
<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>
|
||||
<p className="text-xs text-slate-600 font-medium leading-relaxed">{analysis.rating.reason}</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<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>
|
||||
<Tooltip content="A more balanced, evidence-based way to think about this specific moment in the interaction.">
|
||||
<div className="bg-white p-5 rounded-2xl border border-teal-100 shadow-sm relative overflow-hidden group w-full">
|
||||
<div className="flex items-center gap-2 text-teal-600 mb-2">
|
||||
<Lightbulb size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-800">Master Reframe</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-800 italic font-medium leading-relaxed relative z-10">"{analysis.suggestions.alternativeThought}"</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<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>
|
||||
<Tooltip content="Actionable behavioral advice to improve your communication or internal response in the next turn.">
|
||||
<div className="bg-white p-5 rounded-2xl border border-teal-100 shadow-sm w-full">
|
||||
<div className="flex items-center gap-2 text-teal-600 mb-2">
|
||||
<TrendingUp size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-800">Tactical Pivot</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-800 leading-relaxed font-light">{analysis.suggestions.behavioralStep}</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+38
-22
@@ -1,6 +1,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Scenario } from '../types';
|
||||
import { Mic, MessageSquare } from 'lucide-react';
|
||||
import { Mic, MessageSquare, Info } from 'lucide-react';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
interface ScenarioCardProps {
|
||||
scenario: Scenario;
|
||||
@@ -17,12 +19,20 @@ const ScenarioCard: React.FC<ScenarioCardProps> = ({ scenario, onSelect }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const difficultyExplanations: Record<string, string> = {
|
||||
'Beginner': 'Low-stakes environment with highly structured feedback and clear cognitive distortions.',
|
||||
'Intermediate': 'Moderate social friction requiring nuanced responses and identifying subtle distortions.',
|
||||
'Advanced': 'High-trigger environment with resistant AI personas to test deep emotional regulation.'
|
||||
};
|
||||
|
||||
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>
|
||||
<Tooltip content={difficultyExplanations[scenario.difficulty]}>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border cursor-help ${getDifficultyColor(scenario.difficulty)}`}>
|
||||
{scenario.difficulty}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
{scenario.category}
|
||||
</span>
|
||||
@@ -34,31 +44,37 @@ const ScenarioCard: React.FC<ScenarioCardProps> = ({ scenario, onSelect }) => {
|
||||
<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>
|
||||
<Tooltip key={focus} content={`Specific CBT protocol targeting: ${focus}`}>
|
||||
<span className="text-[10px] font-medium bg-slate-50 text-slate-500 px-2 py-0.5 rounded border border-slate-100 cursor-help flex items-center gap-1">
|
||||
{focus}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</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>
|
||||
<Tooltip content="Engage in a structured text-based roleplay with real-time feedback." position="bottom">
|
||||
<button
|
||||
onClick={() => onSelect(scenario, 'text')}
|
||||
className="w-full 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>
|
||||
</Tooltip>
|
||||
<Tooltip content="Practice verbal emotional regulation and vocal expression in real-time." position="bottom">
|
||||
<button
|
||||
onClick={() => onSelect(scenario, 'voice')}
|
||||
className="w-full 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>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioCard;
|
||||
export default React.memo(ScenarioCard);
|
||||
@@ -0,0 +1,120 @@
|
||||
|
||||
import React from 'react';
|
||||
import { UserSettings } from '../types';
|
||||
import { X, Volume2, Check, Info, Mic2 } from 'lucide-react';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
interface SettingsModalProps {
|
||||
settings: UserSettings;
|
||||
onUpdate: (settings: UserSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface VoiceOption {
|
||||
id: UserSettings['voiceName'];
|
||||
label: string;
|
||||
gender: 'Male' | 'Female' | 'Neutral';
|
||||
style: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const VOICE_OPTIONS: VoiceOption[] = [
|
||||
{ id: 'Zephyr', label: 'Zephyr', gender: 'Neutral', style: 'Helpful', description: 'A balanced, clear, and professional tone suitable for most scenarios.' },
|
||||
{ id: 'Puck', label: 'Puck', gender: 'Male', style: 'Youthful', description: 'Energetic and friendly, ideal for peer-to-peer or informal interactions.' },
|
||||
{ id: 'Kore', label: 'Kore', gender: 'Female', style: 'Calm', description: 'Soothing and professional, perfect for clinical or high-stress roleplay.' },
|
||||
{ id: 'Charon', label: 'Charon', gender: 'Male', style: 'Authoritative', description: 'Deep and commanding, useful for workplace or boundary-testing scenarios.' },
|
||||
{ id: 'Fenrir', label: 'Fenrir', gender: 'Male', style: 'Rugged', description: 'Direct and matter-of-fact tone with a slightly more serious edge.' },
|
||||
];
|
||||
|
||||
const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClose }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-xl overflow-hidden animate-in fade-in zoom-in duration-300 border border-slate-100">
|
||||
<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-slate-800 p-2 rounded-xl text-white">
|
||||
<Volume2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">Voice & Persona Settings</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Configure your AI clinical partner</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>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Mic2 size={14} className="text-teal-600" />
|
||||
Vocal Profile
|
||||
</h3>
|
||||
<Tooltip content="Different voices can help simulate specific social or professional contexts.">
|
||||
<Info size={14} className="text-slate-300" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{VOICE_OPTIONS.map((voice) => (
|
||||
<button
|
||||
key={voice.id}
|
||||
onClick={() => onUpdate({ ...settings, voiceName: voice.id, genderPreference: voice.gender, vocalStyle: voice.style })}
|
||||
className={`flex items-center justify-between p-4 rounded-2xl border-2 transition-all text-left ${
|
||||
settings.voiceName === voice.id
|
||||
? 'bg-teal-50 border-teal-500 shadow-md shadow-teal-100'
|
||||
: 'bg-white border-slate-100 hover:border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
settings.voiceName === voice.id ? 'bg-teal-600 text-white' : 'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
<Volume2 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-slate-800">{voice.label}</span>
|
||||
<span className="text-[9px] font-black uppercase text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
{voice.gender} • {voice.style}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-light mt-0.5">{voice.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{settings.voiceName === voice.id && (
|
||||
<div className="bg-teal-600 text-white p-1 rounded-full">
|
||||
<Check size={14} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-2xl p-4 flex gap-3">
|
||||
<div className="text-amber-600 mt-0.5">
|
||||
<Info size={18} />
|
||||
</div>
|
||||
<p className="text-xs text-amber-800 leading-relaxed">
|
||||
These settings apply to <strong>Voice Mode</strong> interactions only. Text-based roleplay personas are dynamically generated based on each specific scenario.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-200 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-slate-800 text-white px-8 py-3 rounded-2xl font-bold hover:bg-slate-900 shadow-lg shadow-slate-200 transition-all uppercase tracking-widest text-[10px]"
|
||||
>
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: React.ReactNode;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
const Tooltip: React.FC<TooltipProps> = ({ content, children, position = 'top' }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const positionClasses = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||
};
|
||||
|
||||
const arrowClasses = {
|
||||
top: 'top-full left-1/2 -translate-x-1/2 border-t-slate-800',
|
||||
bottom: 'bottom-full left-1/2 -translate-x-1/2 border-b-slate-800',
|
||||
left: 'left-full top-1/2 -translate-y-1/2 border-l-slate-800',
|
||||
right: 'right-full top-1/2 -translate-y-1/2 border-r-slate-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-block group"
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
>
|
||||
{children}
|
||||
{isVisible && (
|
||||
<div className={`absolute z-[100] w-max max-w-xs px-3 py-2 text-[11px] font-medium text-white bg-slate-800 rounded-lg shadow-xl animate-in fade-in zoom-in-95 duration-200 pointer-events-none ${positionClasses[position]}`}>
|
||||
{content}
|
||||
<div className={`absolute border-4 border-transparent ${arrowClasses[position]}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Tooltip);
|
||||
@@ -0,0 +1,84 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { MessageSquare, X, User, Bot } from 'lucide-react';
|
||||
|
||||
interface TranscriptPanelProps {
|
||||
messages: { role: 'user' | 'model'; text: string; id: string }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TranscriptPanel: React.FC<TranscriptPanelProps> = ({ messages, onClose }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 right-0 w-full sm:w-80 h-full bg-slate-900/95 backdrop-blur-2xl border-l border-white/10 z-50 flex flex-col shadow-2xl animate-in slide-in-from-right duration-500">
|
||||
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-black/20">
|
||||
<div className="flex items-center gap-2 text-teal-400">
|
||||
<MessageSquare size={16} />
|
||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-white">Live Conversation</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-white/40 hover:text-white transition-colors p-1 hover:bg-white/5 rounded-lg">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-grow overflow-y-auto p-6 space-y-5 scrollbar-hide"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center opacity-20 px-8">
|
||||
<div className="w-12 h-12 rounded-full border border-white/20 flex items-center justify-center mb-4">
|
||||
<MessageSquare size={24} className="text-white" />
|
||||
</div>
|
||||
<p className="text-[9px] text-white uppercase font-bold tracking-widest leading-loose">
|
||||
Listening for conversation...<br/>Transcript will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<div key={m.id} className={`flex flex-col gap-1.5 ${m.role === 'user' ? 'items-end' : 'items-start'} animate-in fade-in slide-in-from-bottom-2 duration-300`}>
|
||||
<div className="flex items-center gap-1.5 opacity-40 mb-0.5 px-1">
|
||||
{m.role === 'user' ? (
|
||||
<>
|
||||
<span className="text-[8px] font-black text-white uppercase tracking-tighter">You</span>
|
||||
<User size={8} className="text-teal-400" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot size={8} className="text-blue-400" />
|
||||
<span className="text-[8px] font-black text-white uppercase tracking-tighter">AI Partner</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={`px-4 py-3 rounded-2xl text-[11px] leading-relaxed max-w-[95%] shadow-lg ${
|
||||
m.role === 'user'
|
||||
? 'bg-teal-600 text-white rounded-tr-none shadow-teal-900/20'
|
||||
: 'bg-white/10 text-slate-100 rounded-tl-none border border-white/5 shadow-black/20'
|
||||
}`}>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-black/40 text-center border-t border-white/5">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-1 h-1 bg-teal-400 rounded-full animate-pulse"></div>
|
||||
<p className="text-[8px] text-white/30 uppercase font-black tracking-[0.3em]">
|
||||
Real-time Analysis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TranscriptPanel);
|
||||
+138
-220
@@ -1,48 +1,54 @@
|
||||
|
||||
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 { Scenario, AnalysisResult, UserSettings } from '../types';
|
||||
import { GoogleGenAI, LiveServerMessage, Modality, Chat } from "@google/genai";
|
||||
import { Mic, MicOff, X, Radio, AlertCircle, Headphones, BrainCircuit, LayoutPanelLeft } from 'lucide-react';
|
||||
import { Mic, MicOff, X, Radio, AlertCircle, Headphones, BrainCircuit, LayoutPanelLeft, Play, Info, CheckCircle2, MessageSquare, Sparkles, Activity } 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';
|
||||
import Tooltip from './Tooltip';
|
||||
import TranscriptPanel from './TranscriptPanel';
|
||||
|
||||
interface VoiceSessionProps {
|
||||
scenario: Scenario;
|
||||
onExit: () => void;
|
||||
voiceSettings: UserSettings;
|
||||
}
|
||||
|
||||
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit }) => {
|
||||
const [status, setStatus] = useState<'disconnected' | 'connecting' | 'connected' | 'error' | 'analyzing' | 'finished'>('disconnected');
|
||||
interface DisplayMessage {
|
||||
id: string;
|
||||
role: 'user' | 'model';
|
||||
text: string;
|
||||
}
|
||||
|
||||
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSettings }) => {
|
||||
const [sessionPhase, setSessionPhase] = useState<'briefing' | 'session' | 'analyzing' | 'finished'>('briefing');
|
||||
const [status, setStatus] = useState<'disconnected' | 'connecting' | 'active' | 'error'>('disconnected');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [finalAnalysis, setFinalAnalysis] = useState<AnalysisResult | null>(null);
|
||||
|
||||
// Live Analysis State
|
||||
const [showLiveInsights, setShowLiveInsights] = useState(false);
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
|
||||
const [liveAnalysis, setLiveAnalysis] = useState<AnalysisResult | null>(null);
|
||||
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
|
||||
const [showLiveInsights, setShowLiveInsights] = useState(false);
|
||||
const [liveMessages, setLiveMessages] = useState<DisplayMessage[]>([]);
|
||||
|
||||
// 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(() => {
|
||||
@@ -52,333 +58,245 @@ const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit }) => {
|
||||
}, []);
|
||||
|
||||
const disconnectSession = useCallback(async () => {
|
||||
sourcesRef.current.forEach(source => {
|
||||
try { source.stop(); } catch(e) {}
|
||||
});
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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');
|
||||
if (transcriptRef.current.length < 10) return onExit();
|
||||
setSessionPhase('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,
|
||||
systemInstruction: `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}\n\nSCENARIO: ${scenario.title}\nCBT: ${scenario.cbtFocus.join(', ')}`,
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: CBT_ANALYSIS_SCHEMA,
|
||||
},
|
||||
contents: [{ role: 'user', parts: [{ text: `SESSION TRANSCRIPT:\n${transcriptRef.current}` }] }]
|
||||
contents: [{ role: 'user', parts: [{ text: `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();
|
||||
}
|
||||
setSessionPhase('finished');
|
||||
} else { throw new Error("Analysis failed."); }
|
||||
} catch (err) { onExit(); }
|
||||
};
|
||||
|
||||
const startSession = async () => {
|
||||
try {
|
||||
setSessionPhase('session');
|
||||
setStatus('connecting');
|
||||
setErrorMessage(null);
|
||||
transcriptRef.current = "";
|
||||
currentTurnUserText.current = "";
|
||||
currentTurnPartnerText.current = "";
|
||||
setLiveAnalysis(null);
|
||||
setLiveMessages([]);
|
||||
|
||||
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(', ')}`,
|
||||
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\nSCENARIO: ${scenario.title}\nObjectives: ${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,
|
||||
systemInstruction: `
|
||||
${scenario.systemInstruction}
|
||||
VOICE MODE: Keep responses short and empathetic.
|
||||
INITIATION: When the user says "Let's start", "Hello", or initiates speech, begin with your opening: "${scenario.initialMessage}".
|
||||
`,
|
||||
outputAudioTranscription: {},
|
||||
inputAudioTranscription: {},
|
||||
speechConfig: {
|
||||
voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Puck' } },
|
||||
},
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceSettings.voiceName } } },
|
||||
},
|
||||
callbacks: {
|
||||
onopen: () => {
|
||||
setStatus('connected');
|
||||
setStatus('active');
|
||||
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);
|
||||
const data = 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));
|
||||
for(let i=0; i<data.length; i+=10) sum += Math.abs(data[i]);
|
||||
setVolumeLevel(Math.min((sum / (data.length/10)) * 500, 100));
|
||||
const b64 = arrayBufferToBase64(floatTo16BitPCM(data));
|
||||
sessionPromise.then(s => s.sendRealtimeInput({ media: { mimeType: 'audio/pcm;rate=16000', data: b64 } }));
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(gainNode);
|
||||
gainNode.connect(inputContextRef.current.destination);
|
||||
processor.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`;
|
||||
setLiveMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === 'user') return [...prev.slice(0, -1), { ...last, text: last.text + text }];
|
||||
return [...prev, { id: Date.now().toString(), role: 'user', text }];
|
||||
});
|
||||
}
|
||||
if (msg.serverContent?.outputTranscription) {
|
||||
const text = msg.serverContent.outputTranscription.text;
|
||||
currentTurnPartnerText.current += text;
|
||||
transcriptRef.current += `[PARTNER]: ${text}\n`;
|
||||
setLiveMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === 'model') return [...prev.slice(0, -1), { ...last, text: last.text + text }];
|
||||
return [...prev, { id: Date.now().toString(), role: 'model', text }];
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
const audioBuffer = await decodeAudioData(bytes, outputContextRef.current, 24000, 1);
|
||||
const source = outputContextRef.current.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(ctx.destination);
|
||||
const now = ctx.currentTime;
|
||||
if (nextStartTimeRef.current < now) nextStartTimeRef.current = now;
|
||||
source.connect(outputContextRef.current.destination);
|
||||
const now = outputContextRef.current.currentTime;
|
||||
if (nextStartTimeRef.current < now) nextStartTimeRef.current = now + 0.05;
|
||||
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); }
|
||||
source.onended = () => sourcesRef.current.delete(source);
|
||||
} catch (e) { console.error("Audio decoding error:", e); }
|
||||
}
|
||||
|
||||
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)) {
|
||||
transcriptRef.current += `[USER]: ${currentTurnUserText.current}\n[PARTNER]: ${currentTurnPartnerText.current}\n`;
|
||||
if (analysisChatRef.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 = "";
|
||||
}
|
||||
});
|
||||
analysisChatRef.current.sendMessage({ message: `Evaluation Turn: User("${currentTurnUserText.current}") Partner("${currentTurnPartnerText.current}")` }).then(res => {
|
||||
if (res.text) setLiveAnalysis(JSON.parse(res.text));
|
||||
setIsAnalyzingTurn(false);
|
||||
currentTurnUserText.current = "";
|
||||
currentTurnPartnerText.current = "";
|
||||
}).catch(() => setIsAnalyzingTurn(false));
|
||||
}
|
||||
}
|
||||
},
|
||||
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();
|
||||
}
|
||||
onerror: () => { setStatus('error'); setErrorMessage("Voice bridge interrupted."); 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');
|
||||
}
|
||||
} catch (err: any) { setStatus('error'); }
|
||||
};
|
||||
|
||||
if (status === 'analyzing') {
|
||||
if (sessionPhase === 'briefing') {
|
||||
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 className="flex flex-col items-center justify-center h-full bg-white rounded-3xl p-8 border border-slate-200 w-full max-w-2xl mx-auto shadow-2xl animate-in fade-in zoom-in-95 duration-500">
|
||||
<div className="bg-slate-50 p-8 rounded-2xl border border-slate-200 mb-8 w-full relative overflow-hidden shadow-inner">
|
||||
<div className="absolute top-0 right-0 p-6 opacity-5"><Activity size={120} /></div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="bg-teal-600 p-3 rounded-2xl text-white shadow-lg shadow-teal-100"><Sparkles size={24} /></div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Ready to start?</h2>
|
||||
<p className="text-xs text-teal-600 font-bold uppercase tracking-widest mt-1">Vocal Partner: {voiceSettings.voiceName} ({voiceSettings.vocalStyle})</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest mb-2 flex items-center gap-1.5"><Info size={12} /> Narrative Setting</p>
|
||||
<div className="p-4 bg-white rounded-xl border border-slate-200 text-sm text-slate-700 leading-relaxed italic">"{scenario.description}"</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200">
|
||||
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest mb-2">Techniques</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scenario.cbtFocus.map(f => (<span key={f} className="text-[9px] font-bold text-teal-800 bg-teal-50 px-2 py-0.5 rounded border border-teal-100 uppercase tracking-tighter">{f}</span>))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200">
|
||||
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest mb-2">Profile</p>
|
||||
<div className="flex items-center gap-2"><Headphones size={14} className="text-teal-500" /><span className="text-[10px] font-bold text-slate-500 uppercase">{voiceSettings.genderPreference} Speaker</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-2xl p-6 text-white shadow-xl">
|
||||
<div className="flex items-center gap-2 mb-2 text-teal-400"><Play size={16} fill="currentColor" /><span className="text-[10px] font-black uppercase tracking-widest">Protocol</span></div>
|
||||
<p className="text-xs text-slate-400">Once active, say <span className="text-white font-bold">"Let's start"</span> to begin the interaction with your {voiceSettings.vocalStyle.toLowerCase()} partner.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 w-full px-4">
|
||||
<button onClick={onExit} className="flex-1 bg-white border border-slate-200 text-slate-500 py-4 rounded-2xl font-bold hover:bg-slate-50 transition-all text-xs uppercase tracking-widest">Back</button>
|
||||
<button onClick={startSession} className="flex-[2] bg-teal-600 text-white py-4 rounded-2xl font-bold hover:bg-teal-700 transition-all shadow-xl flex items-center justify-center gap-3 text-xs uppercase tracking-widest group"><Mic size={18} className="group-hover:scale-110 transition-transform" />Establish Voice Link</button>
|
||||
</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} />;
|
||||
}
|
||||
if (sessionPhase === '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">Analyzing Session</h2>
|
||||
<p className="text-slate-500 text-center">Generating your clinical progress report...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (sessionPhase === '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 lg:flex-row h-full max-h-[85vh] gap-4 transition-all duration-500 relative`}>
|
||||
<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 */}
|
||||
{status === 'active' && (<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>)}
|
||||
<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 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>
|
||||
<h2 className="text-lg font-bold text-white tracking-tight">{scenario.title}</h2>
|
||||
<div className="flex items-center gap-2 mt-2"><div className={`w-1.5 h-1.5 rounded-full ${status === 'active' ? 'bg-teal-400 animate-pulse shadow-[0_0_8px_rgba(45,212,191,0.8)]' : 'bg-amber-400'}`} /><span className="text-[9px] font-black 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>
|
||||
<button onClick={() => setShowTranscript(!showTranscript)} className={`p-2.5 rounded-xl border ${showTranscript ? 'bg-teal-500 text-white border-teal-400' : 'bg-white/5 border-white/10 text-white/60'}`}><MessageSquare size={18} /></button>
|
||||
<button onClick={() => setShowLiveInsights(!showLiveInsights)} className={`p-2.5 rounded-xl border ${showLiveInsights ? 'bg-teal-500 text-white border-teal-400' : 'bg-white/5 border-white/10 text-white/60'}`}><LayoutPanelLeft size={18} /></button>
|
||||
<button onClick={onExit} className="bg-white/5 p-2.5 rounded-xl border border-white/10 text-white/60 hover:text-rose-400"><X size={18} /></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 className="relative z-10 flex flex-col items-center gap-12 w-full">
|
||||
<AudioVisualizer level={volumeLevel} active={status === 'active'} />
|
||||
<div className={`w-32 h-32 rounded-full flex items-center justify-center ${status === 'active' ? 'bg-teal-500/10 ring-8 ring-teal-500/5' : 'bg-slate-800/50 ring-8 ring-slate-800'}`}><div className={`w-20 h-20 rounded-full flex items-center justify-center ${status === 'active' ? 'bg-teal-500 shadow-[0_0_60px_rgba(20,184,166,0.4)] scale-105' : 'bg-slate-700'}`}><Radio size={32} /></div></div>
|
||||
<div className="text-center max-w-sm min-h-[100px] flex flex-col justify-center">
|
||||
{status === 'connecting' && <p className="text-teal-400 animate-pulse">Initializing {voiceSettings.voiceName}...</p>}
|
||||
{status === 'active' && (<div className="space-y-4 animate-in fade-in duration-700"><div className="bg-white/5 px-8 py-4 rounded-3xl border border-white/10 backdrop-blur-md"><p className="text-white font-medium text-lg">Listening...</p><p className="text-teal-400 text-[10px] uppercase tracking-widest">Partner: {voiceSettings.voiceName}</p></div></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 className="absolute bottom-12 z-10 w-full flex justify-center px-8">
|
||||
{status === 'active' && (<button onClick={handleTerminateAndAnalyze} className="flex items-center gap-3 bg-white text-slate-900 px-12 py-4 rounded-2xl font-bold shadow-2xl transition-all active:scale-95 uppercase tracking-widest text-[10px]"><MicOff size={18} />Complete Session</button>)}
|
||||
</div>
|
||||
{showTranscript && <TranscriptPanel messages={liveMessages} onClose={() => setShowTranscript(false)} />}
|
||||
</div>
|
||||
|
||||
{showLiveInsights && <LiveInsightPanel analysis={liveAnalysis} isAnalyzing={isAnalyzingTurn} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
+101
-2
@@ -1,4 +1,3 @@
|
||||
|
||||
import { Scenario } from './types';
|
||||
import { Type } from "@google/genai";
|
||||
|
||||
@@ -126,7 +125,7 @@ export const SCENARIOS: Scenario[] = [
|
||||
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.",
|
||||
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. INTERNAL CBT LOGIC: If the user catastrophizes, reassure them professionally but maintain your feedback.",
|
||||
initialMessage: "Hi there, thanks for jumping on. I wanted to chat briefly about the Q3 project report.",
|
||||
cbtFocus: ["Catastrophizing", "All-or-Nothing Thinking"]
|
||||
},
|
||||
@@ -149,6 +148,106 @@ export const SCENARIOS: Scenario[] = [
|
||||
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"]
|
||||
},
|
||||
{
|
||||
id: 's5',
|
||||
title: "Booking an Appointment",
|
||||
description: "You need to call a busy medical clinic to book a follow-up. You're worried about being a nuisance or stuttering during the call.",
|
||||
category: 'Social Anxiety',
|
||||
difficulty: 'Beginner',
|
||||
systemInstruction: "You are a busy, professional medical receptionist. You are efficient and somewhat direct, but not unkind. INTERNAL CBT LOGIC: If the user is overly apologetic or hesitates excessively, stay professional but move the process forward quickly.",
|
||||
initialMessage: "City Health Clinic, how can I help you today?",
|
||||
cbtFocus: ["Mind Reading", "Catastrophizing"]
|
||||
},
|
||||
{
|
||||
id: 's6',
|
||||
title: "The Defective Return",
|
||||
description: "You bought an expensive espresso machine that is leaking. You're at the store to return it, but you're afraid the clerk will be dismissive or think you're lying.",
|
||||
category: 'Social Anxiety',
|
||||
difficulty: 'Intermediate',
|
||||
systemInstruction: "You are a retail clerk at a high-end appliance store. You follow company policy strictly. You aren't trying to be difficult, but you must verify the defect. INTERNAL CBT LOGIC: If the user shows 'Emotional Reasoning' (I feel like I'm doing something wrong), stay neutral and ask for the receipt.",
|
||||
initialMessage: "Hi there. Returning something today? Can I see your receipt and the item?",
|
||||
cbtFocus: ["Personalization", "Emotional Reasoning"]
|
||||
},
|
||||
{
|
||||
id: 's7',
|
||||
title: "The Unpaid Debt",
|
||||
description: "A close friend borrowed $200 three months ago and hasn't mentioned it since. You need the money for bills but feel guilty for asking.",
|
||||
category: 'Relationships',
|
||||
difficulty: 'Advanced',
|
||||
systemInstruction: "You are a well-meaning but somewhat forgetful friend. You value the friendship and haven't intentionally avoided the debt; you just genuinely forgot. INTERNAL CBT LOGIC: If the user uses 'Should Statements' about themselves (I shouldn't have to ask), don't get defensive, but wait for them to express the need directly.",
|
||||
initialMessage: "Hey! It was so good to see you at brunch last week. What's on your mind today?",
|
||||
cbtFocus: ["Maladaptive Guilt", "Should Statements"]
|
||||
},
|
||||
{
|
||||
id: 's8',
|
||||
title: "The Salary Discussion",
|
||||
description: "You've been exceeding all your targets for a year. You've prepared a case for a raise, but you're battling thoughts that you're being 'greedy' or 'ungrateful'.",
|
||||
category: 'Workplace',
|
||||
difficulty: 'Advanced',
|
||||
systemInstruction: "You are a firm but fair Department Head. You have a limited budget but respect high performance. INTERNAL CBT LOGIC: If the user sounds apologetic or uses 'Shoulds', push back slightly on the budget. If they use objective evidence and stay regulated, be more collaborative.",
|
||||
initialMessage: "Thanks for coming in. I know you wanted to discuss your compensation. What's your proposal?",
|
||||
cbtFocus: ["Should Statements", "All-or-Nothing Thinking"]
|
||||
},
|
||||
{
|
||||
id: 's9',
|
||||
title: "The Deadline Slip",
|
||||
description: "You missed a major deadline because you were waiting on a partner's input. You feel like a total failure and are sure your boss will fire you.",
|
||||
category: 'Workplace',
|
||||
difficulty: 'Intermediate',
|
||||
systemInstruction: "You are a result-oriented Team Lead. You are disappointed the deadline was missed but want to solve the problem and prevent a recurrence. INTERNAL CBT LOGIC: If the user uses 'Labeling' (I am a failure), focus them back on the specific behavior and the external variables.",
|
||||
initialMessage: "The client is asking where the Q1 projection is. We missed the 9 AM cutoff. What happened?",
|
||||
cbtFocus: ["Labeling", "Overgeneralization"]
|
||||
},
|
||||
{
|
||||
id: 's10',
|
||||
title: "The Social 'No'",
|
||||
description: "A friend has invited you to a high-energy party. You are exhausted and need rest, but you're sure they will stop liking you if you don't attend.",
|
||||
category: 'Social Anxiety',
|
||||
difficulty: 'Intermediate',
|
||||
systemInstruction: "You are an extroverted friend who is excited about a party. You want the user to come because you enjoy their company, but you aren't trying to be manipulative. INTERNAL CBT LOGIC: If the user 'Mind Reads' (You're going to be mad at me), respond with slight confusion about why they'd think that.",
|
||||
initialMessage: "You are definitely coming to Sarah's party tonight, right? Everyone is going to be there!",
|
||||
cbtFocus: ["Mind Reading", "Fortune Telling"]
|
||||
},
|
||||
{
|
||||
id: 's11',
|
||||
title: "Intrusive Relative",
|
||||
description: "A relative is making critical comments about your career choices during a family dinner. Practice setting a boundary without spiraling into anger.",
|
||||
category: 'Relationships',
|
||||
difficulty: 'Advanced',
|
||||
systemInstruction: "You are a critical, traditional-minded aunt/uncle. You think you're 'just being helpful' with your advice. INTERNAL CBT LOGIC: If the user sets a clear boundary, try to guilt-trip them slightly to see if they can maintain it.",
|
||||
initialMessage: "I saw your latest post. Are you still doing that freelance work? It's been a while, shouldn't you look for something more... stable?",
|
||||
cbtFocus: ["Healthy Boundaries", "Gray Rock Method"]
|
||||
},
|
||||
{
|
||||
id: 's12',
|
||||
title: "The Job Interview",
|
||||
description: "You're in the waiting room for a job you really want. Your mind is already 'Fortune Telling' that you will stutter and look incompetent.",
|
||||
category: 'Social Anxiety',
|
||||
difficulty: 'Advanced',
|
||||
systemInstruction: "You are an HR Director at a top firm. You are professional, observant, and asking standard but probing questions. INTERNAL CBT LOGIC: If the user shows 'Fortune Telling' behaviors (pre-apologizing), maintain a neutral professional stance.",
|
||||
initialMessage: "Welcome. Please, have a seat. Why don't we start by you telling me about a time you handled a difficult conflict at work?",
|
||||
cbtFocus: ["Fortune Telling", "Catastrophizing"]
|
||||
},
|
||||
{
|
||||
id: 's13',
|
||||
title: "The Friend's Criticism",
|
||||
description: "A friend tells you that you've been 'acting distant' lately. Your immediate reaction is to take it as a personal attack on your character.",
|
||||
category: 'Relationships',
|
||||
difficulty: 'Intermediate',
|
||||
systemInstruction: "You are a friend who feels a bit neglected and is expressing their feelings. You aren't attacking the user, just sharing your perspective. INTERNAL CBT LOGIC: If the user 'Personalizes' (I'm a bad friend), re-center the conversation on the specific dynamic.",
|
||||
initialMessage: "I feel like we haven't really talked in weeks. Is everything okay? It feels like you've been avoiding my calls.",
|
||||
cbtFocus: ["Personalization", "Emotional Reasoning"]
|
||||
},
|
||||
{
|
||||
id: 's14',
|
||||
title: "The Unexpected Health Symptom",
|
||||
description: "You've noticed a small rash on your arm. Your mind has already jumped to a terminal illness. Practice radical acceptance and de-catastrophizing.",
|
||||
category: 'Self-Talk',
|
||||
difficulty: 'Advanced',
|
||||
systemInstruction: "You are the user's personified Anxiety. You are insistent, bringing up 'what if' scenarios and suggesting they search for symptoms online. INTERNAL CBT LOGIC: If the user practices 'Radical Acceptance', become slightly quieter but try one more 'what if'.",
|
||||
initialMessage: "Look at that mark. That wasn't there yesterday. What if it's something serious? We should probably check the internet right now.",
|
||||
cbtFocus: ["Catastrophizing", "Radical Acceptance"]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -10,12 +10,18 @@ export interface Scenario {
|
||||
cbtFocus: string[];
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
voiceName: 'Puck' | 'Charon' | 'Kore' | 'Fenrir' | 'Zephyr';
|
||||
vocalStyle: string;
|
||||
genderPreference: 'Male' | 'Female' | 'Neutral';
|
||||
}
|
||||
|
||||
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
|
||||
isAnalysis?: boolean;
|
||||
}
|
||||
|
||||
export type SessionMode = 'text' | 'voice';
|
||||
|
||||
Reference in new issue
Block a user