- 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
242 lines
8.6 KiB
TypeScript
242 lines
8.6 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { Scenario, Message, AnalysisResult, UserSettings } from '../types';
|
|
import { BrainCircuit, ChevronLeft, Flag, Loader2 } from 'lucide-react';
|
|
import { streamOpenRouterChat, analyzeTurnWithOpenRouter, generateSessionSummaryReport } from '../services/openRouterService';
|
|
import { saveSession } from '../services/sessionStorage';
|
|
|
|
// Sub-components
|
|
import ChatMessage from './ChatMessage';
|
|
import ChatInput from './ChatInput';
|
|
import LiveInsightPanel from './LiveInsightPanel';
|
|
import SessionReport from './SessionReport';
|
|
|
|
interface ChatSessionProps {
|
|
scenario: Scenario;
|
|
userSettings?: UserSettings;
|
|
onExit: () => void;
|
|
}
|
|
|
|
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');
|
|
|
|
// Clinical Feedback State
|
|
const [liveInsight, setLiveInsight] = useState<AnalysisResult | null>(null);
|
|
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
|
|
const [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages, scrollToBottom]);
|
|
|
|
// Session Initialization
|
|
useEffect(() => {
|
|
// 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]);
|
|
|
|
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;
|
|
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);
|
|
}
|
|
}, [messages, scenario, isLoading, userSettings]);
|
|
|
|
const handleFinishSession = async () => {
|
|
if (messages.length < 2) return onExit();
|
|
|
|
setStatus('analyzing');
|
|
|
|
try {
|
|
const reportData = await generateSessionSummaryReport(
|
|
messages,
|
|
scenario.title,
|
|
scenario.description,
|
|
{
|
|
apiKey: userSettings?.openRouterApiKey,
|
|
model: userSettings?.supervisorModel
|
|
}
|
|
);
|
|
|
|
setFinalReport(reportData);
|
|
saveSession(scenario, 'text', messages, reportData);
|
|
setStatus('finished');
|
|
} catch (err) {
|
|
console.error("Post-session analysis failure", err);
|
|
onExit();
|
|
}
|
|
};
|
|
|
|
if (status === 'analyzing') {
|
|
return (
|
|
<div className="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 className="bg-white p-12 rounded-full shadow-lg border border-teal-100 mb-8 relative">
|
|
<BrainCircuit size={64} className="text-teal-600 animate-pulse" />
|
|
<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 via OpenRouter...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (status === 'finished' && finalReport) {
|
|
return (
|
|
<SessionReport
|
|
analysis={finalReport}
|
|
scenarioTitle={scenario.title}
|
|
messages={messages}
|
|
onExit={onExit}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-[calc(100vh-100px)] max-w-6xl mx-auto w-full gap-4 px-2">
|
|
{/* Interaction Header */}
|
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-slate-200 flex justify-between items-center">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={onExit} className="p-2 hover:bg-slate-50 rounded-lg text-slate-400 hover:text-teal-600 transition-colors">
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<div>
|
|
<h2 className="font-bold text-slate-800 text-lg leading-tight">{scenario.title}</h2>
|
|
<div className="flex gap-2">
|
|
{scenario.cbtFocus.slice(0, 3).map(f => (
|
|
<span key={f} className="text-[10px] font-bold text-teal-600 uppercase tracking-tight">{f}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleFinishSession}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-teal-50 text-teal-700 hover:bg-teal-600 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all border border-teal-100"
|
|
>
|
|
<Flag size={14} />
|
|
Complete Interaction
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col lg:flex-row gap-4 h-full overflow-hidden">
|
|
{/* Core Chat Module */}
|
|
<div className="flex-grow flex flex-col bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
|
<div className="flex-grow overflow-y-auto p-6 space-y-6 scrollbar-hide">
|
|
{messages.map((msg) => (
|
|
<ChatMessage key={msg.id} message={msg} isLoading={isLoading && msg.id === messages[messages.length - 1].id} />
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
<ChatInput onSend={handleSend} disabled={isLoading} />
|
|
</div>
|
|
|
|
{/* Real-time Insights Module */}
|
|
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default React.memo(ChatSession);
|