Files
clarity-helper/src/lib/components/ScenarioGenerator.svelte
T
jpremill 1feea40684 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
2026-09-29 23:35:05 -04:00

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>