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:
1 parent
e92c489db5
commit
a4946ed04d
18 files changed
+3585
-320
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { SCENARIOS } from './constants';
|
import { SCENARIOS } from './constants';
|
||||||
import { Scenario, SessionMode, UserSettings } from './types';
|
import { Scenario, SessionMode, UserSettings } from './types';
|
||||||
import ScenarioCard from './components/ScenarioCard';
|
import ScenarioCard from './components/ScenarioCard';
|
||||||
@@ -17,26 +17,44 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
genderPreference: 'Neutral'
|
genderPreference: 'Neutral'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CUSTOM_SCENARIOS_KEY = 'cognitive-clarity-custom-scenarios';
|
||||||
|
const SETTINGS_KEY = 'cognitive-clarity-settings';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [activeScenario, setActiveScenario] = useState<Scenario | null>(null);
|
const [activeScenario, setActiveScenario] = useState<Scenario | null>(null);
|
||||||
const [sessionMode, setSessionMode] = useState<SessionMode>('text');
|
const [sessionMode, setSessionMode] = useState<SessionMode>('text');
|
||||||
const [selectedDifficulty, setSelectedDifficulty] = useState<string>('All');
|
const [selectedDifficulty, setSelectedDifficulty] = useState<string>('All');
|
||||||
const [isPreparing, setIsPreparing] = useState(false);
|
const [isPreparing, setIsPreparing] = useState(false);
|
||||||
|
|
||||||
// Custom Scenarios State
|
// Custom Scenarios State with LocalStorage persistence
|
||||||
const [customScenarios, setCustomScenarios] = useState<Scenario[]>([]);
|
const [customScenarios, setCustomScenarios] = useState<Scenario[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(CUSTOM_SCENARIOS_KEY);
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load custom scenarios", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const [showGenerator, setShowGenerator] = useState(false);
|
const [showGenerator, setShowGenerator] = useState(false);
|
||||||
const [showReference, setShowReference] = useState(false);
|
const [showReference, setShowReference] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
// User Settings State
|
// User Settings State
|
||||||
const [userSettings, setUserSettings] = useState<UserSettings>(() => {
|
const [userSettings, setUserSettings] = useState<UserSettings>(() => {
|
||||||
const saved = localStorage.getItem('cognitive-clarity-settings');
|
const saved = localStorage.getItem(SETTINGS_KEY);
|
||||||
return saved ? JSON.parse(saved) : DEFAULT_SETTINGS;
|
return saved ? JSON.parse(saved) : DEFAULT_SETTINGS;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save scenarios whenever they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('cognitive-clarity-settings', JSON.stringify(userSettings));
|
localStorage.setItem(CUSTOM_SCENARIOS_KEY, JSON.stringify(customScenarios));
|
||||||
|
}, [customScenarios]);
|
||||||
|
|
||||||
|
// Save settings whenever they change
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(userSettings));
|
||||||
}, [userSettings]);
|
}, [userSettings]);
|
||||||
|
|
||||||
const handleSelectScenario = (scenario: Scenario, mode: SessionMode) => {
|
const handleSelectScenario = (scenario: Scenario, mode: SessionMode) => {
|
||||||
@@ -57,6 +75,10 @@ const App: React.FC = () => {
|
|||||||
setShowGenerator(false);
|
setShowGenerator(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteScenario = useCallback((id: string) => {
|
||||||
|
setCustomScenarios(prev => prev.filter(s => s.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const allScenarios = [...customScenarios, ...SCENARIOS];
|
const allScenarios = [...customScenarios, ...SCENARIOS];
|
||||||
const filteredScenarios = allScenarios.filter(scenario =>
|
const filteredScenarios = allScenarios.filter(scenario =>
|
||||||
selectedDifficulty === 'All' || scenario.difficulty === selectedDifficulty
|
selectedDifficulty === 'All' || scenario.difficulty === selectedDifficulty
|
||||||
@@ -125,6 +147,7 @@ const App: React.FC = () => {
|
|||||||
scenario={activeScenario}
|
scenario={activeScenario}
|
||||||
onExit={handleExitSession}
|
onExit={handleExitSession}
|
||||||
voiceSettings={userSettings}
|
voiceSettings={userSettings}
|
||||||
|
onUpdateSettings={setUserSettings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -233,7 +256,12 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredScenarios.map(scenario => (
|
{filteredScenarios.map(scenario => (
|
||||||
<ScenarioCard key={scenario.id} scenario={scenario} onSelect={handleSelectScenario} />
|
<ScenarioCard
|
||||||
|
key={scenario.id}
|
||||||
|
scenario={scenario}
|
||||||
|
onSelect={handleSelectScenario}
|
||||||
|
onDelete={scenario.id.startsWith('custom-') ? handleDeleteScenario : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGenerator(true)}
|
onClick={() => setShowGenerator(true)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
This contains everything you need to run your app locally.
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
View your app in AI Studio: https://ai.studio/apps/drive/1MTS72LTsm0ZY5GzQzl-a8Rpt72k1csd9
|
View your app in AI Studio: https://ai.studio/apps/56e6d01a-b890-4a16-b7c2-fc7b3e52f59a
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Scenario } from '../types';
|
import { Scenario } from '../types';
|
||||||
import { Mic, MessageSquare, Info } from 'lucide-react';
|
import { Mic, MessageSquare, Trash2 } from 'lucide-react';
|
||||||
import Tooltip from './Tooltip';
|
import Tooltip from './Tooltip';
|
||||||
|
|
||||||
interface ScenarioCardProps {
|
interface ScenarioCardProps {
|
||||||
scenario: Scenario;
|
scenario: Scenario;
|
||||||
onSelect: (scenario: Scenario, mode: 'text' | 'voice') => void;
|
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) => {
|
const getDifficultyColor = (diff: string) => {
|
||||||
switch (diff) {
|
switch (diff) {
|
||||||
case 'Beginner': return 'bg-teal-50 text-teal-800 border-teal-100';
|
case 'Beginner': return 'bg-teal-50 text-teal-800 border-teal-100';
|
||||||
@@ -26,19 +27,34 @@ const ScenarioCard: React.FC<ScenarioCardProps> = ({ scenario, onSelect }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<Tooltip content={difficultyExplanations[scenario.difficulty]}>
|
<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)}`}>
|
<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}
|
{scenario.difficulty}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
{!onDelete && (
|
||||||
{scenario.category}
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||||
</span>
|
{scenario.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
<p className="text-slate-600 text-sm mb-5 flex-grow leading-relaxed font-light">{scenario.description}</p>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { GoogleGenAI, Type } from "@google/genai";
|
import { GoogleGenAI, Type } from "@google/genai";
|
||||||
import { Scenario } from '../types';
|
import { Scenario } from '../types';
|
||||||
import { CBT_GLOSSARY } from '../constants';
|
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 {
|
interface ScenarioGeneratorProps {
|
||||||
onGenerate: (scenario: Scenario) => void;
|
onGenerate: (scenario: Scenario) => void;
|
||||||
@@ -12,6 +13,7 @@ interface ScenarioGeneratorProps {
|
|||||||
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel }) => {
|
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel }) => {
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]);
|
const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]);
|
||||||
|
const [selectedDifficulty, setSelectedDifficulty] = useState<Scenario['difficulty']>('Intermediate');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: Type.STRING },
|
id: { type: Type.STRING },
|
||||||
title: { type: Type.STRING },
|
title: { type: Type.STRING },
|
||||||
|
characterName: { type: Type.STRING },
|
||||||
description: { type: Type.STRING },
|
description: { type: Type.STRING },
|
||||||
category: {
|
category: {
|
||||||
type: Type.STRING,
|
type: Type.STRING,
|
||||||
@@ -51,7 +54,7 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
|
|||||||
items: { type: Type.STRING }
|
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
|
const selectedContext = selectedConcepts.length > 0
|
||||||
@@ -69,17 +72,18 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
|
|||||||
|
|
||||||
CORE INSTRUCTIONS:
|
CORE INSTRUCTIONS:
|
||||||
1. CLINICAL FOCUS: ${selectedContext}
|
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.
|
- 'Social Anxiety': Situations involving social performance, fear of judgment, or networking.
|
||||||
- 'Workplace': Scenarios involving colleagues, supervisors, career growth, or professional conflict.
|
- 'Workplace': Scenarios involving colleagues, supervisors, career growth, or professional conflict.
|
||||||
- 'Relationships': Dynamics with family, friends, or romantic partners; boundary setting.
|
- 'Relationships': Dynamics with family, friends, or romantic partners; boundary setting.
|
||||||
- 'Self-Talk': Purely internal cognitive restructuring, rumination, or self-compassion work.
|
- 'Self-Talk': Purely internal cognitive restructuring, rumination, or self-compassion work.
|
||||||
3. DIFFICULTY CALIBRATION: Assign a level based on cognitive load:
|
3. DIFFICULTY CALIBRATION: The user has explicitly requested a '${selectedDifficulty}' level.
|
||||||
- 'Beginner': Low stakes, high support, clear-cut distortions.
|
Generate the scenario content and the AI character's behavior to match this complexity:
|
||||||
- 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions.
|
- 'Beginner': Low stakes, high support, clear-cut distortions, AI character is cooperative.
|
||||||
- 'Advanced': High emotional triggers, persistent clinical resistance from AI character, deep-seated patterns.
|
- 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions, AI character has specific needs/points.
|
||||||
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.
|
- 'Advanced': High emotional triggers, persistent clinical resistance from AI character, complex deep-seated cognitive patterns.
|
||||||
5. CONSISTENCY: Ensure 'cbtFocus' includes the selected concepts.
|
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: [
|
contents: [
|
||||||
@@ -89,6 +93,8 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
|
|||||||
|
|
||||||
if (result.text) {
|
if (result.text) {
|
||||||
const scenario = JSON.parse(result.text) as Scenario;
|
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}`;
|
scenario.id = `custom-${Date.now()}-${scenario.id}`;
|
||||||
onGenerate(scenario);
|
onGenerate(scenario);
|
||||||
} else {
|
} else {
|
||||||
@@ -162,6 +168,29 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
|
|||||||
</div>
|
</div>
|
||||||
</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) */}
|
{/* Clinical Targets (Selection) */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
<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;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { UserSettings } from '../types';
|
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 Tooltip from './Tooltip';
|
||||||
|
import AudioDeviceSelector from './AudioDeviceSelector';
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
settings: UserSettings;
|
settings: UserSettings;
|
||||||
@@ -29,8 +30,8 @@ const VOICE_OPTIONS: VoiceOption[] = [
|
|||||||
const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClose }) => {
|
const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClose }) => {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md">
|
<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="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">
|
<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="flex items-center gap-3">
|
||||||
<div className="bg-slate-800 p-2 rounded-xl text-white">
|
<div className="bg-slate-800 p-2 rounded-xl text-white">
|
||||||
<Volume2 size={20} />
|
<Volume2 size={20} />
|
||||||
@@ -45,7 +46,7 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClo
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8 overflow-y-auto">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
<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>
|
</div>
|
||||||
</section>
|
</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="bg-amber-50 border border-amber-100 rounded-2xl p-4 flex gap-3">
|
||||||
<div className="text-amber-600 mt-0.5">
|
<div className="text-amber-600 mt-0.5">
|
||||||
<Info size={18} />
|
<Info size={18} />
|
||||||
@@ -104,7 +119,7 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClo
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
onClick={onClose}
|
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]"
|
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
@@ -1,81 +1,62 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Scenario, UserSettings, Message, AnalysisResult } from '../types';
|
import { Scenario, UserSettings, AnalysisResult } from '../types';
|
||||||
import { GoogleGenAI, LiveServerMessage, Modality } from '@google/genai';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
import { Mic, MicOff, PhoneOff, MessageSquare, BrainCircuit, Flag, Loader2 } from 'lucide-react';
|
import { SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
|
||||||
import { SYSTEM_ANALYSIS_PROMPT, SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
|
import { useLiveSession } from '../hooks/useLiveSession';
|
||||||
import { floatTo16BitPCM, arrayBufferToBase64, base64ToUint8Array, decodeAudioData } from '../services/audioUtils';
|
import { AlertCircle, Settings2, X, BrainCircuit, Zap, Info } from 'lucide-react';
|
||||||
|
|
||||||
// Sub-components
|
// Sub-components
|
||||||
import AudioVisualizer from './AudioVisualizer';
|
import AudioVisualizer from './AudioVisualizer';
|
||||||
import TranscriptPanel from './TranscriptPanel';
|
import TranscriptPanel from './TranscriptPanel';
|
||||||
import LiveInsightPanel from './LiveInsightPanel';
|
import LiveInsightPanel from './LiveInsightPanel';
|
||||||
import SessionReport from './SessionReport';
|
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 {
|
interface VoiceSessionProps {
|
||||||
scenario: Scenario;
|
scenario: Scenario;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
voiceSettings: UserSettings;
|
voiceSettings: UserSettings;
|
||||||
|
onUpdateSettings: (settings: UserSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual encoding helper as per guidelines
|
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSettings, onUpdateSettings }) => {
|
||||||
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 [showTranscript, setShowTranscript] = useState(false);
|
const [showTranscript, setShowTranscript] = useState(false);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [showDeviceSelector, setShowDeviceSelector] = useState(false);
|
||||||
const [status, setStatus] = useState<'connecting' | 'active' | 'analyzing' | 'finished'>('connecting');
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
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 [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
|
const [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
|
||||||
|
|
||||||
// Refs for Audio & Live API
|
const {
|
||||||
const sessionPromiseRef = useRef<Promise<any> | null>(null);
|
status,
|
||||||
const inputAudioCtxRef = useRef<AudioContext | null>(null);
|
setStatus,
|
||||||
const outputAudioCtxRef = useRef<AudioContext | null>(null);
|
isMuted,
|
||||||
const nextStartTimeRef = useRef<number>(0);
|
toggleMute,
|
||||||
const audioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
|
audioLevel,
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
aiAudioLevel,
|
||||||
const analysisChatRef = useRef<any>(null);
|
isAiSpeaking,
|
||||||
|
isUserSpeaking,
|
||||||
|
messages,
|
||||||
|
liveInsight,
|
||||||
|
isAnalyzingTurn,
|
||||||
|
isSilenceDetected,
|
||||||
|
sessionPromiseRef,
|
||||||
|
startSession
|
||||||
|
} = useLiveSession(scenario, voiceSettings);
|
||||||
|
|
||||||
// Transcription buffers
|
const activeLevel = isAiSpeaking ? aiAudioLevel : audioLevel;
|
||||||
const currentInputTranscription = useRef('');
|
|
||||||
const currentOutputTranscription = useRef('');
|
|
||||||
|
|
||||||
const stopAllAudio = useCallback(() => {
|
const handleStart = useCallback(() => {
|
||||||
audioSourcesRef.current.forEach(source => {
|
setHasStarted(true);
|
||||||
try { source.stop(); } catch (e) {}
|
startSession();
|
||||||
});
|
}, [startSession]);
|
||||||
audioSourcesRef.current.clear();
|
|
||||||
nextStartTimeRef.current = 0;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFinishSession = async () => {
|
const handleFinishSession = useCallback(async () => {
|
||||||
setStatus('analyzing');
|
setStatus('analyzing');
|
||||||
// Close live session
|
|
||||||
if (sessionPromiseRef.current) {
|
if (sessionPromiseRef.current) {
|
||||||
sessionPromiseRef.current.then(session => session.close());
|
sessionPromiseRef.current.then(session => session.close());
|
||||||
}
|
}
|
||||||
@@ -107,182 +88,10 @@ SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`;
|
|||||||
console.error("Post-session analysis failure", err);
|
console.error("Post-session analysis failure", err);
|
||||||
onExit();
|
onExit();
|
||||||
}
|
}
|
||||||
};
|
}, [messages, scenario, setStatus, sessionPromiseRef, 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (status === 'connecting') return <ConnectionState />;
|
||||||
|
if (status === 'analyzing') return <AnalyzingState />;
|
||||||
if (status === 'finished' && finalReport) {
|
if (status === 'finished' && finalReport) {
|
||||||
return <SessionReport analysis={finalReport} onExit={onExit} />;
|
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">
|
<div className="flex flex-col lg:flex-row h-full gap-4 relative overflow-hidden">
|
||||||
{/* Main Interaction Area */}
|
{/* Main Interaction Area */}
|
||||||
<div className="flex-grow bg-slate-900 rounded-3xl overflow-hidden flex flex-col relative shadow-2xl border border-white/5">
|
<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 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 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>
|
||||||
|
|
||||||
<div className="p-8 flex justify-between items-center relative z-10">
|
<VoiceHeader
|
||||||
<div className="flex items-center gap-4">
|
title={scenario.title}
|
||||||
<button onClick={onExit} className="p-3 bg-white/5 hover:bg-white/10 rounded-2xl text-white/50 hover:text-white transition-all">
|
onExit={onExit}
|
||||||
<PhoneOff size={20} />
|
onFinish={handleFinishSession}
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="flex-grow flex flex-col items-center justify-center p-12 relative z-10">
|
<div className="flex-grow flex flex-col items-center justify-center p-12 relative z-10">
|
||||||
<AudioVisualizer level={audioLevel} active={true} isAiSpeaking={isAiSpeaking} />
|
{!hasStarted && status === 'active' ? (
|
||||||
|
<div className="flex flex-col items-center animate-in fade-in zoom-in duration-700">
|
||||||
<div className="mt-12 text-center">
|
<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">
|
||||||
<p className={`text-sm transition-all duration-500 font-light ${isAiSpeaking ? 'text-blue-300' : 'text-teal-300'}`}>
|
<div className="absolute inset-0 bg-teal-500/20 rounded-full animate-ping"></div>
|
||||||
{isAiSpeaking ? `Vocalizing Response...` : (isMuted ? 'Microphone Muted' : 'Listening...')}
|
<BrainCircuit size={40} className="text-teal-400 relative z-10" />
|
||||||
</p>
|
</div>
|
||||||
<p className="text-[10px] text-white/30 uppercase font-bold tracking-[0.3em] mt-4">Clinical Fidelity Level: 99.8%</p>
|
<h3 className="text-2xl font-bold text-white mb-2">Neural Link Established</h3>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div className="p-8 flex justify-center gap-6 relative z-10 bg-black/20 backdrop-blur-md">
|
{/* Device Selector Overlay */}
|
||||||
<button
|
{showDeviceSelector && (
|
||||||
onClick={() => setIsMuted(!isMuted)}
|
<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">
|
||||||
className={`p-5 rounded-full transition-all ${isMuted ? 'bg-rose-600 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
<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">
|
||||||
{isMuted ? <MicOff size={24} /> : <Mic size={24} />}
|
<div className="flex items-center gap-3">
|
||||||
</button>
|
<div className="bg-slate-800 p-2 rounded-xl text-white">
|
||||||
<button
|
<Settings2 size={18} />
|
||||||
onClick={() => setShowTranscript(!showTranscript)}
|
</div>
|
||||||
className={`p-5 rounded-full transition-all ${showTranscript ? 'bg-teal-600 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
<h3 className="font-bold text-slate-800">Audio Input</h3>
|
||||||
>
|
</div>
|
||||||
<MessageSquare size={24} />
|
<button onClick={() => setShowDeviceSelector(false)} className="p-2 hover:bg-slate-100 rounded-xl transition-colors text-slate-400">
|
||||||
</button>
|
<X size={20} />
|
||||||
</div>
|
</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)} />}
|
{showTranscript && <TranscriptPanel messages={messages} onClose={() => setShowTranscript(false)} />}
|
||||||
</div>
|
</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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
+24
-10
@@ -112,36 +112,40 @@ export const SCENARIOS: Scenario[] = [
|
|||||||
{
|
{
|
||||||
id: 's1',
|
id: 's1',
|
||||||
title: "The Silent Friend",
|
title: "The Silent Friend",
|
||||||
|
characterName: "Alex",
|
||||||
description: "You sent a text to a close friend hours ago and haven't heard back. You're starting to imagine they're angry with you or that you've done something wrong.",
|
description: "You sent a text to a close friend hours ago and haven't heard back. You're starting to imagine they're angry with you or that you've done something wrong.",
|
||||||
category: 'Relationships',
|
category: 'Relationships',
|
||||||
difficulty: 'Beginner',
|
difficulty: 'Beginner',
|
||||||
systemInstruction: "You are a busy friend who just hasn't had a chance to reply yet. You are friendly and non-confrontational. If asked, you explain you were just preoccupied with chores.",
|
systemInstruction: "You are Alex, a busy friend who just hasn't had a chance to reply yet. You are friendly and non-confrontational. If asked, you explain you were just preoccupied with chores.",
|
||||||
initialMessage: "Hey! Sorry I'm just getting back to you, it's been a crazy day. What's up?",
|
initialMessage: "Hey! Sorry I'm just getting back to you, it's been a crazy day. What's up?",
|
||||||
cbtFocus: ["Mind Reading", "Fortune Telling"]
|
cbtFocus: ["Mind Reading", "Fortune Telling"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's2',
|
id: 's2',
|
||||||
title: "Critical Feedback",
|
title: "Critical Feedback",
|
||||||
|
characterName: "Sarah",
|
||||||
description: "Your manager asks for a quick sync. You immediately assume the worst about your performance and upcoming review.",
|
description: "Your manager asks for a quick sync. You immediately assume the worst about your performance and upcoming review.",
|
||||||
category: 'Workplace',
|
category: 'Workplace',
|
||||||
difficulty: 'Intermediate',
|
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. INTERNAL CBT LOGIC: If the user catastrophizes, reassure them professionally but maintain your feedback.",
|
systemInstruction: "You are Sarah, 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.",
|
initialMessage: "Hi there, thanks for jumping on. I wanted to chat briefly about the Q3 project report.",
|
||||||
cbtFocus: ["Catastrophizing", "All-or-Nothing Thinking"]
|
cbtFocus: ["Catastrophizing", "All-or-Nothing Thinking"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's3',
|
id: 's3',
|
||||||
title: "The Social Spotlight",
|
title: "The Social Spotlight",
|
||||||
|
characterName: "David",
|
||||||
description: "You're at a networking event and you just made a small slip-up in a conversation. You feel like everyone noticed and is judging you.",
|
description: "You're at a networking event and you just made a small slip-up in a conversation. You feel like everyone noticed and is judging you.",
|
||||||
category: 'Social Anxiety',
|
category: 'Social Anxiety',
|
||||||
difficulty: 'Advanced',
|
difficulty: 'Advanced',
|
||||||
systemInstruction: "You are an attendee at a networking event. You are polite and didn't even notice the user's minor slip-up. You are focused on professional exchange.",
|
systemInstruction: "You are David, an attendee at a networking event. You are polite and didn't even notice the user's minor slip-up. You are focused on professional exchange.",
|
||||||
initialMessage: "That's an interesting point about the industry trends. So, how did you get started in this field?",
|
initialMessage: "That's an interesting point about the industry trends. So, how did you get started in this field?",
|
||||||
cbtFocus: ["Spotlight Effect", "Emotional Reasoning"]
|
cbtFocus: ["Spotlight Effect", "Emotional Reasoning"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's4',
|
id: 's4',
|
||||||
title: "Inner Critic Echo",
|
title: "Inner Critic Echo",
|
||||||
|
characterName: "The Critic",
|
||||||
description: "A minor mistake at home triggers a spiral of self-deprecating thoughts. Practice internal cognitive restructuring by talking back to your personified inner critic.",
|
description: "A minor mistake at home triggers a spiral of self-deprecating thoughts. Practice internal cognitive restructuring by talking back to your personified inner critic.",
|
||||||
category: 'Self-Talk',
|
category: 'Self-Talk',
|
||||||
difficulty: 'Beginner',
|
difficulty: 'Beginner',
|
||||||
@@ -152,6 +156,7 @@ export const SCENARIOS: Scenario[] = [
|
|||||||
{
|
{
|
||||||
id: 's5',
|
id: 's5',
|
||||||
title: "Booking an Appointment",
|
title: "Booking an Appointment",
|
||||||
|
characterName: "Receptionist",
|
||||||
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.",
|
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',
|
category: 'Social Anxiety',
|
||||||
difficulty: 'Beginner',
|
difficulty: 'Beginner',
|
||||||
@@ -162,6 +167,7 @@ export const SCENARIOS: Scenario[] = [
|
|||||||
{
|
{
|
||||||
id: 's6',
|
id: 's6',
|
||||||
title: "The Defective Return",
|
title: "The Defective Return",
|
||||||
|
characterName: "Store Clerk",
|
||||||
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.",
|
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',
|
category: 'Social Anxiety',
|
||||||
difficulty: 'Intermediate',
|
difficulty: 'Intermediate',
|
||||||
@@ -172,76 +178,84 @@ export const SCENARIOS: Scenario[] = [
|
|||||||
{
|
{
|
||||||
id: 's7',
|
id: 's7',
|
||||||
title: "The Unpaid Debt",
|
title: "The Unpaid Debt",
|
||||||
|
characterName: "Jordan",
|
||||||
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.",
|
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',
|
category: 'Relationships',
|
||||||
difficulty: 'Advanced',
|
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.",
|
systemInstruction: "You are Jordan, 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?",
|
initialMessage: "Hey! It was so good to see you at brunch last week. What's on your mind today?",
|
||||||
cbtFocus: ["Maladaptive Guilt", "Should Statements"]
|
cbtFocus: ["Maladaptive Guilt", "Should Statements"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's8',
|
id: 's8',
|
||||||
title: "The Salary Discussion",
|
title: "The Salary Discussion",
|
||||||
|
characterName: "Mr. Henderson",
|
||||||
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'.",
|
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',
|
category: 'Workplace',
|
||||||
difficulty: 'Advanced',
|
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.",
|
systemInstruction: "You are Mr. Henderson, 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?",
|
initialMessage: "Thanks for coming in. I know you wanted to discuss your compensation. What's your proposal?",
|
||||||
cbtFocus: ["Should Statements", "All-or-Nothing Thinking"]
|
cbtFocus: ["Should Statements", "All-or-Nothing Thinking"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's9',
|
id: 's9',
|
||||||
title: "The Deadline Slip",
|
title: "The Deadline Slip",
|
||||||
|
characterName: "Marcus",
|
||||||
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.",
|
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',
|
category: 'Workplace',
|
||||||
difficulty: 'Intermediate',
|
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.",
|
systemInstruction: "You are Marcus, 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?",
|
initialMessage: "The client is asking where the Q1 projection is. We missed the 9 AM cutoff. What happened?",
|
||||||
cbtFocus: ["Labeling", "Overgeneralization"]
|
cbtFocus: ["Labeling", "Overgeneralization"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's10',
|
id: 's10',
|
||||||
title: "The Social 'No'",
|
title: "The Social 'No'",
|
||||||
|
characterName: "Chloe",
|
||||||
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.",
|
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',
|
category: 'Social Anxiety',
|
||||||
difficulty: 'Intermediate',
|
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.",
|
systemInstruction: "You are Chloe, 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!",
|
initialMessage: "You are definitely coming to Sarah's party tonight, right? Everyone is going to be there!",
|
||||||
cbtFocus: ["Mind Reading", "Fortune Telling"]
|
cbtFocus: ["Mind Reading", "Fortune Telling"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's11',
|
id: 's11',
|
||||||
title: "Intrusive Relative",
|
title: "Intrusive Relative",
|
||||||
|
characterName: "Aunt Martha",
|
||||||
description: "A relative is making critical comments about your career choices during a family dinner. Practice setting a boundary without spiraling into anger.",
|
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',
|
category: 'Relationships',
|
||||||
difficulty: 'Advanced',
|
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.",
|
systemInstruction: "You are Aunt Martha, a critical, traditional-minded relative. 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?",
|
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"]
|
cbtFocus: ["Healthy Boundaries", "Gray Rock Method"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's12',
|
id: 's12',
|
||||||
title: "The Job Interview",
|
title: "The Job Interview",
|
||||||
|
characterName: "Ms. Sterling",
|
||||||
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.",
|
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',
|
category: 'Social Anxiety',
|
||||||
difficulty: 'Advanced',
|
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.",
|
systemInstruction: "You are Ms. Sterling, 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?",
|
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"]
|
cbtFocus: ["Fortune Telling", "Catastrophizing"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's13',
|
id: 's13',
|
||||||
title: "The Friend's Criticism",
|
title: "The Friend's Criticism",
|
||||||
|
characterName: "Leo",
|
||||||
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.",
|
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',
|
category: 'Relationships',
|
||||||
difficulty: 'Intermediate',
|
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.",
|
systemInstruction: "You are Leo, 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.",
|
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"]
|
cbtFocus: ["Personalization", "Emotional Reasoning"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's14',
|
id: 's14',
|
||||||
title: "The Unexpected Health Symptom",
|
title: "The Unexpected Health Symptom",
|
||||||
|
characterName: "Anxiety",
|
||||||
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.",
|
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',
|
category: 'Self-Talk',
|
||||||
difficulty: 'Advanced',
|
difficulty: 'Advanced',
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { GoogleGenAI, LiveServerMessage, Modality } from '@google/genai';
|
||||||
|
import { Scenario, UserSettings, Message, AnalysisResult } from '../types';
|
||||||
|
import { SYSTEM_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
|
||||||
|
import { floatTo16BitPCM, encodeBase64, decodeBase64, decodeAudioData } from '../services/audioUtils';
|
||||||
|
|
||||||
|
export type SessionStatus = 'connecting' | 'active' | 'analyzing' | 'finished';
|
||||||
|
|
||||||
|
export function useLiveSession(scenario: Scenario, voiceSettings: UserSettings) {
|
||||||
|
const [status, setStatus] = useState<SessionStatus>('connecting');
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
const [aiAudioLevel, setAiAudioLevel] = useState(0);
|
||||||
|
const [isAiSpeaking, setIsAiSpeaking] = useState(false);
|
||||||
|
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [isSilenceDetected, setIsSilenceDetected] = useState(false);
|
||||||
|
|
||||||
|
// Insights state
|
||||||
|
const [liveInsight, setLiveInsight] = useState<AnalysisResult | null>(null);
|
||||||
|
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
|
||||||
|
|
||||||
|
// Refs for audio processing
|
||||||
|
const sessionPromiseRef = useRef<Promise<any> | null>(null);
|
||||||
|
const inputAudioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const outputAudioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const aiAnalyserRef = useRef<AnalyserNode | 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 silenceCheckTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const aiLevelUpdateTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Buffers for transcription
|
||||||
|
const currentInputTranscription = useRef('');
|
||||||
|
const currentOutputTranscription = useRef('');
|
||||||
|
|
||||||
|
const stopAllAudio = useCallback(() => {
|
||||||
|
audioSourcesRef.current.forEach(source => {
|
||||||
|
try { source.stop(); } catch (e) {}
|
||||||
|
});
|
||||||
|
audioSourcesRef.current.clear();
|
||||||
|
nextStartTimeRef.current = 0;
|
||||||
|
setAiAudioLevel(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => setIsMuted(prev => !prev), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||||
|
|
||||||
|
// Setup Live Insights Chat
|
||||||
|
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 updateAiLevel = () => {
|
||||||
|
if (aiAnalyserRef.current && isAiSpeaking) {
|
||||||
|
const dataArray = new Uint8Array(aiAnalyserRef.current.frequencyBinCount);
|
||||||
|
aiAnalyserRef.current.getByteFrequencyData(dataArray);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
|
||||||
|
const avg = sum / dataArray.length;
|
||||||
|
setAiAudioLevel(avg * 0.8); // Scale for visualizer
|
||||||
|
} else {
|
||||||
|
setAiAudioLevel(0);
|
||||||
|
}
|
||||||
|
aiLevelUpdateTimerRef.current = requestAnimationFrame(updateAiLevel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
inputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
|
||||||
|
outputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
|
||||||
|
|
||||||
|
aiAnalyserRef.current = outputAudioCtxRef.current.createAnalyser();
|
||||||
|
aiAnalyserRef.current.fftSize = 256;
|
||||||
|
aiAnalyserRef.current.connect(outputAudioCtxRef.current.destination);
|
||||||
|
|
||||||
|
updateAiLevel();
|
||||||
|
|
||||||
|
const constraints: MediaStreamConstraints = {
|
||||||
|
audio: voiceSettings.preferredAudioDeviceId
|
||||||
|
? { deviceId: { exact: voiceSettings.preferredAudioDeviceId } }
|
||||||
|
: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
// Silence detection logic: If after 5 seconds of active session,
|
||||||
|
// the max audio level seen is very low, flag it.
|
||||||
|
let maxLevelSeen = 0;
|
||||||
|
silenceCheckTimerRef.current = setTimeout(() => {
|
||||||
|
if (maxLevelSeen < 1.0) { // Threshold for silence
|
||||||
|
setIsSilenceDetected(true);
|
||||||
|
}
|
||||||
|
}, 6000);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Volume calculation
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < inputData.length; i++) sum += inputData[i] * inputData[i];
|
||||||
|
const level = Math.sqrt(sum / inputData.length) * 100;
|
||||||
|
setAudioLevel(level);
|
||||||
|
setIsUserSpeaking(level > 2.0);
|
||||||
|
|
||||||
|
if (level > maxLevelSeen) maxLevelSeen = level;
|
||||||
|
if (level > 2.0 && isSilenceDetected) setIsSilenceDetected(false);
|
||||||
|
|
||||||
|
if (isMuted) return;
|
||||||
|
|
||||||
|
const pcm16 = floatTo16BitPCM(inputData);
|
||||||
|
const base64 = encodeBase64(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) => {
|
||||||
|
// 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) {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', text: userText, timestamp: new Date() }]);
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(decodeBase64(audioBase64), audioCtx, 24000, 1);
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(aiAnalyserRef.current!);
|
||||||
|
|
||||||
|
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.debug("Session closed"),
|
||||||
|
onerror: (e) => console.error("Session error", e)
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
responseModalities: [Modality.AUDIO],
|
||||||
|
speechConfig: {
|
||||||
|
voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceSettings.voiceName } }
|
||||||
|
},
|
||||||
|
systemInstruction: `
|
||||||
|
${scenario.systemInstruction}
|
||||||
|
|
||||||
|
VOCAL REALISM PROTOCOL:
|
||||||
|
- Speak with natural human patterns (fillers like "um", "uh", "well...").
|
||||||
|
- Use brief hesitations when thinking or emotionally activated.
|
||||||
|
- Vary pace based on character emotion.
|
||||||
|
- Strictly in character; no clinical meta-feedback.
|
||||||
|
`,
|
||||||
|
inputAudioTranscription: {},
|
||||||
|
outputAudioTranscription: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionPromiseRef.current = sessionPromise;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Connect failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopAllAudio();
|
||||||
|
if (silenceCheckTimerRef.current) clearTimeout(silenceCheckTimerRef.current);
|
||||||
|
if (aiLevelUpdateTimerRef.current) cancelAnimationFrame(aiLevelUpdateTimerRef.current);
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
setStatus,
|
||||||
|
isMuted,
|
||||||
|
toggleMute,
|
||||||
|
audioLevel,
|
||||||
|
aiAudioLevel,
|
||||||
|
isAiSpeaking,
|
||||||
|
isUserSpeaking,
|
||||||
|
messages,
|
||||||
|
liveInsight,
|
||||||
|
isAnalyzingTurn,
|
||||||
|
isSilenceDetected,
|
||||||
|
sessionPromiseRef,
|
||||||
|
startSession: () => {
|
||||||
|
if (sessionPromiseRef.current) {
|
||||||
|
sessionPromiseRef.current.then(session => {
|
||||||
|
// We send a "hidden" text input to the model to trigger its initial message
|
||||||
|
// The Live API supports sending text turns in the clientContent
|
||||||
|
session.send({
|
||||||
|
clientContent: {
|
||||||
|
turns: [{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: "Please start the scenario now. Greet me as your character." }]
|
||||||
|
}],
|
||||||
|
turnComplete: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-1
@@ -31,8 +31,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<link rel="stylesheet" href="/index.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
<script type="module" src="/index.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
|
|||||||
+4
-11
@@ -1,4 +1,3 @@
|
|||||||
import { LiveServerMessage, Modality } from "@google/genai";
|
|
||||||
|
|
||||||
// Convert Float32Array (Web Audio API) to PCM Int16 (Gemini API requirement)
|
// Convert Float32Array (Web Audio API) to PCM Int16 (Gemini API requirement)
|
||||||
export function floatTo16BitPCM(float32Array: Float32Array): ArrayBuffer {
|
export function floatTo16BitPCM(float32Array: Float32Array): ArrayBuffer {
|
||||||
@@ -14,10 +13,9 @@ export function floatTo16BitPCM(float32Array: Float32Array): ArrayBuffer {
|
|||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base64 encoding for the payload
|
// Manual encoding helper as per guidelines
|
||||||
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
export function encodeBase64(bytes: Uint8Array): string {
|
||||||
let binary = '';
|
let binary = '';
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const len = bytes.byteLength;
|
const len = bytes.byteLength;
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
binary += String.fromCharCode(bytes[i]);
|
binary += String.fromCharCode(bytes[i]);
|
||||||
@@ -25,8 +23,8 @@ export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|||||||
return btoa(binary);
|
return btoa(binary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert Base64 string back to Uint8Array
|
// Manual decoding helper as per guidelines
|
||||||
export function base64ToUint8Array(base64: string): Uint8Array {
|
export function decodeBase64(base64: string): Uint8Array {
|
||||||
const binaryString = atob(base64);
|
const binaryString = atob(base64);
|
||||||
const len = binaryString.length;
|
const len = binaryString.length;
|
||||||
const bytes = new Uint8Array(len);
|
const bytes = new Uint8Array(len);
|
||||||
@@ -45,20 +43,15 @@ export async function decodeAudioData(
|
|||||||
): Promise<AudioBuffer> {
|
): Promise<AudioBuffer> {
|
||||||
// Ensure the byte length is even for Int16Array
|
// Ensure the byte length is even for Int16Array
|
||||||
if (data.byteLength % 2 !== 0) {
|
if (data.byteLength % 2 !== 0) {
|
||||||
console.warn("Received odd byte length for PCM16 audio, trimming last byte.");
|
|
||||||
data = data.subarray(0, data.byteLength - 1);
|
data = data.subarray(0, data.byteLength - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the underlying buffer, but handle offsets if the Uint8Array is a view
|
|
||||||
const dataInt16 = new Int16Array(data.buffer, data.byteOffset, data.byteLength / 2);
|
const dataInt16 = new Int16Array(data.buffer, data.byteOffset, data.byteLength / 2);
|
||||||
|
|
||||||
const buffer = ctx.createBuffer(numChannels, dataInt16.length / numChannels, sampleRate);
|
const buffer = ctx.createBuffer(numChannels, dataInt16.length / numChannels, sampleRate);
|
||||||
|
|
||||||
for (let channel = 0; channel < numChannels; channel++) {
|
for (let channel = 0; channel < numChannels; channel++) {
|
||||||
const channelData = buffer.getChannelData(channel);
|
const channelData = buffer.getChannelData(channel);
|
||||||
// Determine step size if multiple channels (usually 1 for this API)
|
|
||||||
for (let i = 0; i < dataInt16.length; i++) {
|
for (let i = 0; i < dataInt16.length; i++) {
|
||||||
// Normalize Int16 to Float32 [-1.0, 1.0]
|
|
||||||
channelData[i] = dataInt16[i] / 32768.0;
|
channelData[i] = dataInt16[i] / 32768.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export interface Scenario {
|
|||||||
systemInstruction: string;
|
systemInstruction: string;
|
||||||
initialMessage: string;
|
initialMessage: string;
|
||||||
cbtFocus: string[];
|
cbtFocus: string[];
|
||||||
|
characterName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSettings {
|
export interface UserSettings {
|
||||||
voiceName: 'Puck' | 'Charon' | 'Kore' | 'Fenrir' | 'Zephyr';
|
voiceName: 'Puck' | 'Charon' | 'Kore' | 'Fenrir' | 'Zephyr';
|
||||||
vocalStyle: string;
|
vocalStyle: string;
|
||||||
genderPreference: 'Male' | 'Female' | 'Neutral';
|
genderPreference: 'Male' | 'Female' | 'Neutral';
|
||||||
|
preferredAudioDeviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|||||||
Reference in new issue
Block a user