feat(ui): redesign voice cockpit with clinical companion drawer, Svelte 5 migration, and Gemini 3.8 voice
- 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
This commit is contained in:
1 parent
c82b112624
commit
1feea40684
148 files changed
+11230
-1706
No files matched your search
+130
-207
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { Scenario } from '../types';
|
||||
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';
|
||||
@@ -8,13 +7,15 @@ 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 }) => {
|
||||
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel, userSettings }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]);
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState<Scenario['difficulty']>('Intermediate');
|
||||
@@ -34,240 +35,162 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY });
|
||||
|
||||
const schema = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
id: { type: Type.STRING },
|
||||
title: { type: Type.STRING },
|
||||
characterName: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
category: {
|
||||
type: Type.STRING,
|
||||
enum: ['Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk']
|
||||
},
|
||||
difficulty: {
|
||||
type: Type.STRING,
|
||||
enum: ['Beginner', 'Intermediate', 'Advanced']
|
||||
},
|
||||
systemInstruction: { type: Type.STRING },
|
||||
initialMessage: { type: Type.STRING },
|
||||
cbtFocus: {
|
||||
type: Type.ARRAY,
|
||||
items: { type: Type.STRING }
|
||||
}
|
||||
},
|
||||
required: ['id', 'title', 'characterName', 'description', 'category', 'difficulty', 'systemInstruction', 'initialMessage', 'cbtFocus']
|
||||
};
|
||||
|
||||
const selectedContext = selectedConcepts.length > 0
|
||||
? `Focus specifically on these CBT concepts: ${selectedConcepts.join(', ')}.`
|
||||
: '';
|
||||
|
||||
const result = await ai.models.generateContent({
|
||||
model: 'gemini-3-flash-preview',
|
||||
config: {
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: schema,
|
||||
systemInstruction: `
|
||||
[CONTEXT]
|
||||
You are a Clinical CBT Scenario Architect.
|
||||
The user requires a structured clinical simulation designed to address specific cognitive distortions and behavioral challenges.
|
||||
Clinical Focus: ${selectedContext}
|
||||
|
||||
[TASK]
|
||||
Transform the user's prompt into a cohesive, structured clinical scenario that sets up a targeted roleplay environment.
|
||||
|
||||
[CONSTRAINTS]
|
||||
- CATEGORIZATION: You MUST categorize the scenario appropriately (Social Anxiety, Workplace, Relationships, or Self-Talk).
|
||||
- DIFFICULTY CALIBRATION: The requested difficulty is '${selectedDifficulty}'.
|
||||
- 'Beginner': Low stakes, high support, clear-cut distortions, AI character is cooperative.
|
||||
- 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions, AI character has specific needs.
|
||||
- 'Advanced': High emotional triggers, persistent clinical resistance, complex cognitive patterns.
|
||||
- INTERNAL LOGIC: Provide a detailed "INTERNAL CBT LOGIC" section instructing the AI character how to react to the user's progress or cognitive lapses during the future roleplay.
|
||||
- CHARACTER VOICE: Define a realistic, emotionally consistent voice profile.
|
||||
|
||||
[OUTPUT REQUIREMENTS]
|
||||
Return ONLY a valid JSON object conforming exactly to the requested schema. Ensure all arrays and nested objects are populated correctly.
|
||||
`
|
||||
},
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: `Generate a clinical scenario for: ${prompt || 'A scenario exploring the chosen CBT concepts.'}` }] }
|
||||
]
|
||||
});
|
||||
|
||||
if (result.text) {
|
||||
const scenario = JSON.parse(result.text) as Scenario;
|
||||
// Enforce the user-selected difficulty in the final object
|
||||
scenario.difficulty = selectedDifficulty;
|
||||
scenario.id = `custom-${Date.now()}-${scenario.id}`;
|
||||
onGenerate(scenario);
|
||||
} else {
|
||||
throw new Error("No content generated");
|
||||
}
|
||||
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("Generation error:", err);
|
||||
setError("Clinical scenario synthesis failed. Please refine your parameters.");
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const getConceptVariant = (category: string, isSelected: boolean) => {
|
||||
if (!isSelected) return 'outline';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getConceptColorClass = (category: string, isSelected: boolean) => {
|
||||
if (!isSelected) return 'text-slate-500 border-slate-200 hover:border-slate-300';
|
||||
switch (category) {
|
||||
case 'Distortion': return 'bg-rose-600 border-rose-600 hover:bg-rose-700 shadow-md shadow-rose-100';
|
||||
case 'Technique': return 'bg-teal-600 border-teal-600 hover:bg-teal-700 shadow-md shadow-teal-100';
|
||||
case 'Concept': return 'bg-amber-600 border-amber-600 hover:bg-amber-700 shadow-md shadow-amber-100';
|
||||
default: return 'bg-slate-800 border-slate-800 hover:bg-slate-900';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md overflow-y-auto">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-300 border border-slate-100 my-8 flex flex-col max-h-[90vh]">
|
||||
<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-teal-600 text-white flex justify-between items-start relative overflow-hidden shrink-0">
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||
<Sparkles size={28} className="text-teal-200" />
|
||||
Scenario Synthesis
|
||||
</h2>
|
||||
<p className="text-teal-50 text-sm mt-1 font-light opacity-90">
|
||||
Construct an evidence-based clinical environment.
|
||||
</p>
|
||||
<div 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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="relative z-10 text-white/70 hover:text-white transition-colors bg-white/10 hover:bg-white/20 border border-white/10"
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-10 -mt-10 blur-2xl"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-grow p-8 bg-[#FCFDFF]">
|
||||
<div className="space-y-6 pb-4">
|
||||
{/* Prompting Guide */}
|
||||
<Card className="bg-teal-50/50 border-teal-100 p-4 shadow-none">
|
||||
<div className="flex items-center gap-2 text-teal-700 mb-3">
|
||||
<Info size={16} />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider">Clinical Blueprint</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<UserCircle2 size={12} /> Character Role
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Define who the AI plays.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<Target size={12} /> The Challenge
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Describe the social friction.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<BrainCircuit size={12} /> CBT Focus
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Target specific distortions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Difficulty Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Communication Difficulty
|
||||
<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>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['Beginner', 'Intermediate', 'Advanced'] as const).map((level) => (
|
||||
<Button
|
||||
key={level}
|
||||
variant={selectedDifficulty === level ? "default" : "outline"}
|
||||
onClick={() => setSelectedDifficulty(level)}
|
||||
className={`h-12 flex items-center justify-center gap-2 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 hover:bg-teal-100 hover:text-teal-800'
|
||||
: 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<BarChart size={14} className={selectedDifficulty === level ? 'text-teal-600' : 'text-slate-400'} />
|
||||
{level}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{item.term}
|
||||
{isSelected && <Check size={12} className="ml-1.5 inline" />}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Clinical Targets (Selection) */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Clinical Targets (Optional)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CBT_GLOSSARY.map(concept => {
|
||||
const isSelected = selectedConcepts.includes(concept.term);
|
||||
return (
|
||||
<Badge
|
||||
key={concept.term}
|
||||
variant={getConceptVariant(concept.category, isSelected) as any}
|
||||
onClick={() => toggleConcept(concept.term)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold border transition-all flex items-center gap-1.5 cursor-pointer hover:opacity-90 ${getConceptColorClass(concept.category, isSelected)}`}
|
||||
>
|
||||
{isSelected && <Check size={10} />}
|
||||
{concept.term}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">
|
||||
Simulation Context / Custom Instructions
|
||||
</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g. Practicing assertiveness with a pushy salesperson who won't take 'no' for an answer."
|
||||
className="w-full h-28 p-5 border-slate-200 rounded-2xl focus-visible:ring-teal-500 resize-none text-slate-800 placeholder:text-slate-400 text-sm leading-relaxed shadow-inner bg-white"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-rose-50 text-rose-600 p-4 rounded-xl text-xs flex items-center gap-3 border border-rose-100 font-medium">
|
||||
<AlertCircle size={18} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end shrink-0">
|
||||
{/* 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={(!prompt.trim() && selectedConcepts.length === 0) || isLoading}
|
||||
className="bg-teal-600 text-white h-12 px-8 rounded-2xl font-bold hover:bg-teal-700 flex items-center gap-3 transition-all shadow-lg shadow-teal-100 text-sm uppercase tracking-widest"
|
||||
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={20} className="animate-spin" />
|
||||
Synthesizing...
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Synthesizing Scenario...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={20} />
|
||||
Initiate Build
|
||||
<Sparkles size={16} />
|
||||
Generate Simulation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user