251 lines
9.7 KiB
TypeScript
251 lines
9.7 KiB
TypeScript
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>
|
||
);
|
||
};
|