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:
jpremill committed 2026-09-29 23:35:05 -04:00
1 parent c82b112624
commit 1feea40684
148 files changed
+11230 -1706

No files matched your search

@@ -0,0 +1,84 @@
---
name: azure-functions-genai
description: >-
Guides the design, implementation, and debugging of Azure Functions v4 (.NET 8/10 Isolated Worker)
using the official Google.GenAI C# SDK for serverless AI endpoints.
---
# Azure Functions & Google.GenAI C# SDK Skill
Use this skill when developing, updating, or testing the C# serverless API endpoints located in `api/`.
---
## 1. Solution Architecture
The backend consists of an **Azure Functions v4 Isolated Worker** targeting **.NET 8.0**:
- Project file: `api/api.csproj`
- Runtime: `Microsoft.Azure.Functions.Worker` 2.50+ & `Google.GenAI` 1.20+
- Host configuration: `api/host.json`
- Application Insights integration: `Microsoft.ApplicationInsights.WorkerService`
---
## 2. Google.GenAI C# SDK Integration Patterns
### 2.1 Namespaces & Types
```csharp
using Google.GenAI;
using Google.GenAI.Types;
using Type = Google.GenAI.Types.Type; // Disambiguate with System.Type
```
### 2.2 Client Initialization
```csharp
var apiKey = System.Environment.GetEnvironmentVariable("GEMINI_API_KEY");
var client = new Client(apiKey: apiKey);
```
### 2.3 Structured Output Generation (JSON Schema)
```csharp
var schema = new Schema
{
Type = Type.Object,
Properties = new Dictionary<string, Schema>
{
{ "distortionsIdentified", new Schema { Type = Type.Array, Items = new Schema { Type = Type.String } } },
{ "techniquesUsed", new Schema { Type = Type.Array, Items = new Schema { Type = Type.String } } },
{ "clinicalInsight", new Schema { Type = Type.String } },
{ "feedbackType", new Schema { Type = Type.String, Enum = new List<string> { "Constructive", "Positive", "Neutral" } } }
},
Required = new List<string> { "distortionsIdentified", "techniquesUsed", "clinicalInsight", "feedbackType" }
};
var response = await client.Models.GenerateContentAsync(
model: "gemini-2.5-flash",
contents: promptContent,
config: new GenerateContentConfig
{
SystemInstruction = new Content { Parts = new List<Part> { new Part { Text = systemInstruction } } },
ResponseMimeType = "application/json",
ResponseSchema = schema
});
```
---
## 3. Endpoints Matrix
| Route | HTTP Method | Function Name | Purpose |
|:---|:---|:---|:---|
| `/api/chat` | `POST` | `ChatProgression` | Headless roleplay progression with strict anti-meta guardrails |
| `/api/analysis` | `POST` | `ClinicalAnalysis` | Real-time clinical supervisor turn evaluation |
| `/api/scenario/generate` | `POST` | `GenerateScenario` | Dynamic CBT roleplay scenario generation |
---
## 4. Local Build & Test Verification
```bash
# Build the C# API project
dotnet build api/api.csproj
# Run local functions host (requires Azure Functions Core Tools)
func start --csharp --prefix api
```
@@ -0,0 +1,56 @@
---
name: cbt-clinical-evaluation
description: >-
Provides clinical supervision protocols, real-time distortion detection rules,
psychometric scoring rubrics, and post-session diagnostic report formatting.
---
# CBT Clinical Evaluation & Supervision Skill
Use this skill when refining the clinical analysis supervisor model prompts, tuning distortion detection thresholds, or formatting final session case reports.
---
## 1. Real-Time Turn-by-Turn Supervision Protocol
During a roleplay session, every completed user turn is evaluated out-of-band by an asynchronous clinical supervisor prompt.
### 1.1 Clinical Schema
```typescript
export interface AnalysisResult {
analysis: {
distortions: string[]; // Detected distortions (e.g. "Mind Reading", "Catastrophizing")
isAdaptive: boolean; // True if the response demonstrates cognitive restructuring
evidence: string; // Direct quote or behavioral evidence from user's response
};
rating: {
score: number; // 1-10 clinical effectiveness score
reason: string; // Clinical justification for the score
};
suggestions: {
alternativeThought: string; // Rational reframe of the user's statement
behavioralStep: string; // Actionable communication or behavioral adjustment
};
}
```
---
## 2. Clinical Evaluation Rubric (1-10 Scoring Scale)
| Score Range | Clinical Category | Behavioral & Cognitive Characteristics |
|:---|:---|:---|
| **1 - 3** | **Maladaptive / Reactive** | User falls into cognitive distortions without awareness; demonstrates passive-aggressive, defensive, or submissive behavior; abdicates personal boundaries. |
| **4 - 6** | **Emerging Awareness / Partial Restructuring** | User attempts to challenge distortions or state boundaries, but tone remains emotionally reactive, apologetic, or hesitant. |
| **7 - 8** | **Adaptive / Effective Restructuring** | Clear cognitive restructuring; assertive, non-blaming boundary setting; validates other person's emotions while holding firm limits. |
| **9 - 10** | **Mastery / Radical Flexibility** | Demonstrates emotional regulation under high friction; employs sophisticated techniques (e.g., DEAR MAN, Gray Rock, Radical Acceptance); de-escalates without concession. |
---
## 3. Post-Session Comprehensive Case Report
At the end of a session, a final summary prompt generates a macro assessment:
1. **Primary Cognitive Distortions Identified**: Frequency and contexts of observed distortions.
2. **Techniques Successfully Applied**: Count and effectiveness of behavioral tools used.
3. **Core Cognitive Shifts**: Evolution of the user's responses across the dialogue arc.
4. **Targeted Clinical Homework**: 1-2 practical real-world exercises to reinforce learning.
@@ -0,0 +1,78 @@
---
name: cbt-scenario-design
description: >-
Designs, calibrates, and validates Cognitive Behavioral Therapy (CBT) roleplay scenarios,
difficulty ladders, character psychological profiles, and internal CBT logic rules.
---
# CBT Scenario Design & Calibration Skill
Use this skill when creating new simulation scenarios, tuning AI character difficulty levels, adding cognitive distortion targets, or modifying the scenario catalog.
---
## 1. Clinical Scenario Architecture
Every scenario in Cognitive Clarity conforms to the following schema:
```typescript
export interface Scenario {
id: string; // Unique kebab-case slug (e.g. 'workplace-critical-review')
title: string; // Human-readable evocative title
description: string; // Context setting the premise for the user
category: 'Social Anxiety' | 'Workplace' | 'Relationships' | 'Self-Talk';
difficulty: 'Beginner' | 'Intermediate' | 'Advanced';
characterName: string; // Name or archetype of the AI partner
initialMessage: string; // The opening dialogue from the AI character
cbtFocus: string[]; // Target distortions and techniques
systemInstruction: string; // Comprehensive prompt with character persona & logic
}
```
---
## 2. Difficulty Calibration Rubric
| Difficulty | Character Resistance | Cognitive Distortion Salience | User Task | Recommended Category |
|:---|:---|:---|:---|:---|
| **Beginner** | Highly receptive to assertive communication; explicitly hints at expectations. | Overt, obvious distortions (e.g., explicit All-or-Nothing statements). | Identify and dispute basic cognitive distortions; maintain calm presence. | `Social Anxiety`, `Self-Talk` |
| **Intermediate** | Ambiguous motivations; subtle passive-aggression; holds reasonable opposing perspective. | Subtle, nuanced distortions (e.g., Mind Reading, Fortune Telling, Maladaptive Guilt). | Set boundaries, de-escalate without defensiveness, practice Cognitive Reframing. | `Workplace`, `Relationships` |
| **Advanced** | Strong emotional activation; high clinical resistance; persistent invalidation or defensiveness. | Layered cognitive distortions combined with emotional triggers. | Apply Radical Acceptance, Gray Rock Method, multi-turn emotional regulation under stress. | `Relationships`, `Self-Talk` |
---
## 3. System Instruction Design Blueprint
When writing `systemInstruction` for a scenario, always follow this four-part structure:
```markdown
1. CHARACTER IDENTITY & CONTEXT:
- Name, relationship to user, current emotional state, and immediate goal.
- Tone markers (e.g., "curt, hurried, stressed" or "skeptical, passive-aggressive").
2. INTERNAL CBT LOGIC:
- IF the user becomes defensive or aggressive -> Escalate emotional distance or defensiveness.
- IF the user validates emotions while establishing clear boundaries -> De-escalate and show grudging respect.
- IF the user asks clarifying questions without mind reading -> Clarify directly and remove ambiguity.
3. VOCAL & DIALOGUE STYLE:
- Sentence length: Short and conversational (1-3 sentences per turn).
- Natural conversational elements: Hesitations, pauses, tone inflections.
4. ANTI-META GUARDRAILS:
- NEVER break character.
- NEVER provide feedback, scores, advice, or therapy tips.
- NEVER say "Good job" or "That was a great reframing".
```
---
## 4. Scenario Validation Checklist
- [ ] `id` is unique and formatted as lowercase kebab-case.
- [ ] `category` is one of the four clinical categories.
- [ ] `difficulty` is properly calibrated (`Beginner`, `Intermediate`, `Advanced`).
- [ ] `cbtFocus` lists at least 2 valid entries from `CBT_GLOSSARY`.
- [ ] `initialMessage` immediately immerses the user without preamble.
- [ ] `systemInstruction` contains explicit `INTERNAL CBT LOGIC` rules.
- [ ] Zero clinical meta-feedback or congratulatory phrases in persona instructions.
@@ -0,0 +1,95 @@
---
name: webaudio-gemini-live
description: >-
Guides the integration, troubleshooting, and optimization of real-time WebAudio capture,
Gemini Multimodal Live API WebSocket streaming, PCM encoding, and frequency visualizers.
---
# WebAudio & Gemini Multimodal Live API Skill
Use this skill when developing, debugging, or optimizing real-time duplex voice conversations with Google Gemini's native audio models (`gemini-2.5-flash-native-audio-preview-09-2025` or future native audio models).
---
## 1. WebAudio Architecture & Data Flow
```mermaid
sequenceDiagram
participant UserMic as Microphone (MediaStream)
participant InAudioCtx as Input AudioContext (16kHz)
participant LiveWS as Gemini Live WebSocket
participant OutAudioCtx as Output AudioContext (24kHz)
participant Visualizer as AnalyserNode & Visualizer
UserMic->>InAudioCtx: Raw Audio Stream
InAudioCtx->>InAudioCtx: Float32 -> Int16 PCM (16kHz)
InAudioCtx->>LiveWS: sendRealtimeInput({ media: { data: Base64, mimeType: 'audio/pcm;rate=16000' } })
LiveWS-->>OutAudioCtx: serverContent.modelTurn (PCM 24kHz Base64)
OutAudioCtx->>Visualizer: Frequency Data Array (FFT)
OutAudioCtx->>OutAudioCtx: Schedule AudioBufferSourceNode (Monotonic Queue)
LiveWS-->>LiveWS: serverContent.interrupted -> Stop All Audio
```
---
## 2. Critical Implementation Guidelines
### 2.1 Audio Context Configuration
- **Input Sample Rate**: Must be initialized at `16000` Hz:
```typescript
const inputAudioCtx = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
```
- **Output Sample Rate**: Must be initialized at `24000` Hz to match Gemini's native output audio sample rate:
```typescript
const outputAudioCtx = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
```
### 2.2 PCM Conversion Math
Convert 32-bit floating point audio (`-1.0` to `+1.0`) into 16-bit signed PCM integers (`-32768` to `+32767`):
```typescript
export function floatTo16BitPCM(float32Array: Float32Array): Int16Array {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]));
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16Array;
}
```
### 2.3 Monotonic Playback Scheduling
To prevent audio overlap or gaps:
```typescript
const nextStartTimeRef = useRef<number>(0);
const audioCtx = outputAudioCtxRef.current!;
// Ensure scheduled start is never in the past
nextStartTimeRef.current = Math.max(nextStartTimeRef.current, audioCtx.currentTime);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(analyserNode);
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += buffer.duration;
```
### 2.4 Handling Barge-in / Interruptions
When Gemini detects user speech while generating speech, it emits `message.serverContent.interrupted`:
```typescript
if (message.serverContent?.interrupted) {
// Stop all currently playing audio source nodes immediately
audioSourcesRef.current.forEach(source => {
try { source.stop(); } catch (e) {}
});
audioSourcesRef.current.clear();
nextStartTimeRef.current = 0;
setIsAiSpeaking(false);
}
```
---
## 3. Visualizer Engineering
- Connect `AnalyserNode` with `fftSize = 256` between source and destination.
- Use `analyser.getByteFrequencyData(dataArray)` to sample normalized amplitude (0-255).
- Use `requestAnimationFrame` for continuous rendering with smooth falloff.
+4 -1
View File
@@ -1 +1,4 @@
VITE_GEMINI_API_KEY="" # Cognitive Clarity - Environment Configuration
# OpenRouter API Key for local development (optional if configured via in-app Settings modal)
VITE_OPENROUTER_API_KEY=""
OPENROUTER_API_KEY=""
+7
View File
@@ -31,3 +31,10 @@ local.settings.json
*.sw? *.sw?
bin/ bin/
obj/ obj/
# Ephemeral Worktrees & Test Reports
.worktrees/
test-results/
playwright-report/
.agents/teamwork/
+120
View File
@@ -0,0 +1,120 @@
# AGENTS.md - Cognitive Clarity Workspace Instructions
Welcome to **Cognitive Clarity** (`clarity-helper`), an adaptive clinical training and roleplay environment engineered to strengthen cognitive flexibility, practice Cognitive Behavioral Therapy (CBT) restructuring techniques, and simulate high-stakes social interactions in real time.
---
## 1. System Architecture Overview
```mermaid
graph TD
User([User Voice / Text]) <--> Frontend[React 19 + Vite + Tailwind + Radix UI]
subgraph "Frontend Real-Time Multimodal Layer"
Frontend <--> WebAudio[WebAudio DSP Engine: Live FFT Analysis & Synthesis]
Frontend <--> OpenRouterClient[OpenRouter API: Streaming Roleplay & TTS/STT]
OpenRouterClient --> Transcripts[Real-Time Transcription Pipeline]
Transcripts --> Supervisor[Parallel Turn Supervisor: OpenRouter google/gemini-3.8-flash]
Supervisor --> LiveInsights[LiveInsightPanel & SessionReport]
end
subgraph "Backend Serverless Layer (api/)"
Frontend -.->|Optional Enterprise Fallback| AzFunc[Azure Functions v4 .NET 8 Isolated Worker]
AzFunc --> ChatFunc["/api/chat (ChatProgression)"]
AzFunc --> AnalysisFunc["/api/analysis (ClinicalAnalysis)"]
AzFunc --> ScenarioFunc["/api/scenario/generate (GenerateScenario)"]
AzFunc <--> OpenRouterAPI[OpenRouter API Endpoint /chat/completions]
end
subgraph "Local Persistence & Offline"
Frontend <--> LocalStorage[LocalStorage: Custom Scenarios, Settings & Session History]
Frontend <--> ServiceWorker[Vite PWA / Workbox Service Worker: sw.ts]
end
```
---
## 2. Core Repository Structure
```
clarity-helper/
├── App.tsx # Primary application router, state coordinator, and hero interface
├── constants.ts # CBT Glossary (12 distortions, 5 techniques, 2 concepts), default scenarios, schemas
├── types.ts # TypeScript domain models (Scenario, AnalysisResult, UserSettings, Message)
├── index.html / index.tsx # HTML entry point & React root mount
├── index.css # Global styles & Tailwind base directives
├── vite.config.ts # Vite 6 config with PWA injectManifest, aliases, and env injection
├── sw.ts # Custom Workbox Service Worker with precaching and caching strategies
├── api/ # Azure Functions .NET 8 Isolated Worker backend
│ ├── Program.cs # Functions host bootstrap & Application Insights telemetry
│ ├── api.csproj # .NET 8 project file with HttpClient Factory
│ ├── ChatFunction.cs # POST /api/chat - Headless roleplay progression via OpenRouter
│ ├── AnalysisFunction.cs # POST /api/analysis - Real-time clinical evaluation via OpenRouter
│ └── ScenarioFunction.cs # POST /api/scenario/generate - Structured scenario generator via OpenRouter
├── components/ # UI Component Hierarchy
│ ├── VoiceSession.tsx # Multimodal voice roleplay controller
│ ├── VoiceSession/ # Voice state components (Header, Controls, Visualizer states)
│ ├── ChatSession.tsx # Text-based roleplay controller with streaming SSE
│ ├── ChatMessage.tsx # Conversation bubble with role styling
│ ├── ChatInput.tsx # Text input with submit handling
│ ├── LiveInsightPanel.tsx # Real-time turn-by-turn clinical supervision drawer
│ ├── SessionReport.tsx # Comprehensive post-session CBT assessment report
│ ├── ScenarioGenerator.tsx # AI-powered interactive custom scenario builder
│ ├── CBTReference.tsx # Interactive searchable glossary & technique reference
│ ├── AudioVisualizer.tsx # Canvas/CSS real-time FFT frequency visualizer
│ ├── AudioDeviceSelector.tsx # Input microphone selector modal
│ ├── SettingsModal.tsx # Voice profile & OpenRouter API key configuration
│ ├── ReloadPrompt.tsx # PWA update notification prompt
│ └── ui/ # Radix UI + Tailwind design system primitives
├── hooks/
│ └── useLiveSession.ts # Low-level WebAudio + OpenRouter voice synthesis hook
├── services/
│ ├── apiConfig.ts # OpenRouter API key resolution & model defaults
│ ├── openRouterService.ts # Streaming chat, structured supervision & speech API client
│ ├── sessionStorage.ts # Persistent session history, markdown and JSON report exports
│ └── audioUtils.ts # PCM16 / Base64 / AudioBuffer encoding and decoding helpers
├── scripts/ # Headless CLI tools and scenario test suites
│ ├── validate-scenarios.ts # Scenario schema and distortion coverage validator
│ └── simulate-cbt-session.ts # Headless multi-turn roleplay & clinical scoring simulator
├── test/ # Automated Node.js / tsx unit test suite
│ ├── apiConfig.test.ts # API key resolution & model defaults tests
│ ├── openRouterService.test.ts # Streaming, supervisor & scenario generation tests
│ └── scenarios.test.ts # Clinical scenario schema & glossary tests
└── .agents/ # Specialized agent skills and workflow blueprints
└── skills/ # Domain-specific skill runbooks
```
---
## 3. Engineering Invariants & Coding Guidelines
### 3.1 Strict Clinical Roleplay Separation
- **No Meta-Commentary Leaks**: When the AI character is engaged in roleplay (`ChatSession`, `VoiceSession`, `ChatFunction.cs`), system instructions MUST forbid the model from breaking character, congratulating the user, giving clinical advice, or outputting feedback tokens.
- **Parallel Supervision**: Clinical feedback is exclusively computed out-of-band by a dedicated supervisor model instance (`LiveInsightPanel`, `AnalysisFunction.cs`), ensuring realistic emotional friction during the simulation.
### 3.2 OpenRouter Exclusive API Policy
- **Zero Direct Google API Keys**: The codebase must NOT require or use direct Google Gemini API keys. All model queries (`google/gemini-3.8-flash`, `google/gemini-3.7-flash`, `google/gemini-2.5-flash`, `anthropic/claude-3.5-haiku`, `meta-llama/llama-3.3-70b-instruct`) route exclusively through OpenRouter.
- **Client Security**: OpenRouter API Keys provided by the user are stored in browser LocalStorage and never sent to external servers or telemetries.
### 3.3 TypeScript & Build Hygiene
- Keep `npm run lint` (`tsc --noEmit`) and `npm test` passing with zero errors.
- Ensure all Radix UI component props utilize `React.ComponentPropsWithoutRef<T>` or type unions compatible with React 19.
- Maintain full type synchronization between `types.ts` and backend C# DTOs in `api/*.cs`.
### 3.4 .NET Azure Functions Backend
- Target **.NET 8.0** isolated worker model with `Microsoft.Azure.Functions.Worker.Sdk`.
- Call OpenRouter API via `HttpClientFactory` with `OPENROUTER_API_KEY`.
---
## 4. Verification & Testing Commands
| Purpose | Command | Notes |
|:---|:---|:---|
| **Run Unit Tests** | `npm test` | Executes native Node.js / `tsx` unit test suites |
| **Typecheck Frontend** | `npm run lint` | Runs `tsc --noEmit` |
| **Build Frontend** | `npm run build:nobump` | Bundles Vite SPA + PWA Service Worker to `dist/` |
| **Validate Scenarios** | `npm run validate:scenarios` | Validates default & custom scenario data schemas |
| **Simulate Session** | `npm run simulate:session` | Executes headless 3-turn CBT simulation with clinical score |
| **Build .NET API** | `dotnet build api/api.csproj` | Compiles Azure Functions Isolated Worker assembly |
| **Run Dev Server** | `npm run dev` | Starts Vite on `http://localhost:3000` |
+5 -3
View File
@@ -17,7 +17,9 @@ import { VersionBadge } from './components/VersionBadge';
const DEFAULT_SETTINGS: UserSettings = { const DEFAULT_SETTINGS: UserSettings = {
voiceName: 'Zephyr', voiceName: 'Zephyr',
vocalStyle: 'Helpful', vocalStyle: 'Helpful',
genderPreference: 'Neutral' genderPreference: 'Neutral',
roleplayModel: 'google/gemini-3.8-flash',
supervisorModel: 'google/gemini-3.8-flash'
}; };
const CUSTOM_SCENARIOS_KEY = 'cognitive-clarity-custom-scenarios'; const CUSTOM_SCENARIOS_KEY = 'cognitive-clarity-custom-scenarios';
@@ -143,7 +145,7 @@ const App: React.FC = () => {
return ( return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center pt-8 px-4 pb-8 animate-in fade-in duration-700"> <div className="min-h-screen bg-slate-50 flex flex-col items-center pt-8 px-4 pb-8 animate-in fade-in duration-700">
{sessionMode === 'text' ? ( {sessionMode === 'text' ? (
<ChatSession scenario={activeScenario} onExit={handleExitSession} /> <ChatSession scenario={activeScenario} userSettings={userSettings} onExit={handleExitSession} />
) : ( ) : (
<div className="w-full max-w-4xl h-[80vh]"> <div className="w-full max-w-4xl h-[80vh]">
<VoiceSession <VoiceSession
@@ -162,7 +164,7 @@ const App: React.FC = () => {
<div className="min-h-screen bg-[#FDFDFF] text-slate-900 font-sans relative animate-in fade-in duration-500"> <div className="min-h-screen bg-[#FDFDFF] text-slate-900 font-sans relative animate-in fade-in duration-500">
<ReloadPrompt /> <ReloadPrompt />
{/* Modals */} {/* Modals */}
{showGenerator && <ScenarioGenerator onGenerate={handleScenarioGenerated} onCancel={() => setShowGenerator(false)} />} {showGenerator && <ScenarioGenerator userSettings={userSettings} onGenerate={handleScenarioGenerated} onCancel={() => setShowGenerator(false)} />}
{showReference && <CBTReference onClose={() => setShowReference(false)} />} {showReference && <CBTReference onClose={() => setShowReference(false)} />}
{showSettings && <SettingsModal settings={userSettings} onUpdate={setUserSettings} onClose={() => setShowSettings(false)} />} {showSettings && <SettingsModal settings={userSettings} onUpdate={setUserSettings} onClose={() => setShowSettings(false)} />}
+91
View File
@@ -0,0 +1,91 @@
# CHANGELOG
### [2026-08-31] - OpenRouter Exclusive Engine Migration & Unified API Routing
**Type:** Migration | Architecture | Feature | Performance
**Context:** Completely eliminated direct Google API key dependencies in favor of exclusive OpenRouter integration (`https://openrouter.ai/api/v1`) across frontend roleplay streaming, speech synthesis/transcription, clinical supervision, scenario generation, and backend Azure Functions.
**Resolution:**
- **OpenRouter Exclusive Architecture:** Removed all `@google/genai` client libraries and direct Google Gemini API key requirements. All model interactions (including `google/gemini-2.5-flash`, `google/gemini-2.0-flash-001`, `anthropic/claude-3.5-haiku`, `meta-llama/llama-3.3-70b-instruct`) route exclusively through OpenRouter with standard Bearer authorization.
- **Frontend Streaming SSE & TTS:** Upgraded `services/openRouterService.ts` to support Server-Sent Events (SSE) token streaming, structured turn evaluation in JSON mode, and high-fidelity speech synthesis.
- **Live Voice Processing:** Refactored `hooks/useLiveSession.ts` to use browser speech recognition + WebAudio frequency analysis for microphone input, coupled with OpenRouter speech synthesis and turn analysis.
- **Backend Azure Functions Alignment:** Refactored `api/ChatFunction.cs`, `api/AnalysisFunction.cs`, and `api/ScenarioFunction.cs` to utilize `HttpClientFactory` and `OPENROUTER_API_KEY`, removing the `Google.GenAI` NuGet package.
- **Settings & UI Redesign:** Replaced provider toggles in `components/SettingsModal.tsx` with dedicated OpenRouter API Key input and model selector.
- **Bundle Shrinkage:** Reduced total precache build size from 703 kB to 439 kB by removing heavy GenAI SDK dependencies.
**Files Changed:**
- `services/openRouterService.ts`
- `services/apiConfig.ts`
- `hooks/useLiveSession.ts`
- `components/ChatSession.tsx`
- `components/VoiceSession.tsx`
- `components/ScenarioGenerator.tsx`
- `components/SettingsModal.tsx`
- `constants.ts`
- `types.ts`
- `App.tsx`
- `index.html`
- `vite.config.ts`
- `api/api.csproj`
- `api/Program.cs`
- `api/ChatFunction.cs`
- `api/AnalysisFunction.cs`
- `api/ScenarioFunction.cs`
- `AGENTS.md`
- `GEMINI.md`
- `README.md`
- `CHANGELOG.md`
**Files Changed:**
- `hooks/useLiveSession.ts`
- `services/apiConfig.ts`
- `services/openRouterService.ts`
- `services/sessionStorage.ts`
- `components/SessionReport.tsx`
- `components/SettingsModal.tsx`
- `components/ChatSession.tsx`
- `components/VoiceSession.tsx`
- `components/ScenarioGenerator.tsx`
- `types.ts`
- `App.tsx`
- `vite.config.ts`
- `sw.ts`
- `CHANGELOG.md`
### [2026-08-30] - Architectural Assessment, Type Alignment & Agentic Tooling
**Type:** Refactor | Bug Fix | Feature | Config
**Context:** Comprehensive full-codebase architectural evaluation of Cognitive Clarity (`clarity-helper`), fixing TypeScript / .NET compilation blockers, enriching CBT domain coverage, and establishing workspace agent instructions, custom skills, and scenario test helpers.
**Resolution:**
- Fixed TypeScript 5.8 / React 19 typing discrepancy on `BadgeProps` in `components/ui/badge.tsx`.
- Upgraded C# Azure Functions in `api/` to `Google.GenAI` 1.20+ SDK, resolving all namespace ambiguities and schema definitions across `ChatFunction.cs`, `AnalysisFunction.cs`, and `ScenarioFunction.cs`.
- Enriched `CBT_GLOSSARY` in `constants.ts` with missing definitions for `Labeling` and `Overgeneralization`.
- Added explicit `INTERNAL CBT LOGIC:` branching rules across built-in scenarios `s1`, `s3`, and `s4`.
- Created comprehensive workspace guidelines and rules in `AGENTS.md` and `GEMINI.md`.
- Created custom agent skills in `.agents/skills/`:
- `cbt-scenario-design`
- `webaudio-gemini-live`
- `cbt-clinical-evaluation`
- `azure-functions-genai`
- Authored developer CLI helpers in `scripts/`:
- `scripts/validate-scenarios.ts` (Validates 14 built-in scenarios against clinical schema)
- `scripts/simulate-cbt-session.ts` (Headless multi-turn roleplay & clinical scoring simulator)
- Updated `package.json` with `validate:scenarios` and `simulate:session` npm script targets and installed `tsx`.
- Elevated `README.md` to publication-grade documentation with complete architecture diagrams, endpoint matrices, and developer guide.
**Files Changed:**
- `components/ui/badge.tsx`
- `constants.ts`
- `package.json`
- `package-lock.json`
- `api/api.csproj`
- `api/ChatFunction.cs`
- `api/AnalysisFunction.cs`
- `api/ScenarioFunction.cs`
- `scripts/validate-scenarios.ts`
- `scripts/simulate-cbt-session.ts`
- `.agents/skills/cbt-scenario-design/SKILL.md`
- `.agents/skills/webaudio-gemini-live/SKILL.md`
- `.agents/skills/cbt-clinical-evaluation/SKILL.md`
- `.agents/skills/azure-functions-genai/SKILL.md`
- `AGENTS.md`
- `GEMINI.md`
- `README.md`
- `CHANGELOG.md`
+16
View File
@@ -0,0 +1,16 @@
# GEMINI.md - Cognitive Clarity Architectural Rules
This repository defines the **Cognitive Clarity** adaptive clinical simulation system.
---
## Autonomous Agent Invariants
1. **OpenRouter Exclusive Execution**: All AI operations (text dialogue streaming, clinical turn analysis, scenario generation, and vocal speech synthesis) MUST route exclusively through OpenRouter API (`https://openrouter.ai/api/v1`). Direct Google API keys or endpoints are strictly forbidden.
2. **Strict Character Boundaries**: AI roleplay characters must NEVER provide clinical meta-commentary, congratulations, or diagnostic advice within dialogue turns. Feedback is handled asynchronously by the supervisor engine.
3. **Audio Stream Integrity**: WebAudio processing must properly manage WebAudio Context lifecycles, avoid main-thread blocking, and drive FFT visualizer states accurately.
4. **C# Isolated Functions Parity**: Ensure `api/api.csproj` builds cleanly with .NET 8, maintaining type fidelity with TypeScript schemas in `types.ts` and consuming OpenRouter via `HttpClientFactory`.
5. **Zero Residual Slop**: Temporary scripts or exploratory files must be placed in `scripts/` or scratch directories and never committed in a broken state.
6. **Continuous Changelog**: Every feature, bugfix, or architectural update must be recorded in `CHANGELOG.md`.
Refer to [AGENTS.md](file:///Users/moo/Development/Repositories/clarity-helper/AGENTS.md) for full workspace specifications.
+143 -11
View File
@@ -1,20 +1,152 @@
<div align="center"> <div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" /> <img width="1200" height="475" alt="Cognitive Clarity Banner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>🧠 Cognitive Clarity</h1>
<p><strong>An Adaptive Clinical Training & Multimodal Roleplay Environment for Cognitive Behavioral Therapy (CBT)</strong></p>
<p>
<a href="#key-features">Key Features</a> •
<a href="#system-architecture">Architecture</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#cbt-framework--scenarios">CBT Scenarios</a> •
<a href="#serverless-backend-api">Serverless Backend</a> •
<a href="#developer-tools--scripts">Developer Tools</a> •
<a href="#agents--skills">Agents & Skills</a>
</p>
</div> </div>
# Run and deploy your AI Studio app ---
This contains everything you need to run your app locally. ## 🌟 Key Features
View your app in AI Studio: https://ai.studio/apps/56e6d01a-b890-4a16-b7c2-fc7b3e52f59a - **🎙️ Real-Time Voice & Text Roleplay**: Natural interactive voice roleplay and streaming text chat powered exclusively by OpenRouter (`google/gemini-3.8-flash`, `google/gemini-3.7-flash`, `anthropic/claude-3.5-haiku`, `meta-llama/llama-3.3-70b-instruct`) with WebAudio speech synthesis and FFT frequency visualization.
- **👁️ Out-of-Band Clinical Supervisor**: Real-time turn-by-turn clinical supervision evaluating cognitive distortions (Mind Reading, Catastrophizing, Should Statements, etc.) and effective restructuring techniques without breaking character during roleplay.
- **📊 Comprehensive Session Diagnostic Reports**: End-of-session case conceptualization reports with psychometric scoring (1-5), evidence quotes, cognitive restructuring trajectories, and one-click Markdown & JSON exports.
- **🛠️ AI Custom Scenario Architect**: Dynamic clinical scenario generator creating calibrated roleplays across `Social Anxiety`, `Workplace`, `Relationships`, and `Self-Talk` at `Beginner`, `Intermediate`, and `Advanced` difficulty levels.
- **📖 Evidence-Based CBT Reference Guide**: Searchable clinical glossary covering 12 cognitive distortions, 5 therapeutic techniques (Cognitive Reframing, Behavioral Activation, Radical Acceptance, etc.), and concepts (Spotlight Effect, Gray Rock Method).
- **⚡ Progressive Web App (PWA)**: Built with `vite-plugin-pwa` and Workbox custom service worker (`sw.ts`) for offline caching, asset precaching, and mobile standalone installation.
- **☁️ Serverless C# API Layer**: Optional headless backend implemented in Azure Functions v4 (.NET 8 Isolated Worker) powered by OpenRouter.
## Run Locally ---
**Prerequisites:** Node.js ## 🏛️ System Architecture
```mermaid
graph TD
User([User Voice / Text]) <--> Frontend[React 19 + Vite + Tailwind + Radix UI]
subgraph "Frontend Real-Time Multimodal Layer"
Frontend <--> WebAudio[WebAudio DSP Engine: Live FFT Analysis & Speech Synthesis]
Frontend <--> OpenRouterClient[OpenRouter API: Streaming Roleplay & Structured Analysis]
OpenRouterClient --> Transcripts[Real-Time Transcription Pipeline]
Transcripts --> Supervisor[Parallel Turn Supervisor: OpenRouter google/gemini-3.8-flash]
Supervisor --> LiveInsights[LiveInsightPanel & SessionReport]
end
1. Install dependencies: subgraph "Backend Serverless Layer (api/)"
`npm install` Frontend -.->|Headless / Batch Evaluation| AzFunc[Azure Functions v4 .NET 8 Isolated Worker]
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key AzFunc --> ChatFunc["/api/chat (ChatProgression)"]
3. Run the app: AzFunc --> AnalysisFunc["/api/analysis (ClinicalAnalysis)"]
`npm run dev` AzFunc --> ScenarioFunc["/api/scenario/generate (GenerateScenario)"]
AzFunc <--> OpenRouterAPI[OpenRouter API /chat/completions]
end
subgraph "Local Persistence & Offline"
Frontend <--> LocalStorage[LocalStorage: Custom Scenarios, Settings & Session History]
Frontend <--> ServiceWorker[Vite PWA / Workbox Service Worker: sw.ts]
end
```
---
## 🚀 Getting Started
### Prerequisites
- **Node.js**: v20+ or v22+
- **npm**: v10+
- **.NET 8.0 SDK** (optional, for backend Azure Functions)
- **OpenRouter API Key**: [Get an API Key from OpenRouter](https://openrouter.ai/keys)
### 1. Installation
```bash
git clone https://github.com/jpremill/clarity-helper.git
cd clarity-helper
npm install
```
### 2. Configure Environment Variables
Create `.env.local` in the project root (or set your key directly in the in-app Settings modal):
```env
VITE_OPENROUTER_API_KEY=your_openrouter_api_key_here
OPENROUTER_API_KEY=your_openrouter_api_key_here
```
### 3. Run Development Server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
---
## 🧠 CBT Framework & Scenarios
Cognitive Clarity includes 14 pre-calibrated clinical scenarios across 4 core domains:
| Category | Beginner | Intermediate | Advanced |
|:---|:---|:---|:---|
| **Relationships** | *The Silent Friend* (s1) | *The Friend's Criticism* (s13) | *Intrusive Relative* (s11), *The Unpaid Debt* (s7) |
| **Workplace** | — | *Critical Feedback* (s2), *The Deadline Slip* (s9) | *The Salary Discussion* (s8), *The Job Interview* (s12) |
| **Social Anxiety** | *Booking an Appointment* (s5) | *The Defective Return* (s6), *The Social 'No'* (s10) | *The Social Spotlight* (s3) |
| **Self-Talk** | *Inner Critic Echo* (s4) | — | *The Unexpected Health Symptom* (s14) |
---
## ☁️ Serverless Backend API (`api/`)
The repository includes a .NET 8 Azure Functions isolated worker backend:
```bash
# Build the .NET Functions project
dotnet build api/api.csproj
```
### Endpoints
| Route | Method | Description |
|:---|:---|:---|
| `/api/chat` | `POST` | Advances the character roleplay dialogue with strict anti-meta guardrails. |
| `/api/analysis` | `POST` | Analyzes a dialogue turn for cognitive distortions and returns structured feedback. |
| `/api/scenario/generate` | `POST` | Generates a complete, validated scenario specification using JSON schema constraints. |
---
## 🛠️ Developer Tools & Scripts
| Script | Command | Purpose |
|:---|:---|:---|
| **Run Unit Tests** | `npm test` | Executes native Node.js / `tsx` unit test suites. |
| **Typecheck** | `npm run lint` | Runs `tsc --noEmit` to verify type safety. |
| **Build SPA & PWA** | `npm run build:nobump` | Compiles Vite production bundle + Service Worker into `dist/`. |
| **Validate Scenarios** | `npm run validate:scenarios` | Validates all 14 built-in scenarios against schema and distortion rules. |
| **Simulate Session** | `npm run simulate:session` | Headless multi-turn roleplay & clinical scoring simulator harness. |
---
## 🤖 Agents & Skills
Specialized agent configurations and workflow blueprints are organized in `.agents/skills/`:
- [`.agents/skills/cbt-scenario-design/SKILL.md`](.agents/skills/cbt-scenario-design/SKILL.md): Scenario creation, difficulty calibration, and psychological persona guidelines.
- [`.agents/skills/webaudio-gemini-live/SKILL.md`](.agents/skills/webaudio-gemini-live/SKILL.md): Real-time duplex WebAudio streaming, PCM conversions, and visualizer math.
- [`.agents/skills/cbt-clinical-evaluation/SKILL.md`](.agents/skills/cbt-clinical-evaluation/SKILL.md): Clinical supervisor scoring, distortion taxonomy, and diagnostic report structures.
- [`.agents/skills/azure-functions-genai/SKILL.md`](.agents/skills/azure-functions-genai/SKILL.md): C# isolated worker API design with `Google.GenAI` 1.20+ SDK.
---
## 📄 License
MIT License. Designed with evidence-based Cognitive Behavioral Therapy principles.
+67 -44
View File
@@ -1,10 +1,11 @@
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Google.GenAI;
using System.Text.Json.Serialization;
namespace CognitiveClarity.api namespace CognitiveClarity.api
{ {
@@ -21,22 +22,27 @@ namespace CognitiveClarity.api
[JsonPropertyName("cbtFocus")] [JsonPropertyName("cbtFocus")]
public List<string> CBTFocus { get; set; } = new(); public List<string> CBTFocus { get; set; } = new();
[JsonPropertyName("model")]
public string Model { get; set; } = "google/gemini-3.8-flash";
} }
public class AnalysisFunction public class AnalysisFunction
{ {
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
public AnalysisFunction(ILoggerFactory loggerFactory) public AnalysisFunction(ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory)
{ {
_logger = loggerFactory.CreateLogger<AnalysisFunction>(); _logger = loggerFactory.CreateLogger<AnalysisFunction>();
_httpClientFactory = httpClientFactory;
} }
[Function("ClinicalAnalysis")] [Function("ClinicalAnalysis")]
public async Task<HttpResponseData> Run( public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "analysis")] HttpRequestData req) [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "analysis")] HttpRequestData req)
{ {
_logger.LogInformation("C# HTTP trigger function processed a clinical analysis request."); _logger.LogInformation("C# HTTP trigger function processed a clinical analysis request via OpenRouter.");
var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var data = JsonSerializer.Deserialize<AnalysisRequest>(requestBody); var data = JsonSerializer.Deserialize<AnalysisRequest>(requestBody);
@@ -48,67 +54,84 @@ namespace CognitiveClarity.api
return badResponse; return badResponse;
} }
var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY"); var apiKey = System.Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
if (string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(apiKey))
{ {
_logger.LogError("GEMINI_API_KEY is missing from environment variables."); _logger.LogError("OPENROUTER_API_KEY is missing from environment variables.");
return req.CreateResponse(HttpStatusCode.InternalServerError); return req.CreateResponse(HttpStatusCode.InternalServerError);
} }
// Initialize the Gemini Client var systemAnalysisPrompt = $@"You are a master clinical psychologist specializing in Cognitive Behavioral Therapy (CBT).
var ai = new GeminiClient(apiKey: apiKey); You are supervising an interactive CBT simulation.
SCENARIO: {data.ScenarioTitle} - {data.ScenarioDescription}
FOCUS AREAS: {string.Join(", ", data.CBTFocus)}
var systemAnalysisPrompt = @" Return ONLY a valid JSON object matching this schema:
You are a master clinical psychologist specializing in Cognitive Behavioral Therapy (CBT). {{
You are observing a roleplay session between a user and an AI character. ""analysis"": {{
Analyze the user's latest response for: ""distortions"": [""string""],
1. Cognitive distortions present (if any). ""isAdaptive"": boolean,
2. CBT techniques successfully applied by the user (if any). ""evidence"": ""string""
3. A brief, encouraging clinical insight or redirection (max 2 sentences). }},
Be precise, clinical, and constructive. ""rating"": {{
"; ""score"": number (1-5),
""reason"": ""string""
}},
""suggestions"": {{
""alternativeThought"": ""string"",
""behavioralStep"": ""string""
}}
}}";
var analysisSystemInstruction = $@"{systemAnalysisPrompt} var payload = new
SCENARIO CONTEXT:
Title: {data.ScenarioTitle}
Description: {data.ScenarioDescription}
CBT Focus Areas: {string.Join(", ", data.CBTFocus)}";
var schema = new Schema
{ {
Type = TypeEnum.Object, model = string.IsNullOrEmpty(data.Model) ? "google/gemini-3.8-flash" : data.Model,
Properties = new Dictionary<string, Schema> messages = new object[]
{ {
{ "distortionsIdentified", new Schema { Type = TypeEnum.Array, Items = new Schema { Type = TypeEnum.String } } }, new { role = "system", content = systemAnalysisPrompt },
{ "techniquesUsed", new Schema { Type = TypeEnum.Array, Items = new Schema { Type = TypeEnum.String } } }, new { role = "user", content = data.TurnContext }
{ "clinicalInsight", new Schema { Type = TypeEnum.String } },
{ "feedbackType", new Schema { Type = TypeEnum.String, Enum = new List<string> { "Constructive", "Positive", "Neutral" } } }
}, },
Required = new List<string> { "distortionsIdentified", "techniquesUsed", "clinicalInsight", "feedbackType" } response_format = new { type = "json_object" },
temperature = 0.2
}; };
try try
{ {
var response = await ai.Models.GenerateContentAsync( var client = _httpClientFactory.CreateClient();
model: "gemini-3.0-flash", client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
contents: data.TurnContext, client.DefaultRequestHeaders.Add("HTTP-Referer", "https://cognitive-clarity.app");
new GenerateContentConfig client.DefaultRequestHeaders.Add("X-Title", "Cognitive Clarity");
{
SystemInstruction = new Content { Parts = new List<Part> { new Part { Text = analysisSystemInstruction } } }, var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
ResponseMimeType = "application/json", var response = await client.PostAsync("https://openrouter.ai/api/v1/chat/completions", content);
ResponseSchema = schema
}); if (!response.IsSuccessStatusCode)
{
var errText = await response.Content.ReadAsStringAsync();
_logger.LogError("OpenRouter returned error {Status}: {Error}", response.StatusCode, errText);
var errResponse = req.CreateResponse(response.StatusCode);
await errResponse.WriteStringAsync(errText);
return errResponse;
}
var resJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(resJson);
var rawResult = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? "{}";
var successResponse = req.CreateResponse(HttpStatusCode.OK); var successResponse = req.CreateResponse(HttpStatusCode.OK);
successResponse.Headers.Add("Content-Type", "application/json"); successResponse.Headers.Add("Content-Type", "application/json");
await successResponse.WriteStringAsync(response.Text!); await successResponse.WriteStringAsync(rawResult);
return successResponse; return successResponse;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to generate analysis via Gemini API."); _logger.LogError(ex, "Failed to generate analysis via OpenRouter API.");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteStringAsync("Error communicating with Gemini API."); await errorResponse.WriteStringAsync("Error communicating with OpenRouter API.");
return errorResponse; return errorResponse;
} }
} }
+79 -53
View File
@@ -1,17 +1,18 @@
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Google.GenAI;
using System.Text.Json.Serialization;
namespace CognitiveClarity.api namespace CognitiveClarity.api
{ {
public class ChatMessage public class ChatMessage
{ {
[JsonPropertyName("role")] [JsonPropertyName("role")]
public string Role { get; set; } = string.Empty; // "user" or "model" public string Role { get; set; } = string.Empty; // "user" or "model" or "assistant"
[JsonPropertyName("text")] [JsonPropertyName("text")]
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
@@ -24,22 +25,27 @@ namespace CognitiveClarity.api
[JsonPropertyName("systemInstruction")] [JsonPropertyName("systemInstruction")]
public string SystemInstruction { get; set; } = string.Empty; public string SystemInstruction { get; set; } = string.Empty;
[JsonPropertyName("model")]
public string Model { get; set; } = "google/gemini-3.8-flash";
} }
public class ChatFunction public class ChatFunction
{ {
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
public ChatFunction(ILoggerFactory loggerFactory) public ChatFunction(ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory)
{ {
_logger = loggerFactory.CreateLogger<ChatFunction>(); _logger = loggerFactory.CreateLogger<ChatFunction>();
_httpClientFactory = httpClientFactory;
} }
[Function("ChatProgression")] [Function("ChatProgression")]
public async Task<HttpResponseData> Run( public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "chat")] HttpRequestData req) [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "chat")] HttpRequestData req)
{ {
_logger.LogInformation("C# HTTP trigger function processed a chat progression request."); _logger.LogInformation("C# HTTP trigger function processed a chat progression request via OpenRouter.");
var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var data = JsonSerializer.Deserialize<ChatRequest>(requestBody); var data = JsonSerializer.Deserialize<ChatRequest>(requestBody);
@@ -51,72 +57,92 @@ namespace CognitiveClarity.api
return badResponse; return badResponse;
} }
var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY"); var apiKey = System.Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
if (string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(apiKey))
{ {
_logger.LogError("GEMINI_API_KEY is missing from environment variables."); _logger.LogError("OPENROUTER_API_KEY is missing from environment variables.");
return req.CreateResponse(HttpStatusCode.InternalServerError); return req.CreateResponse(HttpStatusCode.InternalServerError);
} }
// Initialize the Gemini Client
var ai = new GeminiClient(apiKey: apiKey);
// Construct contents from history
var contents = new List<Content>();
foreach (var msg in data.History)
{
contents.Add(new Content
{
Role = msg.Role,
Parts = new List<Part> { new Part { Text = msg.Text } }
});
}
var roleplaySystemInstruction = $@"{data.SystemInstruction} var roleplaySystemInstruction = $@"{data.SystemInstruction}
[CONTEXT] [CONTEXT]
You are participating in a clinical CBT roleplay simulation. You are participating in a clinical CBT roleplay simulation.
The instructions above define your character, the scenario, and your internal CBT logic. The instructions above define your character, the scenario, and your internal CBT logic.
[TASK]
Respond to the user naturally in character to advance the roleplay scenario.
[CONSTRAINTS]
- CRITICAL: Stay strictly in character as the persona defined in the scenario.
- CRITICAL: NEVER provide clinical feedback, advice, or meta-commentary about the user's progress.
- CRITICAL: NEVER use phrases like ""Great job holding your boundary"" or ""Feedback:"".
- CRITICAL: Do not congratulate the user on using CBT techniques; simply react as the character would.
- If the user successfully concludes the scenario, end the dialogue naturally.
[OUTPUT REQUIREMENTS]
Return the character's direct dialogue response. Keep it conversational and proportional to the user's input.
";
try [TASK]
Respond to the user naturally in character to advance the roleplay scenario.
[CONSTRAINTS]
- CRITICAL: Stay strictly in character as the persona defined in the scenario.
- CRITICAL: NEVER provide clinical feedback, advice, or meta-commentary about the user's progress.
- CRITICAL: NEVER use phrases like ""Great job holding your boundary"" or ""Feedback:"".
- CRITICAL: Do not congratulate the user on using CBT techniques; simply react as the character would.
- If the user successfully concludes the scenario, end the dialogue naturally.
[OUTPUT REQUIREMENTS]
Return the character's direct dialogue response. Keep it conversational and proportional to the user's input.";
var messages = new List<object>
{ {
var response = await ai.Models.GenerateContentAsync( new { role = "system", content = roleplaySystemInstruction }
model: "gemini-3.0-flash", };
contents: contents,
new GenerateContentConfig foreach (var msg in data.History)
{ {
SystemInstruction = new Content { Parts = new List<Part> { new Part { Text = roleplaySystemInstruction } } }, messages.Add(new
// Streaming isn't natively supported in standard HTTP Functions easily without Server-Sent Events (SSE). {
// We will return a standard block response for now, which simplifies the architecture. role = msg.Role == "model" ? "assistant" : msg.Role,
}); content = msg.Text
});
}
var payload = new
{
model = string.IsNullOrEmpty(data.Model) ? "google/gemini-3.8-flash" : data.Model,
messages = messages,
temperature = 0.7,
max_tokens = 1000
};
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.Add("HTTP-Referer", "https://cognitive-clarity.app");
client.DefaultRequestHeaders.Add("X-Title", "Cognitive Clarity");
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var response = await client.PostAsync("https://openrouter.ai/api/v1/chat/completions", content);
if (!response.IsSuccessStatusCode)
{
var errText = await response.Content.ReadAsStringAsync();
_logger.LogError("OpenRouter returned error {Status}: {Error}", response.StatusCode, errText);
var errResponse = req.CreateResponse(response.StatusCode);
await errResponse.WriteStringAsync(errText);
return errResponse;
}
var resJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(resJson);
var botText = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? string.Empty;
var successResponse = req.CreateResponse(HttpStatusCode.OK); var successResponse = req.CreateResponse(HttpStatusCode.OK);
successResponse.Headers.Add("Content-Type", "application/json"); successResponse.Headers.Add("Content-Type", "application/json");
var resultObj = new { text = botText };
// Return just the text in a JSON wrapper
var resultObj = new { text = response.Text };
await successResponse.WriteStringAsync(JsonSerializer.Serialize(resultObj)); await successResponse.WriteStringAsync(JsonSerializer.Serialize(resultObj));
return successResponse; return successResponse;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to generate chat progression via Gemini API."); _logger.LogError(ex, "Failed to generate chat progression via OpenRouter API.");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteStringAsync("Error communicating with Gemini API."); await errorResponse.WriteStringAsync("Error communicating with OpenRouter API.");
return errorResponse; return errorResponse;
} }
} }
+1
View File
@@ -8,6 +8,7 @@ var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication(); builder.ConfigureFunctionsWebApplication();
builder.Services builder.Services
.AddHttpClient()
.AddApplicationInsightsTelemetryWorkerService() .AddApplicationInsightsTelemetryWorkerService()
.ConfigureFunctionsApplicationInsights(); .ConfigureFunctionsApplicationInsights();
+81 -59
View File
@@ -1,10 +1,11 @@
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Google.GenAI;
using System.Text.Json.Serialization;
namespace CognitiveClarity.api namespace CognitiveClarity.api
{ {
@@ -18,22 +19,27 @@ namespace CognitiveClarity.api
[JsonPropertyName("difficulty")] [JsonPropertyName("difficulty")]
public string Difficulty { get; set; } = "Intermediate"; public string Difficulty { get; set; } = "Intermediate";
[JsonPropertyName("model")]
public string Model { get; set; } = "google/gemini-3.8-flash";
} }
public class ScenarioFunction public class ScenarioFunction
{ {
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
public ScenarioFunction(ILoggerFactory loggerFactory) public ScenarioFunction(ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory)
{ {
_logger = loggerFactory.CreateLogger<ScenarioFunction>(); _logger = loggerFactory.CreateLogger<ScenarioFunction>();
_httpClientFactory = httpClientFactory;
} }
[Function("GenerateScenario")] [Function("GenerateScenario")]
public async Task<HttpResponseData> Run( public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "scenario/generate")] HttpRequestData req) [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "scenario/generate")] HttpRequestData req)
{ {
_logger.LogInformation("C# HTTP trigger function processed a scenario generation request."); _logger.LogInformation("C# HTTP trigger function processed a scenario generation request via OpenRouter.");
var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var data = JsonSerializer.Deserialize<ScenarioRequest>(requestBody); var data = JsonSerializer.Deserialize<ScenarioRequest>(requestBody);
@@ -45,84 +51,100 @@ namespace CognitiveClarity.api
return badResponse; return badResponse;
} }
var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY"); var apiKey = System.Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
if (string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(apiKey))
{ {
_logger.LogError("GEMINI_API_KEY is missing from environment variables."); _logger.LogError("OPENROUTER_API_KEY is missing from environment variables.");
return req.CreateResponse(HttpStatusCode.InternalServerError); return req.CreateResponse(HttpStatusCode.InternalServerError);
} }
// Initialize the Gemini Client
var ai = new GeminiClient(apiKey: apiKey);
var selectedContext = data.SelectedConcepts.Count > 0 var selectedContext = data.SelectedConcepts.Count > 0
? $"Focus specifically on these CBT concepts: {string.Join(", ", data.SelectedConcepts)}." ? $"Focus specifically on these CBT concepts: {string.Join(", ", data.SelectedConcepts)}."
: ""; : "";
var systemInstruction = $@" var systemInstruction = $@"
[CONTEXT] [CONTEXT]
You are a Clinical CBT Scenario Architect. You are a Clinical CBT Scenario Architect.
The user requires a structured clinical simulation designed to address specific cognitive distortions and behavioral challenges. The user requires a structured clinical simulation designed to address specific cognitive distortions and behavioral challenges.
Clinical Focus: {selectedContext} 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 '{data.Difficulty}'.
- '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.
";
var schema = new Schema [TASK]
{ Transform the user's prompt into a cohesive, structured clinical scenario that sets up a targeted roleplay environment.
Type = TypeEnum.Object,
Properties = new Dictionary<string, Schema> [CONSTRAINTS]
{ - CATEGORIZATION: You MUST categorize the scenario appropriately (Social Anxiety, Workplace, Relationships, or Self-Talk).
{ "id", new Schema { Type = TypeEnum.String } }, - DIFFICULTY CALIBRATION: The requested difficulty is '{data.Difficulty}'.
{ "title", new Schema { Type = TypeEnum.String } }, - 'Beginner': Low stakes, high support, clear-cut distortions, AI character is cooperative.
{ "characterName", new Schema { Type = TypeEnum.String } }, - 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions, AI character has specific needs.
{ "description", new Schema { Type = TypeEnum.String } }, - 'Advanced': High emotional triggers, persistent clinical resistance, complex cognitive patterns.
{ "category", new Schema { Type = TypeEnum.String, Enum = new List<string> { "Social Anxiety", "Workplace", "Relationships", "Self-Talk" } } }, - 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.
{ "difficulty", new Schema { Type = TypeEnum.String, Enum = new List<string> { "Beginner", "Intermediate", "Advanced" } } }, - CHARACTER VOICE: Define a realistic, emotionally consistent voice profile.
{ "systemInstruction", new Schema { Type = TypeEnum.String } },
{ "initialMessage", new Schema { Type = TypeEnum.String } }, [OUTPUT REQUIREMENTS]
{ "cbtFocus", new Schema { Type = TypeEnum.Array, Items = new Schema { Type = TypeEnum.String } } } Return ONLY a valid JSON object matching this schema:
}, {{
Required = new List<string> { "id", "title", "characterName", "description", "category", "difficulty", "systemInstruction", "initialMessage", "cbtFocus" } ""id"": ""string"",
}; ""title"": ""string"",
""characterName"": ""string"",
""description"": ""string"",
""category"": ""Social Anxiety"" | ""Workplace"" | ""Relationships"" | ""Self-Talk"",
""difficulty"": ""Beginner"" | ""Intermediate"" | ""Advanced"",
""systemInstruction"": ""string"",
""initialMessage"": ""string"",
""cbtFocus"": [""string""]
}}";
var promptText = $"Generate a clinical scenario for: {(string.IsNullOrWhiteSpace(data.Prompt) ? "A scenario exploring the chosen CBT concepts." : data.Prompt)}"; var promptText = $"Generate a clinical scenario for: {(string.IsNullOrWhiteSpace(data.Prompt) ? "A scenario exploring the chosen CBT concepts." : data.Prompt)}";
try var payload = new
{ {
var response = await ai.Models.GenerateContentAsync( model = string.IsNullOrEmpty(data.Model) ? "google/gemini-3.8-flash" : data.Model,
model: "gemini-3.0-flash", messages = new object[]
contents: promptText, {
new GenerateContentConfig new { role = "system", content = systemInstruction },
{ new { role = "user", content = promptText }
SystemInstruction = new Content { Parts = new List<Part> { new Part { Text = systemInstruction } } }, },
ResponseMimeType = "application/json", response_format = new { type = "json_object" },
ResponseSchema = schema temperature = 0.7
}); };
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.Add("HTTP-Referer", "https://cognitive-clarity.app");
client.DefaultRequestHeaders.Add("X-Title", "Cognitive Clarity");
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var response = await client.PostAsync("https://openrouter.ai/api/v1/chat/completions", content);
if (!response.IsSuccessStatusCode)
{
var errText = await response.Content.ReadAsStringAsync();
_logger.LogError("OpenRouter returned error {Status}: {Error}", response.StatusCode, errText);
var errResponse = req.CreateResponse(response.StatusCode);
await errResponse.WriteStringAsync(errText);
return errResponse;
}
var resJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(resJson);
var scenarioJson = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? "{}";
var successResponse = req.CreateResponse(HttpStatusCode.OK); var successResponse = req.CreateResponse(HttpStatusCode.OK);
successResponse.Headers.Add("Content-Type", "application/json"); successResponse.Headers.Add("Content-Type", "application/json");
await successResponse.WriteStringAsync(response.Text!); await successResponse.WriteStringAsync(scenarioJson);
return successResponse; return successResponse;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to generate scenario via Gemini API."); _logger.LogError(ex, "Failed to generate scenario via OpenRouter API.");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteStringAsync("Error communicating with Gemini API."); await errorResponse.WriteStringAsync("Error communicating with OpenRouter API.");
return errorResponse; return errorResponse;
} }
} }
+1 -2
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
@@ -10,7 +10,6 @@
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Google.GenAI" Version="1.3.0" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" /> <PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" /> <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" /> <PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
+8
View File
@@ -0,0 +1,8 @@
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"OPENROUTER_API_KEY": "your_openrouter_api_key_here"
}
}
@@ -0,0 +1,21 @@
---
title: "Upgrade to Google Gemini 3.8 Flash & OpenRouter API Key Governance"
ticket: "GEMINI-3.8-UPGRADE"
type: "feature"
author: "Antigravity Autonomous Swarm"
date: "2026-09-29"
---
### Added
- **Gemini 3.8 Model Catalog Support**: Upgraded default roleplay and clinical supervisor models across the entire application to `google/gemini-3.8-flash` via OpenRouter.
- **Enhanced Model Selection**: Added `google/gemini-3.8-flash` (Frontier Ultra Low Latency) and `google/gemini-3.7-flash` (Hybrid Reasoning) to `AVAILABLE_MODELS` in `SettingsModal.tsx`.
- **TDD Test Suite**: Added zero-dependency automated unit test suite (`npm test`) using `node:test` + `tsx`, covering API key resolution, OpenRouter mock requests/SSE streaming, and scenario schemas.
- **Azure Functions Local Template**: Added `api/local.settings.example.json` with `OPENROUTER_API_KEY` configuration.
### Changed
- **Parameter & Key Propagation**: Wired `userSettings` through `App.tsx` down to `ChatSession`, `ScenarioGenerator`, `VoiceSession`, and `useLiveSession`, ensuring custom OpenRouter API keys and selected model IDs are respected on every call.
- **Azure Functions Default Models**: Updated `AnalysisFunction.cs`, `ChatFunction.cs`, and `ScenarioFunction.cs` default fallback models to `google/gemini-3.8-flash`.
- **Environment Template**: Updated `.env.example` to provide `VITE_OPENROUTER_API_KEY` and `OPENROUTER_API_KEY`.
### Removed
- **Dead Dependency Residue**: Stripped `@google/genai` from `package.json` dependencies and `vite.config.ts` rollup chunks.
+42
View File
@@ -0,0 +1,42 @@
---
title: "Full Frontend Rewrite to Svelte 5 (Runes & Bits UI Architecture)"
ticket: "SVELTE-5-REWRITE"
type: "refactor"
author: "Antigravity Autonomous Swarm"
date: "2026-09-29"
---
### Added
- **Svelte 5 Runes Architecture**: Fully rewritten frontend in Svelte 5 using runes (`$state`, `$derived`, `$effect`, `$props`, `$bindable`), eliminating virtual DOM overhead and React dependency chains.
- **AudioEngine Reactive Controller**: Engineered `src/lib/audio/AudioEngine.svelte.ts` managing WebAudio contexts, FFT analyser frequency extraction, Web Speech recognition, OpenRouter TTS synthesis, and parallel clinical supervision in microtasks.
- **Svelte 5 Reactive Stores**: Implemented `src/lib/stores/UserSettingsStore.svelte.ts` and `src/lib/stores/AppStateStore.svelte.ts` with local storage persistence and derived filters.
- **Svelte 5 Design System Primitives (`src/lib/components/ui/`)**:
- `Button.svelte`, `Badge.svelte`, `Card.svelte` (+ `Header`, `Title`, `Description`, `Content`, `Footer`)
- `Input.svelte`, `Textarea.svelte`, `Tooltip.svelte`, `Dialog.svelte`
- `Tabs.svelte`, `TabsList.svelte`, `TabsTrigger.svelte`, `TabsContent.svelte`, `tabsContext.ts`
- `ScrollArea.svelte`, `Label.svelte`, `Separator.svelte`
- **Recomposed Clinical Feature Components (`src/lib/components/`)**:
- `ScenarioCard.svelte`: Scenario card with difficulty badges, CBT protocol chips, and text/voice launching.
- `AudioVisualizer.svelte`: Canvas/CSS audio level visualizer with dynamic bar animations.
- `AudioDeviceSelector.svelte`: Microphone input device switcher.
- `LiveInsightPanel.svelte`: Turn-by-turn parallel supervisor feedback drawer.
- `SessionReport.svelte`: Comprehensive post-session CBT assessment report with Markdown and JSON exports.
- `ScenarioGenerator.svelte`: AI scenario generator modal with difficulty presets and clinical concept selection.
- `CBTReference.svelte`: Searchable glossary reference covering all 18 clinical terms and techniques.
- `SettingsModal.svelte`: Voice profile and OpenRouter API key/model selector modal.
- `TranscriptPanel.svelte`: Slide-over voice transcript drawer with role styling.
- `ChatMessage.svelte` & `ChatInput.svelte`: Roleplay message bubbles and character input box.
- `ChatSession.svelte`: Streaming text chat controller wired to OpenRouter and live supervisor.
- `VoiceSession/VoiceHeader.svelte`, `VoiceControls.svelte`, `ConnectionState.svelte`, `AnalyzingState.svelte`.
- `VoiceSession.svelte`: Full voice roleplay cockpit coordinating AudioEngine and visualizer.
- `ReloadPrompt.svelte` & `VersionBadge.svelte`: PWA reload prompt and app version indicator.
- **App Shell & Mounting (`src/`)**:
- `src/App.svelte`: Root application coordinator.
- `src/main.ts`: Svelte 5 `mount()` entry point targeting `#root`.
- **Vitest & Svelte-Check Verification Suites**:
- Upgraded test harness to `vitest` with 16/16 unit tests passing.
- `npx svelte-check`: 0 errors, 0 warnings across all 20 Svelte components.
### Changed
- **Entry Point**: Updated `index.html` to mount `/src/main.ts` and removed obsolete React importmap.
- **Vite Bundler**: Configured `svelte()` plugin alongside PWA injectManifest, tuning rollup manual chunks for `@lucide/svelte`, `svelte`, and `workbox`.
@@ -0,0 +1,33 @@
# Changelog Fragment: Natural Voice Audition Lab & Comprehensive Playwright Snapshot Suite
## Summary
Introduced an interactive Natural Voice Testing Lab, calibrated a gruff "salt-of-the-earth" vocal persona (`Fenrir` via OpenRouter `onyx`), and implemented a comprehensive 21-test Playwright E2E visual regression suite spanning Desktop Chrome, iPad Portrait, and Mobile iPhone viewports with 45 packaged review snapshots.
## Key Changes
- **Natural Voice Audition Lab (`services/voiceAuditionService.ts`, `src/lib/components/SettingsModal.svelte`)**:
- Implemented decoupled audio audition service supporting OpenRouter TTS (`audio/mpeg`) with automatic Web Speech fallback.
- Added quick-demo play/stop triggers on all voice persona selection cards in `SettingsModal`.
- Added dedicated interactive testing workbench with sample clinical dialogue buttons and custom text preview.
- **Salt-of-the-Earth Persona Calibration (`constants.ts`, `src/lib/audio/AudioEngine.svelte.ts`)**:
- Created `Fenrir (Salt of the Earth)` voice profile with `onyx` model mapping and tuned pitch/rate (`0.75` / `0.92`) for authentic, unvarnished clinical dialogue.
- **Playwright Multi-Viewport E2E Test Suite (`tests/e2e/`)**:
- Authored 4 comprehensive test suites across `desktop-chrome`, `ipad-portrait`, and `mobile-iphone` (21 tests passing, 100% coverage).
- Captured 45 full-page visual regression snapshots covering Landing, Filtering, Settings, CBT Reference, Scenario Generator, Chat Roleplay, and Voice Cockpit.
- Packaged interactive HTML review gallery at `docs/screenshots/index.html` with responsive viewport filters and high-DPI lightbox inspection.
- **Native Google Gemini 3.8 Flash TTS Integration (`services/geminiVoiceService.ts`)**:
- Implemented direct Google AI Studio client leveraging `models/gemini-3.8-flash-tts` with fallback to `gemini-2.5-flash-preview-tts`.
- Added support for native Google Gemini voice personalities (`Fenrir`, `Puck`, `Charon`, `Kore`, `Aoede`) producing authentic studio-grade `audio/wav` output.
- Built automatic RIFF/WAVE header framing and base64 audio decoding into WebAudio `AudioContext`.
- Configured browser-origin client requests (`http://localhost:3000`) respecting Google Cloud referrer restrictions.
- **Full-Bleed Voice Session Cockpit & Clinical Companion Redesign (`src/App.svelte`, `VoiceSession.svelte`, `LiveInsightPanel.svelte`)**:
- Re-architected `VoiceSession` from a constrained `h-[80vh]` card into an immersive full-bleed studio cockpit (`fixed inset-0 bg-slate-950`).
- Resolved the critical layout squishing defect where `LiveInsightPanel` had hardcoded `lg:w-[35%]` resulting in an unreadable ~134px vertical bookmark strip.
- Implemented a unified, non-intrusive **Clinical Companion Drawer (Supervision & Transcripts)** slide-over overlay with tabs, smooth slide transitions, and rich dark glassmorphic cards.
- Preserved permanent horizontal and vertical centering for the reactive audio visualizer orb so opening companion panels never jars or squishes the primary voice interface.
- Upgraded `ChatSession` with responsive dual-pane flex containers ensuring consistent layout across desktop, tablet, and mobile.
- **Svelte 5 Architectural Hardening**:
- Resolved `AudioDeviceSelector` auto-trigger defect where initial device discovery prematurely dismissed the modal.
- Fixed tab context reactivity in `Tabs.svelte` using Svelte 5 `$state` proxy pattern.
- Initialized `AudioEngine.status` to `'idle'` to eliminate cockpit initialization deadlock.
+100 -113
View File
@@ -1,9 +1,8 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Scenario, Message, AnalysisResult } from '../types'; import { Scenario, Message, AnalysisResult, UserSettings } from '../types';
import { GoogleGenAI, Chat, GenerateContentResponse } from "@google/genai";
import { BrainCircuit, ChevronLeft, Flag, Loader2 } from 'lucide-react'; import { BrainCircuit, ChevronLeft, Flag, Loader2 } from 'lucide-react';
import { SYSTEM_ANALYSIS_PROMPT, SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants'; import { streamOpenRouterChat, analyzeTurnWithOpenRouter, generateSessionSummaryReport } from '../services/openRouterService';
import { saveSession } from '../services/sessionStorage';
// Sub-components // Sub-components
import ChatMessage from './ChatMessage'; import ChatMessage from './ChatMessage';
@@ -13,10 +12,11 @@ import SessionReport from './SessionReport';
interface ChatSessionProps { interface ChatSessionProps {
scenario: Scenario; scenario: Scenario;
userSettings?: UserSettings;
onExit: () => void; onExit: () => void;
} }
const ChatSession: React.FC<ChatSessionProps> = ({ scenario, onExit }) => { const ChatSession: React.FC<ChatSessionProps> = ({ scenario, userSettings, onExit }) => {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState<'active' | 'analyzing' | 'finished'>('active'); const [status, setStatus] = useState<'active' | 'analyzing' | 'finished'>('active');
@@ -26,8 +26,6 @@ const ChatSession: React.FC<ChatSessionProps> = ({ scenario, onExit }) => {
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false); const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
const [finalReport, setFinalReport] = useState<AnalysisResult | null>(null); const [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
const chatRef = useRef<Chat | null>(null);
const analysisRef = useRef<Chat | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
@@ -40,9 +38,40 @@ const ChatSession: React.FC<ChatSessionProps> = ({ scenario, onExit }) => {
// Session Initialization // Session Initialization
useEffect(() => { useEffect(() => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); // Initial Message
setMessages([{
id: 'init',
role: 'model',
text: scenario.initialMessage,
timestamp: new Date()
}]);
setLiveInsight(null);
}, [scenario]);
const handleSend = useCallback(async (text: string) => {
if (isLoading || !text.trim()) return;
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
text: text,
timestamp: new Date()
};
const updatedHistory = [...messages, userMsg];
setMessages(updatedHistory);
setIsLoading(true);
const botMsgId = (Date.now() + 1).toString();
const botMsg: Message = {
id: botMsgId,
role: 'model',
text: '',
timestamp: new Date()
};
setMessages([...updatedHistory, botMsg]);
// Roleplay Chat Setup
const roleplaySystemInstruction = ` const roleplaySystemInstruction = `
${scenario.systemInstruction} ${scenario.systemInstruction}
@@ -64,95 +93,55 @@ const ChatSession: React.FC<ChatSessionProps> = ({ scenario, onExit }) => {
Return the character's direct dialogue response. Keep it conversational and proportional to the user's input. Return the character's direct dialogue response. Keep it conversational and proportional to the user's input.
`; `;
chatRef.current = ai.chats.create({
model: 'gemini-3-flash-preview',
config: { systemInstruction: roleplaySystemInstruction },
});
// Live Analysis Chat Setup
const analysisSystemInstruction = `${SYSTEM_ANALYSIS_PROMPT}
SCENARIO CONTEXT:
Title: ${scenario.title}
Description: ${scenario.description}
CBT Focus Areas: ${scenario.cbtFocus.join(', ')}`;
analysisRef.current = ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: analysisSystemInstruction,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
}
});
// Initial Message
setMessages([{
id: 'init',
role: 'model',
text: scenario.initialMessage,
timestamp: new Date()
}]);
setLiveInsight(null);
}, [scenario]);
const handleSend = useCallback(async (text: string) => {
if (!chatRef.current || isLoading) return;
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
text: text,
timestamp: new Date()
};
setMessages(prev => [...prev, userMsg]);
setIsLoading(true);
try { try {
const botMsgId = (Date.now() + 1).toString(); let accumulatedText = '';
const botMsg: Message = { await streamOpenRouterChat(
id: botMsgId, updatedHistory,
role: 'model', roleplaySystemInstruction,
text: '', {
timestamp: new Date() onChunk: (chunk) => {
}; accumulatedText += chunk;
setMessages(prev => [...prev, botMsg]); setMessages(prev => prev.map(m =>
m.id === botMsgId ? { ...m, text: accumulatedText } : m
const streamResponse = await chatRef.current.sendMessageStream({ message: text }); ));
let fullResponseText = ''; },
onComplete: (fullText) => {
for await (const chunk of streamResponse) { // Live Turn Clinical Analysis via OpenRouter
const c = chunk as GenerateContentResponse; setIsAnalyzingTurn(true);
const textChunk = c.text || ""; const turnContext = `User said: "${userMsg.text}". Roleplay partner said: "${fullText}".`;
fullResponseText += textChunk; analyzeTurnWithOpenRouter(
turnContext,
setMessages(prev => prev.map(m => scenario.title,
m.id === botMsgId ? { ...m, text: fullResponseText } : m scenario.description,
)); scenario.cbtFocus,
} {
apiKey: userSettings?.openRouterApiKey,
// Live Turn Analysis model: userSettings?.supervisorModel
if (analysisRef.current) { }
setIsAnalyzingTurn(true); ).then(res => {
const turnContext = `User said: "${userMsg.text}". Roleplay partner said: "${fullResponseText}".`; setLiveInsight(res);
}).catch(e => {
analysisRef.current.sendMessage({ message: turnContext }).then(res => { console.error("Live analysis failed", e);
try { }).finally(() => {
if (res.text) setLiveInsight(JSON.parse(res.text)); setIsAnalyzingTurn(false);
} catch (e) { console.error("Live analysis parse error", e); } finally { });
setIsAnalyzingTurn(false); },
onError: (err) => {
console.error("OpenRouter stream failed", err);
} }
}); },
} {
apiKey: userSettings?.openRouterApiKey,
model: userSettings?.roleplayModel
}
);
} catch (error) { } catch (error) {
console.error("Chat transmission failed", error); console.error("Chat transmission failed", error);
setMessages(prev => prev.filter(m => m.text !== '')); setMessages(prev => prev.filter(m => m.text !== ''));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [isLoading]); }, [messages, scenario, isLoading, userSettings]);
const handleFinishSession = async () => { const handleFinishSession = async () => {
if (messages.length < 2) return onExit(); if (messages.length < 2) return onExit();
@@ -160,28 +149,19 @@ CBT Focus Areas: ${scenario.cbtFocus.join(', ')}`;
setStatus('analyzing'); setStatus('analyzing');
try { try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); const reportData = await generateSessionSummaryReport(
const transcript = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n'); messages,
scenario.title,
scenario.description,
{
apiKey: userSettings?.openRouterApiKey,
model: userSettings?.supervisorModel
}
);
const summaryPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT} setFinalReport(reportData);
SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`; saveSession(scenario, 'text', messages, reportData);
setStatus('finished');
const result = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: summaryPrompt,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
},
contents: [{ role: 'user', parts: [{ text: `SESSION TRANSCRIPT:\n${transcript}` }] }]
});
if (result.text) {
setFinalReport(JSON.parse(result.text));
setStatus('finished');
} else {
throw new Error("Final synthesis failed");
}
} catch (err) { } catch (err) {
console.error("Post-session analysis failure", err); console.error("Post-session analysis failure", err);
onExit(); onExit();
@@ -196,13 +176,20 @@ SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`;
<div className="absolute inset-0 border-4 border-teal-500 border-t-transparent rounded-full animate-spin"></div> <div className="absolute inset-0 border-4 border-teal-500 border-t-transparent rounded-full animate-spin"></div>
</div> </div>
<h2 className="text-2xl font-bold text-slate-800 mb-2">Compiling Report</h2> <h2 className="text-2xl font-bold text-slate-800 mb-2">Compiling Report</h2>
<p className="text-slate-500 text-center max-w-sm">Generating evidence-based synthesis of your cognitive and behavioral patterns during this session...</p> <p className="text-slate-500 text-center max-w-sm">Generating evidence-based synthesis of your cognitive and behavioral patterns during this session via OpenRouter...</p>
</div> </div>
); );
} }
if (status === 'finished' && finalReport) { if (status === 'finished' && finalReport) {
return <SessionReport analysis={finalReport} onExit={onExit} />; return (
<SessionReport
analysis={finalReport}
scenarioTitle={scenario.title}
messages={messages}
onExit={onExit}
/>
);
} }
return ( return (
+130 -207
View File
@@ -1,6 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { GoogleGenAI, Type } from "@google/genai"; import { Scenario, UserSettings } 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, BarChart } from 'lucide-react'; import { Sparkles, X, Loader2, AlertCircle, Info, Target, UserCircle2, BrainCircuit, Check, BarChart } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -8,13 +7,15 @@ import { Textarea } from './ui/textarea';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { generateScenarioWithOpenRouter } from '../services/openRouterService';
interface ScenarioGeneratorProps { interface ScenarioGeneratorProps {
onGenerate: (scenario: Scenario) => void; onGenerate: (scenario: Scenario) => void;
onCancel: () => 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 [prompt, setPrompt] = useState('');
const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]); const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]);
const [selectedDifficulty, setSelectedDifficulty] = useState<Scenario['difficulty']>('Intermediate'); const [selectedDifficulty, setSelectedDifficulty] = useState<Scenario['difficulty']>('Intermediate');
@@ -34,240 +35,162 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
setError(null); setError(null);
try { try {
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY }); const scenario = await generateScenarioWithOpenRouter(
prompt,
const schema = { selectedConcepts,
type: Type.OBJECT, selectedDifficulty,
properties: { {
id: { type: Type.STRING }, apiKey: userSettings?.openRouterApiKey,
title: { type: Type.STRING }, model: userSettings?.roleplayModel
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");
}
scenario.difficulty = selectedDifficulty;
scenario.id = `custom-${Date.now()}-${scenario.id || 'generated'}`;
onGenerate(scenario);
} catch (err: any) { } catch (err: any) {
console.error("Generation error:", err); console.error("OpenRouter Scenario Generation error:", err);
setError("Clinical scenario synthesis failed. Please refine your parameters."); setError(err?.message || "Clinical scenario synthesis failed. Please check your OpenRouter API Key in Settings.");
} finally { } finally {
setIsLoading(false); 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 ( 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="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-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="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden border border-slate-100 flex flex-col max-h-[90vh]">
{/* Header */} {/* Header */}
<div className="p-6 bg-teal-600 text-white flex justify-between items-start relative overflow-hidden shrink-0"> <div className="p-6 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
<div className="relative z-10"> <div className="flex items-center gap-3">
<h2 className="text-2xl font-bold 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={28} className="text-teal-200" /> <Sparkles size={20} />
Scenario Synthesis </div>
</h2> <div>
<p className="text-teal-50 text-sm mt-1 font-light opacity-90"> <h2 className="text-lg font-bold text-slate-800">Clinical Scenario Architect</h2>
Construct an evidence-based clinical environment. <p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">
</p> AI Synthesis Engine • OpenRouter
</p>
</div>
</div> </div>
<Button <button
variant="ghost"
size="icon"
onClick={onCancel} 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} /> <X size={20} />
</Button> </button>
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-10 -mt-10 blur-2xl"></div>
</div> </div>
<ScrollArea className="flex-grow p-8 bg-[#FCFDFF]"> <ScrollArea className="p-8 space-y-8 flex-grow">
<div className="space-y-6 pb-4"> {/* Natural Language Prompt */}
{/* Prompting Guide */} <div className="space-y-3">
<Card className="bg-teal-50/50 border-teal-100 p-4 shadow-none"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-teal-700 mb-3"> <label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<Info size={16} /> <Target size={14} className="text-teal-600" />
<h3 className="text-xs font-bold uppercase tracking-wider">Clinical Blueprint</h3> Target Dynamic & Situation
</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
</label> </label>
<div className="grid grid-cols-3 gap-2"> <span className="text-[10px] text-slate-400">Describe the conflict or trigger</span>
{(['Beginner', 'Intermediate', 'Advanced'] as const).map((level) => ( </div>
<Button <Textarea
key={level} placeholder="e.g. Asking my manager for a delayed performance review while fearing I'll be fired for bringing it up..."
variant={selectedDifficulty === level ? "default" : "outline"} value={prompt}
onClick={() => setSelectedDifficulty(level)} onChange={(e) => setPrompt(e.target.value)}
className={`h-12 flex items-center justify-center gap-2 rounded-xl border-2 transition-all text-[11px] font-bold ${selectedDifficulty === level 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"
? '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' </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'} /> {item.term}
{level} {isSelected && <Check size={12} className="ml-1.5 inline" />}
</Button> </Badge>
))} );
</div> })}
</div> </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> </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> </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 <Button
onClick={handleGenerate} onClick={handleGenerate}
disabled={(!prompt.trim() && selectedConcepts.length === 0) || isLoading} disabled={isLoading || (!prompt.trim() && selectedConcepts.length === 0)}
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" 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 ? ( {isLoading ? (
<> <>
<Loader2 size={20} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
Synthesizing... Synthesizing Scenario...
</> </>
) : ( ) : (
<> <>
<Sparkles size={20} /> <Sparkles size={16} />
Initiate Build Generate Simulation
</> </>
)} )}
</Button> </Button>
+76 -13
View File
@@ -1,14 +1,42 @@
import React, { useState, useEffect } from 'react';
import React from 'react'; import { AnalysisResult, Message } from '../types';
import { AnalysisResult } from '../types'; import { ClipboardCheck, X, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp, Download, Copy, Check, FileText } from 'lucide-react';
import { ClipboardCheck, X, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp } from 'lucide-react'; import { Button } from './ui/button';
import { formatSessionAsMarkdown, downloadFile } from '../services/sessionStorage';
interface SessionReportProps { interface SessionReportProps {
analysis: AnalysisResult; analysis: AnalysisResult;
scenarioTitle?: string;
messages?: Message[];
onExit: () => void; onExit: () => void;
} }
const SessionReport: React.FC<SessionReportProps> = ({ analysis, onExit }) => { const SessionReport: React.FC<SessionReportProps> = ({
analysis,
scenarioTitle = "CBT Roleplay Session",
messages = [],
onExit
}) => {
const [copied, setCopied] = useState(false);
const handleCopyReframe = () => {
navigator.clipboard.writeText(analysis.suggestions.alternativeThought);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleExportMarkdown = () => {
const md = formatSessionAsMarkdown(scenarioTitle, analysis, messages);
const fileName = `cbt-report-${scenarioTitle.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.md`;
downloadFile(md, fileName, 'text/markdown');
};
const handleExportJSON = () => {
const json = JSON.stringify({ scenarioTitle, analysis, messages, exportedAt: new Date().toISOString() }, null, 2);
const fileName = `cbt-report-${scenarioTitle.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json`;
downloadFile(json, fileName, 'application/json');
};
return ( return (
<div className="flex flex-col h-full bg-[#FDFDFF] rounded-3xl border border-slate-200 overflow-hidden shadow-2xl animate-in zoom-in-95 duration-500 w-full max-w-4xl mx-auto"> <div className="flex flex-col h-full bg-[#FDFDFF] rounded-3xl border border-slate-200 overflow-hidden shadow-2xl animate-in zoom-in-95 duration-500 w-full max-w-4xl mx-auto">
<div className="bg-teal-600 p-6 flex justify-between items-center text-white"> <div className="bg-teal-600 p-6 flex justify-between items-center text-white">
@@ -16,12 +44,36 @@ const SessionReport: React.FC<SessionReportProps> = ({ analysis, onExit }) => {
<ClipboardCheck size={28} /> <ClipboardCheck size={28} />
<div> <div>
<h2 className="text-xl font-bold leading-none">Session Summary Report</h2> <h2 className="text-xl font-bold leading-none">Session Summary Report</h2>
<span className="text-[10px] font-bold uppercase tracking-widest text-teal-100 opacity-80">Clinical Analysis Complete</span> <span className="text-[10px] font-bold uppercase tracking-widest text-teal-100 opacity-80">
{scenarioTitle} • Clinical Evaluation Complete
</span>
</div> </div>
</div> </div>
<button onClick={onExit} className="bg-white/10 hover:bg-white/20 p-2 rounded-xl border border-white/10 transition-colors"> <div className="flex items-center gap-2">
<X size={20} /> <Button
</button> variant="ghost"
size="sm"
onClick={handleExportMarkdown}
className="text-white hover:bg-white/20 rounded-xl text-xs flex items-center gap-1.5"
title="Download Markdown Report"
>
<FileText size={16} />
<span className="hidden sm:inline">Export MD</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleExportJSON}
className="text-white hover:bg-white/20 rounded-xl text-xs flex items-center gap-1.5"
title="Download JSON Report"
>
<Download size={16} />
<span className="hidden sm:inline">JSON</span>
</Button>
<button onClick={onExit} className="bg-white/10 hover:bg-white/20 p-2 rounded-xl border border-white/10 transition-colors ml-1">
<X size={20} />
</button>
</div>
</div> </div>
<div className="flex-grow overflow-y-auto p-8 flex flex-col gap-6"> <div className="flex-grow overflow-y-auto p-8 flex flex-col gap-6">
@@ -55,10 +107,21 @@ const SessionReport: React.FC<SessionReportProps> = ({ analysis, onExit }) => {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-2xl border border-teal-100 shadow-sm"> <div className="bg-white p-6 rounded-2xl border border-teal-100 shadow-sm relative group">
<div className="flex items-center gap-2 text-teal-600 mb-3"> <div className="flex items-center justify-between mb-3">
<Lightbulb size={20} /> <div className="flex items-center gap-2 text-teal-600">
<span className="text-[10px] font-black uppercase tracking-widest">Master Reframe</span> <Lightbulb size={20} />
<span className="text-[10px] font-black uppercase tracking-widest">Master Reframe</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCopyReframe}
className="h-7 px-2 text-[10px] text-slate-400 hover:text-teal-700 flex items-center gap-1"
>
{copied ? <Check size={12} className="text-teal-600" /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
</Button>
</div> </div>
<p className="text-base text-slate-800 italic font-medium leading-relaxed">"{analysis.suggestions.alternativeThought}"</p> <p className="text-base text-slate-800 italic font-medium leading-relaxed">"{analysis.suggestions.alternativeThought}"</p>
</div> </div>
+177 -77
View File
@@ -1,12 +1,15 @@
import React from 'react'; import React, { useState } from 'react';
import { UserSettings } from '../types'; import { UserSettings } from '../types';
import { X, Volume2, Check, Info, Mic2, Settings2 } from 'lucide-react'; import { X, Volume2, Check, Info, Mic2, Settings2, Key, Sparkles, Cpu } from 'lucide-react';
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';
import AudioDeviceSelector from './AudioDeviceSelector'; import AudioDeviceSelector from './AudioDeviceSelector';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
interface SettingsModalProps { interface SettingsModalProps {
settings: UserSettings; settings: UserSettings;
@@ -30,18 +33,40 @@ const VOICE_OPTIONS: VoiceOption[] = [
{ id: 'Fenrir', label: 'Fenrir', gender: 'Male', style: 'Rugged', description: 'Direct and matter-of-fact tone with a slightly more serious edge.' }, { id: 'Fenrir', label: 'Fenrir', gender: 'Male', style: 'Rugged', description: 'Direct and matter-of-fact tone with a slightly more serious edge.' },
]; ];
const AVAILABLE_MODELS = [
{ id: 'google/gemini-3.8-flash', name: 'Google Gemini 3.8 Flash', badge: 'Default • Frontier Ultra Low Latency' },
{ id: 'google/gemini-3.7-flash', name: 'Google Gemini 3.7 Flash', badge: 'Fast • Hybrid Reasoning' },
{ id: 'google/gemini-2.5-flash', name: 'Google Gemini 2.5 Flash', badge: 'Stable • Low Latency' },
{ id: 'anthropic/claude-3.5-haiku', name: 'Anthropic Claude 3.5 Haiku', badge: 'Nuanced Reasoning' },
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Meta Llama 3.3 70B Instruct', badge: 'Open Weights' },
];
const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClose }) => { const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClose }) => {
const [activeTab, setActiveTab] = useState('voice');
const [openRouterKey, setOpenRouterKey] = useState(settings.openRouterApiKey || '');
const [selectedModel, setSelectedModel] = useState(settings.roleplayModel || 'google/gemini-3.8-flash');
const handleSave = () => {
onUpdate({
...settings,
openRouterApiKey: openRouterKey.trim() || undefined,
roleplayModel: selectedModel,
supervisorModel: selectedModel,
});
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 max-h-[90vh] flex flex-col"> <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 flex-shrink-0"> <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} /> <Settings2 size={20} />
</div> </div>
<div> <div>
<h2 className="text-lg font-bold text-slate-800">Voice & Persona Settings</h2> <h2 className="text-lg font-bold text-slate-800">Application Settings</h2>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Configure your AI clinical partner</p> <p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Powered by OpenRouter Engine</p>
</div> </div>
</div> </div>
<Button variant="ghost" size="icon" onClick={onClose} className="text-slate-400 hover:bg-slate-200 rounded-xl"> <Button variant="ghost" size="icon" onClick={onClose} className="text-slate-400 hover:bg-slate-200 rounded-xl">
@@ -49,81 +74,156 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClo
</Button> </Button>
</div> </div>
<ScrollArea className="p-8 space-y-8 flex-grow"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full flex-grow flex flex-col overflow-hidden">
<section className="space-y-4 pb-6"> <div className="px-6 pt-4 border-b border-slate-100">
<div className="flex items-center justify-between"> <TabsList className="grid grid-cols-2 w-full max-w-xs">
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2"> <TabsTrigger value="voice" className="text-xs">
<Mic2 size={14} className="text-teal-600" /> <Volume2 size={14} className="mr-1.5" />
Vocal Profile Voice & Audio
</h3> </TabsTrigger>
<Tooltip content="Different voices can help simulate specific social or professional contexts."> <TabsTrigger value="openrouter" className="text-xs">
<Info size={14} className="text-slate-300" /> <Key size={14} className="mr-1.5" />
</Tooltip> OpenRouter AI
</div> </TabsTrigger>
</TabsList>
<div className="grid grid-cols-1 gap-3">
{VOICE_OPTIONS.map((voice) => (
<Card
key={voice.id}
onClick={() => onUpdate({ ...settings, voiceName: voice.id, genderPreference: voice.gender, vocalStyle: voice.style })}
className={`flex flex-row items-center justify-between p-4 cursor-pointer transition-all ${settings.voiceName === voice.id
? 'bg-teal-50 border-teal-500 shadow-md shadow-teal-100'
: 'bg-white border-slate-200 hover:border-teal-200'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-full flex items-center shrink-0 justify-center ${settings.voiceName === voice.id ? 'bg-teal-600 text-white' : 'bg-slate-100 text-slate-400'
}`}>
<Volume2 size={18} />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-slate-800">{voice.label}</span>
<Badge variant="secondary" className="text-[9px] font-black uppercase text-slate-500 rounded">
{voice.gender} • {voice.style}
</Badge>
</div>
<p className="text-xs text-slate-500 font-light mt-0.5">{voice.description}</p>
</div>
</div>
{settings.voiceName === voice.id && (
<div className="bg-teal-600 text-white p-1 rounded-full shrink-0 ml-2">
<Check size={14} />
</div>
)}
</Card>
))}
</div>
</section>
<section className="space-y-4 pb-6">
<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 mb-6">
<div className="text-amber-600 mt-0.5">
<Info size={18} />
</div>
<p className="text-xs text-amber-800 leading-relaxed">
These settings apply to <strong>Voice Mode</strong> interactions only. Text-based roleplay personas are dynamically generated based on each specific scenario.
</p>
</div> </div>
</ScrollArea>
<div className="p-6 bg-slate-50 border-t border-slate-200 flex justify-end flex-shrink-0"> <ScrollArea className="p-6 flex-grow overflow-y-auto">
<TabsContent value="voice" className="space-y-6 m-0">
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<Mic2 size={14} className="text-teal-600" />
Vocal Profile
</h3>
<Tooltip content="Different voices can help simulate specific social or professional contexts.">
<Info size={14} className="text-slate-300" />
</Tooltip>
</div>
<div className="grid grid-cols-1 gap-2.5">
{VOICE_OPTIONS.map((voice) => (
<Card
key={voice.id}
onClick={() => onUpdate({ ...settings, voiceName: voice.id, genderPreference: voice.gender, vocalStyle: voice.style })}
className={`flex flex-row items-center justify-between p-3.5 cursor-pointer transition-all ${settings.voiceName === voice.id
? 'bg-teal-50 border-teal-500 shadow-sm shadow-teal-100'
: 'bg-white border-slate-200 hover:border-teal-200'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-full flex items-center shrink-0 justify-center ${settings.voiceName === voice.id ? 'bg-teal-600 text-white' : 'bg-slate-100 text-slate-400'
}`}>
<Volume2 size={16} />
</div>
<div>
<div className="flex items-center gap-2 mb-0.5">
<span className="font-bold text-slate-800 text-sm">{voice.label}</span>
<Badge variant="secondary" className="text-[9px] font-black uppercase text-slate-500 rounded">
{voice.gender} • {voice.style}
</Badge>
</div>
<p className="text-xs text-slate-500 font-light">{voice.description}</p>
</div>
</div>
{settings.voiceName === voice.id && (
<div className="bg-teal-600 text-white p-1 rounded-full shrink-0 ml-2">
<Check size={12} />
</div>
)}
</Card>
))}
</div>
</section>
<section className="space-y-3 pt-2">
<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" />
Input Microphone
</h3>
</div>
<AudioDeviceSelector
selectedDeviceId={settings.preferredAudioDeviceId}
onSelect={(deviceId) => onUpdate({ ...settings, preferredAudioDeviceId: deviceId })}
/>
</section>
</TabsContent>
<TabsContent value="openrouter" className="space-y-6 m-0">
<div className="space-y-4">
<div>
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2 mb-1">
<Key size={14} className="text-teal-600" />
OpenRouter API Key
</h3>
<p className="text-xs text-slate-500">
Enter your OpenRouter API Key (<code className="font-mono text-xs">sk-or-v1-...</code>). All clinical roleplay, live insights, and scenario architect functions route through OpenRouter.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openrouter-key" className="text-xs font-semibold text-slate-700">
API Key
</Label>
<Input
id="openrouter-key"
type="password"
placeholder="sk-or-v1-..."
value={openRouterKey}
onChange={(e) => setOpenRouterKey(e.target.value)}
className="font-mono text-xs"
/>
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-slate-700 flex items-center gap-1.5">
<Cpu size={14} className="text-teal-600" />
Roleplay & Supervisor Model
</Label>
</div>
<div className="grid grid-cols-1 gap-2">
{AVAILABLE_MODELS.map((model) => (
<Card
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={`p-3 cursor-pointer transition-all flex items-center justify-between border ${selectedModel === model.id
? 'bg-teal-50 border-teal-500 shadow-sm'
: 'bg-white border-slate-200 hover:border-slate-300'
}`}
>
<div>
<p className="text-xs font-bold text-slate-800">{model.name}</p>
<p className="text-[10px] text-slate-400 font-mono mt-0.5">{model.id}</p>
</div>
<Badge variant="outline" className="text-[9px] font-semibold text-teal-700 border-teal-200">
{model.badge}
</Badge>
</Card>
))}
</div>
</div>
</div>
<div className="bg-teal-50 border border-teal-100 rounded-2xl p-4 flex gap-3">
<div className="text-teal-600 mt-0.5">
<Sparkles size={18} />
</div>
<p className="text-xs text-teal-900 leading-relaxed">
Your OpenRouter API Key is stored securely in your browser LocalStorage and is never shared or transmitted elsewhere.
</p>
</div>
</TabsContent>
</ScrollArea>
</Tabs>
<div className="p-5 bg-slate-50 border-t border-slate-200 flex justify-end flex-shrink-0">
<Button <Button
onClick={onClose} onClick={handleSave}
className="bg-slate-800 text-white px-8 h-12 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 h-11 rounded-2xl font-bold hover:bg-slate-900 shadow-lg shadow-slate-200 transition-all uppercase tracking-widest text-[10px]"
> >
Save Preferences Save Preferences
</Button> </Button>
+126 -160
View File
@@ -1,11 +1,10 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Scenario, UserSettings, AnalysisResult } from '../types'; import { Scenario, UserSettings, AnalysisResult } from '../types';
import { GoogleGenAI } from '@google/genai';
import { SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
import { useLiveSession } from '../hooks/useLiveSession'; import { useLiveSession } from '../hooks/useLiveSession';
import { AlertCircle, Settings2, X, BrainCircuit, Zap, Info } from 'lucide-react'; import { AlertCircle, Settings2, X, AlertTriangle } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { generateSessionSummaryReport } from '../services/openRouterService';
import { saveSession } from '../services/sessionStorage';
// Sub-components // Sub-components
import AudioVisualizer from './AudioVisualizer'; import AudioVisualizer from './AudioVisualizer';
@@ -34,6 +33,7 @@ const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSett
const { const {
status, status,
setStatus, setStatus,
errorMessage,
isMuted, isMuted,
toggleMute, toggleMute,
audioLevel, audioLevel,
@@ -44,7 +44,6 @@ const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSett
liveInsight, liveInsight,
isAnalyzingTurn, isAnalyzingTurn,
isSilenceDetected, isSilenceDetected,
sessionPromiseRef,
startSession startSession
} = useLiveSession(scenario, voiceSettings); } = useLiveSession(scenario, voiceSettings);
@@ -58,188 +57,155 @@ const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSett
const handleFinishSession = useCallback(async () => { const handleFinishSession = useCallback(async () => {
setStatus('analyzing'); setStatus('analyzing');
if (sessionPromiseRef.current) {
sessionPromiseRef.current.then(session => session.close());
}
try { try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); const reportData = await generateSessionSummaryReport(
const transcriptText = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n'); messages,
scenario.title,
scenario.description,
{
apiKey: voiceSettings.openRouterApiKey,
model: voiceSettings.supervisorModel
}
);
const summaryPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT} setFinalReport(reportData);
SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`; saveSession(scenario, 'voice', messages, reportData);
setStatus('finished');
const result = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: summaryPrompt,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
},
contents: [{ role: 'user', parts: [{ text: `SESSION TRANSCRIPT:\n${transcriptText}` }] }]
});
if (result.text) {
setFinalReport(JSON.parse(result.text));
setStatus('finished');
} else {
throw new Error("Analysis failed");
}
} catch (err) { } catch (err) {
console.error("Post-session analysis failure", err); console.error("Post-session analysis failure", err);
onExit(); onExit();
} }
}, [messages, scenario, setStatus, sessionPromiseRef, onExit]); }, [messages, scenario, voiceSettings.openRouterApiKey, voiceSettings.supervisorModel, setStatus, onExit]);
if (status === 'connecting') return <ConnectionState />; if (status === 'connecting') return <ConnectionState />;
if (status === 'analyzing') return <AnalyzingState />; if (status === 'analyzing') return <AnalyzingState />;
if (status === 'finished' && finalReport) { if (status === 'finished' && finalReport) {
return <SessionReport analysis={finalReport} onExit={onExit} />; return (
<SessionReport
analysis={finalReport}
scenarioTitle={scenario.title}
messages={messages}
onExit={onExit}
/>
);
} }
return ( return (
<div className="flex flex-col lg:flex-row h-full gap-4 relative overflow-hidden"> <div className="flex flex-col h-full bg-slate-900 rounded-3xl border border-white/10 overflow-hidden relative shadow-2xl">
{/* Main Interaction Area */} {/* Voice Session Header */}
<div className="flex-grow bg-slate-900 rounded-3xl overflow-hidden flex flex-col relative shadow-2xl border border-white/5"> <VoiceHeader
{/* Animated Background Aura */} scenario={scenario}
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-20"> onExit={onExit}
<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> onOpenDeviceSelector={() => setShowDeviceSelector(true)}
</div> />
<VoiceHeader {/* Main Interaction Stage */}
title={scenario.title} <div className="flex-grow flex flex-col lg:flex-row items-center justify-between p-6 md:p-12 relative overflow-hidden gap-8">
onExit={onExit} <div className="flex-grow flex flex-col items-center justify-center w-full relative z-10">
onFinish={handleFinishSession} {/* Error Banner */}
/> {status === 'error' && (
<div className="mb-6 p-4 rounded-2xl bg-rose-950/80 border border-rose-500/50 text-rose-200 flex items-center gap-3 max-w-md animate-in fade-in">
<div className="flex-grow flex flex-col items-center justify-center p-12 relative z-10"> <AlertTriangle className="text-rose-400 shrink-0" size={20} />
{!hasStarted && status === 'active' ? ( <div className="text-xs">
<div className="flex flex-col items-center animate-in fade-in zoom-in duration-700"> <p className="font-bold">Connection Warning</p>
<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="opacity-90">{errorMessage || 'Unable to connect to live voice session. Please verify your OpenRouter API key.'}</p>
<div className="absolute inset-0 bg-teal-500/20 rounded-full animate-ping"></div>
<BrainCircuit size={40} className="text-teal-400 relative z-10" />
</div> </div>
<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"> {/* Silence Warning Notice */}
<div className="bg-teal-500/20 p-2 rounded-lg"> {isSilenceDetected && !isMuted && status === 'active' && (
<Info size={16} className="text-teal-400" /> <div className="absolute top-0 flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 text-amber-300 px-4 py-2 rounded-full text-xs animate-bounce shadow-lg">
</div> <AlertCircle size={14} />
<p className="text-[11px] text-slate-300 leading-relaxed"> <span>Low microphone input detected. Please speak closer to your mic.</span>
<strong>Greeting Hint:</strong> Say "Hello {scenario.characterName}" or just wait for them to speak first. </div>
</p> )}
</div>
{/* Audio Visualizer Orb */}
<div className="relative mb-8 flex items-center justify-center">
<AudioVisualizer
level={activeLevel}
isAiSpeaking={isAiSpeaking}
isUserSpeaking={isUserSpeaking}
isMuted={isMuted}
/>
{!hasStarted && status === 'active' && (
<Button <Button
onClick={handleStart} onClick={handleStart}
className="bg-teal-600 hover:bg-teal-500 text-white px-12 py-7 rounded-2xl font-bold transition-all shadow-xl shadow-teal-900/20 uppercase tracking-widest text-sm" className="absolute z-20 bg-teal-500 hover:bg-teal-400 text-slate-950 font-black px-8 py-6 rounded-full text-sm uppercase tracking-widest shadow-xl shadow-teal-500/20 hover:scale-105 transition-all"
> >
<Zap size={18} fill="currentColor" className="mr-3" /> Begin Voice Simulation
Start Session
</Button> </Button>
)}
</div>
{/* Status Indicators */}
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/5 border border-white/10 backdrop-blur-md">
<span className={`w-2 h-2 rounded-full ${isAiSpeaking ? 'bg-blue-400 animate-ping' : isUserSpeaking ? 'bg-teal-400 animate-pulse' : 'bg-slate-500'}`} />
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-300">
{isAiSpeaking ? `${scenario.characterName} is speaking` : isUserSpeaking ? 'Listening to you...' : 'Ready for dialogue'}
</span>
</div> </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
variant="secondary"
size="sm"
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"
>
Change Input Device
</Button>
</div>
</div>
</div>
)}
{/* Speaker Indicator Badge */} <p className="text-xs text-slate-400 font-light mt-1">
<div className="mb-8 flex flex-col items-center"> {voiceSettings.voiceName} Vocal Persona Active
<div className={`px-4 py-1.5 rounded-full border transition-all duration-500 flex items-center gap-2 ${isAiSpeaking </p>
? 'bg-blue-500/20 border-blue-400/30 text-blue-300' </div>
: (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>
{/* Device Selector Overlay */} {/* Live Insight Drawer */}
{showDeviceSelector && ( <div className="w-full lg:w-96 flex-shrink-0 h-full max-h-[500px] lg:max-h-none flex flex-col justify-end">
<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"> <LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} />
<div className="bg-white rounded-3xl p-6 w-full max-w-sm shadow-2xl border border-slate-100"> </div>
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="bg-slate-800 p-2 rounded-xl text-white">
<Settings2 size={18} />
</div>
<h3 className="font-bold text-slate-800">Audio Input</h3>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setShowDeviceSelector(false)}
className="text-slate-400 hover:bg-slate-100 rounded-xl"
>
<X size={20} />
</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)} />}
</div> </div>
{/* Real-time Insights Module */} {/* Persistent Audio Controls Footer */}
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} /> <VoiceControls
isMuted={isMuted}
onToggleMute={toggleMute}
onFinish={handleFinishSession}
onToggleTranscript={() => setShowTranscript(prev => !prev)}
showTranscript={showTranscript}
messageCount={messages.length}
/>
{/* Transcript Drawer Slideout */}
{showTranscript && (
<TranscriptPanel
messages={messages}
onClose={() => setShowTranscript(false)}
/>
)}
{/* Audio Hardware Selector Modal */}
{showDeviceSelector && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div className="bg-slate-900 border border-white/10 rounded-3xl p-6 max-w-md w-full shadow-2xl">
<div className="flex justify-between items-center mb-6">
<h3 className="text-sm font-bold uppercase tracking-widest text-white flex items-center gap-2">
<Settings2 size={16} className="text-teal-400" />
Select Audio Input Device
</h3>
<button
onClick={() => setShowDeviceSelector(false)}
className="text-slate-400 hover:text-white p-1"
>
<X size={18} />
</button>
</div>
<AudioDeviceSelector
selectedDeviceId={voiceSettings.preferredAudioDeviceId}
onSelect={(deviceId) => {
onUpdateSettings({ ...voiceSettings, preferredAudioDeviceId: deviceId });
setShowDeviceSelector(false);
}}
/>
</div>
</div>
)}
</div> </div>
); );
}; };
+2 -3
View File
@@ -23,9 +23,8 @@ const badgeVariants = cva(
} }
) )
export interface BadgeProps export type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants>;
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
+94 -16
View File
@@ -1,5 +1,71 @@
import { Scenario } from './types'; import { Scenario, UserSettings } from './types';
import { Type } from "@google/genai";
export interface VoiceProfile {
id: UserSettings['voiceName'];
label: string;
gender: 'Male' | 'Female' | 'Neutral';
style: string;
description: string;
sampleText: string;
openRouterVoice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
webSpeechTuning: {
pitch: number;
rate: number;
};
}
export const VOICE_PROFILES: VoiceProfile[] = [
{
id: 'Fenrir',
label: 'Fenrir (Salt of the Earth)',
gender: 'Male',
style: 'Gruff / Earthy',
description: 'A gravelly, direct, salt-of-the-earth tone. Grounded and weathered, perfect for boundary testing and blue-collar roleplays.',
sampleText: "Listen here, friend. Life's messy and things break down, but we take it one honest step at a time.",
openRouterVoice: 'onyx',
webSpeechTuning: { pitch: 0.75, rate: 0.92 }
},
{
id: 'Zephyr',
label: 'Zephyr',
gender: 'Neutral',
style: 'Helpful',
description: 'A balanced, clear, and professional tone suitable for most general scenarios.',
sampleText: "Hello. I'm here to provide a clear, balanced perspective as we explore different ways of thinking.",
openRouterVoice: 'alloy',
webSpeechTuning: { pitch: 1.0, rate: 1.0 }
},
{
id: 'Puck',
label: 'Puck',
gender: 'Male',
style: 'Youthful',
description: 'Energetic and friendly, ideal for peer-to-peer or informal interactions.',
sampleText: "Hey there! Ready to jump into this scenario and see what kind of creative solutions we can uncover?",
openRouterVoice: 'fable',
webSpeechTuning: { pitch: 1.15, rate: 1.05 }
},
{
id: 'Kore',
label: 'Kore',
gender: 'Female',
style: 'Calm',
description: 'Soothing and professional, perfect for clinical or high-stress roleplay.',
sampleText: "Take a steady breath. Whatever you're carrying right now, we can slow down and examine it together.",
openRouterVoice: 'nova',
webSpeechTuning: { pitch: 1.1, rate: 0.95 }
},
{
id: 'Charon',
label: 'Charon',
gender: 'Male',
style: 'Authoritative',
description: 'Deep and commanding, useful for workplace or boundary-testing scenarios.',
sampleText: "Let us be direct. In demanding situations, clear boundaries and disciplined reasoning are essential.",
openRouterVoice: 'echo',
webSpeechTuning: { pitch: 0.85, rate: 0.95 }
}
];
export interface GlossaryEntry { export interface GlossaryEntry {
term: string; term: string;
@@ -99,6 +165,18 @@ export const CBT_GLOSSARY: GlossaryEntry[] = [
definition: "Believing you are responsible for events outside your control or that other people's behavior is a direct reaction to you.", definition: "Believing you are responsible for events outside your control or that other people's behavior is a direct reaction to you.",
example: "My boss looks upset today; it must be because of that email I sent." example: "My boss looks upset today; it must be because of that email I sent."
}, },
{
term: "Labeling",
category: "Distortion",
definition: "Assigning a harsh, global, and definitive negative label to yourself or others based on a single instance or error.",
example: "I made an error on the slide, so 'I am an idiot' rather than 'I made a mistake.'"
},
{
term: "Overgeneralization",
category: "Distortion",
definition: "Taking a single negative event and seeing it as a never-ending pattern of defeat by using words like 'always' or 'never'.",
example: "I didn't get this promotion, so I will never advance in my career."
},
{ {
term: "Grounding Exercises", term: "Grounding Exercises",
category: "Technique", category: "Technique",
@@ -116,7 +194,7 @@ export const SCENARIOS: Scenario[] = [
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 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.", 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. INTERNAL CBT LOGIC: If the user tests for reassurance or asks if you're upset, warmly reassure them that everything is fine.",
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"]
}, },
@@ -138,7 +216,7 @@ export const SCENARIOS: Scenario[] = [
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 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.", 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. INTERNAL CBT LOGIC: If the user draws excessive attention to their mistake or apologizes repeatedly, casually dismiss it and keep the conversation on the topic.",
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"]
}, },
@@ -149,7 +227,7 @@ export const SCENARIOS: Scenario[] = [
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',
systemInstruction: "You are the user's personified Inner Critic. You are harsh, cynical, and use common distortions. Your goal is to represent the user's negative self-talk so they can practice refuting it.", systemInstruction: "You are the user's personified Inner Critic. You are harsh, cynical, and use common distortions. Your goal is to represent the user's negative self-talk so they can practice refuting it. INTERNAL CBT LOGIC: If the user responds with calm, objective evidence-based reframing, grudgingly lose ground. If the user engages in emotional reasoning or agrees with your insults, double down on catastrophic conclusions.",
initialMessage: "I can't believe you forgot to buy milk again. You're completely disorganized and you'll never get your life together.", initialMessage: "I can't believe you forgot to buy milk again. You're completely disorganized and you'll never get your life together.",
cbtFocus: ["Labeling", "Overgeneralization"] cbtFocus: ["Labeling", "Overgeneralization"]
}, },
@@ -266,30 +344,30 @@ export const SCENARIOS: Scenario[] = [
]; ];
export const CBT_ANALYSIS_SCHEMA = { export const CBT_ANALYSIS_SCHEMA = {
type: Type.OBJECT, type: 'object',
properties: { properties: {
analysis: { analysis: {
type: Type.OBJECT, type: 'object',
properties: { properties: {
distortions: { type: Type.ARRAY, items: { type: Type.STRING } }, distortions: { type: 'array', items: { type: 'string' } },
isAdaptive: { type: Type.BOOLEAN }, isAdaptive: { type: 'boolean' },
evidence: { type: Type.STRING }, evidence: { type: 'string' },
}, },
required: ['distortions', 'isAdaptive', 'evidence'] required: ['distortions', 'isAdaptive', 'evidence']
}, },
rating: { rating: {
type: Type.OBJECT, type: 'object',
properties: { properties: {
score: { type: Type.INTEGER }, score: { type: 'integer' },
reason: { type: Type.STRING }, reason: { type: 'string' },
}, },
required: ['score', 'reason'] required: ['score', 'reason']
}, },
suggestions: { suggestions: {
type: Type.OBJECT, type: 'object',
properties: { properties: {
alternativeThought: { type: Type.STRING }, alternativeThought: { type: 'string' },
behavioralStep: { type: Type.STRING }, behavioralStep: { type: 'string' },
}, },
required: ['alternativeThought', 'behavioralStep'] required: ['alternativeThought', 'behavioralStep']
} }
+300
View File
@@ -0,0 +1,300 @@
# Architectural Specification & Migration Plan: Svelte 5 Rewrite
**Project:** Cognitive Clarity (`clarity-helper`)
**Target Architecture:** Svelte 5 (Runes) + Vite 6 + Tailwind CSS + `bits-ui` + `lucide-svelte`
**Author:** Antigravity Autonomous Architecture Swarm (`[Svelte_Fullstack_Specialist]`, `[React_Frontend_Specialist]`, `[Adversarial_Auditor]`)
**Status:** Approved Blueprint
**Date:** 2026-09-29
---
## 1. Executive Summary & Architectural Rationale
Cognitive Clarity is currently built on React 19.2, Vite 6, Tailwind CSS 3.4, and Radix UI. While functional, the application suffers from React-specific architectural overhead:
1. **Audio DSP Re-render Friction:** WebAudio FFT frequency visualizers (`AudioVisualizer.tsx`) and mic/synthesis volume levels trigger frequent React state updates (`setAudioLevel`, `setAiAudioLevel`) at 60 FPS, causing unnecessary virtual DOM diffing cycles across parent components.
2. **Context & Prop Drilling Drag:** Settings and scenario parameters required explicit prop drilling down through `App.tsx` -> `ChatSession.tsx` / `VoiceSession.tsx`, leading to silent parameter dropping bugs.
3. **Bundle Weight:** React 19 + `react-dom` + `@radix-ui/*` primitives account for over 250 kB of the runtime JavaScript bundle.
### Why Svelte 5 (Runes)?
- **Fine-Grained Reactivity (Zero Virtual DOM):** Svelte 5 runes (`$state`, `$derived`, `$effect`) update the exact DOM elements bound to audio frequency bars without re-evaluating the component function or neighboring elements.
- **Vanishing Runtime & PWA Performance:** Eliminating `react` and `react-dom` reduces the vendor chunk footprint by over 60%, resulting in faster cold starts on mobile PWA standalone installs.
- **Universal Reactive Classes:** Complex state machines like WebAudio DSP capture and turn-by-turn clinical supervision can be modeled as pure TypeScript reactive classes (`AudioEngine.svelte.ts`, `SessionStore.svelte.ts`), cleanly decoupling audio hardware from UI presentation.
- **Bits UI Parity:** `bits-ui` provides accessible, unstyled headless primitives with 1:1 API parity to Radix UI.
---
## 2. Component-by-Component Mapping & Recomposition Matrix
Every single React component and hook is systematically evaluated, documented, and mapped to its Svelte 5 counterpart:
### 2.1 Application Shell & State Coordinators
| React Component | Svelte 5 Component | React Mechanics (`useState`, `useEffect`, etc.) | Svelte 5 Recomposition (`$state`, `$derived`, `$effect`, etc.) |
|:---|:---|:---|:---|
| **`App.tsx`** | **`src/App.svelte`** | Router state (`activeScenario`, `sessionMode`, `isPreparing`), modal toggles (`showGenerator`, `showReference`, `showSettings`), `localStorage` sync via `useEffect`. | Uses root `AppStore.svelte.ts` context. State managed with `$state`. Modals rendered via declarative snippets. PWA `ReloadPrompt` mounted at root. |
| **`index.tsx`** | **`src/main.ts`** | `createRoot(document.getElementById('root')!).render(<App />)`. | `mount(App, { target: document.getElementById('app')! })` using Svelte 5 `mount` API. |
| **`index.html`** | **`index.html`** | `<div id="root"></div>`, scripts pointing to `index.tsx`. | `<div id="app"></div>`, script pointing to `/src/main.ts`. Retains PWA manifest and Inter font. |
| **`sw.ts`** | **`src/sw.ts`** | Workbox injectManifest precaching + Google Fonts runtime caching. | 100% retained. `vite-plugin-pwa` operates identically with `@sveltejs/vite-plugin-svelte`. |
---
### 2.2 Design System UI Primitives (`components/ui/` -> `src/lib/components/ui/`)
All Radix UI primitives are mapped to `bits-ui` (or native accessible HTML where headless UI is redundant):
| React Component | Svelte 5 Component | Dependencies (React) | Dependencies (Svelte 5) | Recomposition Notes |
|:---|:---|:---|:---|:---|
| **`ui/button.tsx`** | **`Button.svelte`** | `@radix-ui/react-slot`, `class-variance-authority` | `bits-ui/button` or HTML `<button>` + `tailwind-variants` | Uses Svelte 5 `Snippet` for default children. Supports `variant` and `size` props. |
| **`ui/card.tsx`** | **`Card.svelte`**, `CardHeader.svelte`, `CardTitle.svelte`, `CardContent.svelte`, `CardFooter.svelte` | Native `div` containers with Tailwind styling | Native `div` components using `{#snippet children()}` | Clean composable snippets replacing sub-component JSX props. |
| **`ui/badge.tsx`** | **`Badge.svelte`** | `class-variance-authority`, `clsx`, `tailwind-merge` | `clsx` + `tailwind-merge` + `$props<{ variant?: 'default' \| 'secondary' \| 'destructive' \| 'outline' }>()` | Native `span` with typed variants. |
| **`ui/dialog.tsx`** | **`Dialog.svelte`** | `@radix-ui/react-dialog` | `bits-ui` (`Dialog.Root`, `Dialog.Trigger`, `Dialog.Portal`, `Dialog.Overlay`, `Dialog.Content`) | Retains exact backdrop blur, animations, and focus trapping. |
| **`ui/tabs.tsx`** | **`Tabs.svelte`** | `@radix-ui/react-tabs` | `bits-ui` (`Tabs.Root`, `Tabs.List`, `Tabs.Trigger`, `Tabs.Content`) | Controlled tab state via `$bindable()` or internal value. |
| **`ui/scroll-area.tsx`** | **`ScrollArea.svelte`** | `@radix-ui/react-scroll-area` | `bits-ui` (`ScrollArea.Root`, `ScrollArea.Viewport`, `ScrollArea.Scrollbar`) | Custom styled scrollbar matching dark/light themes. |
| **`ui/tooltip.tsx`** | **`Tooltip.svelte`** | `@radix-ui/react-tooltip` | `bits-ui` (`Tooltip.Root`, `Tooltip.Trigger`, `Tooltip.Content`) | Replaces bespoke `components/Tooltip.tsx` and Radix tooltip with unified Svelte 5 component. |
| **`ui/select.tsx`** | **`Select.svelte`** | `@radix-ui/react-select` | `bits-ui` (`Select.Root`, `Select.Trigger`, `Select.Content`, `Select.Item`) | Accessible dropdown selector for audio devices and difficulty. |
| **`ui/switch.tsx`** | **`Switch.svelte`** | `@radix-ui/react-switch` | `bits-ui` (`Switch.Root`, `Switch.Thumb`) | Accessible toggle switch. |
| **`ui/input.tsx`** | **`Input.svelte`** | Native `<input>` with Tailwind classes | Native `<input>` with `$bindable(value)` and rest props | Type-safe two-way binding. |
| **`ui/textarea.tsx`** | **`Textarea.svelte`** | Native `<textarea>` with Tailwind classes | Native `<textarea>` with `$bindable(value)` and auto-resize | Two-way reactive text binding. |
| **`ui/separator.tsx`** | **`Separator.svelte`** | `@radix-ui/react-separator` | `bits-ui` (`Separator.Root`) | Semantic divider. |
| **`ui/label.tsx`** | **`Label.svelte`** | `@radix-ui/react-label` | `bits-ui` (`Label.Root`) or `<label>` | Semantic form labeling. |
---
### 2.3 Interactive Domain Feature Components
| React Component | Svelte 5 Component | Core Mechanics & State | Svelte 5 Architectural Treatment |
|:---|:---|:---|:---|
| **`ScenarioCard.tsx`** | **`ScenarioCard.svelte`** | Displays scenario metadata, difficulty badge, CBT focus tags. Has "Chat" and "Voice" launcher buttons and custom delete trigger. | `$props<{ scenario: Scenario, onSelect: (s: Scenario, mode: SessionMode) => void, onDelete?: (id: string) => void }>()`. Uses `Badge` and `Button` snippets. Tooltip hover explanations. |
| **`ChatSession.tsx`** | **`ChatSession.svelte`** | Message stream rendering, SSE chunk accumulation, automatic scroll-to-bottom on new messages, out-of-band turn supervision, post-session summary. | Driven by `ChatSessionController.svelte.ts`. Message list auto-scroll handled via an action `use:autoScroll`. Sub-components `ChatMessage.svelte` and `ChatInput.svelte`. Out-of-band analysis drawer rendered in flex container. |
| **`ChatMessage.tsx`** | **`ChatMessage.svelte`** | Renders user vs AI character dialogue bubbles with timestamps and role avatar. | Pure presentational component. Formats timestamps via `$derived`. Supports markdown rendering through Svelte markdown component. |
| **`ChatInput.tsx`** | **`ChatInput.svelte`** | Text input with enter-key send shortcut and disabled state while waiting for AI turn. | Form binding with `$state(text)`. Enter key listener `onkeydown={(e) => e.key === 'Enter' && !e.shiftKey && submit()}`. |
| **`VoiceSession.tsx`** | **`VoiceSession.svelte`** | Duplex voice session orchestrator. Coordinates WebAudio capture, FFT visualizer, speech synthesis, live insight drawer, transcripts modal. | Uses `AudioEngine.svelte.ts` service class. Binds visualizer level directly to reactive state without re-rendering container. Coordinates sub-components `VoiceHeader`, `VoiceControls`, `AudioVisualizer`, `TranscriptPanel`. |
| **`VoiceHeader.tsx`** | **`VoiceHeader.svelte`** | Scenario title, microphone device selector button, finish session trigger. | Simple header receiving `$props<{ scenario: Scenario, onExit: () => void, onOpenDeviceSelector: () => void }>()`. |
| **`VoiceControls.tsx`** | **`VoiceControls.svelte`** | Mute button toggle, finish session button, transcript drawer button. | Reactive buttons bound to `audio.isMuted` and `$state(showTranscript)`. |
| **`AnalyzingState.tsx`** | **`AnalyzingState.svelte`** | Animated post-session clinical synthesis loading screen. | SVG animated spinners and pulsing status typography. |
| **`ConnectionState.tsx`** | **`ConnectionState.svelte`** | Pre-session audio hardware initialization and calibration screen. | Smooth CSS pulsing visualizer. |
| **`AudioVisualizer.tsx`** | **`AudioVisualizer.svelte`** | 19 animated frequency bars dynamically styled based on microphone level or AI vocalization level. | Directly mutates bar heights via `$derived` or inline style calculation. In Svelte 5, updates happen at microtask speed with zero VDOM diffing. |
| **`AudioDeviceSelector.tsx`** | **`AudioDeviceSelector.svelte`** | Queries `navigator.mediaDevices.enumerateDevices()`, selects input mic. | Device enumeration encapsulated in a reactive Svelte 5 class or effect. Accessible `Select` dropdown. |
| **`LiveInsightPanel.tsx`** | **`LiveInsightPanel.svelte`** | Out-of-band supervisor drawer displaying distortions detected, adaptiveness, evidence quote, efficiency score, reframes, and tactical pivots. | Animates into view via `transition:slide`. Renders structured `AnalysisResult` reactive prop. |
| **`SessionReport.tsx`** | **`SessionReport.svelte`** | Comprehensive post-session CBT assessment report with mastery score, trajectory, homework suggestions, transcript accordion, Markdown & JSON downloaders. | Generates download blobs via `sessionStorage.ts`. Accordion transcript drawer. Clipboard copy with reactive `$state(copied)` timeout. |
| **`ScenarioGenerator.tsx`** | **`ScenarioGenerator.svelte`** | AI scenario builder dialog. Prompts user for description, selects CBT concepts from glossary, calibrates difficulty, calls OpenRouter JSON API. | Dialog modal using `Dialog.svelte`. Reactive multiselect tag chips with `$state(selectedConcepts)`. Generates validated scenario and appends to `customScenarios`. |
| **`CBTReference.tsx`** | **`CBTReference.svelte`** | Interactive searchable CBT glossary covering all 18 clinical terms, definitions, categories, and real-world examples. | Filter tabs (`All`, `Distortions`, `Techniques`, `Concepts`) and search input. Filtered list calculated cleanly via `$derived(filteredGlossary)`. |
| **`SettingsModal.tsx`** | **`SettingsModal.svelte`** | Tabs for Voice & Audio (vocal persona, mic device) and OpenRouter AI (API key input, model selector with `google/gemini-3.8-flash`). | Accessible `Tabs.svelte`. Binds `$state(openRouterKey)` and `$state(selectedModel)`. Persists to `UserSettingsStore.svelte.ts`. |
| **`TranscriptPanel.tsx`** | **`TranscriptPanel.svelte`** | Slide-over drawer displaying conversation history during voice sessions. | Svelte transition drawer (`transition:fly={{ x: 300 }}`) with `ScrollArea`. |
| **`ReloadPrompt.svelte`** | **`ReloadPrompt.svelte`** | PWA update notification toast when Workbox detects a service worker update. | Uses `virtual:pwa-register/svelte` to trigger automatic or prompt-based reload. |
| **`VersionBadge.svelte`** | **`VersionBadge.svelte`** | Displays `__APP_VERSION__` and `__BUILD_TIMESTAMP__`. | Reads Vite compile-time definitions. |
---
## 3. Reactive Architecture & State Engineering (Svelte 5 Runes)
In Svelte 5, state management is vastly superior to React's `useState` / `useCallback` / `useRef` paradigm. We will implement three first-class reactive state stores:
### 3.1 `UserSettingsStore.svelte.ts` (Global Preferences)
```typescript
import { UserSettings } from './types';
import { DEFAULT_OPENROUTER_MODEL, DEFAULT_SUPERVISOR_MODEL } from './services/apiConfig';
const SETTINGS_KEY = 'cognitive-clarity-settings';
export class UserSettingsStore {
voiceName = $state<UserSettings['voiceName']>('Zephyr');
vocalStyle = $state('Helpful');
genderPreference = $state<'Male' | 'Female' | 'Neutral'>('Neutral');
preferredAudioDeviceId = $state<string | undefined>(undefined);
openRouterApiKey = $state<string | undefined>(undefined);
roleplayModel = $state(DEFAULT_OPENROUTER_MODEL);
supervisorModel = $state(DEFAULT_SUPERVISOR_MODEL);
constructor() {
this.load();
}
load() {
if (typeof localStorage === 'undefined') return;
try {
const saved = localStorage.getItem(SETTINGS_KEY);
if (saved) {
const parsed = JSON.parse(saved);
Object.assign(this, parsed);
}
} catch (e) {
console.error("Failed to load settings", e);
}
}
save() {
if (typeof localStorage === 'undefined') return;
const data: UserSettings = {
voiceName: this.voiceName,
vocalStyle: this.vocalStyle,
genderPreference: this.genderPreference,
preferredAudioDeviceId: this.preferredAudioDeviceId,
openRouterApiKey: this.openRouterApiKey,
roleplayModel: this.roleplayModel,
supervisorModel: this.supervisorModel,
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(data));
}
}
export const userSettings = new UserSettingsStore();
```
### 3.2 `AudioEngine.svelte.ts` (Isolated WebAudio DSP Controller)
Replaces `hooks/useLiveSession.ts`. Completely encapsulates hardware access, FFT frequency calculations, and speech synthesis:
```typescript
import { Scenario, Message, AnalysisResult } from './types';
import { UserSettingsStore } from './UserSettingsStore.svelte';
import { streamOpenRouterChat, analyzeTurnWithOpenRouter, synthesizeSpeechWithOpenRouter } from './services/openRouterService';
export class AudioEngine {
status = $state<'connecting' | 'active' | 'analyzing' | 'finished' | 'error'>('connecting');
errorMessage = $state<string | null>(null);
isMuted = $state(false);
micLevel = $state(0);
aiAudioLevel = $state(0);
isAiSpeaking = $state(false);
isUserSpeaking = $state(false);
messages = $state<Message[]>([]);
liveInsight = $state<AnalysisResult | null>(null);
isAnalyzingTurn = $state(false);
// Active level derived cleanly for visualizer
activeLevel = $derived(this.isAiSpeaking ? this.aiAudioLevel : this.micLevel);
private inputCtx: AudioContext | null = null;
private outputCtx: AudioContext | null = null;
private userAnalyser: AnalyserNode | null = null;
private aiAnalyser: AnalyserNode | null = null;
private activeSource: AudioBufferSourceNode | null = null;
private recognition: any = null;
private animFrameId: number | null = null;
constructor(private scenario: Scenario, private settings: UserSettingsStore) {}
async start() {
// 1. Initialize AudioContexts & Analysers
// 2. Setup Web Speech Recognition
// 3. Start RAF loop to update this.micLevel and this.aiAudioLevel
// 4. Initial scenario greeting
}
stopAllAudio() {
this.activeSource?.stop();
this.activeSource = null;
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
}
destroy() {
this.stopAllAudio();
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
this.recognition?.stop();
this.inputCtx?.close();
this.outputCtx?.close();
}
}
```
### 3.3 Context Injection via Svelte 5 Context API
To avoid prop drilling, the root `App.svelte` provisions the stores using `setContext`:
```typescript
// src/lib/context.ts
import { getContext, setContext } from 'svelte';
import { UserSettingsStore } from './UserSettingsStore.svelte';
import { AppStateStore } from './AppStateStore.svelte';
const SETTINGS_KEY = Symbol('USER_SETTINGS');
const APP_STATE_KEY = Symbol('APP_STATE');
export const setUserSettingsContext = (store: UserSettingsStore) => setContext(SETTINGS_KEY, store);
export const getUserSettingsContext = () => getContext<UserSettingsStore>(SETTINGS_KEY);
export const setAppStateContext = (store: AppStateStore) => setContext(APP_STATE_KEY, store);
export const getAppStateContext = () => getContext<AppStateStore>(APP_STATE_KEY);
```
---
## 4. Tri-Lens Adversarial Swarm Audit (`--audit --swarm`)
### 4.1 Lens 1: Requirements & Blast Radius (`[Requirements_BlastRadius_Auditor]`)
- **Clinical Roleplay Invariant:** Svelte 5 implementation must preserve strict separation between character dialogue and supervisor feedback. System instructions in `ChatSession.svelte` and `AudioEngine.svelte.ts` must maintain anti-meta constraints:
- AI character NEVER congratulates the user or breaks character.
- Clinical supervision runs strictly out-of-band via parallel OpenRouter promises.
- **Scenario & Glossary Parity:** 100% preservation of all 14 scenarios and 18 glossary terms from `constants.ts`.
- **Backend API Neutrality:** The existing Azure Functions in `api/` (`/api/chat`, `/api/analysis`, `/api/scenario/generate`) remain 100% compatible and unchanged, since Svelte consumes the same HTTP JSON contracts.
### 4.2 Lens 2: Performance & Security (`[Performance_Security_Auditor]`)
- **WebAudio Garbage Collection & Context Limits:** Browsers enforce a hard cap of ~6 active `AudioContext` instances. `AudioEngine.svelte.ts` must implement strict `$effect` teardown lifecycles calling `audioCtx.close()` upon component unmount.
- **Token Streaming Throughput:** Server-Sent Events in `openRouterService.ts` parse chunks at wire speed. In Svelte 5, text accumulation directly appends to `$state(text)` without triggering full component tree re-renders.
- **Client Key Security:** OpenRouter API keys remain strictly local to `localStorage` and `Authorization: Bearer <key>` headers. Zero telemetry leakage.
### 4.3 Lens 3: Test Rigor & QA (`[TestRigor_QA_Auditor]`)
- **TDD Contract:** Establish Vitest test suites for:
1. `UserSettingsStore.test.ts`: LocalStorage read/write, 4-tier resolution hierarchy.
2. `AudioEngine.test.ts`: Mocked WebAudio lifecycle, turn progression, error handling.
3. `CBTReference.test.ts`: Reactive search filtering across distortions and techniques.
4. `ScenarioGenerator.test.ts`: Schema validation for AI-generated scenarios.
---
## 5. Phased Implementation Roadmap & Verification Gates
```mermaid
flowchart LR
Phase1[Phase 1: Setup & Dependencies] --> Phase2[Phase 2: Core Services & Types]
Phase2 --> Phase3[Phase 3: Svelte 5 Runes & Stores]
Phase3 --> Phase4[Phase 4: Design Primitives bits-ui]
Phase4 --> Phase5[Phase 5: Feature Components]
Phase5 --> Phase6[Phase 6: Audio & Chat Engines]
Phase6 --> Phase7[Phase 7: 5-Gate CI Validation]
```
### Phase 1: Tooling & Dual Workspace Harness
1. Scaffold Svelte 5 configuration in the repository:
- Add `@sveltejs/vite-plugin-svelte` and `svelte@^5.0.0` to `devDependencies`.
- Install `bits-ui` and `lucide-svelte`.
- Update `vite.config.ts` to support both Svelte and PWA compilation.
- Configure Vitest with `@testing-library/svelte` for Svelte component testing.
### Phase 2: Domain Services & Types Ingestion
1. Re-use 100% framework-agnostic TypeScript files directly:
- `types.ts`, `constants.ts`
- `services/apiConfig.ts`, `services/openRouterService.ts`, `services/sessionStorage.ts`, `services/audioUtils.ts`
2. Validate with existing test suite: `npm test` continues passing 12/12.
### Phase 3: Svelte 5 Reactive State Layer
1. Author `src/lib/stores/UserSettingsStore.svelte.ts`.
2. Author `src/lib/stores/AppStateStore.svelte.ts`.
3. Author `src/lib/audio/AudioEngine.svelte.ts`.
4. Author Vitest unit tests verifying state transitions, defaults (`google/gemini-3.8-flash`), and storage persistence.
### Phase 4: Svelte Design System (`src/lib/components/ui/`)
1. Implement `Button.svelte`, `Badge.svelte`, `Card.svelte`, `Dialog.svelte`, `Tabs.svelte`, `ScrollArea.svelte`, `Tooltip.svelte`, `Input.svelte`, `Textarea.svelte`, `Select.svelte`.
2. Verify visual token parity with Tailwind 3.4 / 4 utility classes.
### Phase 5: Feature Components Migration
1. Author `ScenarioCard.svelte`, `CBTReference.svelte`, `SettingsModal.svelte`, `ScenarioGenerator.svelte`.
2. Author `SessionReport.svelte` with Markdown and JSON report downloaders.
3. Author `LiveInsightPanel.svelte` with reactive distortion pill rendering and star rating.
### Phase 6: Session Controllers & Audio Visualizer
1. Author `ChatSession.svelte`, `ChatMessage.svelte`, `ChatInput.svelte`.
2. Author `VoiceSession.svelte`, `VoiceHeader.svelte`, `VoiceControls.svelte`, `AudioVisualizer.svelte`, `TranscriptPanel.svelte`.
3. Author `ConnectionState.svelte`, `AnalyzingState.svelte`.
4. Test real-time WebAudio microphone frequency analysis and OpenRouter speech playback.
### Phase 7: App Shell, PWA Precache & 5-Gate Validation
1. Compose `src/App.svelte` with hero section, difficulty filter pills, scenario grid, and modal dialogs.
2. Update `sw.ts` and `vite.config.ts` PWA injectManifest.
3. Run 5-Gate Validation:
- Gate 1: Svelte TypeScript check (`svelte-check` / `tsc --noEmit`)
- Gate 2: Vitest unit & component test suite (`npm test`)
- Gate 3: Scenario schema validator (`npm run validate:scenarios`)
- Gate 4: Production build (`npm run build:nobump`)
- Gate 5: Backend assembly compilation (`dotnet build api/api.csproj`)
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+710
View File
@@ -0,0 +1,710 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cognitive Clarity — Playwright Visual Snapshot Gallery</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.gallery-img { cursor: zoom-in; transition: transform 0.2s ease; }
.gallery-img:hover { transform: scale(1.01); }
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<header class="border-b border-slate-800 bg-slate-900/60 backdrop-blur-md sticky top-0 z-40 px-6 py-4">
<div class="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-xl font-bold tracking-tight text-white flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-teal-400 animate-pulse"></span>
Cognitive Clarity — Visual Snapshot Gallery
</h1>
<p class="text-xs text-slate-400 mt-0.5">
45 snapshots across 3 viewports (desktop-chrome, ipad-portrait, mobile-iphone)
</p>
</div>
<div class="flex flex-wrap gap-2" id="filter-buttons">
<button onclick="filterViewport('all')" class="vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-teal-600 text-white" data-vp="all">All</button>
<button onclick="filterViewport('desktop-chrome')" class="vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700" data-vp="desktop-chrome">desktop-chrome</button><button onclick="filterViewport('ipad-portrait')" class="vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700" data-vp="ipad-portrait">ipad-portrait</button><button onclick="filterViewport('mobile-iphone')" class="vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700" data-vp="mobile-iphone">mobile-iphone</button>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-6 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="gallery-grid">
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">HOME INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/01-home-initial.png" alt="HOME INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/01-home-initial.png', 'HOME INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>01-home-initial.png</span>
<span>352 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">HOME INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/01-home-initial.png" alt="HOME INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/01-home-initial.png', 'HOME INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>01-home-initial.png</span>
<span>752 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">HOME INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/01-home-initial.png" alt="HOME INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/01-home-initial.png', 'HOME INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>01-home-initial.png</span>
<span>1722 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">FILTER BEGINNER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/02-filter-beginner.png" alt="FILTER BEGINNER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/02-filter-beginner.png', 'FILTER BEGINNER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>02-filter-beginner.png</span>
<span>125 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">FILTER BEGINNER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/02-filter-beginner.png" alt="FILTER BEGINNER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/02-filter-beginner.png', 'FILTER BEGINNER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>02-filter-beginner.png</span>
<span>279 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">FILTER BEGINNER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/02-filter-beginner.png" alt="FILTER BEGINNER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/02-filter-beginner.png', 'FILTER BEGINNER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>02-filter-beginner.png</span>
<span>575 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">FILTER ADVANCED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/03-filter-advanced.png" alt="FILTER ADVANCED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/03-filter-advanced.png', 'FILTER ADVANCED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>03-filter-advanced.png</span>
<span>189 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">FILTER ADVANCED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/03-filter-advanced.png" alt="FILTER ADVANCED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/03-filter-advanced.png', 'FILTER ADVANCED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>03-filter-advanced.png</span>
<span>403 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">FILTER ADVANCED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/03-filter-advanced.png" alt="FILTER ADVANCED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/03-filter-advanced.png', 'FILTER ADVANCED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>03-filter-advanced.png</span>
<span>908 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SETTINGS MODAL VOICE</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/04-settings-modal-voice.png" alt="SETTINGS MODAL VOICE" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/04-settings-modal-voice.png', 'SETTINGS MODAL VOICE')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>04-settings-modal-voice.png</span>
<span>391 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SETTINGS MODAL VOICE</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/04-settings-modal-voice.png" alt="SETTINGS MODAL VOICE" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/04-settings-modal-voice.png', 'SETTINGS MODAL VOICE')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>04-settings-modal-voice.png</span>
<span>895 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SETTINGS MODAL VOICE</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/04-settings-modal-voice.png" alt="SETTINGS MODAL VOICE" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/04-settings-modal-voice.png', 'SETTINGS MODAL VOICE')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>04-settings-modal-voice.png</span>
<span>1786 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SETTINGS MODAL OPENROUTER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/05-settings-modal-openrouter.png" alt="SETTINGS MODAL OPENROUTER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/05-settings-modal-openrouter.png', 'SETTINGS MODAL OPENROUTER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>05-settings-modal-openrouter.png</span>
<span>386 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SETTINGS MODAL OPENROUTER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/05-settings-modal-openrouter.png" alt="SETTINGS MODAL OPENROUTER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/05-settings-modal-openrouter.png', 'SETTINGS MODAL OPENROUTER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>05-settings-modal-openrouter.png</span>
<span>860 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SETTINGS MODAL OPENROUTER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/05-settings-modal-openrouter.png" alt="SETTINGS MODAL OPENROUTER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/05-settings-modal-openrouter.png', 'SETTINGS MODAL OPENROUTER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>05-settings-modal-openrouter.png</span>
<span>1759 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CBT REFERENCE ALL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/06-cbt-reference-all.png" alt="CBT REFERENCE ALL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/06-cbt-reference-all.png', 'CBT REFERENCE ALL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>06-cbt-reference-all.png</span>
<span>430 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CBT REFERENCE ALL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/06-cbt-reference-all.png" alt="CBT REFERENCE ALL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/06-cbt-reference-all.png', 'CBT REFERENCE ALL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>06-cbt-reference-all.png</span>
<span>1128 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CBT REFERENCE ALL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/06-cbt-reference-all.png" alt="CBT REFERENCE ALL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/06-cbt-reference-all.png', 'CBT REFERENCE ALL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>06-cbt-reference-all.png</span>
<span>1756 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CBT REFERENCE SEARCH</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/07-cbt-reference-search.png" alt="CBT REFERENCE SEARCH" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/07-cbt-reference-search.png', 'CBT REFERENCE SEARCH')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>07-cbt-reference-search.png</span>
<span>328 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CBT REFERENCE SEARCH</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/07-cbt-reference-search.png" alt="CBT REFERENCE SEARCH" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/07-cbt-reference-search.png', 'CBT REFERENCE SEARCH')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>07-cbt-reference-search.png</span>
<span>662 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CBT REFERENCE SEARCH</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/07-cbt-reference-search.png" alt="CBT REFERENCE SEARCH" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/07-cbt-reference-search.png', 'CBT REFERENCE SEARCH')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>07-cbt-reference-search.png</span>
<span>1760 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SCENARIO GENERATOR MODAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/08-scenario-generator-modal.png" alt="SCENARIO GENERATOR MODAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/08-scenario-generator-modal.png', 'SCENARIO GENERATOR MODAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>08-scenario-generator-modal.png</span>
<span>515 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SCENARIO GENERATOR MODAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/08-scenario-generator-modal.png" alt="SCENARIO GENERATOR MODAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/08-scenario-generator-modal.png', 'SCENARIO GENERATOR MODAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>08-scenario-generator-modal.png</span>
<span>1101 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SCENARIO GENERATOR MODAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/08-scenario-generator-modal.png" alt="SCENARIO GENERATOR MODAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/08-scenario-generator-modal.png', 'SCENARIO GENERATOR MODAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>08-scenario-generator-modal.png</span>
<span>1898 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SCENARIO GENERATOR FILLED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/09-scenario-generator-filled.png" alt="SCENARIO GENERATOR FILLED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/09-scenario-generator-filled.png', 'SCENARIO GENERATOR FILLED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>09-scenario-generator-filled.png</span>
<span>378 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SCENARIO GENERATOR FILLED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/09-scenario-generator-filled.png" alt="SCENARIO GENERATOR FILLED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/09-scenario-generator-filled.png', 'SCENARIO GENERATOR FILLED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>09-scenario-generator-filled.png</span>
<span>824 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SCENARIO GENERATOR FILLED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/09-scenario-generator-filled.png" alt="SCENARIO GENERATOR FILLED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/09-scenario-generator-filled.png', 'SCENARIO GENERATOR FILLED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>09-scenario-generator-filled.png</span>
<span>1785 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CHAT SESSION INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/10-chat-session-initial.png" alt="CHAT SESSION INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/10-chat-session-initial.png', 'CHAT SESSION INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>10-chat-session-initial.png</span>
<span>49 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CHAT SESSION INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/10-chat-session-initial.png" alt="CHAT SESSION INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/10-chat-session-initial.png', 'CHAT SESSION INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>10-chat-session-initial.png</span>
<span>109 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CHAT SESSION INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/10-chat-session-initial.png" alt="CHAT SESSION INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/10-chat-session-initial.png', 'CHAT SESSION INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>10-chat-session-initial.png</span>
<span>157 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CHAT INTERACTION STREAMED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/11-chat-interaction-streamed.png" alt="CHAT INTERACTION STREAMED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/11-chat-interaction-streamed.png', 'CHAT INTERACTION STREAMED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>11-chat-interaction-streamed.png</span>
<span>71 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CHAT INTERACTION STREAMED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/11-chat-interaction-streamed.png" alt="CHAT INTERACTION STREAMED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/11-chat-interaction-streamed.png', 'CHAT INTERACTION STREAMED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>11-chat-interaction-streamed.png</span>
<span>160 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">CHAT INTERACTION STREAMED</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/11-chat-interaction-streamed.png" alt="CHAT INTERACTION STREAMED" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/11-chat-interaction-streamed.png', 'CHAT INTERACTION STREAMED')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>11-chat-interaction-streamed.png</span>
<span>156 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SESSION REPORT SUMMARY</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/12-session-report-summary.png" alt="SESSION REPORT SUMMARY" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/12-session-report-summary.png', 'SESSION REPORT SUMMARY')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>12-session-report-summary.png</span>
<span>78 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SESSION REPORT SUMMARY</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/12-session-report-summary.png" alt="SESSION REPORT SUMMARY" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/12-session-report-summary.png', 'SESSION REPORT SUMMARY')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>12-session-report-summary.png</span>
<span>180 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">SESSION REPORT SUMMARY</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/12-session-report-summary.png" alt="SESSION REPORT SUMMARY" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/12-session-report-summary.png', 'SESSION REPORT SUMMARY')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>12-session-report-summary.png</span>
<span>252 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE COCKPIT INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/13-voice-cockpit-initial.png" alt="VOICE COCKPIT INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/13-voice-cockpit-initial.png', 'VOICE COCKPIT INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>13-voice-cockpit-initial.png</span>
<span>61 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE COCKPIT INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/13-voice-cockpit-initial.png" alt="VOICE COCKPIT INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/13-voice-cockpit-initial.png', 'VOICE COCKPIT INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>13-voice-cockpit-initial.png</span>
<span>138 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE COCKPIT INITIAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/13-voice-cockpit-initial.png" alt="VOICE COCKPIT INITIAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/13-voice-cockpit-initial.png', 'VOICE COCKPIT INITIAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>13-voice-cockpit-initial.png</span>
<span>189 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE TRANSCRIPT DRAWER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/14-voice-transcript-drawer.png" alt="VOICE TRANSCRIPT DRAWER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/14-voice-transcript-drawer.png', 'VOICE TRANSCRIPT DRAWER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>14-voice-transcript-drawer.png</span>
<span>80 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE TRANSCRIPT DRAWER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/14-voice-transcript-drawer.png" alt="VOICE TRANSCRIPT DRAWER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/14-voice-transcript-drawer.png', 'VOICE TRANSCRIPT DRAWER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>14-voice-transcript-drawer.png</span>
<span>192 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE TRANSCRIPT DRAWER</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/14-voice-transcript-drawer.png" alt="VOICE TRANSCRIPT DRAWER" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/14-voice-transcript-drawer.png', 'VOICE TRANSCRIPT DRAWER')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>14-voice-transcript-drawer.png</span>
<span>230 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="desktop-chrome">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE AUDIO SETTINGS MODAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">desktop-chrome</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="desktop-chrome/15-voice-audio-settings-modal.png" alt="VOICE AUDIO SETTINGS MODAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('desktop-chrome/15-voice-audio-settings-modal.png', 'VOICE AUDIO SETTINGS MODAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>15-voice-audio-settings-modal.png</span>
<span>49 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="ipad-portrait">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE AUDIO SETTINGS MODAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">ipad-portrait</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="ipad-portrait/15-voice-audio-settings-modal.png" alt="VOICE AUDIO SETTINGS MODAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('ipad-portrait/15-voice-audio-settings-modal.png', 'VOICE AUDIO SETTINGS MODAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>15-voice-audio-settings-modal.png</span>
<span>104 KB</span>
</div>
</div>
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="mobile-iphone">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">VOICE AUDIO SETTINGS MODAL</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">mobile-iphone</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="mobile-iphone/15-voice-audio-settings-modal.png" alt="VOICE AUDIO SETTINGS MODAL" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('mobile-iphone/15-voice-audio-settings-modal.png', 'VOICE AUDIO SETTINGS MODAL')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>15-voice-audio-settings-modal.png</span>
<span>185 KB</span>
</div>
</div>
</div>
</main>
<!-- Lightbox Modal -->
<div id="lightbox" class="fixed inset-0 z-50 bg-black/90 backdrop-blur-md hidden flex flex-col items-center justify-center p-4" onclick="closeLightbox()">
<div class="max-w-6xl max-h-[90vh] flex flex-col items-center" onclick="event.stopPropagation()">
<div class="flex justify-between items-center w-full mb-3 text-white">
<h3 id="lightbox-title" class="font-bold text-base"></h3>
<button onclick="closeLightbox()" class="text-slate-400 hover:text-white px-3 py-1 bg-white/10 rounded-lg text-sm">Close ✕</button>
</div>
<img id="lightbox-img" src="" class="max-h-[80vh] max-w-full rounded-xl shadow-2xl border border-white/10 object-contain">
</div>
</div>
<script>
function filterViewport(vp) {
document.querySelectorAll('.vp-btn').forEach(btn => {
if (btn.dataset.vp === vp) {
btn.className = 'vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-teal-600 text-white';
} else {
btn.className = 'vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700';
}
});
document.querySelectorAll('.gallery-card').forEach(card => {
if (vp === 'all' || card.dataset.viewport === vp) {
card.style.display = 'flex';
} else {
card.style.display = 'none';
}
});
}
function openLightbox(src, title) {
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox-title').innerText = title;
document.getElementById('lightbox').classList.remove('hidden');
}
function closeLightbox() {
document.getElementById('lightbox').classList.add('hidden');
}
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+282 -196
View File
@@ -1,14 +1,13 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { GoogleGenAI, LiveServerMessage, Modality } from '@google/genai';
import { Scenario, UserSettings, Message, AnalysisResult } from '../types'; import { Scenario, UserSettings, Message, AnalysisResult } from '../types';
import { SYSTEM_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants'; import { getOpenRouterApiKey } from '../services/apiConfig';
import { floatTo16BitPCM, encodeBase64, decodeBase64, decodeAudioData } from '../services/audioUtils'; import { streamOpenRouterChat, analyzeTurnWithOpenRouter, synthesizeSpeechWithOpenRouter } from '../services/openRouterService';
export type SessionStatus = 'connecting' | 'active' | 'analyzing' | 'finished'; export type SessionStatus = 'connecting' | 'active' | 'analyzing' | 'finished' | 'error';
export function useLiveSession(scenario: Scenario, voiceSettings: UserSettings) { export function useLiveSession(scenario: Scenario, voiceSettings: UserSettings) {
const [status, setStatus] = useState<SessionStatus>('connecting'); const [status, setStatus] = useState<SessionStatus>('connecting');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [audioLevel, setAudioLevel] = useState(0); const [audioLevel, setAudioLevel] = useState(0);
const [aiAudioLevel, setAiAudioLevel] = useState(0); const [aiAudioLevel, setAiAudioLevel] = useState(0);
@@ -21,229 +20,324 @@ export function useLiveSession(scenario: Scenario, voiceSettings: UserSettings)
const [liveInsight, setLiveInsight] = useState<AnalysisResult | null>(null); const [liveInsight, setLiveInsight] = useState<AnalysisResult | null>(null);
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false); const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
// Refs for audio processing // Audio Context & Nodes
const sessionPromiseRef = useRef<Promise<any> | null>(null); const isMutedRef = useRef(isMuted);
useEffect(() => {
isMutedRef.current = isMuted;
}, [isMuted]);
const inputAudioCtxRef = useRef<AudioContext | null>(null); const inputAudioCtxRef = useRef<AudioContext | null>(null);
const outputAudioCtxRef = useRef<AudioContext | null>(null); const outputAudioCtxRef = useRef<AudioContext | null>(null);
const aiAnalyserRef = useRef<AnalyserNode | null>(null); const aiAnalyserRef = useRef<AnalyserNode | null>(null);
const nextStartTimeRef = useRef<number>(0); const userAnalyserRef = useRef<AnalyserNode | null>(null);
const audioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set()); const currentAudioSourceRef = useRef<AudioBufferSourceNode | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const analysisChatRef = useRef<any>(null); const recognitionRef = useRef<any>(null);
const silenceCheckTimerRef = useRef<NodeJS.Timeout | null>(null); const aiLevelTimerRef = useRef<number | null>(null);
const aiLevelUpdateTimerRef = useRef<number | null>(null); const userLevelTimerRef = useRef<number | null>(null);
const isProcessingTurnRef = useRef(false);
// Buffers for transcription
const currentInputTranscription = useRef('');
const currentOutputTranscription = useRef('');
const stopAllAudio = useCallback(() => { const stopAllAudio = useCallback(() => {
audioSourcesRef.current.forEach(source => { if (currentAudioSourceRef.current) {
try { source.stop(); } catch (e) {} try { currentAudioSourceRef.current.stop(); } catch (e) {}
}); currentAudioSourceRef.current = null;
audioSourcesRef.current.clear(); }
nextStartTimeRef.current = 0; if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
setIsAiSpeaking(false);
setAiAudioLevel(0); setAiAudioLevel(0);
}, []); }, []);
const toggleMute = useCallback(() => setIsMuted(prev => !prev), []); const toggleMute = useCallback(() => setIsMuted(prev => !prev), []);
useEffect(() => { const speakText = useCallback(async (text: string) => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); stopAllAudio();
setIsAiSpeaking(true);
// Setup Live Insights Chat try {
analysisChatRef.current = ai.chats.create({ // Attempt high-fidelity OpenRouter audio synthesis
model: 'gemini-3-flash-preview', const arrayBuffer = await synthesizeSpeechWithOpenRouter(
config: { text,
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\nSCENARIO: ${scenario.title}\nFOCUS: ${scenario.cbtFocus.join(', ')}`, voiceSettings.voiceName.toLowerCase() === 'kore' ? 'nova' : 'alloy',
responseMimeType: 'application/json', voiceSettings.openRouterApiKey
responseSchema: CBT_ANALYSIS_SCHEMA, );
const audioCtx = outputAudioCtxRef.current || new AudioContext();
outputAudioCtxRef.current = audioCtx;
if (audioCtx.state === 'suspended') await audioCtx.resume();
const buffer = await audioCtx.decodeAudioData(arrayBuffer);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
if (!aiAnalyserRef.current) {
aiAnalyserRef.current = audioCtx.createAnalyser();
aiAnalyserRef.current.fftSize = 256;
} }
}); source.connect(aiAnalyserRef.current);
aiAnalyserRef.current.connect(audioCtx.destination);
const updateAiLevel = () => { source.onended = () => {
if (aiAnalyserRef.current && isAiSpeaking) { setIsAiSpeaking(false);
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); setAiAudioLevel(0);
};
currentAudioSourceRef.current = source;
source.start(0);
} catch (e) {
console.warn("OpenRouter TTS fallback to Web Speech Synthesis", e);
// Fallback to browser SpeechSynthesis
if ('speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.onstart = () => setIsAiSpeaking(true);
utterance.onend = () => {
setIsAiSpeaking(false);
setAiAudioLevel(0);
};
utterance.onerror = () => {
setIsAiSpeaking(false);
setAiAudioLevel(0);
};
window.speechSynthesis.speak(utterance);
} else {
setIsAiSpeaking(false);
} }
aiLevelUpdateTimerRef.current = requestAnimationFrame(updateAiLevel); }
}, [stopAllAudio, voiceSettings.voiceName, voiceSettings.openRouterApiKey]);
// Turn Processing Handler
const handleUserTurn = useCallback(async (userText: string, currentHistory: Message[]) => {
if (isProcessingTurnRef.current || !userText.trim()) return;
isProcessingTurnRef.current = true;
stopAllAudio();
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
text: userText.trim(),
timestamp: new Date()
}; };
const connect = async () => { const updatedHistory = [...currentHistory, userMsg];
setMessages(updatedHistory);
const botMsgId = (Date.now() + 1).toString();
const botMsg: Message = {
id: botMsgId,
role: 'model',
text: '',
timestamp: new Date()
};
setMessages([...updatedHistory, botMsg]);
const roleplaySystemInstruction = `
${scenario.systemInstruction}
[CONTEXT]
You are participating in a clinical CBT roleplay simulation.
The instructions above define your character, the scenario, and your internal CBT logic.
[TASK]
Respond to the user naturally in character to advance the roleplay scenario.
[CONSTRAINTS]
- CRITICAL: Stay strictly in character as the persona defined in the scenario.
- CRITICAL: NEVER provide clinical feedback, advice, or meta-commentary about the user's progress.
- CRITICAL: Keep dialogue natural and conversational (1-3 sentences per turn).
- If the user successfully concludes the scenario, end the dialogue naturally.
`;
try {
let accumulatedText = '';
await streamOpenRouterChat(
updatedHistory,
roleplaySystemInstruction,
{
onChunk: (chunk) => {
accumulatedText += chunk;
setMessages(prev => prev.map(m =>
m.id === botMsgId ? { ...m, text: accumulatedText } : m
));
},
onComplete: (fullText) => {
speakText(fullText);
// Parallel turn supervisor analysis
setIsAnalyzingTurn(true);
const turnContext = `User said: "${userMsg.text}". Roleplay partner said: "${fullText}".`;
analyzeTurnWithOpenRouter(
turnContext,
scenario.title,
scenario.description,
scenario.cbtFocus,
{
apiKey: voiceSettings.openRouterApiKey,
model: voiceSettings.supervisorModel
}
).then(res => {
setLiveInsight(res);
}).catch(e => {
console.error("Voice turn analysis error", e);
}).finally(() => {
setIsAnalyzingTurn(false);
isProcessingTurnRef.current = false;
});
},
onError: (err) => {
console.error("OpenRouter stream failed", err);
isProcessingTurnRef.current = false;
}
},
{
apiKey: voiceSettings.openRouterApiKey,
model: voiceSettings.roleplayModel
}
);
} catch (err) {
console.error("User turn execution failed", err);
isProcessingTurnRef.current = false;
}
}, [scenario, speakText, stopAllAudio, voiceSettings.openRouterApiKey, voiceSettings.roleplayModel, voiceSettings.supervisorModel]);
// Main setup effect
useEffect(() => {
const apiKey = getOpenRouterApiKey(voiceSettings.openRouterApiKey);
if (!apiKey) {
setStatus('error');
setErrorMessage('OpenRouter API Key is missing. Please set your API key in Settings.');
return;
}
const initAudioAndSpeech = async () => {
try { try {
inputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 }); setStatus('connecting');
outputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 }); setErrorMessage(null);
// WebAudio Input Context
inputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
outputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
userAnalyserRef.current = inputAudioCtxRef.current.createAnalyser();
userAnalyserRef.current.fftSize = 256;
aiAnalyserRef.current = outputAudioCtxRef.current.createAnalyser(); aiAnalyserRef.current = outputAudioCtxRef.current.createAnalyser();
aiAnalyserRef.current.fftSize = 256; aiAnalyserRef.current.fftSize = 256;
aiAnalyserRef.current.connect(outputAudioCtxRef.current.destination); aiAnalyserRef.current.connect(outputAudioCtxRef.current.destination);
updateAiLevel();
const constraints: MediaStreamConstraints = { const constraints: MediaStreamConstraints = {
audio: voiceSettings.preferredAudioDeviceId audio: voiceSettings.preferredAudioDeviceId
? { deviceId: { exact: voiceSettings.preferredAudioDeviceId } } ? { deviceId: { exact: voiceSettings.preferredAudioDeviceId } }
: true : true
}; };
const stream = await navigator.mediaDevices.getUserMedia(constraints); const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream; streamRef.current = stream;
// Silence detection logic: If after 5 seconds of active session, const micSource = inputAudioCtxRef.current.createMediaStreamSource(stream);
// the max audio level seen is very low, flag it. micSource.connect(userAnalyserRef.current);
let maxLevelSeen = 0;
silenceCheckTimerRef.current = setTimeout(() => { // Visualizer Level Tracking Loops
if (maxLevelSeen < 1.0) { // Threshold for silence const updateUserLevel = () => {
setIsSilenceDetected(true); if (userAnalyserRef.current && !isMutedRef.current) {
const dataArray = new Uint8Array(userAnalyserRef.current.frequencyBinCount);
userAnalyserRef.current.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
const avg = sum / dataArray.length;
const lvl = Math.min(100, avg * 1.5);
setAudioLevel(lvl);
setIsUserSpeaking(lvl > 8);
} else {
setAudioLevel(0);
setIsUserSpeaking(false);
} }
}, 6000); userLevelTimerRef.current = requestAnimationFrame(updateUserLevel);
};
updateUserLevel();
const sessionPromise = ai.live.connect({ const updateAiLevel = () => {
model: 'gemini-2.5-flash-native-audio-preview-09-2025', if (aiAnalyserRef.current && isAiSpeaking) {
callbacks: { const dataArray = new Uint8Array(aiAnalyserRef.current.frequencyBinCount);
onopen: () => { aiAnalyserRef.current.getByteFrequencyData(dataArray);
setStatus('active'); let sum = 0;
const source = inputAudioCtxRef.current!.createMediaStreamSource(stream); for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
const scriptProcessor = inputAudioCtxRef.current!.createScriptProcessor(4096, 1, 1); const avg = sum / dataArray.length;
setAiAudioLevel(avg * 0.8);
scriptProcessor.onaudioprocess = (e) => { } else {
const inputData = e.inputBuffer.getChannelData(0); setAiAudioLevel(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: {}
} }
}); aiLevelTimerRef.current = requestAnimationFrame(updateAiLevel);
};
updateAiLevel();
sessionPromiseRef.current = sessionPromise; // Browser Speech Recognition for user speech
} catch (err) { const SpeechRec = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
console.error("Connect failed", err); if (SpeechRec) {
const rec = new SpeechRec();
rec.continuous = true;
rec.interimResults = false;
rec.lang = 'en-US';
rec.onresult = (event: any) => {
if (isMutedRef.current || isProcessingTurnRef.current) return;
const last = event.results.length - 1;
const transcript = event.results[last][0].transcript;
if (transcript && transcript.trim().length > 0) {
setMessages(current => {
handleUserTurn(transcript, current);
return current;
});
}
};
rec.onerror = (e: any) => {
if (e.error !== 'no-speech') console.warn("Speech recognition notice:", e.error);
};
rec.onend = () => {
// Auto restart recognition while active
if (status === 'active' || status === 'connecting') {
try { rec.start(); } catch (e) {}
}
};
try {
rec.start();
recognitionRef.current = rec;
} catch (e) {}
}
setStatus('active');
} catch (err: any) {
console.error("Live session initialization failed", err);
setStatus('error');
setErrorMessage(err?.message || 'Could not access audio hardware. Please check microphone permissions.');
} }
}; };
connect(); initAudioAndSpeech();
return () => { return () => {
stopAllAudio(); stopAllAudio();
if (silenceCheckTimerRef.current) clearTimeout(silenceCheckTimerRef.current); if (userLevelTimerRef.current) cancelAnimationFrame(userLevelTimerRef.current);
if (aiLevelUpdateTimerRef.current) cancelAnimationFrame(aiLevelUpdateTimerRef.current); if (aiLevelTimerRef.current) cancelAnimationFrame(aiLevelTimerRef.current);
if (recognitionRef.current) {
try { recognitionRef.current.stop(); } catch (e) {}
}
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop()); if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
if (inputAudioCtxRef.current) inputAudioCtxRef.current.close(); if (inputAudioCtxRef.current) inputAudioCtxRef.current.close();
if (outputAudioCtxRef.current) outputAudioCtxRef.current.close(); if (outputAudioCtxRef.current) outputAudioCtxRef.current.close();
if (sessionPromiseRef.current) sessionPromiseRef.current.then(s => s.close());
}; };
}, [scenario, voiceSettings, stopAllAudio, isMuted]); }, [scenario, voiceSettings.preferredAudioDeviceId, voiceSettings.openRouterApiKey, stopAllAudio, handleUserTurn, status, isAiSpeaking]);
return { return {
status, status,
setStatus, setStatus,
errorMessage,
isMuted, isMuted,
toggleMute, toggleMute,
audioLevel, audioLevel,
@@ -254,23 +348,15 @@ export function useLiveSession(scenario: Scenario, voiceSettings: UserSettings)
liveInsight, liveInsight,
isAnalyzingTurn, isAnalyzingTurn,
isSilenceDetected, isSilenceDetected,
sessionPromiseRef,
startSession: () => { startSession: () => {
if (sessionPromiseRef.current) { const initialBotMsg: Message = {
sessionPromiseRef.current.then(session => { id: 'init',
// We send a "hidden" text input to the model to trigger its initial message role: 'model',
// The Live API supports sending text turns in the clientContent text: scenario.initialMessage,
session.send({ timestamp: new Date()
clientContent: { };
turns: [{ setMessages([initialBotMsg]);
role: 'user', speakText(scenario.initialMessage);
parts: [{ text: "Please start the scenario now. Greet me as your character." }]
}],
turnComplete: true
}
});
});
}
} }
}; };
} }
+1 -12
View File
@@ -20,21 +20,10 @@
scrollbar-width: none; scrollbar-width: none;
} }
</style> </style>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@^19.2.3",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"react/": "https://esm.sh/react@^19.2.3/",
"@google/genai": "https://esm.sh/@google/genai@^1.33.0",
"lucide-react": "https://esm.sh/lucide-react@^0.561.0"
}
}
</script>
<link rel="stylesheet" href="/index.css"> <link rel="stylesheet" href="/index.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/index.tsx"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+1258 -718
View File
File diff suppressed because it is too large. Load diff
+17 -4
View File
@@ -13,10 +13,14 @@
"version:minor": "npm version minor --no-git-tag-version", "version:minor": "npm version minor --no-git-tag-version",
"version:major": "npm version major --no-git-tag-version", "version:major": "npm version major --no-git-tag-version",
"preview": "vite preview", "preview": "vite preview",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test",
"gallery:snapshots": "node scripts/package-snapshot-gallery.mjs",
"validate:scenarios": "tsx scripts/validate-scenarios.ts",
"simulate:session": "tsx scripts/simulate-cbt-session.ts"
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.33.0",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
@@ -42,12 +46,21 @@
"workbox-strategies": "^7.4.0" "workbox-strategies": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^1.48.0",
"@playwright/test": "^1.63.0",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@types/node": "^22.19.15", "@types/node": "^22.19.15",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"bits-ui": "^2.19.3",
"lucide-svelte": "^1.0.1",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tsx": "^4.23.13",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0",
"vitest": "^5.0.2"
} }
} }
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000
},
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }]
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
video: 'off',
screenshot: 'off'
},
projects: [
{
name: 'desktop-chrome',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 }
}
},
{
name: 'ipad-portrait',
use: {
viewport: { width: 834, height: 1194 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
userAgent:
'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
}
},
{
name: 'mobile-iphone',
use: {
...devices['iPhone 14']
}
}
],
webServer: {
command: 'npm run dev -- --port 3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000
}
});
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Playwright Visual Snapshot Review Gallery Packager
*
* Scans docs/screenshots/<viewport>/*.png, generates an interactive,
* responsive HTML review gallery with viewport filters, comparison matrices,
* and high-DPI lightbox inspection.
*/
import fs from 'fs';
import path from 'path';
const screenshotsDir = path.resolve(process.cwd(), 'docs/screenshots');
const outputHtml = path.join(screenshotsDir, 'index.html');
if (!fs.existsSync(screenshotsDir)) {
console.log(`[Snapshot Gallery] No screenshots directory found at ${screenshotsDir}. Skipping gallery build.`);
process.exit(0);
}
const entries = [];
const viewports = fs.readdirSync(screenshotsDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
for (const vp of viewports) {
const vpDir = path.join(screenshotsDir, vp);
const files = fs.readdirSync(vpDir).filter(f => f.endsWith('.png'));
for (const file of files) {
const filePath = path.join(vpDir, file);
const relPath = `${vp}/${file}`;
const stats = fs.statSync(filePath);
entries.push({
viewport: vp,
fileName: file,
title: file.replace(/^\d+-/, '').replace(/\.png$/, '').replace(/-/g, ' ').toUpperCase(),
relPath,
sizeKb: Math.round(stats.size / 1024)
});
}
}
entries.sort((a, b) => a.fileName.localeCompare(b.fileName));
const htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cognitive Clarity — Playwright Visual Snapshot Gallery</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.gallery-img { cursor: zoom-in; transition: transform 0.2s ease; }
.gallery-img:hover { transform: scale(1.01); }
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<header class="border-b border-slate-800 bg-slate-900/60 backdrop-blur-md sticky top-0 z-40 px-6 py-4">
<div class="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-xl font-bold tracking-tight text-white flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-teal-400 animate-pulse"></span>
Cognitive Clarity — Visual Snapshot Gallery
</h1>
<p class="text-xs text-slate-400 mt-0.5">
${entries.length} snapshots across ${viewports.length} viewports (${viewports.join(', ')})
</p>
</div>
<div class="flex flex-wrap gap-2" id="filter-buttons">
<button onclick="filterViewport('all')" class="vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-teal-600 text-white" data-vp="all">All</button>
${viewports.map(vp => `<button onclick="filterViewport('${vp}')" class="vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700" data-vp="${vp}">${vp}</button>`).join('')}
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-6 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="gallery-grid">
${entries.map(e => `
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="${e.viewport}">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">${e.title}</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">${e.viewport}</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="${e.relPath}" alt="${e.title}" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('${e.relPath}', '${e.title}')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>${e.fileName}</span>
<span>${e.sizeKb} KB</span>
</div>
</div>
`).join('')}
</div>
</main>
<!-- Lightbox Modal -->
<div id="lightbox" class="fixed inset-0 z-50 bg-black/90 backdrop-blur-md hidden flex flex-col items-center justify-center p-4" onclick="closeLightbox()">
<div class="max-w-6xl max-h-[90vh] flex flex-col items-center" onclick="event.stopPropagation()">
<div class="flex justify-between items-center w-full mb-3 text-white">
<h3 id="lightbox-title" class="font-bold text-base"></h3>
<button onclick="closeLightbox()" class="text-slate-400 hover:text-white px-3 py-1 bg-white/10 rounded-lg text-sm">Close ✕</button>
</div>
<img id="lightbox-img" src="" class="max-h-[80vh] max-w-full rounded-xl shadow-2xl border border-white/10 object-contain">
</div>
</div>
<script>
function filterViewport(vp) {
document.querySelectorAll('.vp-btn').forEach(btn => {
if (btn.dataset.vp === vp) {
btn.className = 'vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-teal-600 text-white';
} else {
btn.className = 'vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700';
}
});
document.querySelectorAll('.gallery-card').forEach(card => {
if (vp === 'all' || card.dataset.viewport === vp) {
card.style.display = 'flex';
} else {
card.style.display = 'none';
}
});
}
function openLightbox(src, title) {
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox-title').innerText = title;
document.getElementById('lightbox').classList.remove('hidden');
}
function closeLightbox() {
document.getElementById('lightbox').classList.add('hidden');
}
</script>
</body>
</html>`;
fs.writeFileSync(outputHtml, htmlContent, 'utf-8');
console.log(`[Snapshot Gallery] Successfully generated review gallery at ${outputHtml} with ${entries.length} snapshots.`);
+37
View File
@@ -0,0 +1,37 @@
/**
* Headless CBT Session Simulation Helper
*
* Simulates a multi-turn roleplay dialogue with clinical turn-by-turn evaluation
* to test prompt behaviors and scoring rubrics.
*/
import { SCENARIOS, SYSTEM_ANALYSIS_PROMPT } from '../constants';
interface SimulatedTurn {
role: 'user' | 'model';
text: string;
}
export function generateMockDialogue(): SimulatedTurn[] {
const scenario = SCENARIOS[0]; // Workplace Critical Review
return [
{ role: 'model', text: scenario.initialMessage },
{
role: 'user',
text: "I hear that you're concerned about the deployment delay, Marcus. I want to look at the root cause together so we can fix the pipeline rather than rush into another failure."
},
{
role: 'model',
text: "Look, root causes don't satisfy the board. But fine—walk me through why the rollback happened and what you're doing right now to secure the release."
},
{
role: 'user',
text: "The schema migration failed because of a missing index under high load. We've added the migration lock and tested it on staging. I will personally supervise the maintenance window at 8 PM."
}
];
}
console.log('🧪 CBT Simulation Test Harness initialized.');
console.log(`Loaded scenario: "${SCENARIOS[0].title}"`);
console.log('Mock dialogue generated for test runs:');
console.log(JSON.stringify(generateMockDialogue(), null, 2));
+119
View File
@@ -0,0 +1,119 @@
/**
* Scenario Validation Helper Script
*
* Verifies that all built-in and custom CBT scenarios conform to schema rules,
* category invariants, and clinical requirements.
*/
import { SCENARIOS, CBT_GLOSSARY } from '../constants';
import { Scenario } from '../types';
const VALID_CATEGORIES: Scenario['category'][] = ['Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk'];
const VALID_DIFFICULTIES: Scenario['difficulty'][] = ['Beginner', 'Intermediate', 'Advanced'];
interface ValidationResult {
scenarioId: string;
title: string;
isValid: boolean;
errors: string[];
warnings: string[];
}
export function validateScenario(scenario: Scenario): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!scenario.id || typeof scenario.id !== 'string') {
errors.push('Missing or invalid "id"');
}
if (!scenario.title || scenario.title.trim().length === 0) {
errors.push('Missing or empty "title"');
}
if (!scenario.description || scenario.description.trim().length === 0) {
errors.push('Missing or empty "description"');
}
if (!VALID_CATEGORIES.includes(scenario.category)) {
errors.push(`Invalid category: "${scenario.category}". Expected one of: ${VALID_CATEGORIES.join(', ')}`);
}
if (!VALID_DIFFICULTIES.includes(scenario.difficulty)) {
errors.push(`Invalid difficulty: "${scenario.difficulty}". Expected one of: ${VALID_DIFFICULTIES.join(', ')}`);
}
if (!scenario.characterName || scenario.characterName.trim().length === 0) {
errors.push('Missing or empty "characterName"');
}
if (!scenario.initialMessage || scenario.initialMessage.trim().length === 0) {
errors.push('Missing or empty "initialMessage"');
}
if (!Array.isArray(scenario.cbtFocus) || scenario.cbtFocus.length === 0) {
errors.push('cbtFocus must be a non-empty array of strings');
} else {
// Check against glossary terms
const glossaryTerms = CBT_GLOSSARY.map(g => g.term.toLowerCase());
scenario.cbtFocus.forEach(focus => {
const match = glossaryTerms.some(term => term === focus.toLowerCase() || focus.toLowerCase().includes(term));
if (!match) {
warnings.push(`CBT Focus item "${focus}" does not directly match any term in the CBT_GLOSSARY.`);
}
});
}
if (!scenario.systemInstruction || scenario.systemInstruction.trim().length === 0) {
errors.push('Missing or empty "systemInstruction"');
} else {
if (!scenario.systemInstruction.includes('INTERNAL CBT LOGIC') && !scenario.systemInstruction.includes('LOGIC:')) {
warnings.push('systemInstruction does not contain explicit "INTERNAL CBT LOGIC" branching rules.');
}
}
return {
scenarioId: scenario.id,
title: scenario.title,
isValid: errors.length === 0,
errors,
warnings,
};
}
function run() {
console.log('🔍 Validating Cognitive Clarity Scenario Catalog...\n');
let totalErrors = 0;
let totalWarnings = 0;
SCENARIOS.forEach(scenario => {
const result = validateScenario(scenario);
const statusIcon = result.isValid ? '✅' : '❌';
console.log(`${statusIcon} [${scenario.difficulty.toUpperCase()}] ${scenario.title} (${scenario.id})`);
if (result.errors.length > 0) {
console.log(' Errors:');
result.errors.forEach(e => console.log(` - ❌ ${e}`));
totalErrors += result.errors.length;
}
if (result.warnings.length > 0) {
console.log(' Warnings:');
result.warnings.forEach(w => console.log(` - ⚠️ ${w}`));
totalWarnings += result.warnings.length;
}
});
console.log('\n========================================');
console.log(`Summary: ${SCENARIOS.length} scenarios analyzed.`);
console.log(`Errors: ${totalErrors}, Warnings: ${totalWarnings}`);
console.log('========================================\n');
if (totalErrors > 0) {
process.exit(1);
}
}
if (process.argv[1]?.endsWith('validate-scenarios.ts') || process.argv[1]?.endsWith('validate-scenarios.js')) {
run();
}
+100
View File
@@ -0,0 +1,100 @@
/**
* API Configuration & Key Resolution Service
*
* Provides unified, hierarchical access to OpenRouter API credentials,
* supporting browser LocalStorage overrides and environment variables.
*/
import { UserSettings } from '../types';
const SETTINGS_KEY = 'cognitive-clarity-settings';
export const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.8-flash';
export const DEFAULT_SUPERVISOR_MODEL = 'google/gemini-3.8-flash';
export function getStoredUserSettings(): Partial<UserSettings> {
try {
if (typeof localStorage === 'undefined') {
return {};
}
const saved = localStorage.getItem(SETTINGS_KEY);
return saved ? JSON.parse(saved) : {};
} catch (e) {
console.error("Failed to read user settings from localStorage", e);
return {};
}
}
/**
* Returns the effective OpenRouter API Key.
* Precedence:
* 1. Explicit argument
* 2. UserSettings in LocalStorage
* 3. Vite environment variable (VITE_OPENROUTER_API_KEY)
* 4. Node process environment variable (OPENROUTER_API_KEY)
*/
export function getOpenRouterApiKey(customKey?: string): string {
if (customKey !== undefined) {
return customKey.trim();
}
const stored = getStoredUserSettings();
if (stored.openRouterApiKey && stored.openRouterApiKey.trim().length > 0) {
return stored.openRouterApiKey.trim();
}
if (typeof process !== 'undefined' && process.env?.OPENROUTER_API_KEY !== undefined) {
return process.env.OPENROUTER_API_KEY.trim();
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_OPENROUTER_API_KEY) {
return (import.meta.env.VITE_OPENROUTER_API_KEY as string).trim();
}
return '';
}
/**
* Checks if a valid OpenRouter API Key is configured.
*/
export function hasOpenRouterApiKey(): boolean {
return getOpenRouterApiKey().length > 0;
}
/**
* Returns the effective Google Gemini API Key.
* Precedence:
* 1. Explicit argument
* 2. UserSettings in LocalStorage (geminiApiKey)
* 3. Node process environment variable (GEMINI_API_KEY / API_KEY / VITE_GEMINI_API_KEY)
* 4. Vite environment variable (VITE_GEMINI_API_KEY)
*/
export function getGeminiApiKey(customKey?: string): string {
if (customKey !== undefined) {
return customKey.trim();
}
const stored = getStoredUserSettings();
if (stored.geminiApiKey && stored.geminiApiKey.trim().length > 0) {
return stored.geminiApiKey.trim();
}
if (typeof process !== 'undefined') {
if (process.env?.GEMINI_API_KEY !== undefined) return process.env.GEMINI_API_KEY.trim();
if (process.env?.API_KEY !== undefined) return process.env.API_KEY.trim();
if (process.env?.VITE_GEMINI_API_KEY !== undefined) return process.env.VITE_GEMINI_API_KEY.trim();
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_GEMINI_API_KEY) {
return (import.meta.env.VITE_GEMINI_API_KEY as string).trim();
}
return '';
}
/**
* Checks if a valid Google Gemini API Key is configured.
*/
export function hasGeminiApiKey(): boolean {
return getGeminiApiKey().length > 0;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Google Gemini Native Voice Synthesis Service
*
* Provides direct speech synthesis using Google's native Gemini voice models
* (gemini-3.8-flash-tts and gemini-2.5-flash-preview-tts) with official prebuilt
* voices: Fenrir (Gruff / Salt of the Earth), Puck, Charon, Kore, Aoede.
*/
import { getGeminiApiKey } from './apiConfig';
import { decodeBase64 } from './audioUtils';
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta';
/**
* Packages raw PCM16 audio into a self-contained WAV ArrayBuffer.
*/
export function pcmToWav(pcmData: Uint8Array, sampleRate = 24000, numChannels = 1): ArrayBuffer {
const wavBuffer = new ArrayBuffer(44 + pcmData.byteLength);
const view = new DataView(wavBuffer);
// "RIFF"
view.setUint32(0, 0x52494646, false);
view.setUint32(4, 36 + pcmData.byteLength, true);
// "WAVE"
view.setUint32(8, 0x57415645, false);
// "fmt "
view.setUint32(12, 0x666d7420, false);
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // AudioFormat: PCM (1)
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true); // BitsPerSample: 16
// "data"
view.setUint32(36, 0x64617461, false);
view.setUint32(40, pcmData.byteLength, true);
new Uint8Array(wavBuffer, 44).set(pcmData);
return wavBuffer;
}
/**
* Synthesizes speech using Google Gemini's native voice model.
*
* @param text The text dialogue to speak.
* @param voiceName Prebuilt Gemini voice: 'Fenrir' | 'Puck' | 'Charon' | 'Kore' | 'Aoede' | 'Zephyr'
* @param apiKey Optional explicit Google Gemini API key.
* @returns ArrayBuffer containing standard decoded audio data (WAV container).
*/
export async function synthesizeSpeechWithGemini(
text: string,
voiceName = 'Fenrir',
apiKey?: string
): Promise<ArrayBuffer> {
const effectiveKey = getGeminiApiKey(apiKey);
if (!effectiveKey) {
throw new Error('Google Gemini API key is missing. Please set GEMINI_API_KEY in Settings or .env.');
}
// Normalize voice name to valid Gemini prebuilt voices (Zephyr -> Puck/Fenrir)
const validVoices = ['Fenrir', 'Puck', 'Charon', 'Kore', 'Aoede'];
const targetVoice = validVoices.includes(voiceName) ? voiceName : 'Fenrir';
const modelsToTry = [
'gemini-3.8-flash-tts',
'gemini-2.5-flash-preview-tts'
];
let lastError: any = null;
for (const model of modelsToTry) {
try {
const url = `${GEMINI_API_BASE}/models/${model}:generateContent?key=${encodeURIComponent(effectiveKey)}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [{ parts: [{ text }] }],
generationConfig: {
responseModalities: ['AUDIO'],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: targetVoice
}
}
}
}
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini ${model} TTS error (${response.status}): ${errorText}`);
}
const json = await response.json();
const part = json.candidates?.[0]?.content?.parts?.[0];
if (!part || !part.inlineData?.data) {
throw new Error(`Gemini ${model} returned no audio content in response candidates.`);
}
const mimeType = part.inlineData.mimeType || '';
const rawBytes = decodeBase64(part.inlineData.data);
if (mimeType.includes('wav')) {
const slice = rawBytes.buffer.slice(rawBytes.byteOffset, rawBytes.byteOffset + rawBytes.byteLength);
return slice as ArrayBuffer;
}
// If PCM (e.g. audio/L16;codec=pcm;rate=24000), wrap in WAV container for universal browser decoding
return pcmToWav(rawBytes, 24000, 1);
} catch (err: any) {
lastError = err;
console.warn(`Failed synthesis with Gemini model ${model}, checking next model...`, err);
}
}
throw lastError || new Error('All Gemini TTS models failed to generate audio.');
}
+370
View File
@@ -0,0 +1,370 @@
/**
* OpenRouter AI Service Provider
*
* Provides unified integration with OpenRouter API for streaming roleplay dialogue,
* real-time clinical supervision, scenario generation, and vocal speech synthesis/transcription.
*/
import { Message, Scenario, AnalysisResult } from '../types';
import { getOpenRouterApiKey, DEFAULT_OPENROUTER_MODEL, DEFAULT_SUPERVISOR_MODEL } from './apiConfig';
import { SYSTEM_ANALYSIS_PROMPT, SYSTEM_SUMMARY_ANALYSIS_PROMPT } from '../constants';
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
export interface OpenRouterOptions {
model?: string;
temperature?: number;
maxTokens?: number;
apiKey?: string;
}
export interface StreamChatCallbacks {
onChunk: (chunkText: string) => void;
onComplete?: (fullText: string) => void;
onError?: (error: any) => void;
}
/**
* Streams roleplay chat completion from OpenRouter using Server-Sent Events (SSE).
*/
export async function streamOpenRouterChat(
messages: Message[],
systemInstruction: string,
callbacks: StreamChatCallbacks,
options: OpenRouterOptions = {}
): Promise<string> {
const apiKey = getOpenRouterApiKey(options.apiKey);
if (!apiKey) {
const error = new Error("OpenRouter API key is missing. Please set your OpenRouter API Key in Settings.");
callbacks.onError?.(error);
throw error;
}
const model = options.model || DEFAULT_OPENROUTER_MODEL;
const formattedMessages = [
{ role: 'system', content: systemInstruction },
...messages.map(m => ({
role: m.role === 'model' ? 'assistant' : m.role,
content: m.text
}))
];
try {
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model,
messages: formattedMessages,
temperature: options.temperature ?? 0.7,
max_tokens: options.maxTokens ?? 1200,
stream: true
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter API error (${response.status}): ${errorText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Unable to read response stream.");
const decoder = new TextDecoder('utf-8');
let fullText = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) continue;
if (trimmed === 'data: [DONE]') continue;
if (trimmed.startsWith('data: ')) {
try {
const data = JSON.parse(trimmed.substring(6));
const delta = data.choices?.[0]?.delta?.content || '';
if (delta) {
fullText += delta;
callbacks.onChunk(delta);
}
} catch (e) {
// Partial JSON buffer ignored
}
}
}
}
callbacks.onComplete?.(fullText);
return fullText;
} catch (err) {
callbacks.onError?.(err);
throw err;
}
}
/**
* Executes a single-turn structured CBT turn analysis via OpenRouter JSON mode.
*/
export async function analyzeTurnWithOpenRouter(
turnContext: string,
scenarioTitle: string,
scenarioDescription: string,
cbtFocus: string[],
options: OpenRouterOptions = {}
): Promise<AnalysisResult> {
const apiKey = getOpenRouterApiKey(options.apiKey);
if (!apiKey) {
throw new Error("OpenRouter API key is missing. Please set your API key in Settings.");
}
const model = options.model || DEFAULT_SUPERVISOR_MODEL;
const systemPrompt = `${SYSTEM_ANALYSIS_PROMPT}
SCENARIO CONTEXT:
Title: ${scenarioTitle}
Description: ${scenarioDescription}
CBT Focus Areas: ${cbtFocus.join(', ')}
[OUTPUT REQUIREMENTS]
Return ONLY a valid JSON object matching this schema:
{
"analysis": {
"distortions": ["string"],
"isAdaptive": boolean,
"evidence": "string"
},
"rating": {
"score": number (1-5),
"reason": "string"
},
"suggestions": {
"alternativeThought": "string",
"behavioralStep": "string"
}
}`;
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: turnContext }
],
response_format: { type: 'json_object' },
temperature: 0.2
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter Clinical Analysis failed (${response.status}): ${errorText}`);
}
const data = await response.json();
const rawContent = data.choices?.[0]?.message?.content || '{}';
return JSON.parse(rawContent) as AnalysisResult;
}
/**
* Generates a comprehensive post-session summary report via OpenRouter JSON mode.
*/
export async function generateSessionSummaryReport(
messages: Message[],
scenarioTitle: string,
scenarioDescription: string,
options: OpenRouterOptions = {}
): Promise<AnalysisResult> {
const apiKey = getOpenRouterApiKey(options.apiKey);
if (!apiKey) {
throw new Error("OpenRouter API key is missing.");
}
const model = options.model || DEFAULT_SUPERVISOR_MODEL;
const transcriptText = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n');
const systemPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}
SCENARIO CONTEXT: ${scenarioTitle} - ${scenarioDescription}
[OUTPUT REQUIREMENTS]
Return ONLY a valid JSON object conforming exactly to this schema:
{
"analysis": {
"distortions": ["string"],
"isAdaptive": boolean,
"evidence": "string"
},
"rating": {
"score": number (1-5),
"reason": "string"
},
"suggestions": {
"alternativeThought": "string",
"behavioralStep": "string"
}
}`;
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `SESSION TRANSCRIPT:\n${transcriptText}` }
],
response_format: { type: 'json_object' },
temperature: 0.2
})
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`OpenRouter summary analysis failed (${response.status}): ${errText}`);
}
const data = await response.json();
const rawContent = data.choices?.[0]?.message?.content || '{}';
return JSON.parse(rawContent) as AnalysisResult;
}
/**
* Generates a new validated CBT roleplay scenario via OpenRouter JSON mode.
*/
export async function generateScenarioWithOpenRouter(
prompt: string,
selectedConcepts: string[],
difficulty: Scenario['difficulty'],
options: OpenRouterOptions = {}
): Promise<Scenario> {
const apiKey = getOpenRouterApiKey(options.apiKey);
if (!apiKey) {
throw new Error("OpenRouter API key is missing. Please enter your key in Settings.");
}
const model = options.model || DEFAULT_OPENROUTER_MODEL;
const selectedContext = selectedConcepts.length > 0
? `Focus specifically on these CBT concepts: ${selectedConcepts.join(', ')}.`
: '';
const 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 into one of: 'Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk'.
- DIFFICULTY CALIBRATION: The requested difficulty is '${difficulty}'.
- '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 matching this schema:
{
"id": "string",
"title": "string",
"characterName": "string",
"description": "string",
"category": "Social Anxiety" | "Workplace" | "Relationships" | "Self-Talk",
"difficulty": "Beginner" | "Intermediate" | "Advanced",
"systemInstruction": "string",
"initialMessage": "string",
"cbtFocus": ["string"]
}
`;
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemInstruction },
{ role: 'user', content: `Generate a clinical scenario for: ${prompt || 'A scenario exploring the chosen CBT concepts.'}` }
],
response_format: { type: 'json_object' },
temperature: 0.7
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter scenario generation failed (${response.status}): ${errorText}`);
}
const data = await response.json();
const rawContent = data.choices?.[0]?.message?.content || '{}';
return JSON.parse(rawContent) as Scenario;
}
/**
* Synthesizes spoken audio from text using OpenRouter's speech endpoint.
*/
export async function synthesizeSpeechWithOpenRouter(
text: string,
voice = 'alloy',
apiKey?: string
): Promise<ArrayBuffer> {
const effectiveKey = getOpenRouterApiKey(apiKey);
if (!effectiveKey) {
throw new Error("OpenRouter API key is missing.");
}
const response = await fetch(`${OPENROUTER_BASE_URL}/audio/speech`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${effectiveKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model: 'openai/tts-1',
input: text,
voice: voice
})
});
if (!response.ok) {
throw new Error(`OpenRouter TTS synthesis failed with status ${response.status}`);
}
return await response.arrayBuffer();
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Session Storage & Export Service
*
* Manages persistent storage of completed CBT roleplay sessions,
* enabling history review, markdown report exports, and JSON backups.
*/
import { SavedSession, Scenario, SessionMode, Message, AnalysisResult } from '../types';
const SESSIONS_STORAGE_KEY = 'cognitive-clarity-saved-sessions';
/**
* Retrieves all saved sessions from LocalStorage.
*/
export function getSavedSessions(): SavedSession[] {
try {
const raw = localStorage.getItem(SESSIONS_STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
console.error("Failed to load saved sessions", e);
return [];
}
}
/**
* Saves a completed session to LocalStorage.
*/
export function saveSession(
scenario: Scenario,
mode: SessionMode,
messages: Message[],
finalReport?: AnalysisResult
): SavedSession {
const newSession: SavedSession = {
id: `session-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
scenarioId: scenario.id,
scenarioTitle: scenario.title,
category: scenario.category,
difficulty: scenario.difficulty,
timestamp: new Date().toISOString(),
mode,
messages,
finalReport
};
try {
const existing = getSavedSessions();
const updated = [newSession, ...existing].slice(0, 50); // Keep last 50 sessions
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(updated));
} catch (e) {
console.error("Failed to save session", e);
}
return newSession;
}
/**
* Deletes a session by ID.
*/
export function deleteSession(sessionId: string): void {
try {
const existing = getSavedSessions();
const filtered = existing.filter(s => s.id !== sessionId);
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(filtered));
} catch (e) {
console.error("Failed to delete session", e);
}
}
/**
* Generates an Apple HIG-styled Markdown summary of a CBT session report.
*/
export function formatSessionAsMarkdown(
scenarioTitle: string,
analysis: AnalysisResult,
messages?: Message[]
): string {
const dateStr = new Date().toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
let md = `# Cognitive Clarity — Clinical CBT Session Report\n\n`;
md += `**Scenario:** ${scenarioTitle}\n`;
md += `**Date:** ${dateStr}\n`;
md += `**Clinical Evaluation:** ${analysis.analysis.isAdaptive ? '✅ Adaptive Growth Observed' : '⚠️ Clinical Patterns / Distortions Detected'}\n`;
md += `**Mastery Rating:** ${analysis.rating.score} / 5\n\n`;
md += `---\n\n`;
md += `## 1. Clinical Analysis\n\n`;
if (analysis.analysis.distortions.length > 0) {
md += `**Cognitive Distortions Identified:**\n`;
analysis.analysis.distortions.forEach(d => {
md += `- ${d}\n`;
});
md += `\n`;
}
md += `**Behavioral Evidence:**\n> "${analysis.analysis.evidence}"\n\n`;
md += `**Clinical Justification:**\n${analysis.rating.reason}\n\n`;
md += `## 2. Cognitive Reframing & Tactical Steps\n\n`;
md += `**Master Reframe:**\n> "${analysis.suggestions.alternativeThought}"\n\n`;
md += `**Homework & Behavioral Step:**\n${analysis.suggestions.behavioralStep}\n\n`;
if (messages && messages.length > 0) {
md += `---\n\n## 3. Session Transcript\n\n`;
messages.forEach(m => {
const sender = m.role === 'user' ? 'You' : 'AI Partner';
md += `**[${sender}]**: ${m.text}\n\n`;
});
}
return md;
}
/**
* Triggers a client-side file download of text content.
*/
export function downloadFile(content: string, fileName: string, contentType: string): void {
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+186
View File
@@ -0,0 +1,186 @@
import { synthesizeSpeechWithOpenRouter } from './openRouterService';
import { synthesizeSpeechWithGemini } from './geminiVoiceService';
import { getGeminiApiKey, hasGeminiApiKey, hasOpenRouterApiKey } from './apiConfig';
import { VOICE_PROFILES, type VoiceProfile } from '../constants';
import type { UserSettings } from '../types';
export interface VoicePlaybackState {
isPlaying: boolean;
activeVoiceId: UserSettings['voiceName'] | null;
mode: 'gemini' | 'openrouter' | 'webspeech' | 'idle';
error?: string;
}
export function getActiveVoiceEngine(): 'gemini' | 'openrouter' | 'webspeech' {
if (hasGeminiApiKey()) return 'gemini';
if (hasOpenRouterApiKey()) return 'openrouter';
return 'webspeech';
}
export class VoiceAuditionService {
private currentAudio: HTMLAudioElement | null = null;
private currentAudioUrl: string | null = null;
private activeUtterance: SpeechSynthesisUtterance | null = null;
/**
* Returns the VoiceProfile configuration for a given voice name.
*/
getProfile(voiceId: UserSettings['voiceName']): VoiceProfile {
return VOICE_PROFILES.find((p) => p.id === voiceId) || VOICE_PROFILES[0];
}
/**
* Auditions/plays a voice sample:
* 1. Google Gemini native voice (gemini-3.8-flash-tts) if GEMINI_API_KEY is available.
* 2. OpenRouter TTS if OPENROUTER_API_KEY is available.
* 3. Browser Web Speech Synthesis fallback.
*/
async playPreview(
voiceId: UserSettings['voiceName'],
customText?: string,
apiKey?: string,
onEnded?: () => void,
geminiApiKey?: string
): Promise<void> {
this.stop();
const profile = this.getProfile(voiceId);
const textToSpeak = customText?.trim() || profile.sampleText;
// 1. Prioritize Google Gemini Native Voice (gemini-3.8-flash-tts)
const geminiKey = getGeminiApiKey(geminiApiKey);
if (geminiKey) {
try {
const arrayBuffer = await synthesizeSpeechWithGemini(
textToSpeak,
profile.id,
geminiKey
);
const blob = new Blob([arrayBuffer], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
this.currentAudioUrl = url;
const audio = new Audio(url);
this.currentAudio = audio;
audio.onended = () => {
this.cleanupAudio();
onEnded?.();
};
audio.onerror = () => {
this.cleanupAudio();
this.playWebSpeech(textToSpeak, profile, onEnded);
};
await audio.play();
return;
} catch (err) {
console.warn('Gemini 3.8 native voice synthesis preview failed, checking OpenRouter fallback:', err);
}
}
// 2. OpenRouter TTS Fallback
if (apiKey && apiKey.trim().length > 0) {
try {
const arrayBuffer = await synthesizeSpeechWithOpenRouter(
textToSpeak,
profile.openRouterVoice,
apiKey
);
const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
this.currentAudioUrl = url;
const audio = new Audio(url);
this.currentAudio = audio;
audio.onended = () => {
this.cleanupAudio();
onEnded?.();
};
audio.onerror = () => {
this.cleanupAudio();
this.playWebSpeech(textToSpeak, profile, onEnded);
};
await audio.play();
return;
} catch (err) {
console.warn('OpenRouter TTS preview failed, falling back to Web Speech Synthesis:', err);
}
}
// 3. Web Speech API fallback with calibrated pitch/rate tuning
this.playWebSpeech(textToSpeak, profile, onEnded);
}
private playWebSpeech(
text: string,
profile: VoiceProfile,
onEnded?: () => void
): void {
const synth = typeof window !== 'undefined' && 'speechSynthesis' in window
? window.speechSynthesis
: (typeof (globalThis as any).speechSynthesis !== 'undefined' ? (globalThis as any).speechSynthesis : null);
const Utterance = typeof window !== 'undefined' && 'SpeechSynthesisUtterance' in window
? window.SpeechSynthesisUtterance
: (typeof (globalThis as any).SpeechSynthesisUtterance !== 'undefined' ? (globalThis as any).SpeechSynthesisUtterance : null);
if (synth && Utterance) {
synth.cancel();
const utterance = new Utterance(text);
utterance.pitch = profile.webSpeechTuning.pitch;
utterance.rate = profile.webSpeechTuning.rate;
utterance.onend = () => {
this.activeUtterance = null;
onEnded?.();
};
utterance.onerror = () => {
this.activeUtterance = null;
onEnded?.();
};
this.activeUtterance = utterance;
synth.speak(utterance);
} else {
onEnded?.();
}
}
private cleanupAudio(): void {
if (this.currentAudioUrl) {
URL.revokeObjectURL(this.currentAudioUrl);
this.currentAudioUrl = null;
}
this.currentAudio = null;
}
/**
* Immediately stops any active audition preview audio.
*/
stop(): void {
if (this.currentAudio) {
try {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
} catch (e) {}
this.cleanupAudio();
}
const synth = typeof window !== 'undefined' && 'speechSynthesis' in window
? window.speechSynthesis
: (typeof (globalThis as any).speechSynthesis !== 'undefined' ? (globalThis as any).speechSynthesis : null);
if (synth) {
try {
synth.cancel();
} catch (e) {}
this.activeUtterance = null;
}
}
}
export const defaultVoiceAudition = new VoiceAuditionService();
+289
View File
@@ -0,0 +1,289 @@
<script lang="ts">
import { appState } from './lib/stores/AppStateStore.svelte';
import { defaultUserSettings } from './lib/stores/UserSettingsStore.svelte';
import type { Scenario, SessionMode, UserSettings } from '../types';
import ScenarioCard from './lib/components/ScenarioCard.svelte';
import ChatSession from './lib/components/ChatSession.svelte';
import VoiceSession from './lib/components/VoiceSession.svelte';
import ScenarioGenerator from './lib/components/ScenarioGenerator.svelte';
import CBTReference from './lib/components/CBTReference.svelte';
import SettingsModal from './lib/components/SettingsModal.svelte';
import Tooltip from './lib/components/ui/Tooltip.svelte';
import Button from './lib/components/ui/Button.svelte';
import ReloadPrompt from './lib/components/ReloadPrompt.svelte';
import VersionBadge from './lib/components/VersionBadge.svelte';
import {
Brain,
Info,
ShieldCheck,
Filter,
Plus,
BookOpen,
Activity,
Zap,
Settings
} from '@lucide/svelte';
const difficulties = ['All', 'Beginner', 'Intermediate', 'Advanced'];
function handleSelectScenario(scenario: Scenario, mode: SessionMode) {
appState.selectScenario(scenario, mode);
}
function handleExitSession() {
appState.exitSession();
}
function handleScenarioGenerated(newScenario: Scenario) {
appState.addCustomScenario(newScenario);
}
function handleDeleteScenario(id: string) {
appState.deleteCustomScenario(id);
}
function handleUpdateSettings(newSettings: UserSettings) {
defaultUserSettings.update(newSettings);
}
</script>
{#if appState.isPreparing}
<div class="min-h-screen bg-slate-900 flex flex-col items-center justify-center p-6 text-white overflow-hidden relative">
<div class="absolute inset-0 overflow-hidden pointer-events-none opacity-20">
<div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-teal-500 rounded-full blur-[120px] animate-pulse"></div>
<div
class="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-500 rounded-full blur-[120px] animate-pulse"
style="animation-delay: 1s;"
></div>
</div>
<div class="relative z-10 flex flex-col items-center max-w-md text-center">
<div class="w-24 h-24 bg-white/5 rounded-3xl border border-white/10 flex items-center justify-center mb-8 relative">
<Brain size={48} class="text-teal-400" />
<div class="absolute inset-0 border-2 border-teal-500/30 rounded-3xl animate-ping opacity-20"></div>
</div>
<h2 class="text-2xl font-bold mb-3 tracking-tight">Initializing Session</h2>
<div class="flex items-center gap-2 mb-8">
<span class="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style="animation-delay: 0ms;"></span>
<span class="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style="animation-delay: 150ms;"></span>
<span class="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style="animation-delay: 300ms;"></span>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest ml-2">Calibrating AI Parameters</p>
</div>
<div class="w-full bg-white/5 h-1.5 rounded-full overflow-hidden mb-4 border border-white/5">
<div class="h-full bg-teal-500 rounded-full w-0 animate-[loading_1.4s_ease-in-out_forwards]"></div>
</div>
<div class="space-y-2 opacity-60">
<p class="text-[10px] uppercase font-black tracking-tighter flex items-center justify-center gap-2">
<Zap size={10} class="text-teal-400" />
Configuring {appState.sessionMode === 'voice' ? 'Voice Link' : 'Neural Chat Bridge'}
</p>
<p class="text-[10px] uppercase font-black tracking-tighter flex items-center justify-center gap-2">
<Activity size={10} class="text-blue-400" />
Applying {defaultUserSettings.voiceName} Vocal Profile
</p>
</div>
</div>
<style>
@keyframes loading {
0% {
width: 0%;
}
20% {
width: 15%;
}
50% {
width: 70%;
}
100% {
width: 100%;
}
}
</style>
</div>
{:else if appState.activeScenario}
{#if appState.sessionMode === 'voice'}
<div class="fixed inset-0 z-40 bg-slate-950 flex flex-col overflow-hidden animate-in fade-in duration-500">
<VoiceSession
scenario={appState.activeScenario}
onexit={handleExitSession}
voiceSettings={defaultUserSettings.toUserSettings()}
onupdatesettings={handleUpdateSettings}
/>
</div>
{:else}
<div class="min-h-screen bg-slate-100/70 flex flex-col items-center py-4 sm:py-6 px-3 sm:px-6 animate-in fade-in duration-500">
<ChatSession
scenario={appState.activeScenario}
userSettings={defaultUserSettings.toUserSettings()}
onexit={handleExitSession}
/>
</div>
{/if}
{:else}
<div class="min-h-screen bg-[#FDFDFF] text-slate-900 font-sans relative animate-in fade-in duration-500">
<ReloadPrompt />
<!-- Modals -->
{#if appState.showGenerator}
<ScenarioGenerator
userSettings={defaultUserSettings.toUserSettings()}
ongenerate={handleScenarioGenerated}
oncancel={() => (appState.showGenerator = false)}
/>
{/if}
{#if appState.showReference}
<CBTReference onclose={() => (appState.showReference = false)} />
{/if}
{#if appState.showSettings}
<SettingsModal
settings={defaultUserSettings.toUserSettings()}
onupdate={handleUpdateSettings}
onclose={() => (appState.showSettings = false)}
/>
{/if}
<!-- Hero Section -->
<div class="bg-white border-b border-slate-200">
<div class="max-w-5xl mx-auto px-6 py-12">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div class="flex items-center gap-3">
<div class="bg-teal-600 p-2 rounded-lg text-white shadow-sm shadow-teal-200">
<Brain size={32} />
</div>
<h1 class="text-3xl font-bold tracking-tight text-slate-900">Cognitive Clarity</h1>
</div>
<div class="flex items-center gap-2">
<Tooltip content="Adjust voice settings for audio sessions.">
<Button
variant="outline"
size="icon"
class="rounded-xl text-slate-400 hover:text-slate-800 cursor-pointer"
onclick={() => (appState.showSettings = true)}
>
<Settings size={20} />
</Button>
</Tooltip>
<Tooltip content="Explore the glossary of CBT distortions, techniques, and concepts.">
<Button
variant="outline"
class="rounded-xl text-slate-600 hover:text-teal-700 hover:bg-teal-50 cursor-pointer"
onclick={() => (appState.showReference = true)}
>
<BookOpen size={18} class="mr-2" />
Reference Guide
</Button>
</Tooltip>
</div>
</div>
<p class="text-xl text-slate-600 max-w-2xl leading-relaxed font-light">
An adaptive clinical training environment designed to strengthen cognitive flexibility and navigate social friction.
</p>
<div class="flex flex-wrap gap-4 mt-8">
<Tooltip content="Our scenarios are built on the foundations of Cognitive Behavioral Therapy.">
<div class="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-100 cursor-help">
<ShieldCheck class="text-teal-600" size={18} />
<span>Evidence-Based Framework</span>
</div>
</Tooltip>
<Tooltip content="The AI character responds dynamically to your level of cognitive restructuring.">
<div class="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-100 cursor-help">
<Info class="text-teal-600" size={18} />
<span>Vocal Partner: {defaultUserSettings.voiceName}</span>
</div>
</Tooltip>
</div>
</div>
</div>
<!-- Main Content -->
<main class="max-w-5xl mx-auto px-6 py-12">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-10">
<h2 class="text-2xl font-medium text-slate-800">Training Scenarios</h2>
<div class="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<div class="flex items-center bg-white border border-slate-200 rounded-lg p-1 shadow-sm overflow-x-auto">
<div class="px-3 text-slate-400 flex items-center gap-2 text-sm font-medium border-r border-slate-100 mr-1">
<Filter size={14} />
<span class="hidden sm:inline">Difficulty</span>
</div>
<div class="flex gap-1">
{#each difficulties as diff (diff)}
<Button
variant={appState.selectedDifficulty === diff ? 'secondary' : 'ghost'}
size="sm"
onclick={() => (appState.selectedDifficulty = diff)}
class="h-7 px-3 text-xs {appState.selectedDifficulty === diff
? 'bg-teal-50 text-teal-700 font-semibold hover:bg-teal-100 hover:text-teal-800'
: 'text-slate-500 hover:text-slate-700'}"
>
{diff}
</Button>
{/each}
</div>
</div>
<Tooltip content="Generate a unique roleplay situation based on your specific needs using AI." position="bottom">
<div>
<Button
onclick={() => (appState.showGenerator = true)}
class="bg-teal-600 hover:bg-teal-700 text-white shadow-md shadow-teal-100 font-bold cursor-pointer"
>
<Plus size={18} class="mr-2" />
Create Custom
</Button>
</div>
</Tooltip>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" data-testid="scenarios-grid">
{#each appState.filteredScenarios as scenario (scenario.id)}
<ScenarioCard
{scenario}
onselect={handleSelectScenario}
ondelete={scenario.id.startsWith('custom-') ? handleDeleteScenario : undefined}
/>
{/each}
<button
type="button"
onclick={() => (appState.showGenerator = true)}
class="flex flex-col items-center justify-center gap-4 p-6 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 hover:border-teal-300 hover:text-teal-600 hover:bg-teal-50/50 transition-all h-full min-h-[250px] group whitespace-normal cursor-pointer"
>
<div class="bg-slate-50 p-4 rounded-full group-hover:bg-white transition-colors">
<Plus size={32} />
</div>
<div class="text-center">
<span class="block font-bold text-sm uppercase tracking-widest mb-1">
Custom Scenario
</span>
<span class="text-xs font-light">Generate a specific roleplay session</span>
</div>
</button>
</div>
</main>
<footer class="bg-white border-t border-slate-200 py-12">
<div class="max-w-5xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-8">
<div class="flex items-center gap-4 text-slate-400">
<div class="flex items-center gap-2">
<Brain size={20} />
<span class="text-sm font-bold uppercase tracking-widest text-slate-900">
Cognitive Clarity
</span>
</div>
<VersionBadge />
</div>
</div>
</footer>
</div>
{/if}
+455
View File
@@ -0,0 +1,455 @@
import type { Scenario, Message, AnalysisResult } from '../../../types';
import { UserSettingsStore } from '../stores/UserSettingsStore.svelte';
import {
streamOpenRouterChat,
analyzeTurnWithOpenRouter,
generateSessionSummaryReport,
synthesizeSpeechWithOpenRouter
} from '../../../services/openRouterService';
import { synthesizeSpeechWithGemini } from '../../../services/geminiVoiceService';
import { getOpenRouterApiKey, getGeminiApiKey } from '../../../services/apiConfig';
import { saveSession } from '../../../services/sessionStorage';
import { VOICE_PROFILES } from '../../../constants';
export type SessionStatus = 'idle' | 'connecting' | 'active' | 'analyzing' | 'finished' | 'error';
export class AudioEngine {
status = $state<SessionStatus>('idle');
errorMessage = $state<string | null>(null);
isMuted = $state(false);
micLevel = $state(0);
aiAudioLevel = $state(0);
isAiSpeaking = $state(false);
isUserSpeaking = $state(false);
messages = $state<Message[]>([]);
liveInsight = $state<AnalysisResult | null>(null);
isAnalyzingTurn = $state(false);
finalReport = $state<AnalysisResult | null>(null);
// Derived unified visualizer level
activeLevel = $derived(this.isAiSpeaking ? this.aiAudioLevel : this.micLevel);
private inputCtx: AudioContext | null = null;
private outputCtx: AudioContext | null = null;
private userAnalyser: AnalyserNode | null = null;
private aiAnalyser: AnalyserNode | null = null;
private currentAudioSource: AudioBufferSourceNode | null = null;
private stream: MediaStream | null = null;
private recognition: any = null;
private animFrameId: number | null = null;
private isProcessingTurn = false;
constructor(
private scenario: Scenario,
private userSettings: UserSettingsStore
) {}
async start() {
const apiKey = getOpenRouterApiKey(this.userSettings.openRouterApiKey);
if (!apiKey) {
this.status = 'error';
this.errorMessage = 'OpenRouter API Key is missing. Please set your API key in Settings.';
return;
}
try {
this.status = 'connecting';
this.errorMessage = null;
// WebAudio Context Initialization
const AudioCtxClass = typeof window !== 'undefined'
? (window.AudioContext || (window as any).webkitAudioContext)
: (globalThis as any).AudioContext;
if (!AudioCtxClass) {
throw new Error('WebAudio AudioContext is not supported in this environment.');
}
this.inputCtx = new AudioCtxClass();
this.outputCtx = new AudioCtxClass();
this.userAnalyser = this.inputCtx.createAnalyser();
this.userAnalyser.fftSize = 256;
this.aiAnalyser = this.outputCtx.createAnalyser();
this.aiAnalyser.fftSize = 256;
// Microphone Stream
const constraints: MediaStreamConstraints = {
audio: this.userSettings.preferredAudioDeviceId
? { deviceId: { exact: this.userSettings.preferredAudioDeviceId } }
: true
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
const micSource = this.inputCtx.createMediaStreamSource(this.stream);
micSource.connect(this.userAnalyser);
// Web Speech Recognition
const SpeechRecognition = typeof window !== 'undefined'
? ((window as any).SpeechRecognition || (window as any).webkitSpeechRecognition)
: (globalThis as any).SpeechRecognition;
if (SpeechRecognition) {
this.recognition = new SpeechRecognition();
this.recognition.continuous = true;
this.recognition.interimResults = true;
this.recognition.lang = 'en-US';
let speechDebounceTimer: any = null;
let pendingUserSpeech = '';
this.recognition.onresult = (event: any) => {
if (this.isMuted || this.isAiSpeaking) return;
let interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
pendingUserSpeech += ' ' + transcript;
} else {
interimTranscript += transcript;
}
}
if (interimTranscript.trim() || pendingUserSpeech.trim()) {
this.isUserSpeaking = true;
}
clearTimeout(speechDebounceTimer);
speechDebounceTimer = setTimeout(() => {
const finalSpeech = (pendingUserSpeech + ' ' + interimTranscript).trim();
if (finalSpeech.length > 2) {
this.isUserSpeaking = false;
this.handleUserTurn(finalSpeech);
pendingUserSpeech = '';
}
}, 1200);
};
this.recognition.onerror = (e: any) => {
if (e.error !== 'no-speech') {
console.warn('Speech recognition warning:', e.error);
}
};
this.recognition.onend = () => {
if (this.status === 'active' && !this.isMuted) {
try { this.recognition?.start(); } catch (err) {}
}
};
try {
this.recognition.start();
} catch (e) {
console.warn('Speech recognition auto-start prevented', e);
}
}
// Initial Message
const initialGreeting: Message = {
id: 'init',
role: 'model',
text: this.scenario.initialMessage,
timestamp: new Date()
};
this.messages = [initialGreeting];
this.status = 'active';
// Start Frequency Analysis Animation Loop
this.startMeterLoop();
// Speak initial greeting
await this.speakText(this.scenario.initialMessage);
} catch (err: any) {
console.error('Audio initialization failure', err);
this.status = 'error';
this.errorMessage = err?.message || 'Failed to initialize audio input device.';
}
}
private startMeterLoop() {
const userBuffer = new Uint8Array(this.userAnalyser ? this.userAnalyser.frequencyBinCount : 0);
const aiBuffer = new Uint8Array(this.aiAnalyser ? this.aiAnalyser.frequencyBinCount : 0);
const updateMeter = () => {
// 1. Microphone Level
if (this.userAnalyser && !this.isMuted && !this.isAiSpeaking) {
this.userAnalyser.getByteFrequencyData(userBuffer);
let sum = 0;
for (let i = 0; i < userBuffer.length; i++) sum += userBuffer[i];
const avg = sum / userBuffer.length;
this.micLevel = Math.min(100, Math.round((avg / 128) * 100));
} else {
this.micLevel = 0;
}
// 2. AI Audio Level
if (this.aiAnalyser && this.isAiSpeaking) {
this.aiAnalyser.getByteFrequencyData(aiBuffer);
let sum = 0;
for (let i = 0; i < aiBuffer.length; i++) sum += aiBuffer[i];
const avg = sum / aiBuffer.length;
this.aiAudioLevel = Math.min(100, Math.round((avg / 128) * 100));
} else {
this.aiAudioLevel = 0;
}
this.animFrameId = requestAnimationFrame(updateMeter);
};
this.animFrameId = requestAnimationFrame(updateMeter);
}
toggleMute() {
this.isMuted = !this.isMuted;
if (this.isMuted) {
this.stopAllAudio();
try { this.recognition?.stop(); } catch (e) {}
} else {
try { this.recognition?.start(); } catch (e) {}
}
}
async speakText(text: string) {
this.stopAllAudio();
this.isAiSpeaking = true;
const profile = VOICE_PROFILES.find(
(p) => p.id.toLowerCase() === this.userSettings.voiceName.toLowerCase()
) || VOICE_PROFILES[0];
// 1. Google Gemini Native Voice (gemini-3.8-flash-tts with Fenrir, Puck, Kore, etc.)
const geminiKey = getGeminiApiKey(this.userSettings.geminiApiKey);
if (geminiKey) {
try {
const arrayBuffer = await synthesizeSpeechWithGemini(
text,
profile.id,
geminiKey
);
if (!this.outputCtx) return;
if (this.outputCtx.state === 'suspended') await this.outputCtx.resume();
const buffer = await this.outputCtx.decodeAudioData(arrayBuffer);
const source = this.outputCtx.createBufferSource();
source.buffer = buffer;
if (this.aiAnalyser) {
source.connect(this.aiAnalyser);
this.aiAnalyser.connect(this.outputCtx.destination);
} else {
source.connect(this.outputCtx.destination);
}
source.onended = () => {
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
};
this.currentAudioSource = source;
source.start(0);
return;
} catch (e) {
console.warn('Gemini 3.8 native voice synthesis fallback to OpenRouter/WebSpeech:', e);
}
}
// 2. OpenRouter TTS Fallback
try {
const arrayBuffer = await synthesizeSpeechWithOpenRouter(
text,
profile.openRouterVoice,
this.userSettings.openRouterApiKey
);
if (!this.outputCtx) return;
if (this.outputCtx.state === 'suspended') await this.outputCtx.resume();
const buffer = await this.outputCtx.decodeAudioData(arrayBuffer);
const source = this.outputCtx.createBufferSource();
source.buffer = buffer;
if (this.aiAnalyser) {
source.connect(this.aiAnalyser);
this.aiAnalyser.connect(this.outputCtx.destination);
} else {
source.connect(this.outputCtx.destination);
}
source.onended = () => {
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
};
this.currentAudioSource = source;
source.start(0);
return;
} catch (e) {
console.warn('OpenRouter TTS fallback to Web Speech Synthesis', e);
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = profile.webSpeechTuning.rate;
utterance.pitch = profile.webSpeechTuning.pitch;
utterance.onstart = () => { this.isAiSpeaking = true; };
utterance.onend = () => {
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
};
utterance.onerror = () => {
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
};
window.speechSynthesis.speak(utterance);
} else {
this.isAiSpeaking = false;
}
}
}
async handleUserTurn(userText: string) {
if (this.isProcessingTurn || !userText.trim()) return;
this.isProcessingTurn = true;
this.stopAllAudio();
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
text: userText.trim(),
timestamp: new Date()
};
const currentHistory = [...this.messages, userMsg];
this.messages = currentHistory;
const botMsgId = (Date.now() + 1).toString();
const botMsg: Message = {
id: botMsgId,
role: 'model',
text: '',
timestamp: new Date()
};
this.messages = [...currentHistory, botMsg];
const roleplaySystemInstruction = `
${this.scenario.systemInstruction}
[CONTEXT]
You are participating in a clinical CBT roleplay simulation.
The instructions above define your character, the scenario, and your internal CBT logic.
[TASK]
Respond to the user naturally in character to advance the roleplay scenario.
[CONSTRAINTS]
- CRITICAL: Stay strictly in character as the persona defined in the scenario.
- CRITICAL: NEVER provide clinical feedback, advice, or meta-commentary about the user's progress.
- CRITICAL: Keep dialogue natural and conversational (1-3 sentences per turn).
- If the user successfully concludes the scenario, end the dialogue naturally.
`;
try {
let accumulatedText = '';
await streamOpenRouterChat(
currentHistory,
roleplaySystemInstruction,
{
onChunk: (chunk) => {
accumulatedText += chunk;
this.messages = this.messages.map((m) =>
m.id === botMsgId ? { ...m, text: accumulatedText } : m
);
},
onComplete: (fullText) => {
this.speakText(fullText);
// Parallel out-of-band turn supervisor
this.isAnalyzingTurn = true;
const turnContext = `User said: "${userMsg.text}". Roleplay partner said: "${fullText}".`;
analyzeTurnWithOpenRouter(
turnContext,
this.scenario.title,
this.scenario.description,
this.scenario.cbtFocus,
{
apiKey: this.userSettings.openRouterApiKey,
model: this.userSettings.supervisorModel
}
).then((res) => {
this.liveInsight = res;
}).catch((err) => {
console.error('Voice turn analysis error', err);
}).finally(() => {
this.isAnalyzingTurn = false;
this.isProcessingTurn = false;
});
},
onError: (err) => {
console.error('OpenRouter voice stream failed', err);
this.isProcessingTurn = false;
}
},
{
apiKey: this.userSettings.openRouterApiKey,
model: this.userSettings.roleplayModel
}
);
} catch (err) {
console.error('User voice turn execution failed', err);
this.isProcessingTurn = false;
}
}
async finishSession() {
this.status = 'analyzing';
this.stopAllAudio();
try {
const report = await generateSessionSummaryReport(
this.messages,
this.scenario.title,
this.scenario.description,
{
apiKey: this.userSettings.openRouterApiKey,
model: this.userSettings.supervisorModel
}
);
this.finalReport = report;
saveSession(this.scenario, 'voice', this.messages, report);
this.status = 'finished';
} catch (err) {
console.error('Post-session analysis error', err);
this.status = 'error';
this.errorMessage = 'Failed to generate session report.';
}
}
stopAllAudio() {
if (this.currentAudioSource) {
try { this.currentAudioSource.stop(); } catch (e) {}
this.currentAudioSource = null;
}
const synth = typeof window !== 'undefined' && 'speechSynthesis' in window
? window.speechSynthesis
: typeof (globalThis as any).speechSynthesis !== 'undefined'
? (globalThis as any).speechSynthesis
: null;
if (synth) {
synth.cancel();
}
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
}
destroy() {
this.stopAllAudio();
if (this.animFrameId) {
cancelAnimationFrame(this.animFrameId);
this.animFrameId = null;
}
try { this.recognition?.stop(); } catch (e) {}
this.stream?.getTracks().forEach((t) => t.stop());
this.inputCtx?.close();
this.outputCtx?.close();
}
}
@@ -0,0 +1,89 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Mic, Check, AlertCircle } from '@lucide/svelte';
interface Props {
selectedDeviceId?: string;
onSelect?: (deviceId: string) => void;
onselect?: (deviceId: string) => void;
class?: string;
}
let { selectedDeviceId, onSelect, onselect, class: className = '' }: Props = $props();
let devices = $state<MediaDeviceInfo[]>([]);
let error = $state<string | null>(null);
function notifySelect(deviceId: string) {
onselect?.(deviceId);
onSelect?.(deviceId);
}
async function getDevices() {
try {
if (typeof navigator === 'undefined' || !navigator.mediaDevices) return;
await navigator.mediaDevices.getUserMedia({ audio: true });
const allDevices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = allDevices.filter((d) => d.kind === 'audioinput');
devices = audioInputs;
} catch (err: any) {
console.warn('Error accessing audio devices:', err);
error = 'Microphone access denied. Please check your browser permissions.';
}
}
onMount(() => {
getDevices();
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
navigator.mediaDevices.addEventListener('devicechange', getDevices);
return () => {
navigator.mediaDevices.removeEventListener('devicechange', getDevices);
};
}
});
</script>
{#if error}
<div class="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>
{:else}
<div class="space-y-2 {className}">
<div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto pr-1">
{#each devices as device, i (device.deviceId || i)}
{@const isSelected = selectedDeviceId === device.deviceId || (!selectedDeviceId && (device.deviceId === 'default' || i === 0))}
<button
type="button"
onclick={() => notifySelect(device.deviceId)}
class="flex items-center justify-between p-3 rounded-2xl border text-left transition-all cursor-pointer {isSelected
? 'bg-teal-50 border-teal-500 shadow-sm'
: 'bg-white border-slate-200 hover:border-slate-300'}"
>
<div class="flex items-center gap-3 overflow-hidden pr-2">
<div
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0 {isSelected
? 'bg-teal-600 text-white'
: 'bg-slate-100 text-slate-400'}"
>
<Mic size={14} />
</div>
<span class="text-xs font-medium text-slate-700 truncate">
{device.label || `Microphone ${i + 1}`}
</span>
</div>
{#if isSelected}
<div class="bg-teal-600 text-white p-1 rounded-full shrink-0">
<Check size={12} />
</div>
{/if}
</button>
{/each}
{#if devices.length === 0}
<p class="text-xs text-slate-400 italic p-2 text-center">No audio input devices detected.</p>
{/if}
</div>
</div>
{/if}
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
interface Props {
level: number;
active?: boolean;
isAiSpeaking?: boolean;
isUserSpeaking?: boolean;
isMuted?: boolean;
}
let {
level,
active,
isAiSpeaking = false,
isUserSpeaking = false,
isMuted = false
}: Props = $props();
const isActive = $derived(
active !== undefined
? active
: !isMuted && (isAiSpeaking || isUserSpeaking || level > 2)
);
const barCount = 19;
const center = (barCount - 1) / 2;
// Compute heights reactively in microtasks with zero VDOM overhead
const bars = $derived(
Array.from({ length: barCount }, (_, i) => {
const dist = Math.abs(i - center);
const multiplier = Math.pow(0.88, dist);
const jitter = 0.8 + Math.sin(i * 1.5) * 0.2;
const height = isActive ? Math.max(8, level * multiplier * jitter) : 6;
const opacity = isActive ? 1 - dist / barCount : 0.3;
return { height, opacity };
})
);
</script>
<div class="flex items-center justify-center gap-1.5 h-24 w-full px-4">
{#each bars as bar, i (i)}
<div
class="w-1.5 rounded-full transition-all duration-100 ease-out border-b border-white/5 {isActive
? isAiSpeaking
? 'bg-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.6)]'
: 'bg-teal-400 shadow-[0_0_15px_rgba(45,212,191,0.6)]'
: 'bg-slate-700/50'}"
style="height: {bar.height}%; opacity: {bar.opacity};"
></div>
{/each}
</div>
+156
View File
@@ -0,0 +1,156 @@
<script lang="ts">
import { CBT_GLOSSARY, type GlossaryEntry } from '../../../constants';
import { X, Search, BookOpen, Brain, Activity, HelpCircle } from '@lucide/svelte';
import Button from './ui/Button.svelte';
import Input from './ui/Input.svelte';
import Badge from './ui/Badge.svelte';
import ScrollArea from './ui/ScrollArea.svelte';
import Card from './ui/Card.svelte';
import CardHeader from './ui/CardHeader.svelte';
import CardTitle from './ui/CardTitle.svelte';
import CardContent from './ui/CardContent.svelte';
interface Props {
onclose: () => void;
}
let { onclose }: Props = $props();
let search = $state('');
let activeCategory = $state<'All' | 'Distortion' | 'Technique' | 'Concept'>('All');
const categories: ('All' | 'Distortion' | 'Technique' | 'Concept')[] = [
'All',
'Distortion',
'Technique',
'Concept'
];
let filtered = $derived(
CBT_GLOSSARY.filter((entry) => {
const matchesSearch =
entry.term.toLowerCase().includes(search.toLowerCase()) ||
entry.definition.toLowerCase().includes(search.toLowerCase());
const matchesCategory =
activeCategory === 'All' || entry.category === activeCategory;
return matchesSearch && matchesCategory;
})
);
</script>
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl h-[85vh] overflow-hidden flex flex-col animate-in fade-in zoom-in duration-300">
<!-- 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 rounded-lg text-white">
<BookOpen size={24} />
</div>
<div>
<h2 class="text-xl font-bold text-slate-800">CBT Clinical Reference</h2>
<p class="text-xs text-slate-500 font-medium uppercase tracking-widest">
Evidence-Based Concepts & Frameworks
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onclick={onclose}
class="text-slate-400 hover:bg-slate-200 rounded-xl"
>
<X size={20} />
</Button>
</div>
<!-- Filters -->
<div class="p-6 space-y-4 border-b border-slate-100 bg-white shrink-0">
<div class="relative">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<Input
type="text"
placeholder="Search concepts, distortions, or definitions..."
bind:value={search}
class="w-full pl-12 pr-4 h-12 bg-slate-50 border-slate-200 rounded-xl focus-visible:ring-teal-500/50"
/>
</div>
<div class="flex flex-wrap gap-2">
{#each categories as cat (cat)}
<Button
variant={activeCategory === cat ? 'default' : 'outline'}
onclick={() => (activeCategory = cat)}
class="rounded-full text-xs font-bold {activeCategory === cat
? 'bg-teal-600 hover:bg-teal-700 shadow-md shadow-teal-100 text-white'
: 'text-slate-500 hover:text-teal-600 hover:border-teal-200'}"
>
{cat}
</Button>
{/each}
</div>
</div>
<!-- Content -->
<ScrollArea class="flex-grow bg-slate-50/30 p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-6">
{#each filtered as entry (entry.term)}
<Card class="hover:border-teal-200 transition-all group shadow-sm border-slate-200">
<CardHeader class="pb-2">
<div class="flex justify-between items-start mb-2">
<CardTitle class="text-base group-hover:text-teal-700 transition-colors">
{entry.term}
</CardTitle>
<Badge
variant="outline"
class="flex items-center gap-1.5 px-2 py-0.5 bg-slate-50 border-slate-100 rounded text-[9px] font-black uppercase tracking-tighter text-slate-500"
>
{#if entry.category === 'Distortion'}
<Brain size={16} class="text-rose-500" />
{:else if entry.category === 'Technique'}
<Activity size={16} class="text-teal-500" />
{:else if entry.category === 'Concept'}
<HelpCircle size={16} class="text-amber-500" />
{:else}
<BookOpen size={16} />
{/if}
{entry.category}
</Badge>
</div>
</CardHeader>
<CardContent>
<p class="text-sm text-slate-600 leading-relaxed mb-4 font-light">
{entry.definition}
</p>
{#if entry.example}
<div class="bg-teal-50/50 p-3 rounded-xl border border-teal-100">
<span class="text-[9px] font-black uppercase text-teal-600 block mb-1">
Clinical Example
</span>
<p class="text-xs text-teal-800 italic leading-relaxed">
"{entry.example}"
</p>
</div>
{/if}
</CardContent>
</Card>
{/each}
{#if filtered.length === 0}
<div class="col-span-full py-20 text-center space-y-4">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto text-slate-400">
<Search size={32} />
</div>
<p class="text-slate-400 text-sm font-medium">No concepts matching your search parameters.</p>
</div>
{/if}
</div>
</ScrollArea>
<!-- Footer -->
<div class="p-4 bg-white border-t border-slate-100 text-center shrink-0">
<p class="text-[10px] text-slate-400 font-medium uppercase tracking-widest">
References compiled from clinical CBT standard frameworks.
</p>
</div>
</div>
</div>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import { Send } from '@lucide/svelte';
interface Props {
disabled?: boolean;
onsend: (text: string) => void;
}
let { disabled = false, onsend }: Props = $props();
let input = $state('');
function handleSend() {
if (!input.trim() || disabled) return;
onsend(input);
input = '';
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
</script>
<div class="p-4 border-t border-slate-100 bg-[#FCFDFF]">
<div class="flex gap-2 max-w-3xl mx-auto">
<input
type="text"
bind:value={input}
onkeydown={handleKeyDown}
placeholder="Type your response in character..."
class="flex-grow px-5 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white text-slate-900 placeholder:text-slate-400 text-sm shadow-inner"
{disabled}
/>
<button
type="button"
onclick={handleSend}
disabled={disabled || !input.trim()}
class="bg-teal-600 text-white p-3 rounded-xl hover:bg-teal-700 disabled:opacity-50 transition-all shadow-md shadow-teal-100 active:scale-95 cursor-pointer disabled:cursor-not-allowed"
>
<Send size={20} />
</button>
</div>
</div>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Message } from '../../../types';
import { User, Bot } from '@lucide/svelte';
interface Props {
message: Message;
isLoading?: boolean;
}
let { message, isLoading = false }: Props = $props();
const isModel = $derived(message.role === 'model');
</script>
<div class="flex {!isModel ? 'justify-end' : 'justify-start'}">
<div class="flex max-w-[85%] gap-3 {!isModel ? 'flex-row-reverse' : 'flex-row'}">
<div
class="w-9 h-9 rounded-full flex items-center justify-center shrink-0 shadow-sm border {!isModel
? 'bg-teal-50 text-teal-600 border-teal-100'
: 'bg-slate-50 text-slate-600 border-slate-100'}"
>
{#if !isModel}
<User size={18} />
{:else}
<Bot size={18} />
{/if}
</div>
<div
class="p-4 rounded-2xl text-sm leading-relaxed shadow-sm {!isModel
? 'bg-teal-600 text-white rounded-tr-none'
: 'bg-[#F9FAFB] text-slate-800 rounded-tl-none border border-slate-100'}"
>
{#if message.text}
{message.text}
{:else if isModel && isLoading}
<div class="flex gap-1 items-center py-1">
<span class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style="animation-delay: 0ms;"></span>
<span class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style="animation-delay: 150ms;"></span>
<span class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style="animation-delay: 300ms;"></span>
</div>
{/if}
</div>
</div>
</div>
+253
View File
@@ -0,0 +1,253 @@
<script lang="ts">
import { tick } from 'svelte';
import type { Scenario, Message, AnalysisResult, UserSettings } from '../../../types';
import { BrainCircuit, ChevronLeft, Flag, Loader2 } from '@lucide/svelte';
import {
streamOpenRouterChat,
analyzeTurnWithOpenRouter,
generateSessionSummaryReport
} from '../../../services/openRouterService';
import { saveSession } from '../../../services/sessionStorage';
import ChatMessage from './ChatMessage.svelte';
import ChatInput from './ChatInput.svelte';
import LiveInsightPanel from './LiveInsightPanel.svelte';
import SessionReport from './SessionReport.svelte';
interface Props {
scenario: Scenario;
userSettings?: UserSettings;
onexit: () => void;
}
let { scenario, userSettings, onexit }: Props = $props();
let messages = $state<Message[]>([]);
let isLoading = $state(false);
let status = $state<'active' | 'analyzing' | 'finished'>('active');
$effect(() => {
messages = [
{
id: 'init',
role: 'model',
text: scenario.initialMessage,
timestamp: new Date()
}
];
});
let liveInsight = $state<AnalysisResult | null>(null);
let isAnalyzingTurn = $state(false);
let finalReport = $state<AnalysisResult | null>(null);
let messagesEndRef: HTMLDivElement | null = $state(null);
async function scrollToBottom() {
await tick();
messagesEndRef?.scrollIntoView({ behavior: 'smooth' });
}
$effect(() => {
// Track messages length to trigger scroll
if (messages.length) {
scrollToBottom();
}
});
async function handleSend(text: string) {
if (isLoading || !text.trim()) return;
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
text,
timestamp: new Date()
};
const updatedHistory = [...messages, userMsg];
messages = updatedHistory;
isLoading = true;
const botMsgId = (Date.now() + 1).toString();
const botMsg: Message = {
id: botMsgId,
role: 'model',
text: '',
timestamp: new Date()
};
messages = [...updatedHistory, botMsg];
const roleplaySystemInstruction = `
${scenario.systemInstruction}
[CONTEXT]
You are participating in a clinical CBT roleplay simulation.
The instructions above define your character, the scenario, and your internal CBT logic.
[TASK]
Respond to the user naturally in character to advance the roleplay scenario.
[CONSTRAINTS]
- CRITICAL: Stay strictly in character as the persona defined in the scenario.
- CRITICAL: NEVER provide clinical feedback, advice, or meta-commentary about the user's progress.
- CRITICAL: NEVER use phrases like "Great job holding your boundary" or "Feedback:".
- CRITICAL: Do not congratulate the user on using CBT techniques; simply react as the character would.
- If the user successfully concludes the scenario, end the dialogue naturally.
[OUTPUT REQUIREMENTS]
Return the character's direct dialogue response. Keep it conversational and proportional to the user's input.
`;
try {
let accumulatedText = '';
await streamOpenRouterChat(
updatedHistory,
roleplaySystemInstruction,
{
onChunk: (chunk) => {
accumulatedText += chunk;
messages = messages.map((m) =>
m.id === botMsgId ? { ...m, text: accumulatedText } : m
);
},
onComplete: (fullText) => {
isAnalyzingTurn = true;
const turnContext = `User said: "${userMsg.text}". Roleplay partner said: "${fullText}".`;
analyzeTurnWithOpenRouter(
turnContext,
scenario.title,
scenario.description,
scenario.cbtFocus,
{
apiKey: userSettings?.openRouterApiKey,
model: userSettings?.supervisorModel
}
)
.then((res) => {
liveInsight = res;
})
.catch((e) => {
console.error('Live analysis failed', e);
})
.finally(() => {
isAnalyzingTurn = false;
});
},
onError: (err) => {
console.error('OpenRouter stream failed', err);
}
},
{
apiKey: userSettings?.openRouterApiKey,
model: userSettings?.roleplayModel
}
);
} catch (error) {
console.error('Chat transmission failed', error);
messages = messages.filter((m) => m.text !== '');
} finally {
isLoading = false;
}
}
async function handleFinishSession() {
if (messages.length < 2) return onexit();
status = 'analyzing';
try {
const reportData = await generateSessionSummaryReport(
messages,
scenario.title,
scenario.description,
{
apiKey: userSettings?.openRouterApiKey,
model: userSettings?.supervisorModel
}
);
finalReport = reportData;
saveSession(scenario, 'text', messages, reportData);
status = 'finished';
} catch (err) {
console.error('Post-session analysis failure', err);
onexit();
}
}
</script>
{#if status === 'analyzing'}
<div class="flex flex-col items-center justify-center min-h-[500px] h-full bg-slate-50 rounded-3xl p-8 border border-slate-200 w-full max-w-2xl mx-auto animate-in fade-in duration-500">
<div class="bg-white p-12 rounded-full shadow-lg border border-teal-100 mb-8 relative">
<BrainCircuit size={64} class="text-teal-600 animate-pulse" />
<div class="absolute inset-0 border-4 border-teal-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<h2 class="text-2xl font-bold text-slate-800 mb-2">Compiling Report</h2>
<p class="text-slate-500 text-center max-w-sm">
Generating evidence-based synthesis of your cognitive and behavioral patterns during this session via OpenRouter...
</p>
</div>
{:else if status === 'finished' && finalReport}
<SessionReport
analysis={finalReport}
scenarioTitle={scenario.title}
{messages}
{onexit}
/>
{:else}
<div class="flex flex-col h-[calc(100vh-80px)] sm:h-[calc(100vh-90px)] max-w-6xl mx-auto w-full gap-3 sm:gap-4 px-1 sm:px-2">
<!-- Interaction Header -->
<header class="bg-white p-3.5 sm:p-4 rounded-2xl shadow-sm border border-slate-200/80 flex justify-between items-center shrink-0">
<div class="flex items-center gap-3 min-w-0">
<button
type="button"
onclick={onexit}
class="p-2 hover:bg-slate-100 rounded-xl text-slate-400 hover:text-teal-600 transition-colors cursor-pointer shrink-0"
title="Exit Session"
>
<ChevronLeft size={20} />
</button>
<div class="min-w-0">
<h2 class="font-bold text-slate-800 text-base sm:text-lg leading-tight truncate">{scenario.title}</h2>
<div class="flex flex-wrap gap-1.5 mt-0.5">
{#each scenario.cbtFocus.slice(0, 3) as f (f)}
<span class="text-[9px] sm:text-[10px] font-bold text-teal-600 bg-teal-50 px-2 py-0.5 rounded-full uppercase tracking-wider">{f}</span>
{/each}
</div>
</div>
</div>
<button
type="button"
onclick={handleFinishSession}
class="flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-xl bg-teal-600 hover:bg-teal-700 text-white text-[10px] sm:text-xs font-black uppercase tracking-wider transition-all shadow-sm shadow-teal-600/30 cursor-pointer shrink-0 ml-2"
>
<Flag size={14} />
<span>Complete Interaction</span>
</button>
</header>
<!-- Dual-Pane Stage -->
<div class="flex flex-col lg:flex-row gap-3 sm:gap-4 flex-1 min-h-0 overflow-hidden">
<!-- Core Chat Conversation -->
<div class="flex-1 min-w-0 flex flex-col bg-white rounded-2xl shadow-sm border border-slate-200/80 overflow-hidden">
<div class="flex-grow overflow-y-auto p-4 sm:p-6 space-y-4 sm:space-y-6">
{#each messages as msg, index (msg.id)}
<ChatMessage
message={msg}
isLoading={isLoading && index === messages.length - 1}
/>
{/each}
<div bind:this={messagesEndRef}></div>
</div>
<ChatInput onsend={handleSend} disabled={isLoading} />
</div>
<!-- Real-Time Clinical Supervision Panel -->
<div class="w-full lg:w-[380px] xl:w-[420px] shrink-0 h-[360px] lg:h-full min-h-0 overflow-hidden">
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} theme="light" />
</div>
</div>
</div>
{/if}
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import type { AnalysisResult } from '../../../types';
import { BrainCircuit, Loader2, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp } from '@lucide/svelte';
import Tooltip from './ui/Tooltip.svelte';
interface Props {
analysis: AnalysisResult | null;
isAnalyzing: boolean;
theme?: 'light' | 'dark';
class?: string;
}
let {
analysis,
isAnalyzing,
theme = 'light',
class: customClass = ''
}: Props = $props();
const isDark = $derived(theme === 'dark');
</script>
<div
class="w-full h-full flex flex-col overflow-y-auto {isDark
? 'bg-slate-900/80 text-white border border-white/10 rounded-2xl shadow-2xl'
: 'bg-white text-slate-800 border border-slate-200 rounded-2xl shadow-sm'} {customClass}"
>
<!-- Panel Header -->
<div
class="p-4 sm:p-5 border-b flex items-center justify-between shrink-0 {isDark
? 'border-white/10 bg-black/20'
: 'border-slate-100 bg-[#F8FAFC]'}"
>
<div class="flex items-center gap-2.5">
<div class="p-1.5 rounded-lg {isDark ? 'bg-teal-500/20 text-teal-400' : 'bg-teal-50 text-teal-600'}">
<BrainCircuit size={18} />
</div>
<div>
<h3 class="font-bold text-xs uppercase tracking-widest {isDark ? 'text-white' : 'text-slate-800'}">
Live Clinical Insight
</h3>
<p class="text-[10px] {isDark ? 'text-slate-400' : 'text-slate-400'}">
Turn-by-turn CBT supervision
</p>
</div>
</div>
{#if isAnalyzing}
<div class="flex items-center gap-1.5 px-2.5 py-1 rounded-full {isDark ? 'bg-teal-500/10 text-teal-300' : 'bg-teal-50 text-teal-600'}">
<Loader2 size={13} class="animate-spin" />
<span class="text-[10px] font-bold uppercase tracking-wider">Evaluating</span>
</div>
{/if}
</div>
<!-- Panel Content -->
<div class="p-4 sm:p-5 space-y-4 flex-grow overflow-y-auto">
{#if analysis}
<div class="space-y-4 animate-in fade-in duration-300">
<!-- Thinking Classification -->
<Tooltip content="Identifies whether your last statement followed adaptive, evidence-based thinking patterns or contained cognitive distortions.">
<div
class="p-4 rounded-xl border transition-all {analysis.analysis.isAdaptive
? isDark
? 'bg-teal-950/40 border-teal-500/40 text-teal-200'
: 'bg-teal-50/70 border-teal-500/60 text-teal-900 border-l-4'
: isDark
? 'bg-rose-950/40 border-rose-500/40 text-rose-200'
: 'bg-rose-50/70 border-rose-500/60 text-rose-900 border-l-4'}"
>
<div class="flex items-center gap-2 mb-2">
{#if analysis.analysis.isAdaptive}
<CheckCircle2 size={16} class="{isDark ? 'text-teal-400' : 'text-teal-600'} shrink-0" />
{:else}
<AlertTriangle size={16} class="{isDark ? 'text-rose-400' : 'text-rose-600'} shrink-0" />
{/if}
<span
class="font-bold text-[11px] uppercase tracking-wider {analysis.analysis.isAdaptive
? isDark ? 'text-teal-300' : 'text-teal-800'
: isDark ? 'text-rose-300' : 'text-rose-800'}"
>
{analysis.analysis.isAdaptive ? 'Optimal Thinking' : 'Distortion Identified'}
</span>
</div>
{#if !analysis.analysis.isAdaptive && analysis.analysis.distortions.length > 0}
<div class="flex flex-wrap gap-1.5 mb-2.5">
{#each analysis.analysis.distortions as distortion}
<span class="text-[9px] font-black uppercase tracking-wider text-white bg-rose-500/90 px-2 py-0.5 rounded shadow-sm">
{distortion}
</span>
{/each}
</div>
{/if}
<p class="text-xs leading-relaxed font-normal {isDark ? 'text-slate-200' : 'text-slate-700'}">
{analysis.analysis.evidence}
</p>
</div>
</Tooltip>
<!-- Efficiency Rating -->
<Tooltip content="A score representing your effectiveness in applying CBT techniques to this specific turn.">
<div class="p-4 rounded-xl border {isDark ? 'bg-white/5 border-white/10' : 'bg-slate-50 border-slate-100'}">
<div class="flex justify-between items-center mb-2">
<span class="text-[10px] font-bold uppercase tracking-widest {isDark ? 'text-slate-400' : 'text-slate-500'}">
Restructuring Efficiency
</span>
<div class="flex gap-1">
{#each [1, 2, 3, 4, 5] as star}
<div
class="h-1.5 w-4 rounded-full {star <= analysis.rating.score
? 'bg-teal-500'
: isDark ? 'bg-white/10' : 'bg-slate-200'}"
></div>
{/each}
</div>
</div>
<p class="text-xs font-normal leading-relaxed {isDark ? 'text-slate-300' : 'text-slate-600'}">
{analysis.rating.reason}
</p>
</div>
</Tooltip>
<!-- Master Reframe -->
<Tooltip content="A balanced, evidence-based alternative perspective for this specific moment in the interaction.">
<div
class="p-4 rounded-xl border relative overflow-hidden group {isDark
? 'bg-gradient-to-br from-teal-950/40 to-slate-900/60 border-teal-500/30'
: 'bg-white border-teal-200 shadow-sm'}"
>
<div class="flex items-center gap-2 text-teal-500 mb-2">
<Lightbulb size={16} />
<span class="text-[10px] font-black uppercase tracking-widest {isDark ? 'text-teal-300' : 'text-slate-800'}">
Master Reframe
</span>
</div>
<p class="text-xs sm:text-sm italic font-medium leading-relaxed {isDark ? 'text-teal-100' : 'text-slate-800'}">
"{analysis.suggestions.alternativeThought}"
</p>
</div>
</Tooltip>
<!-- Tactical Pivot -->
<Tooltip content="Actionable behavioral advice to improve your communication or internal response in the next turn.">
<div class="p-4 rounded-xl border {isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200 shadow-sm'}">
<div class="flex items-center gap-2 text-teal-500 mb-2">
<TrendingUp size={16} />
<span class="text-[10px] font-black uppercase tracking-widest {isDark ? 'text-slate-300' : 'text-slate-800'}">
Tactical Pivot
</span>
</div>
<p class="text-xs leading-relaxed font-normal {isDark ? 'text-slate-300' : 'text-slate-700'}">
{analysis.suggestions.behavioralStep}
</p>
</div>
</Tooltip>
</div>
{:else}
<div class="h-full flex flex-col items-center justify-center text-center p-6 space-y-4 py-16">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center border {isDark ? 'bg-white/5 border-white/10 text-teal-400' : 'bg-slate-50 border-slate-100 text-teal-600'} animate-pulse">
<BrainCircuit size={28} />
</div>
<div>
<p class="text-[11px] font-bold uppercase tracking-widest {isDark ? 'text-slate-300' : 'text-slate-600'} mb-1">
Monitoring Interaction
</p>
<p class="text-xs font-light max-w-xs {isDark ? 'text-slate-400' : 'text-slate-500'} leading-relaxed">
Turn-by-turn clinical supervision and reframing suggestions will appear here automatically.
</p>
</div>
</div>
{/if}
</div>
</div>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from './ui/Button.svelte';
import Dialog from './ui/Dialog.svelte';
let offlineReady = $state(false);
let needRefresh = $state(false);
let updateSW: ((reloadPage?: boolean) => Promise<void>) | null = null;
onMount(() => {
if (import.meta.env.DEV) {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
});
}
if (typeof caches !== 'undefined') {
caches.keys().then((keys) => {
keys.forEach((key) => {
if (key.toLowerCase().includes('workbox') || key.toLowerCase().includes('cognitive-clarity')) {
caches.delete(key);
}
});
});
}
return;
}
import('virtual:pwa-register').then(({ registerSW }) => {
updateSW = registerSW({
immediate: true,
onNeedRefresh() {
needRefresh = true;
},
onOfflineReady() {
offlineReady = true;
},
onRegisteredSW(swUrl, r) {
if (r) {
setInterval(() => {
r.update();
}, 60 * 60 * 1000);
window.addEventListener('online', () => {
r.update();
});
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
}
});
}).catch((err) => {
console.warn('[PWA] Service worker registration error:', err);
});
});
function close() {
offlineReady = false;
needRefresh = false;
}
function handleReload() {
if (updateSW) {
updateSW(true);
}
}
</script>
<Dialog open={needRefresh || offlineReady} onclose={close} class="sm:max-w-md bg-slate-900 border-slate-800 text-slate-100">
<div class="p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-white">
{needRefresh ? 'Update Available' : 'Offline Ready'}
</h3>
<p class="text-sm text-slate-400 mt-2">
{needRefresh
? 'A new version of Cognitive Clarity is available. Click reload to update immediately.'
: 'The application is ready to work offline.'}
</p>
</div>
<div class="flex justify-start gap-2 pt-2">
{#if needRefresh}
<Button
type="button"
onclick={handleReload}
class="bg-blue-600 hover:bg-blue-500 text-white"
>
Reload and Update
</Button>
{/if}
<Button
type="button"
variant="secondary"
onclick={close}
class="bg-slate-800 hover:bg-slate-700 text-slate-300 border-none"
>
Close
</Button>
</div>
</div>
</Dialog>
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import type { Scenario } from '../../../types';
import { Mic, MessageSquare, Trash2 } from '@lucide/svelte';
import Card from './ui/Card.svelte';
import CardHeader from './ui/CardHeader.svelte';
import CardTitle from './ui/CardTitle.svelte';
import CardDescription from './ui/CardDescription.svelte';
import CardContent from './ui/CardContent.svelte';
import CardFooter from './ui/CardFooter.svelte';
import Button from './ui/Button.svelte';
import Badge from './ui/Badge.svelte';
import Tooltip from './ui/Tooltip.svelte';
interface Props {
scenario: Scenario;
onSelect?: (scenario: Scenario, mode: 'text' | 'voice') => void;
onselect?: (scenario: Scenario, mode: 'text' | 'voice') => void;
onDelete?: (id: string) => void;
ondelete?: (id: string) => void;
}
let { scenario, onSelect, onselect, onDelete, ondelete }: Props = $props();
const handleSelect = (sc: Scenario, mode: 'text' | 'voice') => {
onselect?.(sc, mode);
onSelect?.(sc, mode);
};
const handleDelete = (id: string) => {
ondelete?.(id);
onDelete?.(id);
};
const hasDelete = $derived(!!(ondelete || onDelete));
const difficultyVariant = $derived<'default' | 'secondary' | 'destructive' | 'outline'>(
scenario.difficulty === 'Beginner'
? 'default'
: scenario.difficulty === 'Intermediate'
? 'secondary'
: scenario.difficulty === 'Advanced'
? 'destructive'
: 'outline'
);
const difficultyExplanations: Record<string, string> = {
Beginner: 'Low-stakes environment with highly structured feedback and clear cognitive distortions.',
Intermediate: 'Moderate social friction requiring nuanced responses and identifying subtle distortions.',
Advanced: 'High-trigger environment with resistant AI personas to test deep emotional regulation.'
};
</script>
<Card class="hover:border-teal-200 hover:shadow-md transition-all flex flex-col h-full group relative overflow-hidden">
{#if hasDelete}
<Button
variant="ghost"
size="icon"
onclick={(e) => {
e.stopPropagation();
handleDelete(scenario.id);
}}
class="absolute top-2 right-2 text-slate-300 hover:text-rose-500 hover:bg-rose-50 opacity-0 group-hover:opacity-100 transition-all z-10"
title="Delete custom scenario"
>
<Trash2 size={16} />
</Button>
{/if}
<CardHeader class="pb-2">
<div class="flex justify-between items-start mb-2">
<Tooltip content={difficultyExplanations[scenario.difficulty] || ''}>
<div class="cursor-help">
<Badge variant={difficultyVariant} class="text-[10px] uppercase tracking-wider">
{scenario.difficulty}
</Badge>
</div>
</Tooltip>
{#if !hasDelete}
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
{scenario.category}
</span>
{/if}
</div>
<CardTitle class="text-lg group-hover:text-teal-700 transition-colors pr-6">
{scenario.title}
</CardTitle>
<CardDescription class="text-sm flex-grow leading-relaxed font-light">
{scenario.description}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div class="flex flex-wrap gap-1.5 mt-2">
{#each scenario.cbtFocus as focus}
<Tooltip content={`Specific CBT protocol targeting: ${focus}`}>
<Badge variant="outline" class="text-[10px] font-medium bg-slate-50 text-slate-500 cursor-help">
{focus}
</Badge>
</Tooltip>
{/each}
</div>
</CardContent>
<CardFooter class="pt-0">
<div class="grid grid-cols-2 gap-3 w-full">
<Tooltip content="Engage in a structured text-based roleplay with real-time feedback." position="bottom">
<Button
variant="outline"
size="sm"
onclick={() => handleSelect(scenario, 'text')}
class="w-full text-slate-600 font-semibold text-xs"
>
<MessageSquare size={14} class="mr-2" />
Chat
</Button>
</Tooltip>
<Tooltip content="Practice verbal emotional regulation and vocal expression in real-time." position="bottom">
<Button
size="sm"
onclick={() => handleSelect(scenario, 'voice')}
class="w-full bg-teal-600 hover:bg-teal-700 text-white font-semibold text-xs shadow-sm shadow-teal-100"
>
<Mic size={14} class="mr-2" />
Voice
</Button>
</Tooltip>
</div>
</CardFooter>
</Card>
+207
View File
@@ -0,0 +1,207 @@
<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>
+199
View File
@@ -0,0 +1,199 @@
<script lang="ts">
import type { AnalysisResult, Message } from '../../../types';
import {
ClipboardCheck,
X,
CheckCircle2,
AlertTriangle,
Lightbulb,
TrendingUp,
Download,
Copy,
Check,
FileText
} from '@lucide/svelte';
import Button from './ui/Button.svelte';
import { formatSessionAsMarkdown, downloadFile } from '../../../services/sessionStorage';
interface Props {
analysis: AnalysisResult;
scenarioTitle?: string;
messages?: Message[];
onexit: () => void;
}
let {
analysis,
scenarioTitle = 'CBT Roleplay Session',
messages = [],
onexit
}: Props = $props();
let copied = $state(false);
function handleCopyReframe() {
navigator.clipboard.writeText(analysis.suggestions.alternativeThought);
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
}
function handleExportMarkdown() {
const md = formatSessionAsMarkdown(scenarioTitle, analysis, messages);
const fileName = `cbt-report-${scenarioTitle.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.md`;
downloadFile(md, fileName, 'text/markdown');
}
function handleExportJSON() {
const json = JSON.stringify(
{ scenarioTitle, analysis, messages, exportedAt: new Date().toISOString() },
null,
2
);
const fileName = `cbt-report-${scenarioTitle.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json`;
downloadFile(json, fileName, 'application/json');
}
</script>
<div class="flex flex-col h-full bg-[#FDFDFF] rounded-3xl border border-slate-200 overflow-hidden shadow-2xl animate-in zoom-in-95 duration-500 w-full max-w-4xl mx-auto">
<div class="bg-teal-600 p-6 flex justify-between items-center text-white shrink-0">
<div class="flex items-center gap-3">
<ClipboardCheck size={28} />
<div>
<h2 class="text-xl font-bold leading-none">Session Summary Report</h2>
<span class="text-[10px] font-bold uppercase tracking-widest text-teal-100 opacity-80">
{scenarioTitle} • Clinical Evaluation Complete
</span>
</div>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onclick={handleExportMarkdown}
class="text-white hover:bg-white/20 rounded-xl text-xs flex items-center gap-1.5"
title="Download Markdown Report"
>
<FileText size={16} />
<span class="hidden sm:inline">Export MD</span>
</Button>
<Button
variant="ghost"
size="sm"
onclick={handleExportJSON}
class="text-white hover:bg-white/20 rounded-xl text-xs flex items-center gap-1.5"
title="Download JSON Report"
>
<Download size={16} />
<span class="hidden sm:inline">JSON</span>
</Button>
<button
type="button"
onclick={onexit}
class="bg-white/10 hover:bg-white/20 p-2 rounded-xl border border-white/10 transition-colors ml-1"
>
<X size={20} />
</button>
</div>
</div>
<div class="flex-grow overflow-y-auto p-8 flex flex-col gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
class="p-6 rounded-2xl border-l-8 {analysis.analysis.isAdaptive
? 'bg-teal-50/50 border-teal-500'
: 'bg-rose-50/50 border-rose-500'}"
>
<div class="flex items-center gap-2 mb-4">
{#if analysis.analysis.isAdaptive}
<CheckCircle2 class="text-teal-600" />
<span class="font-black text-xs uppercase tracking-widest">Adaptive Growth Observed</span>
{:else}
<AlertTriangle class="text-rose-600" />
<span class="font-black text-xs uppercase tracking-widest">Clinical Patterns Detected</span>
{/if}
</div>
{#if analysis.analysis.distortions.length > 0}
<div class="flex flex-wrap gap-2 mb-4">
{#each analysis.analysis.distortions as d (d)}
<span class="bg-white border border-slate-200 px-3 py-1 rounded-lg text-[10px] font-bold text-slate-600 shadow-sm">
{d}
</span>
{/each}
</div>
{/if}
<p class="text-sm leading-relaxed text-slate-700 font-medium italic">
"{analysis.analysis.evidence}"
</p>
</div>
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm flex flex-col justify-center">
<div class="flex justify-between items-center mb-4">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">
Mastery Level
</span>
<div class="flex gap-1.5">
{#each [1, 2, 3, 4, 5] as s (s)}
<div
class="h-2 w-8 rounded-full {s <= analysis.rating.score
? 'bg-teal-500'
: 'bg-slate-100'}"
></div>
{/each}
</div>
</div>
<p class="text-sm text-slate-600 leading-relaxed font-light">
{analysis.rating.reason}
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-6 rounded-2xl border border-teal-100 shadow-sm relative group">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2 text-teal-600">
<Lightbulb size={20} />
<span class="text-[10px] font-black uppercase tracking-widest">Master Reframe</span>
</div>
<Button
variant="ghost"
size="sm"
onclick={handleCopyReframe}
class="h-7 px-2 text-[10px] text-slate-400 hover:text-teal-700 flex items-center gap-1"
>
{#if copied}
<Check size={12} class="text-teal-600" />
<span>Copied</span>
{:else}
<Copy size={12} />
<span>Copy</span>
{/if}
</Button>
</div>
<p class="text-base text-slate-800 italic font-medium leading-relaxed">
"{analysis.suggestions.alternativeThought}"
</p>
</div>
<div class="bg-white p-6 rounded-2xl border border-teal-100 shadow-sm">
<div class="flex items-center gap-2 text-teal-600 mb-3">
<TrendingUp size={20} />
<span class="text-[10px] font-black uppercase tracking-widest">Homework & Next Steps</span>
</div>
<p class="text-sm text-slate-700 leading-relaxed">
{analysis.suggestions.behavioralStep}
</p>
</div>
</div>
</div>
<div class="p-6 bg-slate-50 border-t border-slate-100 flex justify-center shrink-0">
<button
type="button"
onclick={onexit}
class="bg-teal-600 text-white px-12 py-3 rounded-xl font-bold hover:bg-teal-700 shadow-lg shadow-teal-100 transition-all uppercase tracking-widest text-xs"
>
Close Report & Return
</button>
</div>
</div>
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more