feat: Add character names to scenarios and UI

Introduces `characterName` to the `Scenario` type, enabling AI personas to be explicitly named. This change also updates the `ScenarioCard` component to display the character name and adds foundational support for potential future scenario deletion functionality. The README has been updated with a new AI Studio link.
This commit is contained in:
John Remillard committed 2026-03-04 11:43:39 -05:00
1 parent e92c489db5
commit a4946ed04d
18 files changed
+3585 -320

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { Mic, Check, AlertCircle } from 'lucide-react';
interface AudioDeviceSelectorProps {
selectedDeviceId?: string;
onSelect: (deviceId: string) => void;
className?: string;
}
const AudioDeviceSelector: React.FC<AudioDeviceSelectorProps> = ({
selectedDeviceId,
onSelect,
className = ""
}) => {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [error, setError] = useState<string | null>(null);
const getDevices = async () => {
try {
// Request permission first to get labels
await navigator.mediaDevices.getUserMedia({ audio: true });
const allDevices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = allDevices.filter(device => device.kind === 'audioinput');
setDevices(audioInputs);
// If no device is selected but we have devices, select the first one
if (!selectedDeviceId && audioInputs.length > 0) {
onSelect(audioInputs[0].deviceId);
}
} catch (err) {
console.error("Error accessing audio devices:", err);
setError("Microphone access denied. Please check your browser permissions.");
}
};
useEffect(() => {
getDevices();
// Listen for device changes (e.g., plugging in a new mic)
navigator.mediaDevices.addEventListener('devicechange', getDevices);
return () => {
navigator.mediaDevices.removeEventListener('devicechange', getDevices);
};
}, []);
if (error) {
return (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-100 rounded-xl text-red-600 text-xs">
<AlertCircle size={14} />
<span>{error}</span>
</div>
);
}
return (
<div className={`space-y-2 ${className}`}>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2 mb-1">
<Mic size={12} />
Input Device
</label>
<div className="grid grid-cols-1 gap-2">
{devices.length === 0 ? (
<p className="text-xs text-slate-400 italic p-2">Searching for devices...</p>
) : (
devices.map((device) => (
<button
key={device.deviceId}
onClick={() => onSelect(device.deviceId)}
className={`flex items-center justify-between p-3 rounded-xl border transition-all text-left text-xs ${
selectedDeviceId === device.deviceId || (device.deviceId === 'default' && !selectedDeviceId)
? 'bg-teal-50 border-teal-500 text-teal-900'
: 'bg-white border-slate-100 hover:border-slate-200 text-slate-600'
}`}
>
<div className="flex items-center gap-3 truncate">
<Mic size={14} className={selectedDeviceId === device.deviceId ? 'text-teal-600' : 'text-slate-400'} />
<span className="truncate font-medium">{device.label || `Microphone ${device.deviceId.slice(0, 5)}...`}</span>
</div>
{(selectedDeviceId === device.deviceId || (device.deviceId === 'default' && !selectedDeviceId)) && (
<Check size={14} className="text-teal-600 flex-shrink-0" />
)}
</button>
))
)}
</div>
</div>
);
};
export default AudioDeviceSelector;
+23 -7
View File
@@ -1,15 +1,16 @@
import React from 'react';
import { Scenario } from '../types';
import { Mic, MessageSquare, Info } from 'lucide-react';
import { Mic, MessageSquare, Trash2 } from 'lucide-react';
import Tooltip from './Tooltip';
interface ScenarioCardProps {
scenario: Scenario;
onSelect: (scenario: Scenario, mode: 'text' | 'voice') => void;
onDelete?: (id: string) => void;
}
const ScenarioCard: React.FC<ScenarioCardProps> = ({ scenario, onSelect }) => {
const ScenarioCard: React.FC<ScenarioCardProps> = ({ scenario, onSelect, onDelete }) => {
const getDifficultyColor = (diff: string) => {
switch (diff) {
case 'Beginner': return 'bg-teal-50 text-teal-800 border-teal-100';
@@ -26,19 +27,34 @@ const ScenarioCard: React.FC<ScenarioCardProps> = ({ scenario, onSelect }) => {
};
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="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 relative">
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(scenario.id);
}}
className="absolute top-4 right-4 p-2 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
title="Delete custom scenario"
>
<Trash2 size={16} />
</button>
)}
<div className="flex justify-between items-start mb-4">
<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>
{!onDelete && (
<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>
<h3 className="text-lg font-bold text-slate-800 mb-2 group-hover:text-teal-700 transition-colors pr-6">{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">
+39 -10
View File
@@ -1,8 +1,9 @@
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';
import { Sparkles, X, Loader2, AlertCircle, Info, Target, UserCircle2, BrainCircuit, Check, BarChart } from 'lucide-react';
interface ScenarioGeneratorProps {
onGenerate: (scenario: Scenario) => void;
@@ -12,6 +13,7 @@ interface ScenarioGeneratorProps {
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel }) => {
const [prompt, setPrompt] = useState('');
const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]);
const [selectedDifficulty, setSelectedDifficulty] = useState<Scenario['difficulty']>('Intermediate');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -35,6 +37,7 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
properties: {
id: { type: Type.STRING },
title: { type: Type.STRING },
characterName: { type: Type.STRING },
description: { type: Type.STRING },
category: {
type: Type.STRING,
@@ -51,7 +54,7 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
items: { type: Type.STRING }
}
},
required: ['id', 'title', 'description', 'category', 'difficulty', 'systemInstruction', 'initialMessage', 'cbtFocus']
required: ['id', 'title', 'characterName', 'description', 'category', 'difficulty', 'systemInstruction', 'initialMessage', 'cbtFocus']
};
const selectedContext = selectedConcepts.length > 0
@@ -69,17 +72,18 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
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:
2. CATEGORIZATION: Based on the user's input, you MUST suggest the most appropriate category for the scenario:
- '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.
3. DIFFICULTY CALIBRATION: The user has explicitly requested a '${selectedDifficulty}' level.
Generate the scenario content and the AI character's behavior to match this complexity:
- 'Beginner': Low stakes, high support, clear-cut distortions, AI character is cooperative.
- 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions, AI character has specific needs/points.
- 'Advanced': High emotional triggers, persistent clinical resistance from AI character, complex deep-seated cognitive patterns.
4. INTERNAL LOGIC: The 'systemInstruction' for the character MUST include a detailed "INTERNAL CBT LOGIC" section. This instructs the AI character how to react to the user's progress or cognitive lapses.
5. CHARACTER VOICE: Define a realistic voice profile for the character, including their likely emotional state (frustrated, busy, anxious, etc.).
`
},
contents: [
@@ -89,6 +93,8 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
if (result.text) {
const scenario = JSON.parse(result.text) as Scenario;
// Enforce the user-selected difficulty in the final object
scenario.difficulty = selectedDifficulty;
scenario.id = `custom-${Date.now()}-${scenario.id}`;
onGenerate(scenario);
} else {
@@ -162,6 +168,29 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
</div>
</div>
{/* Difficulty Selection */}
<div className="space-y-3">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
Communication Difficulty
</label>
<div className="grid grid-cols-3 gap-2">
{(['Beginner', 'Intermediate', 'Advanced'] as const).map((level) => (
<button
key={level}
onClick={() => setSelectedDifficulty(level)}
className={`flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 transition-all text-[11px] font-bold ${
selectedDifficulty === level
? 'bg-teal-50 border-teal-600 text-teal-700 shadow-sm shadow-teal-50'
: 'bg-white border-slate-100 text-slate-400 hover:border-slate-200'
}`}
>
<BarChart size={14} className={selectedDifficulty === level ? 'text-teal-600' : 'text-slate-300'} />
{level}
</button>
))}
</div>
</div>
{/* Clinical Targets (Selection) */}
<div className="space-y-3">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
@@ -229,4 +258,4 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
);
};
export default ScenarioGenerator;
export default ScenarioGenerator;
+20 -5
View File
@@ -1,8 +1,9 @@
import React from 'react';
import { UserSettings } from '../types';
import { X, Volume2, Check, Info, Mic2 } from 'lucide-react';
import { X, Volume2, Check, Info, Mic2, Settings2 } from 'lucide-react';
import Tooltip from './Tooltip';
import AudioDeviceSelector from './AudioDeviceSelector';
interface SettingsModalProps {
settings: UserSettings;
@@ -29,8 +30,8 @@ const VOICE_OPTIONS: VoiceOption[] = [
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="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 max-h-[90vh] flex flex-col">
<div className="p-6 bg-slate-50 border-b border-slate-200 flex justify-between items-center flex-shrink-0">
<div className="flex items-center gap-3">
<div className="bg-slate-800 p-2 rounded-xl text-white">
<Volume2 size={20} />
@@ -45,7 +46,7 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClo
</button>
</div>
<div className="p-8 space-y-8">
<div className="p-8 space-y-8 overflow-y-auto">
<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">
@@ -94,6 +95,20 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClo
</div>
</section>
<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">
<Settings2 size={14} className="text-teal-600" />
Hardware Configuration
</h3>
</div>
<AudioDeviceSelector
selectedDeviceId={settings.preferredAudioDeviceId}
onSelect={(deviceId) => onUpdate({ ...settings, preferredAudioDeviceId: deviceId })}
/>
</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} />
@@ -104,7 +119,7 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClo
</div>
</div>
<div className="p-6 bg-slate-50 border-t border-slate-200 flex justify-end">
<div className="p-6 bg-slate-50 border-t border-slate-200 flex justify-end flex-shrink-0">
<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]"
+161 -268
View File
@@ -1,81 +1,62 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Scenario, UserSettings, Message, AnalysisResult } from '../types';
import { GoogleGenAI, LiveServerMessage, Modality } from '@google/genai';
import { Mic, MicOff, PhoneOff, MessageSquare, BrainCircuit, Flag, Loader2 } from 'lucide-react';
import { SYSTEM_ANALYSIS_PROMPT, SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
import { floatTo16BitPCM, arrayBufferToBase64, base64ToUint8Array, decodeAudioData } from '../services/audioUtils';
import React, { useState, useCallback } from 'react';
import { Scenario, UserSettings, AnalysisResult } from '../types';
import { GoogleGenAI } from '@google/genai';
import { SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
import { useLiveSession } from '../hooks/useLiveSession';
import { AlertCircle, Settings2, X, BrainCircuit, Zap, Info } from 'lucide-react';
// Sub-components
import AudioVisualizer from './AudioVisualizer';
import TranscriptPanel from './TranscriptPanel';
import LiveInsightPanel from './LiveInsightPanel';
import SessionReport from './SessionReport';
import ConnectionState from './VoiceSession/ConnectionState';
import AnalyzingState from './VoiceSession/AnalyzingState';
import VoiceHeader from './VoiceSession/VoiceHeader';
import VoiceControls from './VoiceSession/VoiceControls';
import AudioDeviceSelector from './AudioDeviceSelector';
interface VoiceSessionProps {
scenario: Scenario;
onExit: () => void;
voiceSettings: UserSettings;
onUpdateSettings: (settings: UserSettings) => void;
}
// Manual encoding helper as per guidelines
function encode(bytes: Uint8Array) {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Manual decoding helper as per guidelines
function decode(base64: string) {
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;
}
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSettings }) => {
const [isMuted, setIsMuted] = useState(false);
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSettings, onUpdateSettings }) => {
const [showTranscript, setShowTranscript] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [status, setStatus] = useState<'connecting' | 'active' | 'analyzing' | 'finished'>('connecting');
const [audioLevel, setAudioLevel] = useState(0);
const [isAiSpeaking, setIsAiSpeaking] = useState(false);
// Analysis States
const [liveInsight, setLiveInsight] = useState<AnalysisResult | null>(null);
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
const [showDeviceSelector, setShowDeviceSelector] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
const [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
// Refs for Audio & Live API
const sessionPromiseRef = useRef<Promise<any> | null>(null);
const inputAudioCtxRef = useRef<AudioContext | null>(null);
const outputAudioCtxRef = useRef<AudioContext | null>(null);
const nextStartTimeRef = useRef<number>(0);
const audioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const streamRef = useRef<MediaStream | null>(null);
const analysisChatRef = useRef<any>(null);
const {
status,
setStatus,
isMuted,
toggleMute,
audioLevel,
aiAudioLevel,
isAiSpeaking,
isUserSpeaking,
messages,
liveInsight,
isAnalyzingTurn,
isSilenceDetected,
sessionPromiseRef,
startSession
} = useLiveSession(scenario, voiceSettings);
// Transcription buffers
const currentInputTranscription = useRef('');
const currentOutputTranscription = useRef('');
const activeLevel = isAiSpeaking ? aiAudioLevel : audioLevel;
const stopAllAudio = useCallback(() => {
audioSourcesRef.current.forEach(source => {
try { source.stop(); } catch (e) {}
});
audioSourcesRef.current.clear();
nextStartTimeRef.current = 0;
}, []);
const handleStart = useCallback(() => {
setHasStarted(true);
startSession();
}, [startSession]);
const handleFinishSession = async () => {
const handleFinishSession = useCallback(async () => {
setStatus('analyzing');
// Close live session
if (sessionPromiseRef.current) {
sessionPromiseRef.current.then(session => session.close());
}
@@ -107,182 +88,10 @@ SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`;
console.error("Post-session analysis failure", err);
onExit();
}
};
useEffect(() => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Setup Analysis Model for Live Insights
analysisChatRef.current = ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\nSCENARIO: ${scenario.title}\nFOCUS: ${scenario.cbtFocus.join(', ')}`,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
}
});
const init = async () => {
try {
inputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
outputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const sessionPromise = ai.live.connect({
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
callbacks: {
onopen: () => {
setStatus('active');
const source = inputAudioCtxRef.current!.createMediaStreamSource(stream);
const scriptProcessor = inputAudioCtxRef.current!.createScriptProcessor(4096, 1, 1);
scriptProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
// Calculate simple audio level for visualizer
let sum = 0;
for (let i = 0; i < inputData.length; i++) sum += inputData[i] * inputData[i];
setAudioLevel(Math.sqrt(sum / inputData.length) * 100);
if (isMuted) return;
const pcm16 = floatTo16BitPCM(inputData);
const base64 = encode(new Uint8Array(pcm16));
sessionPromise.then(session => {
session.sendRealtimeInput({
media: { data: base64, mimeType: 'audio/pcm;rate=16000' }
});
});
};
source.connect(scriptProcessor);
scriptProcessor.connect(inputAudioCtxRef.current!.destination);
},
onmessage: async (message: LiveServerMessage) => {
// Handle Transcriptions
if (message.serverContent?.outputTranscription) {
currentOutputTranscription.current += message.serverContent.outputTranscription.text;
} else if (message.serverContent?.inputTranscription) {
currentInputTranscription.current += message.serverContent.inputTranscription.text;
}
if (message.serverContent?.turnComplete) {
const userText = currentInputTranscription.current;
const modelText = currentOutputTranscription.current;
if (userText) {
const userMsg: Message = { id: Date.now().toString(), role: 'user', text: userText, timestamp: new Date() };
setMessages(prev => [...prev, userMsg]);
// Trigger Analysis
if (analysisChatRef.current && modelText) {
setIsAnalyzingTurn(true);
analysisChatRef.current.sendMessage({ message: `User: "${userText}"\nAI: "${modelText}"` })
.then((res: any) => {
try { if (res.text) setLiveInsight(JSON.parse(res.text)); }
catch(e) {} finally { setIsAnalyzingTurn(false); }
});
}
}
if (modelText) {
setMessages(prev => [...prev, { id: (Date.now()+1).toString(), role: 'model', text: modelText, timestamp: new Date() }]);
}
currentInputTranscription.current = '';
currentOutputTranscription.current = '';
}
// Handle Audio Output
const audioBase64 = message.serverContent?.modelTurn?.parts[0]?.inlineData?.data;
if (audioBase64) {
setIsAiSpeaking(true);
const audioCtx = outputAudioCtxRef.current!;
nextStartTimeRef.current = Math.max(nextStartTimeRef.current, audioCtx.currentTime);
const buffer = await decodeAudioData(decode(audioBase64), audioCtx, 24000, 1);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.onended = () => {
audioSourcesRef.current.delete(source);
if (audioSourcesRef.current.size === 0) setIsAiSpeaking(false);
};
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += buffer.duration;
audioSourcesRef.current.add(source);
}
if (message.serverContent?.interrupted) {
stopAllAudio();
setIsAiSpeaking(false);
}
},
onclose: () => console.log("Live session closed"),
onerror: (e) => console.error("Live API error", e)
},
config: {
responseModalities: [Modality.AUDIO],
speechConfig: {
voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceSettings.voiceName } }
},
systemInstruction: `
${scenario.systemInstruction}
VIRTUAL REALITY ROLEPLAY PROTOCOL:
1. Stay strictly in character.
2. DO NOT provide advice or analysis within the conversation.
3. React naturally to the user's voice and emotional tone.
`,
inputAudioTranscription: {},
outputAudioTranscription: {}
}
});
sessionPromiseRef.current = sessionPromise;
} catch (err) {
console.error("Initialization failed", err);
}
};
init();
return () => {
stopAllAudio();
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
if (inputAudioCtxRef.current) inputAudioCtxRef.current.close();
if (outputAudioCtxRef.current) outputAudioCtxRef.current.close();
if (sessionPromiseRef.current) sessionPromiseRef.current.then(s => s.close());
};
}, [scenario, voiceSettings, stopAllAudio, isMuted]);
if (status === 'connecting') {
return (
<div className="flex flex-col items-center justify-center h-full text-slate-800 animate-pulse">
<Loader2 size={48} className="animate-spin text-teal-600 mb-4" />
<p className="font-bold uppercase tracking-widest text-xs">Opening Neural Bridge...</p>
</div>
);
}
if (status === 'analyzing') {
return (
<div className="flex flex-col items-center justify-center h-full text-slate-800">
<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 mb-2">Generating Report</h2>
<p className="text-slate-500 text-center max-w-sm">Synthesizing vocal patterns and cognitive restructuring evidence...</p>
</div>
);
}
}, [messages, scenario, setStatus, sessionPromiseRef, onExit]);
if (status === 'connecting') return <ConnectionState />;
if (status === 'analyzing') return <AnalyzingState />;
if (status === 'finished' && finalReport) {
return <SessionReport analysis={finalReport} onExit={onExit} />;
}
@@ -291,50 +100,134 @@ SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`;
<div className="flex flex-col lg:flex-row h-full gap-4 relative overflow-hidden">
{/* Main Interaction Area */}
<div className="flex-grow bg-slate-900 rounded-3xl overflow-hidden flex flex-col relative shadow-2xl border border-white/5">
{/* Animated Background Aura */}
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-20">
<div className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80%] h-[80%] rounded-full blur-[100px] transition-all duration-1000 ${isAiSpeaking ? 'bg-blue-500 scale-110' : 'bg-teal-500 scale-100'}`}></div>
</div>
<div className="p-8 flex justify-between items-center relative z-10">
<div className="flex items-center gap-4">
<button onClick={onExit} className="p-3 bg-white/5 hover:bg-white/10 rounded-2xl text-white/50 hover:text-white transition-all">
<PhoneOff size={20} />
</button>
<div>
<h2 className="text-white font-bold text-lg">{scenario.title}</h2>
<p className="text-teal-400 text-[10px] font-black uppercase tracking-widest">Active Roleplay Session</p>
</div>
</div>
<button onClick={handleFinishSession} className="bg-teal-600 hover:bg-teal-700 text-white px-6 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-teal-900/40 flex items-center gap-2">
<Flag size={14} /> Finish
</button>
</div>
<VoiceHeader
title={scenario.title}
onExit={onExit}
onFinish={handleFinishSession}
/>
<div className="flex-grow flex flex-col items-center justify-center p-12 relative z-10">
<AudioVisualizer level={audioLevel} active={true} isAiSpeaking={isAiSpeaking} />
<div className="mt-12 text-center">
<p className={`text-sm transition-all duration-500 font-light ${isAiSpeaking ? 'text-blue-300' : 'text-teal-300'}`}>
{isAiSpeaking ? `Vocalizing Response...` : (isMuted ? 'Microphone Muted' : 'Listening...')}
</p>
<p className="text-[10px] text-white/30 uppercase font-bold tracking-[0.3em] mt-4">Clinical Fidelity Level: 99.8%</p>
</div>
{!hasStarted && status === 'active' ? (
<div className="flex flex-col items-center animate-in fade-in zoom-in duration-700">
<div className="w-24 h-24 bg-teal-500/10 rounded-full flex items-center justify-center mb-8 border border-teal-500/20 relative">
<div className="absolute inset-0 bg-teal-500/20 rounded-full animate-ping"></div>
<BrainCircuit size={40} className="text-teal-400 relative z-10" />
</div>
<h3 className="text-2xl font-bold text-white mb-2">Neural Link Established</h3>
<p className="text-slate-400 text-sm mb-8 max-w-xs text-center leading-relaxed">
The simulation is ready. Press start to begin your interaction with <strong>{scenario.characterName}</strong>.
</p>
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 mb-8 flex items-center gap-3 max-w-sm">
<div className="bg-teal-500/20 p-2 rounded-lg">
<Info size={16} className="text-teal-400" />
</div>
<p className="text-[11px] text-slate-300 leading-relaxed">
<strong>Greeting Hint:</strong> Say "Hello {scenario.characterName}" or just wait for them to speak first.
</p>
</div>
<button
onClick={handleStart}
className="bg-teal-600 hover:bg-teal-500 text-white px-12 py-4 rounded-2xl font-bold transition-all shadow-xl shadow-teal-900/20 uppercase tracking-widest text-sm flex items-center gap-3"
>
<Zap size={18} fill="currentColor" />
Start Session
</button>
</div>
) : (
<>
{isSilenceDetected && !isMuted && (
<div className="absolute top-8 left-1/2 -translate-x-1/2 w-full max-w-md animate-in slide-in-from-top-4 duration-500">
<div className="bg-amber-500/90 backdrop-blur-md border border-amber-400 text-white p-4 rounded-2xl shadow-xl flex items-start gap-4">
<div className="bg-white/20 p-2 rounded-xl">
<AlertCircle size={20} />
</div>
<div className="flex-grow">
<h4 className="text-sm font-bold">No Audio Detected</h4>
<p className="text-xs text-white/80 mt-1 leading-relaxed">We can't hear you. Please check your microphone or select a different input device.</p>
<button
onClick={() => setShowDeviceSelector(true)}
className="mt-3 bg-white text-amber-600 px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest hover:bg-amber-50 transition-colors"
>
Change Input Device
</button>
</div>
</div>
</div>
)}
{/* Speaker Indicator Badge */}
<div className="mb-8 flex flex-col items-center">
<div className={`px-4 py-1.5 rounded-full border transition-all duration-500 flex items-center gap-2 ${
isAiSpeaking
? 'bg-blue-500/20 border-blue-400/30 text-blue-300'
: (isUserSpeaking ? 'bg-teal-500/20 border-teal-400/30 text-teal-300' : 'bg-white/5 border-white/10 text-white/40')
}`}>
<div className={`w-2 h-2 rounded-full ${
isAiSpeaking ? 'bg-blue-400 animate-pulse' : (isUserSpeaking ? 'bg-teal-400 animate-pulse' : 'bg-white/20')
}`} />
<span className="text-[10px] font-black uppercase tracking-widest">
{isAiSpeaking ? 'AI Speaking' : (isUserSpeaking ? 'You Speaking' : 'Waiting...')}
</span>
</div>
</div>
<AudioVisualizer level={activeLevel} active={true} isAiSpeaking={isAiSpeaking} />
<div className="mt-12 text-center">
<p className={`text-sm transition-all duration-500 font-light ${isAiSpeaking ? 'text-blue-300' : 'text-teal-300'}`}>
{isAiSpeaking ? `Vocalizing Response...` : (isMuted ? 'Microphone Muted' : 'Listening...')}
</p>
<p className="text-[10px] text-white/30 uppercase font-bold tracking-[0.3em] mt-4">Clinical Fidelity Level: 99.8%</p>
</div>
</>
)}
</div>
<div className="p-8 flex justify-center gap-6 relative z-10 bg-black/20 backdrop-blur-md">
<button
onClick={() => setIsMuted(!isMuted)}
className={`p-5 rounded-full transition-all ${isMuted ? 'bg-rose-600 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
{isMuted ? <MicOff size={24} /> : <Mic size={24} />}
</button>
<button
onClick={() => setShowTranscript(!showTranscript)}
className={`p-5 rounded-full transition-all ${showTranscript ? 'bg-teal-600 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
<MessageSquare size={24} />
</button>
</div>
{/* Device Selector Overlay */}
{showDeviceSelector && (
<div className="absolute inset-0 z-50 bg-slate-900/80 backdrop-blur-sm flex items-center justify-center p-6 animate-in fade-in duration-300">
<div className="bg-white rounded-3xl p-6 w-full max-w-sm shadow-2xl border border-slate-100">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="bg-slate-800 p-2 rounded-xl text-white">
<Settings2 size={18} />
</div>
<h3 className="font-bold text-slate-800">Audio Input</h3>
</div>
<button onClick={() => setShowDeviceSelector(false)} className="p-2 hover:bg-slate-100 rounded-xl transition-colors text-slate-400">
<X size={20} />
</button>
</div>
<AudioDeviceSelector
selectedDeviceId={voiceSettings.preferredAudioDeviceId}
onSelect={(deviceId) => {
onUpdateSettings({ ...voiceSettings, preferredAudioDeviceId: deviceId });
// Don't close immediately so user sees the checkmark
setTimeout(() => setShowDeviceSelector(false), 300);
}}
/>
<p className="text-[10px] text-slate-400 mt-6 leading-relaxed italic">
Changing your input device will restart the current neural link.
</p>
</div>
</div>
)}
<VoiceControls
isMuted={isMuted}
onToggleMute={toggleMute}
showTranscript={showTranscript}
onToggleTranscript={() => setShowTranscript(prev => !prev)}
/>
{showTranscript && <TranscriptPanel messages={messages} onClose={() => setShowTranscript(false)} />}
</div>
@@ -0,0 +1,16 @@
import React from 'react';
import { BrainCircuit } from 'lucide-react';
const AnalyzingState: React.FC = () => (
<div className="flex flex-col items-center justify-center h-full text-slate-800">
<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 mb-2">Generating Report</h2>
<p className="text-slate-500 text-center max-w-sm">Synthesizing vocal patterns and cognitive restructuring evidence...</p>
</div>
);
export default React.memo(AnalyzingState);
@@ -0,0 +1,12 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
const ConnectionState: React.FC = () => (
<div className="flex flex-col items-center justify-center h-full text-slate-800 animate-pulse">
<Loader2 size={48} className="animate-spin text-teal-600 mb-4" />
<p className="font-bold uppercase tracking-widest text-xs">Opening Neural Bridge...</p>
</div>
);
export default React.memo(ConnectionState);
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { Mic, MicOff, MessageSquare } from 'lucide-react';
interface VoiceControlsProps {
isMuted: boolean;
onToggleMute: () => void;
showTranscript: boolean;
onToggleTranscript: () => void;
}
const VoiceControls: React.FC<VoiceControlsProps> = ({
isMuted,
onToggleMute,
showTranscript,
onToggleTranscript
}) => (
<div className="p-8 flex justify-center gap-6 relative z-10 bg-black/20 backdrop-blur-md">
<button
onClick={onToggleMute}
className={`p-5 rounded-full transition-all ${isMuted ? 'bg-rose-600 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
{isMuted ? <MicOff size={24} /> : <Mic size={24} />}
</button>
<button
onClick={onToggleTranscript}
className={`p-5 rounded-full transition-all ${showTranscript ? 'bg-teal-600 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
<MessageSquare size={24} />
</button>
</div>
);
export default React.memo(VoiceControls);
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import { PhoneOff, Flag } from 'lucide-react';
interface VoiceHeaderProps {
title: string;
onExit: () => void;
onFinish: () => void;
}
const VoiceHeader: React.FC<VoiceHeaderProps> = ({ title, onExit, onFinish }) => (
<div className="p-8 flex justify-between items-center relative z-10">
<div className="flex items-center gap-4">
<button onClick={onExit} className="p-3 bg-white/5 hover:bg-white/10 rounded-2xl text-white/50 hover:text-white transition-all">
<PhoneOff size={20} />
</button>
<div>
<h2 className="text-white font-bold text-lg">{title}</h2>
<p className="text-teal-400 text-[10px] font-black uppercase tracking-widest">Active Roleplay Session</p>
</div>
</div>
<button onClick={onFinish} className="bg-teal-600 hover:bg-teal-700 text-white px-6 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-teal-900/40 flex items-center gap-2">
<Flag size={14} /> Finish
</button>
</div>
);
export default React.memo(VoiceHeader);