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