Files
jpremill 1feea40684 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
2026-09-29 23:35:05 -04:00

371 lines
11 KiB
TypeScript

/**
* 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();
}