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
+177
-77
@@ -1,12 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { UserSettings } from '../types';
|
||||
import { X, Volume2, Check, Info, Mic2, Settings2 } from 'lucide-react';
|
||||
import { X, Volume2, Check, Info, Mic2, Settings2, Key, Sparkles, Cpu } from 'lucide-react';
|
||||
import Tooltip from './Tooltip';
|
||||
import AudioDeviceSelector from './AudioDeviceSelector';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
|
||||
interface SettingsModalProps {
|
||||
settings: UserSettings;
|
||||
@@ -30,18 +33,40 @@ const VOICE_OPTIONS: VoiceOption[] = [
|
||||
{ id: 'Fenrir', label: 'Fenrir', gender: 'Male', style: 'Rugged', description: 'Direct and matter-of-fact tone with a slightly more serious edge.' },
|
||||
];
|
||||
|
||||
const AVAILABLE_MODELS = [
|
||||
{ id: 'google/gemini-3.8-flash', name: 'Google Gemini 3.8 Flash', badge: 'Default • Frontier Ultra Low Latency' },
|
||||
{ id: 'google/gemini-3.7-flash', name: 'Google Gemini 3.7 Flash', badge: 'Fast • Hybrid Reasoning' },
|
||||
{ id: 'google/gemini-2.5-flash', name: 'Google Gemini 2.5 Flash', badge: 'Stable • Low Latency' },
|
||||
{ id: 'anthropic/claude-3.5-haiku', name: 'Anthropic Claude 3.5 Haiku', badge: 'Nuanced Reasoning' },
|
||||
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Meta Llama 3.3 70B Instruct', badge: 'Open Weights' },
|
||||
];
|
||||
|
||||
const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState('voice');
|
||||
const [openRouterKey, setOpenRouterKey] = useState(settings.openRouterApiKey || '');
|
||||
const [selectedModel, setSelectedModel] = useState(settings.roleplayModel || 'google/gemini-3.8-flash');
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate({
|
||||
...settings,
|
||||
openRouterApiKey: openRouterKey.trim() || undefined,
|
||||
roleplayModel: selectedModel,
|
||||
supervisorModel: selectedModel,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-md">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-xl overflow-hidden animate-in fade-in zoom-in duration-300 border border-slate-100 max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 bg-slate-50 border-b border-slate-200 flex justify-between items-center flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-slate-800 p-2 rounded-xl text-white">
|
||||
<Volume2 size={20} />
|
||||
<Settings2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">Voice & Persona Settings</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Configure your AI clinical partner</p>
|
||||
<h2 className="text-lg font-bold text-slate-800">Application Settings</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Powered by OpenRouter Engine</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="text-slate-400 hover:bg-slate-200 rounded-xl">
|
||||
@@ -49,81 +74,156 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ settings, onUpdate, onClo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="p-8 space-y-8 flex-grow">
|
||||
<section className="space-y-4 pb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Mic2 size={14} className="text-teal-600" />
|
||||
Vocal Profile
|
||||
</h3>
|
||||
<Tooltip content="Different voices can help simulate specific social or professional contexts.">
|
||||
<Info size={14} className="text-slate-300" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{VOICE_OPTIONS.map((voice) => (
|
||||
<Card
|
||||
key={voice.id}
|
||||
onClick={() => onUpdate({ ...settings, voiceName: voice.id, genderPreference: voice.gender, vocalStyle: voice.style })}
|
||||
className={`flex flex-row items-center justify-between p-4 cursor-pointer transition-all ${settings.voiceName === voice.id
|
||||
? 'bg-teal-50 border-teal-500 shadow-md shadow-teal-100'
|
||||
: 'bg-white border-slate-200 hover:border-teal-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center shrink-0 justify-center ${settings.voiceName === voice.id ? 'bg-teal-600 text-white' : 'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
<Volume2 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-bold text-slate-800">{voice.label}</span>
|
||||
<Badge variant="secondary" className="text-[9px] font-black uppercase text-slate-500 rounded">
|
||||
{voice.gender} • {voice.style}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-light mt-0.5">{voice.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{settings.voiceName === voice.id && (
|
||||
<div className="bg-teal-600 text-white p-1 rounded-full shrink-0 ml-2">
|
||||
<Check size={14} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 pb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-teal-600" />
|
||||
Hardware Configuration
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<AudioDeviceSelector
|
||||
selectedDeviceId={settings.preferredAudioDeviceId}
|
||||
onSelect={(deviceId) => onUpdate({ ...settings, preferredAudioDeviceId: deviceId })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-2xl p-4 flex gap-3 mb-6">
|
||||
<div className="text-amber-600 mt-0.5">
|
||||
<Info size={18} />
|
||||
</div>
|
||||
<p className="text-xs text-amber-800 leading-relaxed">
|
||||
These settings apply to <strong>Voice Mode</strong> interactions only. Text-based roleplay personas are dynamically generated based on each specific scenario.
|
||||
</p>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full flex-grow flex flex-col overflow-hidden">
|
||||
<div className="px-6 pt-4 border-b border-slate-100">
|
||||
<TabsList className="grid grid-cols-2 w-full max-w-xs">
|
||||
<TabsTrigger value="voice" className="text-xs">
|
||||
<Volume2 size={14} className="mr-1.5" />
|
||||
Voice & Audio
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="openrouter" className="text-xs">
|
||||
<Key size={14} className="mr-1.5" />
|
||||
OpenRouter AI
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-200 flex justify-end flex-shrink-0">
|
||||
<ScrollArea className="p-6 flex-grow overflow-y-auto">
|
||||
<TabsContent value="voice" className="space-y-6 m-0">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Mic2 size={14} className="text-teal-600" />
|
||||
Vocal Profile
|
||||
</h3>
|
||||
<Tooltip content="Different voices can help simulate specific social or professional contexts.">
|
||||
<Info size={14} className="text-slate-300" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{VOICE_OPTIONS.map((voice) => (
|
||||
<Card
|
||||
key={voice.id}
|
||||
onClick={() => onUpdate({ ...settings, voiceName: voice.id, genderPreference: voice.gender, vocalStyle: voice.style })}
|
||||
className={`flex flex-row items-center justify-between p-3.5 cursor-pointer transition-all ${settings.voiceName === voice.id
|
||||
? 'bg-teal-50 border-teal-500 shadow-sm shadow-teal-100'
|
||||
: 'bg-white border-slate-200 hover:border-teal-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-full flex items-center shrink-0 justify-center ${settings.voiceName === voice.id ? 'bg-teal-600 text-white' : 'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
<Volume2 size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-bold text-slate-800 text-sm">{voice.label}</span>
|
||||
<Badge variant="secondary" className="text-[9px] font-black uppercase text-slate-500 rounded">
|
||||
{voice.gender} • {voice.style}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-light">{voice.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{settings.voiceName === voice.id && (
|
||||
<div className="bg-teal-600 text-white p-1 rounded-full shrink-0 ml-2">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-teal-600" />
|
||||
Input Microphone
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<AudioDeviceSelector
|
||||
selectedDeviceId={settings.preferredAudioDeviceId}
|
||||
onSelect={(deviceId) => onUpdate({ ...settings, preferredAudioDeviceId: deviceId })}
|
||||
/>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="openrouter" className="space-y-6 m-0">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2 mb-1">
|
||||
<Key size={14} className="text-teal-600" />
|
||||
OpenRouter API Key
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
Enter your OpenRouter API Key (<code className="font-mono text-xs">sk-or-v1-...</code>). All clinical roleplay, live insights, and scenario architect functions route through OpenRouter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-key" className="text-xs font-semibold text-slate-700">
|
||||
API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="openrouter-key"
|
||||
type="password"
|
||||
placeholder="sk-or-v1-..."
|
||||
value={openRouterKey}
|
||||
onChange={(e) => setOpenRouterKey(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-slate-700 flex items-center gap-1.5">
|
||||
<Cpu size={14} className="text-teal-600" />
|
||||
Roleplay & Supervisor Model
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{AVAILABLE_MODELS.map((model) => (
|
||||
<Card
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
className={`p-3 cursor-pointer transition-all flex items-center justify-between border ${selectedModel === model.id
|
||||
? 'bg-teal-50 border-teal-500 shadow-sm'
|
||||
: 'bg-white border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-800">{model.name}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-0.5">{model.id}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] font-semibold text-teal-700 border-teal-200">
|
||||
{model.badge}
|
||||
</Badge>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-teal-50 border border-teal-100 rounded-2xl p-4 flex gap-3">
|
||||
<div className="text-teal-600 mt-0.5">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<p className="text-xs text-teal-900 leading-relaxed">
|
||||
Your OpenRouter API Key is stored securely in your browser LocalStorage and is never shared or transmitted elsewhere.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
<div className="p-5 bg-slate-50 border-t border-slate-200 flex justify-end flex-shrink-0">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="bg-slate-800 text-white px-8 h-12 rounded-2xl font-bold hover:bg-slate-900 shadow-lg shadow-slate-200 transition-all uppercase tracking-widest text-[10px]"
|
||||
onClick={handleSave}
|
||||
className="bg-slate-800 text-white px-8 h-11 rounded-2xl font-bold hover:bg-slate-900 shadow-lg shadow-slate-200 transition-all uppercase tracking-widest text-[10px]"
|
||||
>
|
||||
Save Preferences
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user