feat(voice): per-account choice between Whisper and the browser; STT_ALLOW_BROWSER
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m40s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 15:19:20 +01:00
1 parent 55112b940c
commit a927a94a95
21 files changed
+288 -15

No files matched your search

@@ -18,6 +18,7 @@ import {
Pencil,
Copy,
Archive,
Mic,
} from 'lucide-react';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
@@ -25,6 +26,7 @@ import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download';
import { api, clearToken, triggerHaptic } from '../../lib/api';
import { passwordProblem } from '../../lib/password';
import { loadVoiceConfig, voiceChoiceAvailable } from '../../lib/voice';
import {
AVAILABLE_THEMES,
CornerStyle,
@@ -66,7 +68,7 @@ interface UserSettingsViewProps {
onBack: () => void;
}
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks' | 'data';
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'webhooks' | 'data';
export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels);
@@ -132,6 +134,20 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
onBack,
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
// Voice input: the choice Whisper/browser only exists with a Whisper server that allows the browser
const [voiceChoice, setVoiceChoice] = useState(false);
const [voiceSaving, setVoiceSaving] = useState(false);
useEffect(() => {
void loadVoiceConfig().then(() => setVoiceChoice(voiceChoiceAvailable()));
}, []);
const handleChangeVoiceInput = async (next: '' | 'browser') => {
setVoiceSaving(true);
try {
onUserUpdated(await api.updateSettings({ voiceInput: next }));
} finally {
setVoiceSaving(false);
}
};
const [legal, setLegal] = useState<AuthStatus['legal']>();
// --- Danger zone ---
@@ -642,6 +658,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
</button>
{voiceChoice && (
<button
onClick={() => setSection('voice')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'voice'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Mic className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Spracheingabe</p>
<p className="text-[10px] text-faint">Whisper-Server oder Browser</p>
</div>
</button>
)}
<button
onClick={() => setSection('webhooks')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
@@ -1881,6 +1914,56 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
)}
{/* ================================================================= */}
{/* SECTION: SPRACHEINGABE */}
{/* ================================================================= */}
{section === 'voice' && voiceChoice && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Spracheingabe</h2>
<p className="text-muted mt-0.5">Womit das Mikrofon in MindSift deine Sprache in Text umwandelt.</p>
</div>
<div className="space-y-2" role="radiogroup" aria-label="Spracheingabe">
{([
{
value: '' as const,
title: 'Whisper-Server (Standard)',
text: 'Die Aufnahme geht nur an den Spracherkennungs-Server dieser MindSift-Installation. Funktioniert in jedem Browser.',
},
{
value: 'browser' as const,
title: 'Browser',
text: 'Die Spracherkennung des Browsers. Der Text erscheint sofort beim Sprechen; Chrome und Edge schicken die Aufnahme dafür an Google bzw. Microsoft. In Firefox gibt es sie nicht.',
},
]).map((opt) => {
const selected = (currentUser.voiceInput || '') === opt.value;
return (
<button
key={opt.value || 'server'}
type="button"
role="radio"
aria-checked={selected}
disabled={voiceSaving}
onClick={() => !selected && void handleChangeVoiceInput(opt.value)}
className={`w-full text-left p-3 rounded-card border flex items-start gap-3 transition-colors disabled:opacity-60 ${
selected ? 'border-accent bg-accent/10' : 'border-border bg-canvas hover:bg-panel-hover'
}`}
>
<span
className={`mt-0.5 w-4 h-4 rounded-full border-2 flex-shrink-0 ${selected ? 'border-accent bg-accent' : 'border-strong'}`}
aria-hidden="true"
/>
<span className="space-y-0.5">
<span className="block font-semibold text-main">{opt.title}</span>
<span className="block text-muted leading-relaxed">{opt.text}</span>
</span>
</button>
);
})}
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 5: EXPORT & IMPORT */}
{/* ================================================================= */}