Merge dev into main (v0.42.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
This commit is contained in:
commit
9ab16c67a1
21 files changed
+283
-10
No files matched your search
@@ -137,6 +137,10 @@ STT_LANGUAGE=de
|
|||||||
STT_API_KEY=
|
STT_API_KEY=
|
||||||
# true = Mikrofon überall ausblenden, auch die Browser-Erkennung.
|
# true = Mikrofon überall ausblenden, auch die Browser-Erkennung.
|
||||||
STT_DISABLED=false
|
STT_DISABLED=false
|
||||||
|
# false = die Spracherkennung des Browsers ist verboten (Chrome/Edge schicken die Aufnahme an Google
|
||||||
|
# bzw. Microsoft). Mit Whisper-Server entfällt dann die Auswahl Whisper/Browser in den Kontoeinstellungen;
|
||||||
|
# ohne Whisper-Server gibt es dann keine Spracheingabe.
|
||||||
|
STT_ALLOW_BROWSER=true
|
||||||
|
|
||||||
# Schutzwerte für den Server-Modus
|
# Schutzwerte für den Server-Modus
|
||||||
# Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst.
|
# Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst.
|
||||||
|
|||||||
+8
-1
@@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.42.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Spracheingabe pro Person: Mit eigenem Whisper-Server wählt jede Person in den Kontoeinstellungen unter „Spracheingabe“, ob ihr Mikrofon den Whisper-Server (Standard) oder die Erkennung des Browsers nutzt. Die Wahl gilt sofort auf allen Geräten.
|
||||||
|
- `STT_ALLOW_BROWSER=false` verbietet die Spracherkennung des Browsers (Chrome und Edge schicken die Aufnahme an Google bzw. Microsoft), z. B. für den Einsatz in Firmen. Mit Whisper-Server entfällt dann die Auswahl, ohne Whisper-Server gibt es dann keine Spracheingabe.
|
||||||
|
|
||||||
## [0.41.0] - 2026-11-01
|
## [0.41.0] - 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -686,7 +692,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...dev
|
||||||
|
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
||||||
[0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0
|
[0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0
|
||||||
[0.40.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...v0.40.1
|
[0.40.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...v0.40.1
|
||||||
[0.40.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...v0.40.0
|
[0.40.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...v0.40.0
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
||||||
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
|
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
|
||||||
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
|
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
|
||||||
|
| `STT_ALLOW_BROWSER` | `true` | `false` verbietet die Spracherkennung des Browsers (Chrome/Edge schicken die Aufnahme an Google/Microsoft). Mit Whisper-Server entfällt dann die Auswahl „Whisper-Server / Browser“ in den Kontoeinstellungen; ohne Whisper-Server (und in der Demo) gibt es dann keine Spracheingabe. Mit `true` wählt jede Person selbst, Standard ist der Whisper-Server. |
|
||||||
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
|
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
|
||||||
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
|
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
|
||||||
| `STT_VAD_FILTER` | `true` | Bittet den Whisper-Server, Stille zu überspringen (`vad_filter`, speaches); weniger erfundene Wiederholungen. `false`, falls ein Server das Feld ablehnt. Unabhängig davon entfernt MindSift eine wörtlich wiederholte Schlusspassage. |
|
| `STT_VAD_FILTER` | `true` | Bittet den Whisper-Server, Stille zu überspringen (`vad_filter`, speaches); weniger erfundene Wiederholungen. `false`, falls ein Server das Feld ablehnt. Unabhängig davon entfernt MindSift eine wörtlich wiederholte Schlusspassage. |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ services:
|
|||||||
- STT_LANGUAGE=${STT_LANGUAGE:-de}
|
- STT_LANGUAGE=${STT_LANGUAGE:-de}
|
||||||
- STT_API_KEY=${STT_API_KEY:-}
|
- STT_API_KEY=${STT_API_KEY:-}
|
||||||
- STT_DISABLED=${STT_DISABLED:-false}
|
- STT_DISABLED=${STT_DISABLED:-false}
|
||||||
|
- STT_ALLOW_BROWSER=${STT_ALLOW_BROWSER:-true}
|
||||||
- STT_MAX_SECONDS=${STT_MAX_SECONDS:-600}
|
- STT_MAX_SECONDS=${STT_MAX_SECONDS:-600}
|
||||||
- STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25}
|
- STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25}
|
||||||
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
|
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.41.0",
|
"version": "0.42.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.41.0",
|
"version": "0.42.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.41.0",
|
"version": "0.42.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { useUpdateCheck } from './lib/useUpdateCheck';
|
|||||||
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
import { readViewSession, writeViewSession } from './lib/viewSession';
|
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;
|
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
|
||||||
type ActiveTab = (typeof APP_TABS)[number];
|
type ActiveTab = (typeof APP_TABS)[number];
|
||||||
@@ -172,6 +173,8 @@ export const App: React.FC = () => {
|
|||||||
return tab && APP_TABS.includes(tab) ? tab : 'triage';
|
return tab && APP_TABS.includes(tab) ? tab : 'triage';
|
||||||
});
|
});
|
||||||
useEffect(() => writeViewSession('app', { tab: activeTab }), [activeTab]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (user && user.role !== 'admin' && activeTab === 'admin_settings') setActiveTab('triage');
|
if (user && user.role !== 'admin' && activeTab === 'admin_settings') setActiveTab('triage');
|
||||||
}, [user, activeTab]);
|
}, [user, activeTab]);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Copy,
|
Copy,
|
||||||
Archive,
|
Archive,
|
||||||
|
Mic,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
|
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
|
||||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||||
@@ -25,6 +26,7 @@ import { ServerBackupsCard } from './ServerBackupsCard';
|
|||||||
import { saveBlob } from '../../lib/download';
|
import { saveBlob } from '../../lib/download';
|
||||||
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||||
import { passwordProblem } from '../../lib/password';
|
import { passwordProblem } from '../../lib/password';
|
||||||
|
import { loadVoiceConfig, voiceChoiceAvailable } from '../../lib/voice';
|
||||||
import {
|
import {
|
||||||
AVAILABLE_THEMES,
|
AVAILABLE_THEMES,
|
||||||
CornerStyle,
|
CornerStyle,
|
||||||
@@ -66,7 +68,7 @@ interface UserSettingsViewProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks' | 'data';
|
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'webhooks' | 'data';
|
||||||
|
|
||||||
export function buildNotificationSettings(user: User): NotificationSettings {
|
export function buildNotificationSettings(user: User): NotificationSettings {
|
||||||
const hasLegacy = Array.isArray(user.alarmChannels);
|
const hasLegacy = Array.isArray(user.alarmChannels);
|
||||||
@@ -132,6 +134,20 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
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']>();
|
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
||||||
|
|
||||||
// --- Danger zone ---
|
// --- Danger zone ---
|
||||||
@@ -642,6 +658,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setSection('webhooks')}
|
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 ${
|
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>
|
</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 */}
|
{/* 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;
|
notifyBeforeMinutes?: number;
|
||||||
notificationSettings?: NotificationSettings;
|
notificationSettings?: NotificationSettings;
|
||||||
doneRetentionDays?: number;
|
doneRetentionDays?: number;
|
||||||
|
voiceInput?: '' | 'browser';
|
||||||
}): Promise<User> {
|
}): Promise<User> {
|
||||||
return request<User>('/api/v1/auth/settings', {
|
return request<User>('/api/v1/auth/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface AuthStatus {
|
|||||||
customLinkUrl?: string;
|
customLinkUrl?: string;
|
||||||
};
|
};
|
||||||
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
|
// 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';
|
export type VoiceMode = 'server' | 'browser' | 'off';
|
||||||
@@ -113,6 +113,8 @@ export interface User {
|
|||||||
notificationSettings?: NotificationSettings;
|
notificationSettings?: NotificationSettings;
|
||||||
// Completed tasks are deleted this many days after ticking them off (0 = never)
|
// Completed tasks are deleted this many days after ticking them off (0 = never)
|
||||||
doneRetentionDays?: number;
|
doneRetentionDays?: number;
|
||||||
|
// Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition
|
||||||
|
voiceInput?: '' | 'browser';
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { api } from './api';
|
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';
|
export type VoiceState = 'idle' | 'listening' | 'transcribing';
|
||||||
|
|
||||||
@@ -63,12 +63,17 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
|
const load = () =>
|
||||||
void loadVoiceConfig().then((cfg) => {
|
void loadVoiceConfig().then((cfg) => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setMode(cfg.mode);
|
setMode(cfg.mode);
|
||||||
maxSecondsRef.current = cfg.maxSeconds;
|
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 () => {
|
return () => {
|
||||||
|
window.removeEventListener(VOICE_CONFIG_EVENT, load);
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
clearAllTimers();
|
clearAllTimers();
|
||||||
recorderRef.current?.cancel();
|
recorderRef.current?.cancel();
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// server record with MediaRecorder, the own Whisper server turns it into text after stopping
|
// 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)
|
// browser the browser's speech recognition (Chrome, Edge, Safari; text appears while speaking)
|
||||||
// off no voice input
|
// 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 { api } from './api';
|
||||||
import type { VoiceMode } from './types';
|
import type { VoiceMode } from './types';
|
||||||
|
|
||||||
@@ -15,6 +17,33 @@ export interface VoiceConfig {
|
|||||||
|
|
||||||
let configPromise: Promise<VoiceConfig> | null = null;
|
let configPromise: Promise<VoiceConfig> | null = null;
|
||||||
let loadedConfig: 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 {
|
export function browserRecognition(): any {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
@@ -38,7 +67,9 @@ export function loadVoiceConfig(): Promise<VoiceConfig> {
|
|||||||
configPromise = api
|
configPromise = api
|
||||||
.getAuthStatus()
|
.getAuthStatus()
|
||||||
.then((st) => {
|
.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;
|
return loadedConfig;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -58,6 +89,9 @@ export function currentVoiceConfig(): VoiceConfig {
|
|||||||
export function resetVoiceConfig(): void {
|
export function resetVoiceConfig(): void {
|
||||||
configPromise = null;
|
configPromise = null;
|
||||||
loadedConfig = null;
|
loadedConfig = null;
|
||||||
|
serverMode = undefined;
|
||||||
|
browserAllowed = true;
|
||||||
|
preference = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Containers in order of preference; Safari only records MP4/AAC
|
// Containers in order of preference; Safari only records MP4/AAC
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"sharingMode": s.store.SharingMode(),
|
"sharingMode": s.store.SharingMode(),
|
||||||
"passwordReset": s.passwordResetAvailable(),
|
"passwordReset": s.passwordResetAvailable(),
|
||||||
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
|
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
|
||||||
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds},
|
// browserAllowed: whether people may pick the browser's recognition instead of the Whisper server
|
||||||
|
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds, "browserAllowed": !s.cfg.Speech.NoBrowser},
|
||||||
// Public so the login screen can link Impressum and Datenschutz
|
// Public so the login screen can link Impressum and Datenschutz
|
||||||
"legal": map[string]interface{}{
|
"legal": map[string]interface{}{
|
||||||
"imprintUrl": s.cfg.Legal.ImprintURL,
|
"imprintUrl": s.cfg.Legal.ImprintURL,
|
||||||
@@ -404,6 +405,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
ns := s.store.NotificationSettingsOf(userID)
|
ns := s.store.NotificationSettingsOf(userID)
|
||||||
user.NotificationSettings = &ns
|
user.NotificationSettings = &ns
|
||||||
user.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
user.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
||||||
|
user.VoiceInput = s.store.VoiceInput(userID)
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"user": user,
|
"user": user,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
@@ -515,6 +517,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
NotificationSettings *domain.NotificationSettings `json:"notificationSettings"`
|
NotificationSettings *domain.NotificationSettings `json:"notificationSettings"`
|
||||||
// Days after which completed tasks are deleted (done_cleanup.go)
|
// Days after which completed tasks are deleted (done_cleanup.go)
|
||||||
DoneRetentionDays *int `json:"doneRetentionDays"`
|
DoneRetentionDays *int `json:"doneRetentionDays"`
|
||||||
|
// Voice input: "" (Whisper server, the default) or "browser"
|
||||||
|
VoiceInput *string `json:"voiceInput"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
@@ -607,9 +611,16 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.VoiceInput != nil {
|
||||||
|
if err := s.store.SetVoiceInput(userID, *req.VoiceInput); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
ns := s.store.NotificationSettingsOf(userID)
|
ns := s.store.NotificationSettingsOf(userID)
|
||||||
u.NotificationSettings = &ns
|
u.NotificationSettings = &ns
|
||||||
u.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
u.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
||||||
|
u.VoiceInput = s.store.VoiceInput(userID)
|
||||||
writeJSON(w, http.StatusOK, u)
|
writeJSON(w, http.StatusOK, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ type SpeechConfig struct {
|
|||||||
Language string
|
Language string
|
||||||
APIKey string
|
APIKey string
|
||||||
Disabled bool // STT_DISABLED=true hides voice input everywhere
|
Disabled bool // STT_DISABLED=true hides voice input everywhere
|
||||||
|
NoBrowser bool // STT_ALLOW_BROWSER=false forbids the browser's recognition (it sends audio to Google/Microsoft)
|
||||||
MaxSeconds int // recordings stop after this long (enforced in the browser)
|
MaxSeconds int // recordings stop after this long (enforced in the browser)
|
||||||
MaxUploadMB int
|
MaxUploadMB int
|
||||||
MaxParallel int // transcriptions running at the same time; more wait in line
|
MaxParallel int // transcriptions running at the same time; more wait in line
|
||||||
@@ -102,6 +103,8 @@ func (s SpeechConfig) Mode(demo bool) string {
|
|||||||
return "off"
|
return "off"
|
||||||
case s.URL != "" && !demo:
|
case s.URL != "" && !demo:
|
||||||
return "server"
|
return "server"
|
||||||
|
case s.NoBrowser:
|
||||||
|
return "off"
|
||||||
default:
|
default:
|
||||||
return "browser"
|
return "browser"
|
||||||
}
|
}
|
||||||
@@ -266,6 +269,7 @@ func Load() *Config {
|
|||||||
Language: getEnv("STT_LANGUAGE", "de"),
|
Language: getEnv("STT_LANGUAGE", "de"),
|
||||||
APIKey: getEnv("STT_API_KEY", ""),
|
APIKey: getEnv("STT_API_KEY", ""),
|
||||||
Disabled: strings.ToLower(getEnv("STT_DISABLED", "false")) == "true",
|
Disabled: strings.ToLower(getEnv("STT_DISABLED", "false")) == "true",
|
||||||
|
NoBrowser: strings.ToLower(getEnv("STT_ALLOW_BROWSER", "true")) == "false",
|
||||||
MaxSeconds: envInt("STT_MAX_SECONDS", 600, 5, 3600),
|
MaxSeconds: envInt("STT_MAX_SECONDS", 600, 5, 3600),
|
||||||
MaxUploadMB: envInt("STT_MAX_UPLOAD_MB", 25, 1, 500),
|
MaxUploadMB: envInt("STT_MAX_UPLOAD_MB", 25, 1, 500),
|
||||||
MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32),
|
MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32),
|
||||||
|
|||||||
@@ -120,3 +120,24 @@ func TestCustomLinkValidation(t *testing.T) {
|
|||||||
t.Errorf("expected empty custom link when url is empty, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
t.Errorf("expected empty custom link when url is empty, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// STT_ALLOW_BROWSER=false: without a Whisper server (or in the demo) there is no voice input at all
|
||||||
|
func TestSpeechModeWithoutBrowser(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
cfg SpeechConfig
|
||||||
|
demo bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{SpeechConfig{URL: "http://whisper:8000"}, false, "server"},
|
||||||
|
{SpeechConfig{URL: "http://whisper:8000", NoBrowser: true}, false, "server"},
|
||||||
|
{SpeechConfig{}, false, "browser"},
|
||||||
|
{SpeechConfig{NoBrowser: true}, false, "off"},
|
||||||
|
{SpeechConfig{URL: "http://whisper:8000", NoBrowser: true}, true, "off"},
|
||||||
|
{SpeechConfig{URL: "http://whisper:8000", Disabled: true}, false, "off"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := c.cfg.Mode(c.demo); got != c.want {
|
||||||
|
t.Errorf("%+v demo=%v: got %q, want %q", c.cfg, c.demo, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,8 @@ type User struct {
|
|||||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
||||||
// DoneRetentionDays: completed tasks are deleted this many days after ticking them off (0 = never)
|
// DoneRetentionDays: completed tasks are deleted this many days after ticking them off (0 = never)
|
||||||
DoneRetentionDays int `json:"doneRetentionDays"`
|
DoneRetentionDays int `json:"doneRetentionDays"`
|
||||||
|
// VoiceInput: "" uses the server's Whisper (when set up), "browser" the browser's recognition
|
||||||
|
VoiceInput string `json:"voiceInput"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
|
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ var migrations = []migration{
|
|||||||
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
|
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
|
||||||
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
||||||
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
|
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
|
||||||
|
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Everyone picks how their voice input works: the server's Whisper ("", the default) or the browser's
|
||||||
|
// recognition ("browser"). The server only offers the choice when it has a Whisper server and the
|
||||||
|
// browser is allowed (STT_ALLOW_BROWSER); the app applies it (lib/voice.ts).
|
||||||
|
|
||||||
|
var ErrInvalidVoiceInput = errors.New("Ungültige Spracheingabe (erlaubt: Whisper-Server oder Browser)")
|
||||||
|
|
||||||
|
func (s *Store) migrateVoiceInput() error {
|
||||||
|
return s.addColumnIfMissing("users", "voice_input", "TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceInput returns the user's choice: "" or "browser".
|
||||||
|
func (s *Store) VoiceInput(userID string) string {
|
||||||
|
var v string
|
||||||
|
_ = s.db.QueryRow(`SELECT voice_input FROM users WHERE id = ?`, userID).Scan(&v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetVoiceInput(userID, v string) error {
|
||||||
|
if v != "" && v != "browser" {
|
||||||
|
return ErrInvalidVoiceInput
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`UPDATE users SET voice_input = ? WHERE id = ?`, v, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVoiceInputChoice(t *testing.T) {
|
||||||
|
store, err := NewStore(filepath.Join(t.TempDir(), "voice.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
if v := store.VoiceInput(anna.ID); v != "" {
|
||||||
|
t.Fatalf("default should be the Whisper server (\"\"), got %q", v)
|
||||||
|
}
|
||||||
|
if err := store.SetVoiceInput(anna.ID, "browser"); err != nil || store.VoiceInput(anna.ID) != "browser" {
|
||||||
|
t.Fatalf("browser not stored: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetVoiceInput(anna.ID, "google"); err == nil {
|
||||||
|
t.Fatal("unknown values must be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user