feat(ui): redesign voice cockpit with clinical companion drawer, Svelte 5 migration, and Gemini 3.8 voice
- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer - Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support - Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline - Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots) - Migrate core application to Svelte 5 runes (, , ) with reactive stores - Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
This commit is contained in:
1 parent
c82b112624
commit
1feea40684
148 files changed
+11230
-1706
No files matched your search
+100
-113
@@ -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 (
|
||||
|
||||
+130
-207
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { Scenario } from '../types';
|
||||
import { Scenario, UserSettings } from '../types';
|
||||
import { CBT_GLOSSARY } from '../constants';
|
||||
import { Sparkles, X, Loader2, AlertCircle, Info, Target, UserCircle2, BrainCircuit, Check, BarChart } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
@@ -8,13 +7,15 @@ import { Textarea } from './ui/textarea';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { generateScenarioWithOpenRouter } from '../services/openRouterService';
|
||||
|
||||
interface ScenarioGeneratorProps {
|
||||
onGenerate: (scenario: Scenario) => void;
|
||||
onCancel: () => void;
|
||||
userSettings?: UserSettings;
|
||||
}
|
||||
|
||||
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel }) => {
|
||||
const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCancel, userSettings }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [selectedConcepts, setSelectedConcepts] = useState<string[]>([]);
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState<Scenario['difficulty']>('Intermediate');
|
||||
@@ -34,240 +35,162 @@ const ScenarioGenerator: React.FC<ScenarioGeneratorProps> = ({ onGenerate, onCan
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY });
|
||||
|
||||
const schema = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
id: { type: Type.STRING },
|
||||
title: { type: Type.STRING },
|
||||
characterName: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
category: {
|
||||
type: Type.STRING,
|
||||
enum: ['Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk']
|
||||
},
|
||||
difficulty: {
|
||||
type: Type.STRING,
|
||||
enum: ['Beginner', 'Intermediate', 'Advanced']
|
||||
},
|
||||
systemInstruction: { type: Type.STRING },
|
||||
initialMessage: { type: Type.STRING },
|
||||
cbtFocus: {
|
||||
type: Type.ARRAY,
|
||||
items: { type: Type.STRING }
|
||||
}
|
||||
},
|
||||
required: ['id', 'title', 'characterName', 'description', 'category', 'difficulty', 'systemInstruction', 'initialMessage', 'cbtFocus']
|
||||
};
|
||||
|
||||
const selectedContext = selectedConcepts.length > 0
|
||||
? `Focus specifically on these CBT concepts: ${selectedConcepts.join(', ')}.`
|
||||
: '';
|
||||
|
||||
const result = await ai.models.generateContent({
|
||||
model: 'gemini-3-flash-preview',
|
||||
config: {
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: schema,
|
||||
systemInstruction: `
|
||||
[CONTEXT]
|
||||
You are a Clinical CBT Scenario Architect.
|
||||
The user requires a structured clinical simulation designed to address specific cognitive distortions and behavioral challenges.
|
||||
Clinical Focus: ${selectedContext}
|
||||
|
||||
[TASK]
|
||||
Transform the user's prompt into a cohesive, structured clinical scenario that sets up a targeted roleplay environment.
|
||||
|
||||
[CONSTRAINTS]
|
||||
- CATEGORIZATION: You MUST categorize the scenario appropriately (Social Anxiety, Workplace, Relationships, or Self-Talk).
|
||||
- DIFFICULTY CALIBRATION: The requested difficulty is '${selectedDifficulty}'.
|
||||
- 'Beginner': Low stakes, high support, clear-cut distortions, AI character is cooperative.
|
||||
- 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions, AI character has specific needs.
|
||||
- 'Advanced': High emotional triggers, persistent clinical resistance, complex cognitive patterns.
|
||||
- INTERNAL LOGIC: Provide a detailed "INTERNAL CBT LOGIC" section instructing the AI character how to react to the user's progress or cognitive lapses during the future roleplay.
|
||||
- CHARACTER VOICE: Define a realistic, emotionally consistent voice profile.
|
||||
|
||||
[OUTPUT REQUIREMENTS]
|
||||
Return ONLY a valid JSON object conforming exactly to the requested schema. Ensure all arrays and nested objects are populated correctly.
|
||||
`
|
||||
},
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: `Generate a clinical scenario for: ${prompt || 'A scenario exploring the chosen CBT concepts.'}` }] }
|
||||
]
|
||||
});
|
||||
|
||||
if (result.text) {
|
||||
const scenario = JSON.parse(result.text) as Scenario;
|
||||
// Enforce the user-selected difficulty in the final object
|
||||
scenario.difficulty = selectedDifficulty;
|
||||
scenario.id = `custom-${Date.now()}-${scenario.id}`;
|
||||
onGenerate(scenario);
|
||||
} else {
|
||||
throw new Error("No content generated");
|
||||
}
|
||||
const scenario = await generateScenarioWithOpenRouter(
|
||||
prompt,
|
||||
selectedConcepts,
|
||||
selectedDifficulty,
|
||||
{
|
||||
apiKey: userSettings?.openRouterApiKey,
|
||||
model: userSettings?.roleplayModel
|
||||
}
|
||||
);
|
||||
|
||||
scenario.difficulty = selectedDifficulty;
|
||||
scenario.id = `custom-${Date.now()}-${scenario.id || 'generated'}`;
|
||||
onGenerate(scenario);
|
||||
} catch (err: any) {
|
||||
console.error("Generation error:", err);
|
||||
setError("Clinical scenario synthesis failed. Please refine your parameters.");
|
||||
console.error("OpenRouter Scenario Generation error:", err);
|
||||
setError(err?.message || "Clinical scenario synthesis failed. Please check your OpenRouter API Key in Settings.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getConceptVariant = (category: string, isSelected: boolean) => {
|
||||
if (!isSelected) return 'outline';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getConceptColorClass = (category: string, isSelected: boolean) => {
|
||||
if (!isSelected) return 'text-slate-500 border-slate-200 hover:border-slate-300';
|
||||
switch (category) {
|
||||
case 'Distortion': return 'bg-rose-600 border-rose-600 hover:bg-rose-700 shadow-md shadow-rose-100';
|
||||
case 'Technique': return 'bg-teal-600 border-teal-600 hover:bg-teal-700 shadow-md shadow-teal-100';
|
||||
case 'Concept': return 'bg-amber-600 border-amber-600 hover:bg-amber-700 shadow-md shadow-amber-100';
|
||||
default: return 'bg-slate-800 border-slate-800 hover:bg-slate-900';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md overflow-y-auto">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-300 border border-slate-100 my-8 flex flex-col max-h-[90vh]">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden border border-slate-100 flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="p-6 bg-teal-600 text-white flex justify-between items-start relative overflow-hidden shrink-0">
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||
<Sparkles size={28} className="text-teal-200" />
|
||||
Scenario Synthesis
|
||||
</h2>
|
||||
<p className="text-teal-50 text-sm mt-1 font-light opacity-90">
|
||||
Construct an evidence-based clinical environment.
|
||||
</p>
|
||||
<div className="p-6 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-teal-600 p-2.5 rounded-xl text-white shadow-md shadow-teal-600/20">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">Clinical Scenario Architect</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">
|
||||
AI Synthesis Engine • OpenRouter
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="relative z-10 text-white/70 hover:text-white transition-colors bg-white/10 hover:bg-white/20 border border-white/10"
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-10 -mt-10 blur-2xl"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-grow p-8 bg-[#FCFDFF]">
|
||||
<div className="space-y-6 pb-4">
|
||||
{/* Prompting Guide */}
|
||||
<Card className="bg-teal-50/50 border-teal-100 p-4 shadow-none">
|
||||
<div className="flex items-center gap-2 text-teal-700 mb-3">
|
||||
<Info size={16} />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider">Clinical Blueprint</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<UserCircle2 size={12} /> Character Role
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Define who the AI plays.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<Target size={12} /> The Challenge
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Describe the social friction.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-600 uppercase">
|
||||
<BrainCircuit size={12} /> CBT Focus
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">Target specific distortions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Difficulty Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Communication Difficulty
|
||||
<ScrollArea className="p-8 space-y-8 flex-grow">
|
||||
{/* Natural Language Prompt */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Target size={14} className="text-teal-600" />
|
||||
Target Dynamic & Situation
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['Beginner', 'Intermediate', 'Advanced'] as const).map((level) => (
|
||||
<Button
|
||||
key={level}
|
||||
variant={selectedDifficulty === level ? "default" : "outline"}
|
||||
onClick={() => setSelectedDifficulty(level)}
|
||||
className={`h-12 flex items-center justify-center gap-2 rounded-xl border-2 transition-all text-[11px] font-bold ${selectedDifficulty === level
|
||||
? 'bg-teal-50 border-teal-600 text-teal-700 shadow-sm shadow-teal-50 hover:bg-teal-100 hover:text-teal-800'
|
||||
: 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
<span className="text-[10px] text-slate-400">Describe the conflict or trigger</span>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="e.g. Asking my manager for a delayed performance review while fearing I'll be fired for bringing it up..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] text-sm bg-slate-50/50 border-slate-200 rounded-2xl resize-none focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Tuning */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<BarChart size={14} className="text-teal-600" />
|
||||
Emotional Resistance & Difficulty
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['Beginner', 'Intermediate', 'Advanced'] as const).map((diff) => (
|
||||
<Card
|
||||
key={diff}
|
||||
onClick={() => setSelectedDifficulty(diff)}
|
||||
className={`p-3.5 text-center cursor-pointer transition-all border ${selectedDifficulty === diff
|
||||
? 'bg-teal-50 border-teal-500 shadow-sm shadow-teal-500/10'
|
||||
: 'bg-white border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-slate-800">{diff}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">
|
||||
{diff === 'Beginner' && 'High Support'}
|
||||
{diff === 'Intermediate' && 'Realistic Pushback'}
|
||||
{diff === 'Advanced' && 'Severe Friction'}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clinical Focus Selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<BrainCircuit size={14} className="text-teal-600" />
|
||||
Target Cognitive Distortions & Techniques
|
||||
</label>
|
||||
<span className="text-[10px] text-teal-600 font-bold">
|
||||
{selectedConcepts.length} Selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 max-h-44 overflow-y-auto p-1 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
{CBT_GLOSSARY.map((item) => {
|
||||
const isSelected = selectedConcepts.includes(item.term);
|
||||
return (
|
||||
<Badge
|
||||
key={item.term}
|
||||
onClick={() => toggleConcept(item.term)}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className={`cursor-pointer text-[11px] py-1.5 px-3 rounded-xl transition-all ${isSelected
|
||||
? 'bg-teal-600 hover:bg-teal-700 text-white font-bold shadow-sm'
|
||||
: 'bg-white text-slate-600 hover:border-teal-300'
|
||||
}`}
|
||||
>
|
||||
<BarChart size={14} className={selectedDifficulty === level ? 'text-teal-600' : 'text-slate-400'} />
|
||||
{level}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{item.term}
|
||||
{isSelected && <Check size={12} className="ml-1.5 inline" />}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Clinical Targets (Selection) */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Clinical Targets (Optional)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CBT_GLOSSARY.map(concept => {
|
||||
const isSelected = selectedConcepts.includes(concept.term);
|
||||
return (
|
||||
<Badge
|
||||
key={concept.term}
|
||||
variant={getConceptVariant(concept.category, isSelected) as any}
|
||||
onClick={() => toggleConcept(concept.term)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold border transition-all flex items-center gap-1.5 cursor-pointer hover:opacity-90 ${getConceptColorClass(concept.category, isSelected)}`}
|
||||
>
|
||||
{isSelected && <Check size={10} />}
|
||||
{concept.term}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">
|
||||
Simulation Context / Custom Instructions
|
||||
</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g. Practicing assertiveness with a pushy salesperson who won't take 'no' for an answer."
|
||||
className="w-full h-28 p-5 border-slate-200 rounded-2xl focus-visible:ring-teal-500 resize-none text-slate-800 placeholder:text-slate-400 text-sm leading-relaxed shadow-inner bg-white"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-rose-50 text-rose-600 p-4 rounded-xl text-xs flex items-center gap-3 border border-rose-100 font-medium">
|
||||
<AlertCircle size={18} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-3 text-rose-700 text-xs animate-in fade-in">
|
||||
<AlertCircle size={16} className="shrink-0 text-rose-500" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end shrink-0">
|
||||
{/* Footer Actions */}
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-200 flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="text-xs uppercase tracking-widest font-black text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={(!prompt.trim() && selectedConcepts.length === 0) || isLoading}
|
||||
className="bg-teal-600 text-white h-12 px-8 rounded-2xl font-bold hover:bg-teal-700 flex items-center gap-3 transition-all shadow-lg shadow-teal-100 text-sm uppercase tracking-widest"
|
||||
disabled={isLoading || (!prompt.trim() && selectedConcepts.length === 0)}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white px-8 h-11 rounded-2xl text-xs uppercase tracking-widest font-black shadow-lg shadow-teal-600/20 flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Synthesizing...
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Synthesizing Scenario...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={20} />
|
||||
Initiate Build
|
||||
<Sparkles size={16} />
|
||||
Generate Simulation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+177
-77
@@ -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>
|
||||
|
||||
+126
-160
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user