Files
ICON-CRM/src/components/settings/WhisperSettings.tsx
T
2026-09-14 16:55:38 +02:00

251 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { ConfirmDialog } from '../ui/Modal';
import { labelClass } from '../ui/form';
import { useToast } from '../Toast';
import { useRecorder } from '../../lib/dictation';
import { WHISPER_MODEL, useWhisper } from '../../lib/whisper';
import { fetchModelSize, formatSize } from '../../lib/whisperModels';
/*
* Reiter "Diktat" in den Einstellungen - für jede Rolle sichtbar, denn jeder
* richtet das Diktat auf seinem eigenen Gerät ein.
*
* Einzustellen gibt es hier bewusst nichts: Modell, Genauigkeit, Rechenweg und
* Sprache stehen fest (siehe lib/whisper.tsx). Übrig bleibt, was wirklich
* gebraucht wird - einmal herunterladen, je Sitzung einmal aktivieren. Und der
* Weg, es wieder loszuwerden.
*/
export const WhisperSettings: React.FC = () => {
const { toast } = useToast();
const {
status,
phase,
progress,
progressLabel,
error,
isCached,
capabilities,
load,
clearCache,
isSupported,
} = useWhisper();
const [testText, setTestText] = useState('');
const [askClear, setAskClear] = useState(false);
/** Downloadgröße, direkt bei Hugging Face erfragt. */
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchModelSize(WHISPER_MODEL)
.then((size) => {
if (!cancelled && size) setSizeLabel(formatSize(size.totalBytes));
})
.catch(() => {
// Ohne Angabe läuft es genauso - nur ohne Zahl im Text.
});
return () => {
cancelled = true;
};
}, []);
const recorder = useRecorder(
(text) => {
setTestText(text);
toast('Erkennung abgeschlossen.', 'success');
},
(message) => toast(message, 'error')
);
const handleLoad = async () => {
try {
await load();
toast('Diktat ist bereit.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
}
};
const isLoading = status === 'loading';
return (
<div className="space-y-5">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="text-base font-semibold text-ink-900">Diktat mit Whisper</h2>
<p className="mt-2 max-w-3xl text-sm text-ink-500">
Die Spracherkennung läuft vollständig in diesem Browser. Aufnahmen verlassen das Gerät
nicht und werden nirgends gespeichert – weder auf dem ICON-Server noch bei einem
Dienstleister.
</p>
<p className="mt-2 max-w-3xl text-sm text-ink-500">
Der Download fällt einmal je Browser an; das Modell bleibt danach hier liegen. Aktiviert
wird es bei jeder Anmeldung mit einem Klick – das geschieht bewusst nicht von selbst:
das Einrichten der Erkennung beansprucht für einige Sekunden die Grafikkarte, und das
soll niemanden mitten in der Arbeit treffen.
</p>
</section>
{/* Zustand und die beiden Aktionen */}
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-base font-semibold text-ink-900">
{status === 'ready'
? 'Diktat ist bereit'
: isLoading
? phase === 'init'
? 'Wird eingerichtet …'
: 'Wird geladen …'
: status === 'error'
? 'Laden fehlgeschlagen'
: 'Diktat noch nicht eingerichtet'}
</h3>
<p className="text-sm text-ink-500">
{status === 'ready'
? 'Die Mikrofon-Knöpfe in den Formularen sind aktiv.'
: isLoading
? phase === 'init'
? 'Gleich fertig.'
: progressLabel || 'Download startet …'
: isCached
? 'Das Modell liegt in diesem Browser – ein Klick aktiviert es für diese Sitzung.'
: `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen – danach bleibt es in diesem Browser.`}
</p>
</div>
<div className="flex flex-wrap gap-2">
{status !== 'ready' && (
<button
type="button"
onClick={() => void handleLoad()}
disabled={isLoading || !isSupported}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50"
>
{isLoading
? 'Lädt …'
: isCached
? 'Diktat aktivieren'
: `Sprachmodell laden${sizeLabel ? ` (${sizeLabel})` : ''}`}
</button>
)}
{(isCached || status === 'ready') && (
<button
type="button"
onClick={() => setAskClear(true)}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:border-red-300 hover:text-red-600"
>
Sprachmodell löschen
</button>
)}
</div>
</div>
{isLoading && (
<p className="mt-4 rounded-lg border border-ink-200 bg-ink-50 px-4 py-3 text-sm text-ink-600">
Während das Modell eingerichtet wird, kann die Oberfläche kurz stocken. Das legt sich,
sobald oben „Diktat ist bereit" steht.
</p>
)}
{isLoading && (
<div className="mt-4 h-2 overflow-hidden rounded-full bg-ink-100">
<div
className="h-full rounded-full bg-brand-600 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
)}
{error && (
<p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
{error}
</p>
)}
{/* Nur melden, was den Benutzer betrifft - sonst Stille. */}
{!isSupported && (
<p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
Auf diesem Gerät ist kein Diktat möglich – es fehlt ein Mikrofon oder eine sichere
Verbindung. Die Mikrofon-Knöpfe bleiben ausgeblendet.
</p>
)}
{isSupported && !capabilities.webgpu && (
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Dieses Gerät hat keine nutzbare Grafikbeschleunigung. Das Diktat funktioniert, die
Erkennung dauert aber deutlich länger.
</p>
)}
{status === 'ready' && !isCached && (
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Dieser Browser hat das Modell nicht behalten – beim nächsten Start muss es erneut
geladen werden. Ursache ist meist ein privates Fenster oder eine
Speicherbeschränkung.
</p>
)}
</section>
{/* Probe */}
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="text-base font-semibold text-ink-900">Probe sprechen</h3>
<p className="mt-1 text-sm text-ink-500">
Kurz etwas diktieren, um zu prüfen, ob alles sitzt.
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={recorder.state === 'recording' ? recorder.stop : () => void recorder.start()}
disabled={status !== 'ready' || recorder.state === 'transcribing'}
className={`rounded-lg px-4 py-2 text-sm font-semibold disabled:opacity-50 ${
recorder.state === 'recording'
? 'border border-red-300 bg-red-50 text-red-700'
: 'bg-brand-600 text-white hover:bg-brand-700'
}`}
>
{recorder.state === 'recording'
? `Aufnahme beenden (${recorder.seconds} s)`
: recorder.state === 'transcribing'
? 'Erkenne …'
: 'Aufnahme starten'}
</button>
{recorder.state === 'recording' && (
<button
type="button"
onClick={recorder.cancel}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100"
>
Verwerfen
</button>
)}
{status !== 'ready' && (
<span className="text-xs font-medium text-ink-400">Erst das Sprachmodell laden.</span>
)}
</div>
<div className="mt-4">
<label className={labelClass}>Erkannter Text</label>
<p className="min-h-16 whitespace-pre-wrap rounded-lg border border-ink-200 bg-ink-50 px-4 py-3 text-sm text-ink-700">
{testText || <span className="text-ink-400">Noch keine Probe.</span>}
</p>
</div>
</section>
<ConfirmDialog
open={askClear}
title="Sprachmodell löschen?"
message="Das Modell wird aus diesem Browser entfernt. Für ein weiteres Diktat muss es erneut geladen werden."
confirmLabel="Löschen"
onConfirm={async () => {
await clearCache();
setAskClear(false);
toast('Sprachmodell entfernt.', 'success');
}}
onCancel={() => setAskClear(false)}
/>
</div>
);
};