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
@@ -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();
|
||||
Reference in new issue
Block a user