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
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:
1 parent
55112b940c
commit
a927a94a95
21 files changed
+288
-15
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -78,6 +78,7 @@ import { useUpdateCheck } from './lib/useUpdateCheck';
|
||||
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||
import { APP_VERSION } from './version';
|
||||
import { readViewSession, writeViewSession } from './lib/viewSession';
|
||||
import { setVoicePreference } from './lib/voice';
|
||||
|
||||
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
|
||||
type ActiveTab = (typeof APP_TABS)[number];
|
||||
@@ -172,6 +173,8 @@ export const App: React.FC = () => {
|
||||
return tab && APP_TABS.includes(tab) ? tab : 'triage';
|
||||
});
|
||||
useEffect(() => writeViewSession('app', { tab: activeTab }), [activeTab]);
|
||||
// Whisper or the browser for voice input: the account's choice (lib/voice.ts)
|
||||
useEffect(() => setVoicePreference(user?.voiceInput), [user?.voiceInput]);
|
||||
useEffect(() => {
|
||||
if (user && user.role !== 'admin' && activeTab === 'admin_settings') setActiveTab('triage');
|
||||
}, [user, activeTab]);
|
||||
|
||||
@@ -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 */}
|
||||
{/* ================================================================= */}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getAuthStatus = vi.fn();
|
||||
vi.mock('../api', () => ({ api: { getAuthStatus: (...a: any[]) => getAuthStatus(...a) } }));
|
||||
|
||||
import { loadVoiceConfig, resetVoiceConfig, setVoicePreference, voiceChoiceAvailable } from '../voice';
|
||||
|
||||
// Recording and recognition as far as jsdom needs them to count as available
|
||||
(window as any).MediaRecorder = function () {};
|
||||
Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: vi.fn() }, configurable: true });
|
||||
(window as any).webkitSpeechRecognition = function () {};
|
||||
|
||||
afterEach(() => {
|
||||
resetVoiceConfig();
|
||||
getAuthStatus.mockReset();
|
||||
});
|
||||
|
||||
describe('voice input: Whisper or browser per account', () => {
|
||||
it('uses the browser when the account picked it and the server allows it', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600, browserAllowed: true } });
|
||||
expect((await loadVoiceConfig()).mode).toBe('server');
|
||||
expect(voiceChoiceAvailable()).toBe(true);
|
||||
setVoicePreference('browser');
|
||||
expect((await loadVoiceConfig()).mode).toBe('browser');
|
||||
setVoicePreference('');
|
||||
expect((await loadVoiceConfig()).mode).toBe('server');
|
||||
});
|
||||
|
||||
it('keeps Whisper when the server forbids the browser (STT_ALLOW_BROWSER=false)', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600, browserAllowed: false } });
|
||||
setVoicePreference('browser');
|
||||
expect((await loadVoiceConfig()).mode).toBe('server');
|
||||
expect(voiceChoiceAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('offers no choice without a Whisper server', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600, browserAllowed: true } });
|
||||
await loadVoiceConfig();
|
||||
expect(voiceChoiceAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -301,6 +301,7 @@ export const api = {
|
||||
notifyBeforeMinutes?: number;
|
||||
notificationSettings?: NotificationSettings;
|
||||
doneRetentionDays?: number;
|
||||
voiceInput?: '' | 'browser';
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/auth/settings', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface AuthStatus {
|
||||
customLinkUrl?: string;
|
||||
};
|
||||
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
|
||||
voice?: { mode: VoiceMode; maxSeconds: number };
|
||||
voice?: { mode: VoiceMode; maxSeconds: number; browserAllowed?: boolean };
|
||||
}
|
||||
|
||||
export type VoiceMode = 'server' | 'browser' | 'off';
|
||||
@@ -113,6 +113,8 @@ export interface User {
|
||||
notificationSettings?: NotificationSettings;
|
||||
// Completed tasks are deleted this many days after ticking them off (0 = never)
|
||||
doneRetentionDays?: number;
|
||||
// Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition
|
||||
voiceInput?: '' | 'browser';
|
||||
organizationId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, joinSpeechResults, loadVoiceConfig, VoiceRecorder } from './voice';
|
||||
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, joinSpeechResults, loadVoiceConfig, VOICE_CONFIG_EVENT, VoiceRecorder } from './voice';
|
||||
|
||||
export type VoiceState = 'idle' | 'listening' | 'transcribing';
|
||||
|
||||
@@ -63,12 +63,17 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void loadVoiceConfig().then((cfg) => {
|
||||
if (!mountedRef.current) return;
|
||||
setMode(cfg.mode);
|
||||
maxSecondsRef.current = cfg.maxSeconds;
|
||||
});
|
||||
const load = () =>
|
||||
void loadVoiceConfig().then((cfg) => {
|
||||
if (!mountedRef.current) return;
|
||||
setMode(cfg.mode);
|
||||
maxSecondsRef.current = cfg.maxSeconds;
|
||||
});
|
||||
load();
|
||||
// The account setting (Whisper or browser) can change while this button is on screen
|
||||
window.addEventListener(VOICE_CONFIG_EVENT, load);
|
||||
return () => {
|
||||
window.removeEventListener(VOICE_CONFIG_EVENT, load);
|
||||
mountedRef.current = false;
|
||||
clearAllTimers();
|
||||
recorderRef.current?.cancel();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// server record with MediaRecorder, the own Whisper server turns it into text after stopping
|
||||
// browser the browser's speech recognition (Chrome, Edge, Safari; text appears while speaking)
|
||||
// off no voice input
|
||||
// With a Whisper server everyone may pick the browser instead (account setting), unless the server
|
||||
// forbids it (STT_ALLOW_BROWSER=false).
|
||||
import { api } from './api';
|
||||
import type { VoiceMode } from './types';
|
||||
|
||||
@@ -15,6 +17,33 @@ export interface VoiceConfig {
|
||||
|
||||
let configPromise: Promise<VoiceConfig> | null = null;
|
||||
let loadedConfig: VoiceConfig | null = null;
|
||||
let serverMode: VoiceMode | undefined;
|
||||
let browserAllowed = true;
|
||||
let preference: '' | 'browser' = '';
|
||||
|
||||
/** Fired when the effective mode changes (the account setting was loaded or changed). */
|
||||
export const VOICE_CONFIG_EVENT = 'mindsift:voice-config';
|
||||
|
||||
function applyPreference(mode: VoiceMode | undefined): VoiceMode | undefined {
|
||||
return mode === 'server' && browserAllowed && preference === 'browser' ? 'browser' : mode;
|
||||
}
|
||||
|
||||
/** The account's choice ('' = Whisper server, 'browser'); set when the user is loaded or changes it. */
|
||||
export function setVoicePreference(next: '' | 'browser' | undefined): void {
|
||||
const value = next === 'browser' ? 'browser' : '';
|
||||
if (value === preference) return;
|
||||
preference = value;
|
||||
if (loadedConfig) {
|
||||
loadedConfig = { ...loadedConfig, mode: effectiveMode(applyPreference(serverMode)) };
|
||||
configPromise = Promise.resolve(loadedConfig);
|
||||
}
|
||||
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent(VOICE_CONFIG_EVENT));
|
||||
}
|
||||
|
||||
/** Whether the account may choose between the Whisper server and the browser. */
|
||||
export function voiceChoiceAvailable(): boolean {
|
||||
return serverMode === 'server' && browserAllowed;
|
||||
}
|
||||
|
||||
export function browserRecognition(): any {
|
||||
if (typeof window === 'undefined') return null;
|
||||
@@ -38,7 +67,9 @@ export function loadVoiceConfig(): Promise<VoiceConfig> {
|
||||
configPromise = api
|
||||
.getAuthStatus()
|
||||
.then((st) => {
|
||||
loadedConfig = { mode: effectiveMode(st.voice?.mode), maxSeconds: st.voice?.maxSeconds || 600 };
|
||||
serverMode = st.voice?.mode;
|
||||
browserAllowed = st.voice?.browserAllowed !== false;
|
||||
loadedConfig = { mode: effectiveMode(applyPreference(serverMode)), maxSeconds: st.voice?.maxSeconds || 600 };
|
||||
return loadedConfig;
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -58,6 +89,9 @@ export function currentVoiceConfig(): VoiceConfig {
|
||||
export function resetVoiceConfig(): void {
|
||||
configPromise = null;
|
||||
loadedConfig = null;
|
||||
serverMode = undefined;
|
||||
browserAllowed = true;
|
||||
preference = '';
|
||||
}
|
||||
|
||||
// Containers in order of preference; Safari only records MP4/AAC
|
||||
|
||||
Reference in new issue
Block a user