- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer - Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support - Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline - Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots) - Migrate core application to Svelte 5 runes (, , ) with reactive stores - Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
204 lines
8.5 KiB
TypeScript
204 lines
8.5 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Scenario, UserSettings } from '../types';
|
|
import { CBT_GLOSSARY } from '../constants';
|
|
import { Sparkles, X, Loader2, AlertCircle, Info, Target, UserCircle2, BrainCircuit, Check, BarChart } from 'lucide-react';
|
|
import { Button } from './ui/button';
|
|
import { Textarea } from './ui/textarea';
|
|
import { ScrollArea } from './ui/scroll-area';
|
|
import { Card } from './ui/card';
|
|
import { Badge } from './ui/badge';
|
|
import { generateScenarioWithOpenRouter } from '../services/openRouterService';
|
|
|
|
interface ScenarioGeneratorProps {
|
|
onGenerate: (scenario: Scenario) => void;
|
|
onCancel: () => void;
|
|
userSettings?: UserSettings;
|
|
}
|
|
|
|
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel, userSettings }) => {
|
|
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);
|
|
|
|
const toggleConcept = (term: string) => {
|
|
setSelectedConcepts(prev =>
|
|
prev.includes(term) ? prev.filter(t => t !== term) : [...prev, term]
|
|
);
|
|
};
|
|
|
|
const handleGenerate = async () => {
|
|
if (!prompt.trim() && selectedConcepts.length === 0) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const scenario = await generateScenarioWithOpenRouter(
|
|
prompt,
|
|
selectedConcepts,
|
|
selectedDifficulty,
|
|
{
|
|
apiKey: userSettings?.openRouterApiKey,
|
|
model: userSettings?.roleplayModel
|
|
}
|
|
);
|
|
|
|
scenario.difficulty = selectedDifficulty;
|
|
scenario.id = `custom-${Date.now()}-${scenario.id || 'generated'}`;
|
|
onGenerate(scenario);
|
|
} catch (err: any) {
|
|
console.error("OpenRouter Scenario Generation error:", err);
|
|
setError(err?.message || "Clinical scenario synthesis failed. Please check your OpenRouter API Key in Settings.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md animate-in fade-in duration-300">
|
|
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden border border-slate-100 flex flex-col max-h-[90vh]">
|
|
{/* Header */}
|
|
<div className="p-6 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
|
|
<div className="flex items-center gap-3">
|
|
<div className="bg-teal-600 p-2.5 rounded-xl text-white shadow-md shadow-teal-600/20">
|
|
<Sparkles size={20} />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-bold text-slate-800">Clinical Scenario Architect</h2>
|
|
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">
|
|
AI Synthesis Engine • OpenRouter
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onCancel}
|
|
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 rounded-xl transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<ScrollArea className="p-8 space-y-8 flex-grow">
|
|
{/* Natural Language Prompt */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
|
<Target size={14} className="text-teal-600" />
|
|
Target Dynamic & Situation
|
|
</label>
|
|
<span className="text-[10px] text-slate-400">Describe the conflict or trigger</span>
|
|
</div>
|
|
<Textarea
|
|
placeholder="e.g. Asking my manager for a delayed performance review while fearing I'll be fired for bringing it up..."
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
className="min-h-[100px] text-sm bg-slate-50/50 border-slate-200 rounded-2xl resize-none focus:ring-teal-500 focus:border-teal-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Difficulty Tuning */}
|
|
<div className="space-y-3">
|
|
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
|
<BarChart size={14} className="text-teal-600" />
|
|
Emotional Resistance & Difficulty
|
|
</label>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{(['Beginner', 'Intermediate', 'Advanced'] as const).map((diff) => (
|
|
<Card
|
|
key={diff}
|
|
onClick={() => setSelectedDifficulty(diff)}
|
|
className={`p-3.5 text-center cursor-pointer transition-all border ${selectedDifficulty === diff
|
|
? 'bg-teal-50 border-teal-500 shadow-sm shadow-teal-500/10'
|
|
: 'bg-white border-slate-200 hover:border-slate-300'
|
|
}`}
|
|
>
|
|
<p className="font-bold text-xs text-slate-800">{diff}</p>
|
|
<p className="text-[10px] text-slate-400 mt-0.5">
|
|
{diff === 'Beginner' && 'High Support'}
|
|
{diff === 'Intermediate' && 'Realistic Pushback'}
|
|
{diff === 'Advanced' && 'Severe Friction'}
|
|
</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Clinical Focus Selector */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
|
<BrainCircuit size={14} className="text-teal-600" />
|
|
Target Cognitive Distortions & Techniques
|
|
</label>
|
|
<span className="text-[10px] text-teal-600 font-bold">
|
|
{selectedConcepts.length} Selected
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 max-h-44 overflow-y-auto p-1 bg-slate-50 rounded-2xl border border-slate-100">
|
|
{CBT_GLOSSARY.map((item) => {
|
|
const isSelected = selectedConcepts.includes(item.term);
|
|
return (
|
|
<Badge
|
|
key={item.term}
|
|
onClick={() => toggleConcept(item.term)}
|
|
variant={isSelected ? "default" : "outline"}
|
|
className={`cursor-pointer text-[11px] py-1.5 px-3 rounded-xl transition-all ${isSelected
|
|
? 'bg-teal-600 hover:bg-teal-700 text-white font-bold shadow-sm'
|
|
: 'bg-white text-slate-600 hover:border-teal-300'
|
|
}`}
|
|
>
|
|
{item.term}
|
|
{isSelected && <Check size={12} className="ml-1.5 inline" />}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="p-4 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-3 text-rose-700 text-xs animate-in fade-in">
|
|
<AlertCircle size={16} className="shrink-0 text-rose-500" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
|
|
{/* Footer Actions */}
|
|
<div className="p-6 bg-slate-50 border-t border-slate-200 flex justify-between items-center">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onCancel}
|
|
disabled={isLoading}
|
|
className="text-xs uppercase tracking-widest font-black text-slate-400 hover:text-slate-600"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleGenerate}
|
|
disabled={isLoading || (!prompt.trim() && selectedConcepts.length === 0)}
|
|
className="bg-teal-600 hover:bg-teal-700 text-white px-8 h-11 rounded-2xl text-xs uppercase tracking-widest font-black shadow-lg shadow-teal-600/20 flex items-center gap-2"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 size={16} className="animate-spin" />
|
|
Synthesizing Scenario...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Sparkles size={16} />
|
|
Generate Simulation
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ScenarioGenerator;
|