Quellcode des ICON CRM (5)

This commit is contained in:
2026-09-14 16:55:38 +02:00
parent e383400d7c
commit 4fcb34f324
25 changed files with 7616 additions and 0 deletions
+250
View File
@@ -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>
);
};