Quellcode des ICON CRM (5)
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user