Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
/**
|
|
* Whisper laeuft im Worker, nicht im Hauptthread: die Erkennung rechnet je nach
|
|
* Modell mehrere Sekunden, und solange darf die Oberflaeche nicht einfrieren.
|
|
*
|
|
* Das Modell wird beim ersten Laden vom Hugging-Face-CDN geholt und danach im
|
|
* Browser-Cache gehalten. Die Aufnahme selbst verlaesst das Geraet nie.
|
|
*/
|
|
import { pipeline, env, type AutomaticSpeechRecognitionPipeline } from '@huggingface/transformers';
|
|
|
|
// Ohne das versucht transformers.js zuerst einen lokalen Pfad und laeuft in
|
|
// unnoetige 404er.
|
|
env.allowLocalModels = false;
|
|
env.allowRemoteModels = true;
|
|
|
|
export type WorkerRequest =
|
|
| { type: 'load'; model: string; device: 'webgpu' | 'wasm'; dtype: string }
|
|
| { type: 'transcribe'; id: number; audio: Float32Array; language: string };
|
|
|
|
export type WorkerResponse =
|
|
| { type: 'progress'; loaded: number; total: number; file: string }
|
|
| { type: 'ready'; ms: number; downloadedBytes: number }
|
|
| { type: 'result'; id: number; text: string; ms: number }
|
|
| { type: 'error'; id?: number; message: string };
|
|
|
|
/**
|
|
* `pipeline()` ist ueber alle Aufgabenarten hinweg typisiert; TypeScript kann
|
|
* die entstehende Union nicht mehr darstellen ("union type too complex").
|
|
* Deshalb hier eine schmale Signatur nur fuer die Spracherkennung.
|
|
*/
|
|
type CreatePipeline = (
|
|
task: 'automatic-speech-recognition',
|
|
model: string,
|
|
options: Record<string, unknown>
|
|
) => Promise<AutomaticSpeechRecognitionPipeline>;
|
|
|
|
const createPipeline = pipeline as unknown as CreatePipeline;
|
|
|
|
let transcriber: AutomaticSpeechRecognitionPipeline | null = null;
|
|
/** Groesse je Datei, damit der Fortschritt ueber alle Dateien stimmt. */
|
|
const files = new Map<string, { loaded: number; total: number }>();
|
|
|
|
const post = (message: WorkerResponse) => self.postMessage(message);
|
|
|
|
async function load(model: string, device: 'webgpu' | 'wasm', dtype: string) {
|
|
files.clear();
|
|
const started = performance.now();
|
|
|
|
transcriber = await createPipeline('automatic-speech-recognition', model, {
|
|
device,
|
|
dtype,
|
|
progress_callback: (event: {
|
|
status: string;
|
|
file?: string;
|
|
loaded?: number;
|
|
total?: number;
|
|
}) => {
|
|
if (event.status !== 'progress' || !event.file) return;
|
|
files.set(event.file, { loaded: event.loaded ?? 0, total: event.total ?? 0 });
|
|
let loaded = 0;
|
|
let total = 0;
|
|
for (const entry of files.values()) {
|
|
loaded += entry.loaded;
|
|
total += entry.total;
|
|
}
|
|
post({ type: 'progress', loaded, total, file: event.file });
|
|
},
|
|
});
|
|
|
|
let downloadedBytes = 0;
|
|
for (const entry of files.values()) downloadedBytes += entry.total;
|
|
|
|
post({ type: 'ready', ms: Math.round(performance.now() - started), downloadedBytes });
|
|
}
|
|
|
|
async function transcribe(id: number, audio: Float32Array, language: string) {
|
|
if (!transcriber) throw new Error('Das Modell ist nicht geladen.');
|
|
|
|
const started = performance.now();
|
|
const output = await transcriber(audio, {
|
|
language,
|
|
task: 'transcribe',
|
|
// Laengere Aufnahmen werden in Fenster zerlegt; der Ueberlappungsbereich
|
|
// verhindert abgeschnittene Woerter an den Grenzen.
|
|
chunk_length_s: 30,
|
|
stride_length_s: 5,
|
|
});
|
|
|
|
const text = Array.isArray(output)
|
|
? output.map((part) => part.text ?? '').join(' ')
|
|
: (output.text ?? '');
|
|
|
|
post({ type: 'result', id, text: text.trim(), ms: Math.round(performance.now() - started) });
|
|
}
|
|
|
|
/**
|
|
* Beim Anlegen der ONNX-Sitzung koennen Fehler ausserhalb der await-Kette
|
|
* auftreten - etwa "Array buffer allocation failed", wenn das Modell zu gross
|
|
* fuer einen zusammenhaengenden Speicherblock ist. Ohne diese Handler bleibt
|
|
* das Lade-Promise fuer immer offen und die Oberflaeche haengt bei "Laedt...".
|
|
*/
|
|
function reportFatal(reason: unknown) {
|
|
const raw = reason instanceof Error ? reason.message : String(reason);
|
|
const isMemory = /allocation failed|out of memory|Array buffer/i.test(raw);
|
|
post({
|
|
type: 'error',
|
|
message: isMemory
|
|
? `Der Speicher reicht fuer dieses Modell in dieser Genauigkeit nicht (${raw}). Bitte eine kleinere Genauigkeit wie q4 waehlen.`
|
|
: raw,
|
|
});
|
|
}
|
|
|
|
self.addEventListener('error', (event) => {
|
|
event.preventDefault();
|
|
reportFatal(event.error ?? event.message);
|
|
});
|
|
|
|
self.addEventListener('unhandledrejection', (event) => {
|
|
event.preventDefault();
|
|
reportFatal(event.reason);
|
|
});
|
|
|
|
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
|
const request = event.data;
|
|
try {
|
|
if (request.type === 'load') {
|
|
await load(request.model, request.device, request.dtype);
|
|
} else {
|
|
await transcribe(request.id, request.audio, request.language);
|
|
}
|
|
} catch (err) {
|
|
post({
|
|
type: 'error',
|
|
id: request.type === 'transcribe' ? request.id : undefined,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
};
|