- 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
208 lines
7.7 KiB
Svelte
208 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import type { Scenario, UserSettings } from '../../../types';
|
|
import { CBT_GLOSSARY } from '../../../constants';
|
|
import {
|
|
Sparkles,
|
|
X,
|
|
Loader2,
|
|
AlertCircle,
|
|
Target,
|
|
BrainCircuit,
|
|
Check,
|
|
BarChart
|
|
} from '@lucide/svelte';
|
|
import Button from './ui/Button.svelte';
|
|
import Textarea from './ui/Textarea.svelte';
|
|
import ScrollArea from './ui/ScrollArea.svelte';
|
|
import Card from './ui/Card.svelte';
|
|
import Badge from './ui/Badge.svelte';
|
|
import { generateScenarioWithOpenRouter } from '../../../services/openRouterService';
|
|
|
|
interface Props {
|
|
userSettings?: UserSettings;
|
|
ongenerate: (scenario: Scenario) => void;
|
|
oncancel: () => void;
|
|
}
|
|
|
|
let { userSettings, ongenerate, oncancel }: Props = $props();
|
|
|
|
let prompt = $state('');
|
|
let selectedConcepts = $state<string[]>([]);
|
|
let selectedDifficulty = $state<Scenario['difficulty']>('Intermediate');
|
|
let isLoading = $state(false);
|
|
let error = $state<string | null>(null);
|
|
|
|
function toggleConcept(term: string) {
|
|
if (selectedConcepts.includes(term)) {
|
|
selectedConcepts = selectedConcepts.filter((t) => t !== term);
|
|
} else {
|
|
selectedConcepts = [...selectedConcepts, term];
|
|
}
|
|
}
|
|
|
|
async function handleGenerate() {
|
|
if (!prompt.trim() && selectedConcepts.length === 0) return;
|
|
|
|
isLoading = true;
|
|
error = 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);
|
|
error =
|
|
err?.message ||
|
|
'Clinical scenario synthesis failed. Please check your OpenRouter API Key in Settings.';
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="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 class="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 class="p-6 bg-slate-50 border-b border-slate-200 flex justify-between items-center shrink-0">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-teal-600 p-2.5 rounded-xl text-white shadow-md shadow-teal-600/20">
|
|
<Sparkles size={20} />
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-bold text-slate-800">Clinical Scenario Architect</h2>
|
|
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest">
|
|
AI Synthesis Engine • OpenRouter
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={oncancel}
|
|
class="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 rounded-xl transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<ScrollArea class="p-8 space-y-8 flex-grow">
|
|
<!-- Natural Language Prompt -->
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<label class="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
|
<Target size={14} class="text-teal-600" />
|
|
Target Dynamic & Situation
|
|
</label>
|
|
<span class="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..."
|
|
bind:value={prompt}
|
|
class="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 class="space-y-3 pt-4">
|
|
<span class="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
|
<BarChart size={14} class="text-teal-600" />
|
|
Emotional Resistance & Difficulty
|
|
</span>
|
|
<div class="grid grid-cols-3 gap-3">
|
|
{#each ['Beginner', 'Intermediate', 'Advanced'] as diff (diff)}
|
|
<button
|
|
type="button"
|
|
onclick={() => (selectedDifficulty = diff as Scenario['difficulty'])}
|
|
class="p-3.5 text-center cursor-pointer transition-all border rounded-xl {selectedDifficulty ===
|
|
diff
|
|
? 'bg-teal-50 border-teal-500 shadow-sm shadow-teal-500/10'
|
|
: 'bg-white border-slate-200 hover:border-slate-300'}"
|
|
>
|
|
<p class="font-bold text-xs text-slate-800">{diff}</p>
|
|
<p class="text-[10px] text-slate-400 mt-0.5">
|
|
{diff === 'Beginner' ? 'High Support' : ''}
|
|
{diff === 'Intermediate' ? 'Realistic Pushback' : ''}
|
|
{diff === 'Advanced' ? 'Severe Friction' : ''}
|
|
</p>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Clinical Focus Selector -->
|
|
<div class="space-y-3 pt-4">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
|
<BrainCircuit size={14} class="text-teal-600" />
|
|
Target Cognitive Distortions & Techniques
|
|
</span>
|
|
<span class="text-[10px] text-teal-600 font-bold">
|
|
{selectedConcepts.length} Selected
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 max-h-44 overflow-y-auto p-1 bg-slate-50 rounded-2xl border border-slate-100">
|
|
{#each CBT_GLOSSARY as item (item.term)}
|
|
{@const isSelected = selectedConcepts.includes(item.term)}
|
|
<button
|
|
type="button"
|
|
onclick={() => toggleConcept(item.term)}
|
|
class="cursor-pointer text-[11px] py-1.5 px-3 rounded-xl transition-all border inline-flex items-center gap-1.5 {isSelected
|
|
? 'bg-teal-600 hover:bg-teal-700 text-white font-bold shadow-sm border-teal-600'
|
|
: 'bg-white text-slate-600 hover:border-teal-300 border-slate-200'}"
|
|
>
|
|
{item.term}
|
|
{#if isSelected}
|
|
<Check size={12} class="inline" />
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
{#if error}
|
|
<div class="mt-4 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} class="shrink-0 text-rose-500" />
|
|
<span>{error}</span>
|
|
</div>
|
|
{/if}
|
|
</ScrollArea>
|
|
|
|
<!-- Footer Actions -->
|
|
<div class="p-6 bg-slate-50 border-t border-slate-200 flex justify-between items-center shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
onclick={oncancel}
|
|
disabled={isLoading}
|
|
class="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)}
|
|
class="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"
|
|
>
|
|
{#if isLoading}
|
|
<Loader2 size={16} class="animate-spin" />
|
|
Synthesizing Scenario...
|
|
{:else}
|
|
<Sparkles size={16} />
|
|
Generate Simulation
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|