feat(ui): redesign voice cockpit with clinical companion drawer, Svelte 5 migration, and Gemini 3.8 voice

- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer
- Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support
- Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline
- Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots)
- Migrate core application to Svelte 5 runes (, , ) with reactive stores
- Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
This commit is contained in:
jpremill committed 2026-09-29 23:35:05 -04:00
1 parent c82b112624
commit 1feea40684
148 files changed
+11230 -1706

No files matched your search

+100 -113
View File
@@ -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 (