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
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.
|
||||
@@ -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=""
|
||||
@@ -31,3 +31,10 @@ local.settings.json
|
||||
*.sw?
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# Ephemeral Worktrees & Test Reports
|
||||
.worktrees/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.agents/teamwork/
|
||||
|
||||
@@ -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` |
|
||||
@@ -17,7 +17,9 @@ import { VersionBadge } from './components/VersionBadge';
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
voiceName: 'Zephyr',
|
||||
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';
|
||||
@@ -143,7 +145,7 @@ const App: React.FC = () => {
|
||||
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">
|
||||
{sessionMode === 'text' ? (
|
||||
<ChatSession scenario={activeScenario} onExit={handleExitSession} />
|
||||
<ChatSession scenario={activeScenario} userSettings={userSettings} onExit={handleExitSession} />
|
||||
) : (
|
||||
<div className="w-full max-w-4xl h-[80vh]">
|
||||
<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">
|
||||
<ReloadPrompt />
|
||||
{/* Modals */}
|
||||
{showGenerator && <ScenarioGenerator onGenerate={handleScenarioGenerated} onCancel={() => setShowGenerator(false)} />}
|
||||
{showGenerator && <ScenarioGenerator userSettings={userSettings} onGenerate={handleScenarioGenerated} onCancel={() => setShowGenerator(false)} />}
|
||||
{showReference && <CBTReference onClose={() => setShowReference(false)} />}
|
||||
{showSettings && <SettingsModal settings={userSettings} onUpdate={setUserSettings} onClose={() => setShowSettings(false)} />}
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -1,20 +1,152 @@
|
||||
<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>
|
||||
|
||||
# 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:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
subgraph "Backend Serverless Layer (api/)"
|
||||
Frontend -.->|Headless / Batch Evaluation| 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 /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.
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Google.GenAI;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CognitiveClarity.api
|
||||
{
|
||||
@@ -21,22 +22,27 @@ namespace CognitiveClarity.api
|
||||
|
||||
[JsonPropertyName("cbtFocus")]
|
||||
public List<string> CBTFocus { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public string Model { get; set; } = "google/gemini-3.8-flash";
|
||||
}
|
||||
|
||||
public class AnalysisFunction
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public AnalysisFunction(ILoggerFactory loggerFactory)
|
||||
public AnalysisFunction(ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<AnalysisFunction>();
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[Function("ClinicalAnalysis")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[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 data = JsonSerializer.Deserialize<AnalysisRequest>(requestBody);
|
||||
@@ -48,67 +54,84 @@ namespace CognitiveClarity.api
|
||||
return badResponse;
|
||||
}
|
||||
|
||||
var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY");
|
||||
var apiKey = System.Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
|
||||
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);
|
||||
}
|
||||
|
||||
// Initialize the Gemini Client
|
||||
var ai = new GeminiClient(apiKey: apiKey);
|
||||
var systemAnalysisPrompt = $@"You are a master clinical psychologist specializing in Cognitive Behavioral Therapy (CBT).
|
||||
You are supervising an interactive CBT simulation.
|
||||
SCENARIO: {data.ScenarioTitle} - {data.ScenarioDescription}
|
||||
FOCUS AREAS: {string.Join(", ", data.CBTFocus)}
|
||||
|
||||
var systemAnalysisPrompt = @"
|
||||
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.
|
||||
Analyze the user's latest response for:
|
||||
1. Cognitive distortions present (if any).
|
||||
2. CBT techniques successfully applied by the user (if any).
|
||||
3. A brief, encouraging clinical insight or redirection (max 2 sentences).
|
||||
Be precise, clinical, and constructive.
|
||||
";
|
||||
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""
|
||||
}}
|
||||
}}";
|
||||
|
||||
var analysisSystemInstruction = $@"{systemAnalysisPrompt}
|
||||
SCENARIO CONTEXT:
|
||||
Title: {data.ScenarioTitle}
|
||||
Description: {data.ScenarioDescription}
|
||||
CBT Focus Areas: {string.Join(", ", data.CBTFocus)}";
|
||||
|
||||
var schema = new Schema
|
||||
var payload = new
|
||||
{
|
||||
Type = TypeEnum.Object,
|
||||
Properties = new Dictionary<string, Schema>
|
||||
model = string.IsNullOrEmpty(data.Model) ? "google/gemini-3.8-flash" : data.Model,
|
||||
messages = new object[]
|
||||
{
|
||||
{ "distortionsIdentified", new Schema { Type = TypeEnum.Array, Items = new Schema { Type = TypeEnum.String } } },
|
||||
{ "techniquesUsed", new Schema { Type = TypeEnum.Array, Items = new Schema { Type = TypeEnum.String } } },
|
||||
{ "clinicalInsight", new Schema { Type = TypeEnum.String } },
|
||||
{ "feedbackType", new Schema { Type = TypeEnum.String, Enum = new List<string> { "Constructive", "Positive", "Neutral" } } }
|
||||
new { role = "system", content = systemAnalysisPrompt },
|
||||
new { role = "user", content = data.TurnContext }
|
||||
},
|
||||
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(
|
||||
model: "gemini-3.0-flash",
|
||||
contents: data.TurnContext,
|
||||
new GenerateContentConfig
|
||||
{
|
||||
SystemInstruction = new Content { Parts = new List<Part> { new Part { Text = analysisSystemInstruction } } },
|
||||
ResponseMimeType = "application/json",
|
||||
ResponseSchema = schema
|
||||
});
|
||||
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 rawResult = doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString() ?? "{}";
|
||||
|
||||
var successResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
successResponse.Headers.Add("Content-Type", "application/json");
|
||||
await successResponse.WriteStringAsync(response.Text!);
|
||||
await successResponse.WriteStringAsync(rawResult);
|
||||
return successResponse;
|
||||
}
|
||||
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);
|
||||
await errorResponse.WriteStringAsync("Error communicating with Gemini API.");
|
||||
await errorResponse.WriteStringAsync("Error communicating with OpenRouter API.");
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Google.GenAI;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CognitiveClarity.api
|
||||
{
|
||||
public class ChatMessage
|
||||
{
|
||||
[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")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
@@ -24,22 +25,27 @@ namespace CognitiveClarity.api
|
||||
|
||||
[JsonPropertyName("systemInstruction")]
|
||||
public string SystemInstruction { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public string Model { get; set; } = "google/gemini-3.8-flash";
|
||||
}
|
||||
|
||||
public class ChatFunction
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public ChatFunction(ILoggerFactory loggerFactory)
|
||||
public ChatFunction(ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<ChatFunction>();
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[Function("ChatProgression")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[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 data = JsonSerializer.Deserialize<ChatRequest>(requestBody);
|
||||
@@ -51,72 +57,92 @@ namespace CognitiveClarity.api
|
||||
return badResponse;
|
||||
}
|
||||
|
||||
var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY");
|
||||
var apiKey = System.Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
|
||||
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);
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
[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.
|
||||
";
|
||||
[CONTEXT]
|
||||
You are participating in a clinical CBT roleplay simulation.
|
||||
The instructions above define your character, the scenario, and your internal CBT logic.
|
||||
|
||||
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(
|
||||
model: "gemini-3.0-flash",
|
||||
contents: contents,
|
||||
new GenerateContentConfig
|
||||
{
|
||||
SystemInstruction = new Content { Parts = new List<Part> { new Part { Text = roleplaySystemInstruction } } },
|
||||
// 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.
|
||||
});
|
||||
new { role = "system", content = roleplaySystemInstruction }
|
||||
};
|
||||
|
||||
foreach (var msg in data.History)
|
||||
{
|
||||
messages.Add(new
|
||||
{
|
||||
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);
|
||||
successResponse.Headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Return just the text in a JSON wrapper
|
||||
var resultObj = new { text = response.Text };
|
||||
var resultObj = new { text = botText };
|
||||
await successResponse.WriteStringAsync(JsonSerializer.Serialize(resultObj));
|
||||
return successResponse;
|
||||
}
|
||||
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);
|
||||
await errorResponse.WriteStringAsync("Error communicating with Gemini API.");
|
||||
await errorResponse.WriteStringAsync("Error communicating with OpenRouter API.");
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ var builder = FunctionsApplication.CreateBuilder(args);
|
||||
builder.ConfigureFunctionsWebApplication();
|
||||
|
||||
builder.Services
|
||||
.AddHttpClient()
|
||||
.AddApplicationInsightsTelemetryWorkerService()
|
||||
.ConfigureFunctionsApplicationInsights();
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Google.GenAI;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CognitiveClarity.api
|
||||
{
|
||||
@@ -18,22 +19,27 @@ namespace CognitiveClarity.api
|
||||
|
||||
[JsonPropertyName("difficulty")]
|
||||
public string Difficulty { get; set; } = "Intermediate";
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public string Model { get; set; } = "google/gemini-3.8-flash";
|
||||
}
|
||||
|
||||
public class ScenarioFunction
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public ScenarioFunction(ILoggerFactory loggerFactory)
|
||||
public ScenarioFunction(ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<ScenarioFunction>();
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[Function("GenerateScenario")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[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 data = JsonSerializer.Deserialize<ScenarioRequest>(requestBody);
|
||||
@@ -45,84 +51,100 @@ namespace CognitiveClarity.api
|
||||
return badResponse;
|
||||
}
|
||||
|
||||
var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY");
|
||||
var apiKey = System.Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
|
||||
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);
|
||||
}
|
||||
|
||||
// Initialize the Gemini Client
|
||||
var ai = new GeminiClient(apiKey: apiKey);
|
||||
|
||||
var selectedContext = data.SelectedConcepts.Count > 0
|
||||
? $"Focus specifically on these CBT concepts: {string.Join(", ", data.SelectedConcepts)}."
|
||||
: "";
|
||||
|
||||
var 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 '{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.
|
||||
";
|
||||
[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}
|
||||
|
||||
var schema = new Schema
|
||||
{
|
||||
Type = TypeEnum.Object,
|
||||
Properties = new Dictionary<string, Schema>
|
||||
{
|
||||
{ "id", new Schema { Type = TypeEnum.String } },
|
||||
{ "title", new Schema { Type = TypeEnum.String } },
|
||||
{ "characterName", new Schema { Type = TypeEnum.String } },
|
||||
{ "description", new Schema { Type = TypeEnum.String } },
|
||||
{ "category", new Schema { Type = TypeEnum.String, Enum = new List<string> { "Social Anxiety", "Workplace", "Relationships", "Self-Talk" } } },
|
||||
{ "difficulty", new Schema { Type = TypeEnum.String, Enum = new List<string> { "Beginner", "Intermediate", "Advanced" } } },
|
||||
{ "systemInstruction", new Schema { Type = TypeEnum.String } },
|
||||
{ "initialMessage", new Schema { Type = TypeEnum.String } },
|
||||
{ "cbtFocus", new Schema { Type = TypeEnum.Array, Items = new Schema { Type = TypeEnum.String } } }
|
||||
},
|
||||
Required = new List<string> { "id", "title", "characterName", "description", "category", "difficulty", "systemInstruction", "initialMessage", "cbtFocus" }
|
||||
};
|
||||
[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 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""]
|
||||
}}";
|
||||
|
||||
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: "gemini-3.0-flash",
|
||||
contents: promptText,
|
||||
new GenerateContentConfig
|
||||
{
|
||||
SystemInstruction = new Content { Parts = new List<Part> { new Part { Text = systemInstruction } } },
|
||||
ResponseMimeType = "application/json",
|
||||
ResponseSchema = schema
|
||||
});
|
||||
model = string.IsNullOrEmpty(data.Model) ? "google/gemini-3.8-flash" : data.Model,
|
||||
messages = new object[]
|
||||
{
|
||||
new { role = "system", content = systemInstruction },
|
||||
new { role = "user", content = promptText }
|
||||
},
|
||||
response_format = new { type = "json_object" },
|
||||
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);
|
||||
successResponse.Headers.Add("Content-Type", "application/json");
|
||||
await successResponse.WriteStringAsync(response.Text!);
|
||||
await successResponse.WriteStringAsync(scenarioJson);
|
||||
return successResponse;
|
||||
}
|
||||
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);
|
||||
await errorResponse.WriteStringAsync("Error communicating with Gemini API.");
|
||||
await errorResponse.WriteStringAsync("Error communicating with OpenRouter API.");
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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.Azure.Functions.Worker" Version="2.50.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Scenario, Message, AnalysisResult } from '../types';
|
||||
import { GoogleGenAI, Chat, GenerateContentResponse } from "@google/genai";
|
||||
import { Scenario, Message, AnalysisResult, UserSettings } from '../types';
|
||||
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
|
||||
import ChatMessage from './ChatMessage';
|
||||
@@ -13,10 +12,11 @@ import SessionReport from './SessionReport';
|
||||
|
||||
interface ChatSessionProps {
|
||||
scenario: Scenario;
|
||||
userSettings?: UserSettings;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const ChatSession: React.FC<ChatSessionProps> = ({ scenario, onExit }) => {
|
||||
const ChatSession: React.FC<ChatSessionProps> = ({ scenario, userSettings, onExit }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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 [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
|
||||
|
||||
const chatRef = useRef<Chat | null>(null);
|
||||
const analysisRef = useRef<Chat | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -40,9 +38,40 @@ const ChatSession: React.FC<ChatSessionProps> = ({ scenario, onExit }) => {
|
||||
|
||||
// Session Initialization
|
||||
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 = `
|
||||
${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.
|
||||
`;
|
||||
|
||||
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 {
|
||||
const botMsgId = (Date.now() + 1).toString();
|
||||
const botMsg: Message = {
|
||||
id: botMsgId,
|
||||
role: 'model',
|
||||
text: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, botMsg]);
|
||||
|
||||
const streamResponse = await chatRef.current.sendMessageStream({ message: text });
|
||||
let fullResponseText = '';
|
||||
|
||||
for await (const chunk of streamResponse) {
|
||||
const c = chunk as GenerateContentResponse;
|
||||
const textChunk = c.text || "";
|
||||
fullResponseText += textChunk;
|
||||
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === botMsgId ? { ...m, text: fullResponseText } : m
|
||||
));
|
||||
}
|
||||
|
||||
// Live Turn Analysis
|
||||
if (analysisRef.current) {
|
||||
setIsAnalyzingTurn(true);
|
||||
const turnContext = `User said: "${userMsg.text}". Roleplay partner said: "${fullResponseText}".`;
|
||||
|
||||
analysisRef.current.sendMessage({ message: turnContext }).then(res => {
|
||||
try {
|
||||
if (res.text) setLiveInsight(JSON.parse(res.text));
|
||||
} catch (e) { console.error("Live analysis parse error", e); } finally {
|
||||
setIsAnalyzingTurn(false);
|
||||
let accumulatedText = '';
|
||||
await streamOpenRouterChat(
|
||||
updatedHistory,
|
||||
roleplaySystemInstruction,
|
||||
{
|
||||
onChunk: (chunk) => {
|
||||
accumulatedText += chunk;
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === botMsgId ? { ...m, text: accumulatedText } : m
|
||||
));
|
||||
},
|
||||
onComplete: (fullText) => {
|
||||
// Live Turn Clinical Analysis via OpenRouter
|
||||
setIsAnalyzingTurn(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 => {
|
||||
setLiveInsight(res);
|
||||
}).catch(e => {
|
||||
console.error("Live analysis failed", e);
|
||||
}).finally(() => {
|
||||
setIsAnalyzingTurn(false);
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("OpenRouter stream failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
apiKey: userSettings?.openRouterApiKey,
|
||||
model: userSettings?.roleplayModel
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Chat transmission failed", error);
|
||||
setMessages(prev => prev.filter(m => m.text !== ''));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading]);
|
||||
}, [messages, scenario, isLoading, userSettings]);
|
||||
|
||||
const handleFinishSession = async () => {
|
||||
if (messages.length < 2) return onExit();
|
||||
@@ -160,28 +149,19 @@ CBT Focus Areas: ${scenario.cbtFocus.join(', ')}`;
|
||||
setStatus('analyzing');
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
const transcript = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n');
|
||||
const reportData = await generateSessionSummaryReport(
|
||||
messages,
|
||||
scenario.title,
|
||||
scenario.description,
|
||||
{
|
||||
apiKey: userSettings?.openRouterApiKey,
|
||||
model: userSettings?.supervisorModel
|
||||
}
|
||||
);
|
||||
|
||||
const summaryPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}
|
||||
SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`;
|
||||
|
||||
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");
|
||||
}
|
||||
setFinalReport(reportData);
|
||||
saveSession(scenario, 'text', messages, reportData);
|
||||
setStatus('finished');
|
||||
} catch (err) {
|
||||
console.error("Post-session analysis failure", err);
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'finished' && finalReport) {
|
||||
return <SessionReport analysis={finalReport} onExit={onExit} />;
|
||||
return (
|
||||
<SessionReport
|
||||
analysis={finalReport}
|
||||
scenarioTitle={scenario.title}
|
||||
messages={messages}
|
||||
onExit={onExit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { Scenario } from '../types';
|
||||
import { Scenario, UserSettings } from '../types';
|
||||
import { CBT_GLOSSARY } from '../constants';
|
||||
import { Sparkles, X, Loader2, AlertCircle, Info, Target, UserCircle2, BrainCircuit, Check, BarChart } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
@@ -8,13 +7,15 @@ import { Textarea } from './ui/textarea';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { generateScenarioWithOpenRouter } from '../services/openRouterService';
|
||||
|
||||
interface ScenarioGeneratorProps {
|
||||
onGenerate: (scenario: Scenario) => void;
|
||||
onCancel: () => void;
|
||||
userSettings?: UserSettings;
|
||||
}
|
||||
|
||||
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel }) => {
|
||||
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel, userSettings }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]);
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState<Scenario['difficulty']>('Intermediate');
|
||||
@@ -34,240 +35,162 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY });
|
||||
|
||||
const schema = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
id: { type: Type.STRING },
|
||||
title: { type: Type.STRING },
|
||||
characterName: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
category: {
|
||||
type: Type.STRING,
|
||||
enum: ['Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk']
|
||||
},
|
||||
difficulty: {
|
||||
type: Type.STRING,
|
||||
enum: ['Beginner', 'Intermediate', 'Advanced']
|
||||
},
|
||||
systemInstruction: { type: Type.STRING },
|
||||
initialMessage: { type: Type.STRING },
|
||||
cbtFocus: {
|
||||
type: Type.ARRAY,
|
||||
items: { type: Type.STRING }
|
||||
}
|
||||
},
|
||||
required: ['id', 'title', 'characterName', 'description', 'category', 'difficulty', 'systemInstruction', 'initialMessage', 'cbtFocus']
|
||||
};
|
||||
|
||||
const selectedContext = selectedConcepts.length > 0
|
||||
? `Focus specifically on these CBT concepts: ${selectedConcepts.join(', ')}.`
|
||||
: '';
|
||||
|
||||
const result = await ai.models.generateContent({
|
||||
model: 'gemini-3-flash-preview',
|
||||
config: {
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: schema,
|
||||
systemInstruction: `
|
||||
[CONTEXT]
|
||||
You are a Clinical CBT Scenario Architect.
|
||||
The user requires a structured clinical simulation designed to address specific cognitive distortions and behavioral challenges.
|
||||
Clinical Focus: ${selectedContext}
|
||||
|
||||
[TASK]
|
||||
Transform the user's prompt into a cohesive, structured clinical scenario that sets up a targeted roleplay environment.
|
||||
|
||||
[CONSTRAINTS]
|
||||
- CATEGORIZATION: You MUST categorize the scenario appropriately (Social Anxiety, Workplace, Relationships, or Self-Talk).
|
||||
- DIFFICULTY CALIBRATION: The requested difficulty is '${selectedDifficulty}'.
|
||||
- 'Beginner': Low stakes, high support, clear-cut distortions, AI character is cooperative.
|
||||
- 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions, AI character has specific needs.
|
||||
- 'Advanced': High emotional triggers, persistent clinical resistance, complex cognitive patterns.
|
||||
- INTERNAL LOGIC: Provide a detailed "INTERNAL CBT LOGIC" section instructing the AI character how to react to the user's progress or cognitive lapses during the future roleplay.
|
||||
- CHARACTER VOICE: Define a realistic, emotionally consistent voice profile.
|
||||
|
||||
[OUTPUT REQUIREMENTS]
|
||||
Return ONLY a valid JSON object conforming exactly to the requested schema. Ensure all arrays and nested objects are populated correctly.
|
||||
`
|
||||
},
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: `Generate a clinical scenario for: ${prompt || 'A scenario exploring the chosen CBT concepts.'}` }] }
|
||||
]
|
||||
});
|
||||
|
||||
if (result.text) {
|
||||
const scenario = JSON.parse(result.text) as Scenario;
|
||||
// Enforce the user-selected difficulty in the final object
|
||||
scenario.difficulty = selectedDifficulty;
|
||||
scenario.id = `custom-${Date.now()}-${scenario.id}`;
|
||||
onGenerate(scenario);
|
||||
} else {
|
||||
throw new Error("No content generated");
|
||||
}
|
||||
const scenario = await generateScenarioWithOpenRouter(
|
||||
prompt,
|
||||
selectedConcepts,
|
||||
selectedDifficulty,
|
||||
{
|
||||
apiKey: userSettings?.openRouterApiKey,
|
||||
model: userSettings?.roleplayModel
|
||||
}
|
||||
);
|
||||
|
||||
scenario.difficulty = selectedDifficulty;
|
||||
scenario.id = `custom-${Date.now()}-${scenario.id || 'generated'}`;
|
||||
onGenerate(scenario);
|
||||
} catch (err: any) {
|
||||
console.error("Generation error:", err);
|
||||
setError("Clinical scenario synthesis failed. Please refine your parameters.");
|
||||
console.error("OpenRouter Scenario Generation error:", err);
|
||||
setError(err?.message || "Clinical scenario synthesis failed. Please check your OpenRouter API Key in Settings.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getConceptVariant = (category: string, isSelected: boolean) => {
|
||||
if (!isSelected) return 'outline';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getConceptColorClass = (category: string, isSelected: boolean) => {
|
||||
if (!isSelected) return 'text-slate-500 border-slate-200 hover:border-slate-300';
|
||||
switch (category) {
|
||||
case 'Distortion': return 'bg-rose-600 border-rose-600 hover:bg-rose-700 shadow-md shadow-rose-100';
|
||||
case 'Technique': return 'bg-teal-600 border-teal-600 hover:bg-teal-700 shadow-md shadow-teal-100';
|
||||
case 'Concept': return 'bg-amber-600 border-amber-600 hover:bg-amber-700 shadow-md shadow-amber-100';
|
||||
default: return 'bg-slate-800 border-slate-800 hover:bg-slate-900';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md overflow-y-auto">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-300 border border-slate-100 my-8 flex flex-col max-h-[90vh]">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden border border-slate-100 flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="p-6 bg-teal-600 text-white flex justify-between items-start relative overflow-hidden shrink-0">
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||
<Sparkles size={28} className="text-teal-200" />
|
||||
Scenario Synthesis
|
||||
</h2>
|
||||
<p className="text-teal-50 text-sm mt-1 font-light opacity-90">
|
||||
Construct an evidence-based clinical environment.
|
||||
</p>
|
||||
<div className="p-6 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-teal-600 p-2.5 rounded-xl text-white shadow-md shadow-teal-600/20">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">Clinical Scenario Architect</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">
|
||||
AI Synthesis Engine • OpenRouter
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="relative z-10 text-white/70 hover:text-white transition-colors bg-white/10 hover:bg-white/20 border border-white/10"
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-10 -mt-10 blur-2xl"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-grow p-8 bg-[#FCFDFF]">
|
||||
<div className="space-y-6 pb-4">
|
||||
{/* Prompting Guide */}
|
||||
<Card className="bg-teal-50/50 border-teal-100 p-4 shadow-none">
|
||||
<div className="flex items-center gap-2 text-teal-700 mb-3">
|
||||
<Info size={16} />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider">Clinical Blueprint</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<UserCircle2 size={12} /> Character Role
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Define who the AI plays.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<Target size={12} /> The Challenge
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Describe the social friction.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<BrainCircuit size={12} /> CBT Focus
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Target specific distortions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Difficulty Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Communication Difficulty
|
||||
<ScrollArea className="p-8 space-y-8 flex-grow">
|
||||
{/* Natural Language Prompt */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Target size={14} className="text-teal-600" />
|
||||
Target Dynamic & Situation
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['Beginner', 'Intermediate', 'Advanced'] as const).map((level) => (
|
||||
<Button
|
||||
key={level}
|
||||
variant={selectedDifficulty === level ? "default" : "outline"}
|
||||
onClick={() => setSelectedDifficulty(level)}
|
||||
className={`h-12 flex items-center justify-center gap-2 rounded-xl border-2 transition-all text-[11px] font-bold ${selectedDifficulty === level
|
||||
? 'bg-teal-50 border-teal-600 text-teal-700 shadow-sm shadow-teal-50 hover:bg-teal-100 hover:text-teal-800'
|
||||
: 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
<span className="text-[10px] text-slate-400">Describe the conflict or trigger</span>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="e.g. Asking my manager for a delayed performance review while fearing I'll be fired for bringing it up..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] text-sm bg-slate-50/50 border-slate-200 rounded-2xl resize-none focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Tuning */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<BarChart size={14} className="text-teal-600" />
|
||||
Emotional Resistance & Difficulty
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['Beginner', 'Intermediate', 'Advanced'] as const).map((diff) => (
|
||||
<Card
|
||||
key={diff}
|
||||
onClick={() => setSelectedDifficulty(diff)}
|
||||
className={`p-3.5 text-center cursor-pointer transition-all border ${selectedDifficulty === diff
|
||||
? 'bg-teal-50 border-teal-500 shadow-sm shadow-teal-500/10'
|
||||
: 'bg-white border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-slate-800">{diff}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">
|
||||
{diff === 'Beginner' && 'High Support'}
|
||||
{diff === 'Intermediate' && 'Realistic Pushback'}
|
||||
{diff === 'Advanced' && 'Severe Friction'}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clinical Focus Selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<BrainCircuit size={14} className="text-teal-600" />
|
||||
Target Cognitive Distortions & Techniques
|
||||
</label>
|
||||
<span className="text-[10px] text-teal-600 font-bold">
|
||||
{selectedConcepts.length} Selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 max-h-44 overflow-y-auto p-1 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
{CBT_GLOSSARY.map((item) => {
|
||||
const isSelected = selectedConcepts.includes(item.term);
|
||||
return (
|
||||
<Badge
|
||||
key={item.term}
|
||||
onClick={() => toggleConcept(item.term)}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className={`cursor-pointer text-[11px] py-1.5 px-3 rounded-xl transition-all ${isSelected
|
||||
? 'bg-teal-600 hover:bg-teal-700 text-white font-bold shadow-sm'
|
||||
: 'bg-white text-slate-600 hover:border-teal-300'
|
||||
}`}
|
||||
>
|
||||
<BarChart size={14} className={selectedDifficulty === level ? 'text-teal-600' : 'text-slate-400'} />
|
||||
{level}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{item.term}
|
||||
{isSelected && <Check size={12} className="ml-1.5 inline" />}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Clinical Targets (Selection) */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Clinical Targets (Optional)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CBT_GLOSSARY.map(concept => {
|
||||
const isSelected = selectedConcepts.includes(concept.term);
|
||||
return (
|
||||
<Badge
|
||||
key={concept.term}
|
||||
variant={getConceptVariant(concept.category, isSelected) as any}
|
||||
onClick={() => toggleConcept(concept.term)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold border transition-all flex items-center gap-1.5 cursor-pointer hover:opacity-90 ${getConceptColorClass(concept.category, isSelected)}`}
|
||||
>
|
||||
{isSelected && <Check size={10} />}
|
||||
{concept.term}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">
|
||||
Simulation Context / Custom Instructions
|
||||
</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g. Practicing assertiveness with a pushy salesperson who won't take 'no' for an answer."
|
||||
className="w-full h-28 p-5 border-slate-200 rounded-2xl focus-visible:ring-teal-500 resize-none text-slate-800 placeholder:text-slate-400 text-sm leading-relaxed shadow-inner bg-white"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-rose-50 text-rose-600 p-4 rounded-xl text-xs flex items-center gap-3 border border-rose-100 font-medium">
|
||||
<AlertCircle size={18} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-3 text-rose-700 text-xs animate-in fade-in">
|
||||
<AlertCircle size={16} className="shrink-0 text-rose-500" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end shrink-0">
|
||||
{/* Footer Actions */}
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-200 flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="text-xs uppercase tracking-widest font-black text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={(!prompt.trim() && selectedConcepts.length === 0) || isLoading}
|
||||
className="bg-teal-600 text-white h-12 px-8 rounded-2xl font-bold hover:bg-teal-700 flex items-center gap-3 transition-all shadow-lg shadow-teal-100 text-sm uppercase tracking-widest"
|
||||
disabled={isLoading || (!prompt.trim() && selectedConcepts.length === 0)}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white px-8 h-11 rounded-2xl text-xs uppercase tracking-widest font-black shadow-lg shadow-teal-600/20 flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Synthesizing...
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Synthesizing Scenario...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={20} />
|
||||
Initiate Build
|
||||
<Sparkles size={16} />
|
||||
Generate Simulation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AnalysisResult } from '../types';
|
||||
import { ClipboardCheck, X, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AnalysisResult, Message } from '../types';
|
||||
import { ClipboardCheck, X, CheckCircle2, AlertTriangle, Lightbulb, TrendingUp, Download, Copy, Check, FileText } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { formatSessionAsMarkdown, downloadFile } from '../services/sessionStorage';
|
||||
|
||||
interface SessionReportProps {
|
||||
analysis: AnalysisResult;
|
||||
scenarioTitle?: string;
|
||||
messages?: Message[];
|
||||
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 (
|
||||
<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">
|
||||
@@ -16,12 +44,36 @@ const SessionReport: React.FC<SessionReportProps> = ({ analysis, onExit }) => {
|
||||
<ClipboardCheck size={28} />
|
||||
<div>
|
||||
<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>
|
||||
<button onClick={onExit} className="bg-white/10 hover:bg-white/20 p-2 rounded-xl border border-white/10 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 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 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="flex items-center gap-2 text-teal-600 mb-3">
|
||||
<Lightbulb size={20} />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Master Reframe</span>
|
||||
<div className="bg-white p-6 rounded-2xl border border-teal-100 shadow-sm relative group">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-teal-600">
|
||||
<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>
|
||||
<p className="text-base text-slate-800 italic font-medium leading-relaxed">"{analysis.suggestions.alternativeThought}"</p>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
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 AudioDeviceSelector from './AudioDeviceSelector';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
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 {
|
||||
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.' },
|
||||
];
|
||||
|
||||
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 [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 (
|
||||
<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="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="bg-slate-800 p-2 rounded-xl text-white">
|
||||
<Volume2 size={20} />
|
||||
<Settings2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">Voice & Persona Settings</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Configure your AI clinical partner</p>
|
||||
<h2 className="text-lg font-bold text-slate-800">Application Settings</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Powered by OpenRouter Engine</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="p-8 space-y-8 flex-grow">
|
||||
<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">
|
||||
<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-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>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full flex-grow flex flex-col overflow-hidden">
|
||||
<div className="px-6 pt-4 border-b border-slate-100">
|
||||
<TabsList className="grid grid-cols-2 w-full max-w-xs">
|
||||
<TabsTrigger value="voice" className="text-xs">
|
||||
<Volume2 size={14} className="mr-1.5" />
|
||||
Voice & Audio
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="openrouter" className="text-xs">
|
||||
<Key size={14} className="mr-1.5" />
|
||||
OpenRouter AI
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</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
|
||||
onClick={onClose}
|
||||
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]"
|
||||
onClick={handleSave}
|
||||
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
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
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 { AlertCircle, Settings2, X, BrainCircuit, Zap, Info } from 'lucide-react';
|
||||
import { AlertCircle, Settings2, X, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { generateSessionSummaryReport } from '../services/openRouterService';
|
||||
import { saveSession } from '../services/sessionStorage';
|
||||
|
||||
// Sub-components
|
||||
import AudioVisualizer from './AudioVisualizer';
|
||||
@@ -34,6 +33,7 @@ const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSett
|
||||
const {
|
||||
status,
|
||||
setStatus,
|
||||
errorMessage,
|
||||
isMuted,
|
||||
toggleMute,
|
||||
audioLevel,
|
||||
@@ -44,7 +44,6 @@ const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSett
|
||||
liveInsight,
|
||||
isAnalyzingTurn,
|
||||
isSilenceDetected,
|
||||
sessionPromiseRef,
|
||||
startSession
|
||||
} = useLiveSession(scenario, voiceSettings);
|
||||
|
||||
@@ -58,188 +57,155 @@ const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSett
|
||||
const handleFinishSession = useCallback(async () => {
|
||||
setStatus('analyzing');
|
||||
|
||||
if (sessionPromiseRef.current) {
|
||||
sessionPromiseRef.current.then(session => session.close());
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
const transcriptText = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n');
|
||||
const reportData = await generateSessionSummaryReport(
|
||||
messages,
|
||||
scenario.title,
|
||||
scenario.description,
|
||||
{
|
||||
apiKey: voiceSettings.openRouterApiKey,
|
||||
model: voiceSettings.supervisorModel
|
||||
}
|
||||
);
|
||||
|
||||
const summaryPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}
|
||||
SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`;
|
||||
|
||||
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");
|
||||
}
|
||||
setFinalReport(reportData);
|
||||
saveSession(scenario, 'voice', messages, reportData);
|
||||
setStatus('finished');
|
||||
} catch (err) {
|
||||
console.error("Post-session analysis failure", err);
|
||||
onExit();
|
||||
}
|
||||
}, [messages, scenario, setStatus, sessionPromiseRef, onExit]);
|
||||
}, [messages, scenario, voiceSettings.openRouterApiKey, voiceSettings.supervisorModel, setStatus, onExit]);
|
||||
|
||||
if (status === 'connecting') return <ConnectionState />;
|
||||
if (status === 'analyzing') return <AnalyzingState />;
|
||||
if (status === 'finished' && finalReport) {
|
||||
return <SessionReport analysis={finalReport} onExit={onExit} />;
|
||||
return (
|
||||
<SessionReport
|
||||
analysis={finalReport}
|
||||
scenarioTitle={scenario.title}
|
||||
messages={messages}
|
||||
onExit={onExit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row h-full gap-4 relative overflow-hidden">
|
||||
{/* Main Interaction Area */}
|
||||
<div className="flex-grow bg-slate-900 rounded-3xl overflow-hidden flex flex-col relative shadow-2xl border border-white/5">
|
||||
{/* Animated Background Aura */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-20">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col h-full bg-slate-900 rounded-3xl border border-white/10 overflow-hidden relative shadow-2xl">
|
||||
{/* Voice Session Header */}
|
||||
<VoiceHeader
|
||||
scenario={scenario}
|
||||
onExit={onExit}
|
||||
onOpenDeviceSelector={() => setShowDeviceSelector(true)}
|
||||
/>
|
||||
|
||||
<VoiceHeader
|
||||
title={scenario.title}
|
||||
onExit={onExit}
|
||||
onFinish={handleFinishSession}
|
||||
/>
|
||||
|
||||
<div className="flex-grow flex flex-col items-center justify-center p-12 relative z-10">
|
||||
{!hasStarted && status === 'active' ? (
|
||||
<div className="flex flex-col items-center animate-in fade-in zoom-in duration-700">
|
||||
<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">
|
||||
<div className="absolute inset-0 bg-teal-500/20 rounded-full animate-ping"></div>
|
||||
<BrainCircuit size={40} className="text-teal-400 relative z-10" />
|
||||
{/* Main Interaction Stage */}
|
||||
<div className="flex-grow flex flex-col lg:flex-row items-center justify-between p-6 md:p-12 relative overflow-hidden gap-8">
|
||||
<div className="flex-grow flex flex-col items-center justify-center w-full relative z-10">
|
||||
{/* 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">
|
||||
<AlertTriangle className="text-rose-400 shrink-0" size={20} />
|
||||
<div className="text-xs">
|
||||
<p className="font-bold">Connection Warning</p>
|
||||
<p className="opacity-90">{errorMessage || 'Unable to connect to live voice session. Please verify your OpenRouter API key.'}</p>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">Neural Link Established</h3>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 mb-8 flex items-center gap-3 max-w-sm">
|
||||
<div className="bg-teal-500/20 p-2 rounded-lg">
|
||||
<Info size={16} className="text-teal-400" />
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-300 leading-relaxed">
|
||||
<strong>Greeting Hint:</strong> Say "Hello {scenario.characterName}" or just wait for them to speak first.
|
||||
</p>
|
||||
</div>
|
||||
{/* Silence Warning Notice */}
|
||||
{isSilenceDetected && !isMuted && status === 'active' && (
|
||||
<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">
|
||||
<AlertCircle size={14} />
|
||||
<span>Low microphone input detected. Please speak closer to your mic.</span>
|
||||
</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
|
||||
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" />
|
||||
Start Session
|
||||
Begin Voice Simulation
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
{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 */}
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className={`px-4 py-1.5 rounded-full border transition-all duration-500 flex items-center gap-2 ${isAiSpeaking
|
||||
? 'bg-blue-500/20 border-blue-400/30 text-blue-300'
|
||||
: (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>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-slate-400 font-light mt-1">
|
||||
{voiceSettings.voiceName} Vocal Persona Active
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Selector Overlay */}
|
||||
{showDeviceSelector && (
|
||||
<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">
|
||||
<div className="bg-white rounded-3xl p-6 w-full max-w-sm shadow-2xl border border-slate-100">
|
||||
<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)} />}
|
||||
{/* Live Insight Drawer */}
|
||||
<div className="w-full lg:w-96 flex-shrink-0 h-full max-h-[500px] lg:max-h-none flex flex-col justify-end">
|
||||
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Insights Module */}
|
||||
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} />
|
||||
{/* Persistent Audio Controls Footer */}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,9 +23,8 @@ const badgeVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
export type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof badgeVariants>;
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,71 @@
|
||||
import { Scenario } from './types';
|
||||
import { Type } from "@google/genai";
|
||||
import { Scenario, UserSettings } from './types';
|
||||
|
||||
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 {
|
||||
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.",
|
||||
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",
|
||||
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.",
|
||||
category: 'Relationships',
|
||||
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?",
|
||||
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.",
|
||||
category: 'Social Anxiety',
|
||||
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?",
|
||||
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.",
|
||||
category: 'Self-Talk',
|
||||
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.",
|
||||
cbtFocus: ["Labeling", "Overgeneralization"]
|
||||
},
|
||||
@@ -266,30 +344,30 @@ export const SCENARIOS: Scenario[] = [
|
||||
];
|
||||
|
||||
export const CBT_ANALYSIS_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
type: 'object',
|
||||
properties: {
|
||||
analysis: {
|
||||
type: Type.OBJECT,
|
||||
type: 'object',
|
||||
properties: {
|
||||
distortions: { type: Type.ARRAY, items: { type: Type.STRING } },
|
||||
isAdaptive: { type: Type.BOOLEAN },
|
||||
evidence: { type: Type.STRING },
|
||||
distortions: { type: 'array', items: { type: 'string' } },
|
||||
isAdaptive: { type: 'boolean' },
|
||||
evidence: { type: 'string' },
|
||||
},
|
||||
required: ['distortions', 'isAdaptive', 'evidence']
|
||||
},
|
||||
rating: {
|
||||
type: Type.OBJECT,
|
||||
type: 'object',
|
||||
properties: {
|
||||
score: { type: Type.INTEGER },
|
||||
reason: { type: Type.STRING },
|
||||
score: { type: 'integer' },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['score', 'reason']
|
||||
},
|
||||
suggestions: {
|
||||
type: Type.OBJECT,
|
||||
type: 'object',
|
||||
properties: {
|
||||
alternativeThought: { type: Type.STRING },
|
||||
behavioralStep: { type: Type.STRING },
|
||||
alternativeThought: { type: 'string' },
|
||||
behavioralStep: { type: 'string' },
|
||||
},
|
||||
required: ['alternativeThought', 'behavioralStep']
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 515 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 752 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 662 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 824 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 575 KiB |
|
After Width: | Height: | Size: 908 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -1,14 +1,13 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { GoogleGenAI, LiveServerMessage, Modality } from '@google/genai';
|
||||
import { Scenario, UserSettings, Message, AnalysisResult } from '../types';
|
||||
import { SYSTEM_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
|
||||
import { floatTo16BitPCM, encodeBase64, decodeBase64, decodeAudioData } from '../services/audioUtils';
|
||||
import { getOpenRouterApiKey } from '../services/apiConfig';
|
||||
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) {
|
||||
const [status, setStatus] = useState<SessionStatus>('connecting');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [audioLevel, setAudioLevel] = 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 [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
|
||||
|
||||
// Refs for audio processing
|
||||
const sessionPromiseRef = useRef<Promise<any> | null>(null);
|
||||
// Audio Context & Nodes
|
||||
const isMutedRef = useRef(isMuted);
|
||||
useEffect(() => {
|
||||
isMutedRef.current = isMuted;
|
||||
}, [isMuted]);
|
||||
|
||||
const inputAudioCtxRef = useRef<AudioContext | null>(null);
|
||||
const outputAudioCtxRef = useRef<AudioContext | null>(null);
|
||||
const aiAnalyserRef = useRef<AnalyserNode | null>(null);
|
||||
const nextStartTimeRef = useRef<number>(0);
|
||||
const audioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
|
||||
const userAnalyserRef = useRef<AnalyserNode | null>(null);
|
||||
const currentAudioSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const analysisChatRef = useRef<any>(null);
|
||||
const silenceCheckTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const aiLevelUpdateTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Buffers for transcription
|
||||
const currentInputTranscription = useRef('');
|
||||
const currentOutputTranscription = useRef('');
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const aiLevelTimerRef = useRef<number | null>(null);
|
||||
const userLevelTimerRef = useRef<number | null>(null);
|
||||
const isProcessingTurnRef = useRef(false);
|
||||
|
||||
const stopAllAudio = useCallback(() => {
|
||||
audioSourcesRef.current.forEach(source => {
|
||||
try { source.stop(); } catch (e) {}
|
||||
});
|
||||
audioSourcesRef.current.clear();
|
||||
nextStartTimeRef.current = 0;
|
||||
if (currentAudioSourceRef.current) {
|
||||
try { currentAudioSourceRef.current.stop(); } catch (e) {}
|
||||
currentAudioSourceRef.current = null;
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
setIsAiSpeaking(false);
|
||||
setAiAudioLevel(0);
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => setIsMuted(prev => !prev), []);
|
||||
|
||||
useEffect(() => {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
const speakText = useCallback(async (text: string) => {
|
||||
stopAllAudio();
|
||||
setIsAiSpeaking(true);
|
||||
|
||||
// Setup Live Insights Chat
|
||||
analysisChatRef.current = ai.chats.create({
|
||||
model: 'gemini-3-flash-preview',
|
||||
config: {
|
||||
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\nSCENARIO: ${scenario.title}\nFOCUS: ${scenario.cbtFocus.join(', ')}`,
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: CBT_ANALYSIS_SCHEMA,
|
||||
try {
|
||||
// Attempt high-fidelity OpenRouter audio synthesis
|
||||
const arrayBuffer = await synthesizeSpeechWithOpenRouter(
|
||||
text,
|
||||
voiceSettings.voiceName.toLowerCase() === 'kore' ? 'nova' : 'alloy',
|
||||
voiceSettings.openRouterApiKey
|
||||
);
|
||||
|
||||
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 = () => {
|
||||
if (aiAnalyserRef.current && isAiSpeaking) {
|
||||
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 {
|
||||
source.onended = () => {
|
||||
setIsAiSpeaking(false);
|
||||
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 {
|
||||
inputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
|
||||
outputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
|
||||
|
||||
setStatus('connecting');
|
||||
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.fftSize = 256;
|
||||
aiAnalyserRef.current.connect(outputAudioCtxRef.current.destination);
|
||||
|
||||
updateAiLevel();
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: voiceSettings.preferredAudioDeviceId
|
||||
? { deviceId: { exact: voiceSettings.preferredAudioDeviceId } }
|
||||
: true
|
||||
: true
|
||||
};
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
streamRef.current = stream;
|
||||
|
||||
// Silence detection logic: If after 5 seconds of active session,
|
||||
// the max audio level seen is very low, flag it.
|
||||
let maxLevelSeen = 0;
|
||||
silenceCheckTimerRef.current = setTimeout(() => {
|
||||
if (maxLevelSeen < 1.0) { // Threshold for silence
|
||||
setIsSilenceDetected(true);
|
||||
const micSource = inputAudioCtxRef.current.createMediaStreamSource(stream);
|
||||
micSource.connect(userAnalyserRef.current);
|
||||
|
||||
// Visualizer Level Tracking Loops
|
||||
const updateUserLevel = () => {
|
||||
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({
|
||||
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
|
||||
callbacks: {
|
||||
onopen: () => {
|
||||
setStatus('active');
|
||||
const source = inputAudioCtxRef.current!.createMediaStreamSource(stream);
|
||||
const scriptProcessor = inputAudioCtxRef.current!.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
scriptProcessor.onaudioprocess = (e) => {
|
||||
const inputData = e.inputBuffer.getChannelData(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: {}
|
||||
const updateAiLevel = () => {
|
||||
if (aiAnalyserRef.current && isAiSpeaking) {
|
||||
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);
|
||||
} else {
|
||||
setAiAudioLevel(0);
|
||||
}
|
||||
});
|
||||
aiLevelTimerRef.current = requestAnimationFrame(updateAiLevel);
|
||||
};
|
||||
updateAiLevel();
|
||||
|
||||
sessionPromiseRef.current = sessionPromise;
|
||||
} catch (err) {
|
||||
console.error("Connect failed", err);
|
||||
// Browser Speech Recognition for user speech
|
||||
const SpeechRec = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
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 () => {
|
||||
stopAllAudio();
|
||||
if (silenceCheckTimerRef.current) clearTimeout(silenceCheckTimerRef.current);
|
||||
if (aiLevelUpdateTimerRef.current) cancelAnimationFrame(aiLevelUpdateTimerRef.current);
|
||||
if (userLevelTimerRef.current) cancelAnimationFrame(userLevelTimerRef.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 (inputAudioCtxRef.current) inputAudioCtxRef.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 {
|
||||
status,
|
||||
setStatus,
|
||||
errorMessage,
|
||||
isMuted,
|
||||
toggleMute,
|
||||
audioLevel,
|
||||
@@ -254,23 +348,15 @@ export function useLiveSession(scenario: Scenario, voiceSettings: UserSettings)
|
||||
liveInsight,
|
||||
isAnalyzingTurn,
|
||||
isSilenceDetected,
|
||||
sessionPromiseRef,
|
||||
startSession: () => {
|
||||
if (sessionPromiseRef.current) {
|
||||
sessionPromiseRef.current.then(session => {
|
||||
// We send a "hidden" text input to the model to trigger its initial message
|
||||
// The Live API supports sending text turns in the clientContent
|
||||
session.send({
|
||||
clientContent: {
|
||||
turns: [{
|
||||
role: 'user',
|
||||
parts: [{ text: "Please start the scenario now. Greet me as your character." }]
|
||||
}],
|
||||
turnComplete: true
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
const initialBotMsg: Message = {
|
||||
id: 'init',
|
||||
role: 'model',
|
||||
text: scenario.initialMessage,
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages([initialBotMsg]);
|
||||
speakText(scenario.initialMessage);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -20,21 +20,10 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,10 +13,14 @@
|
||||
"version:minor": "npm version minor --no-git-tag-version",
|
||||
"version:major": "npm version major --no-git-tag-version",
|
||||
"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": {
|
||||
"@google/genai": "^1.33.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -42,12 +46,21 @@
|
||||
"workbox-strategies": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@types/node": "^22.19.15",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"bits-ui": "^2.19.3",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"postcss": "^8.5.8",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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.`);
|
||||
@@ -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));
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||