feat: Projekt Scanner integrieren, Whisper in die Einstellungen

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>
This commit is contained in:
Sascha Wolf
2026-08-06 15:08:59 +02:00
co-authored by Claude Opus 5
commit 82d1b7eab4
106 changed files with 24133 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
import type {
AppUser,
CalendarEvent,
Lead,
LeadConnection,
Opportunity,
Role,
} from '../types';
const TOKEN_KEY = 'perinco_crm_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
/** Wird bei 401 gefeuert, damit die App zurueck auf den Login springt. */
type UnauthorizedHandler = () => void;
let onUnauthorized: UnauthorizedHandler | null = null;
export function setUnauthorizedHandler(handler: UnauthorizedHandler | null) {
onUnauthorized = handler;
}
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
// JSON ist der Normalfall, aber nicht der einzige: der CV-Upload des Scanners
// schickt FormData, dort muss der Browser die multipart-Grenze selbst setzen.
if (options.body && !headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
const token = getToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
let response: Response;
try {
response = await fetch(`/api${path}`, { ...options, headers });
} catch {
throw new ApiError('Server nicht erreichbar. Bitte Verbindung pruefen.', 0);
}
if (response.status === 401) {
clearToken();
onUnauthorized?.();
}
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
const message =
(payload && typeof payload === 'object' && 'error' in payload && String(payload.error)) ||
`Anfrage fehlgeschlagen (HTTP ${response.status}).`;
throw new ApiError(message, response.status);
}
return payload as T;
}
/** Kurzform fuer die Aufrufe in dieser Datei. */
const request = apiRequest;
// ---------- Auth ----------
export const authApi = {
login: (identifier: string, password: string) =>
request<{ token: string; user: AppUser }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ identifier, password }),
}),
me: () => request<{ user: AppUser }>('/auth/me'),
/**
* Eigene Kalenderfarbe und eigenes Foto setzen - ohne Adminrechte.
* null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die Initiale.
*/
updateMe: (input: { color?: string | null; avatar?: string | null }) =>
request<{ user: AppUser }>('/auth/me', { method: 'PATCH', body: JSON.stringify(input) }),
changePassword: (current_password: string, new_password: string) =>
request<{ ok: true }>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password, new_password }),
}),
};
// ---------- Benutzerverwaltung (nur admin) ----------
export interface UserInput {
username: string;
name: string;
email?: string | null;
password?: string;
role?: Role;
color?: string | null;
avatar?: string | null;
is_active?: boolean;
}
export const usersApi = {
list: () => request<AppUser[]>('/users'),
/**
* Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Der Kalender braucht
* die Liste fuer @-Mentions, Teilnehmer und Farben, auch fuer Rolle "user".
*/
directory: () => request<AppUser[]>('/users/directory'),
create: (input: UserInput) =>
request<AppUser>('/users', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: Partial<UserInput>) =>
request<AppUser>(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/users/${id}`, { method: 'DELETE' }),
};
// ---------- Leads ----------
export type LeadInput = Partial<Omit<Lead, 'id' | 'created_at' | 'updated_at'>>;
export const leadsApi = {
list: () => request<Lead[]>('/leads'),
create: (input: LeadInput) =>
request<Lead>('/leads', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: LeadInput) =>
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }),
};
// ---------- Opportunities ----------
export type OpportunityInput = Partial<
Omit<Opportunity, 'id' | 'created_at' | 'updated_at' | 'contacts'>
> & {
/** Ansprechpartner werden immer komplett ersetzt. */
contacts?: { lead_id: number; role?: string | null }[];
};
export const opportunitiesApi = {
list: () => request<Opportunity[]>('/opportunities'),
create: (input: OpportunityInput) =>
request<Opportunity>('/opportunities', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: OpportunityInput) =>
request<Opportunity>(`/opportunities/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
};
// ---------- Kalender ----------
export interface EventInput {
title: string;
description?: string | null;
location?: string | null;
/** `YYYY-MM-DDTHH:MM` oder `YYYY-MM-DD HH:MM:SS` */
starts_at: string;
ends_at: string;
all_day?: boolean;
participant_ids?: number[];
}
export const eventsApi = {
list: () => request<CalendarEvent[]>('/events'),
create: (input: EventInput) =>
request<CalendarEvent>('/events', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: Partial<EventInput>) =>
request<CalendarEvent>(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/events/${id}`, { method: 'DELETE' }),
};
// ---------- Verbindungen ----------
export interface ConnectionInput {
from_lead_id: number;
to_lead_id: number;
type?: LeadConnection['type'];
label?: string | null;
strength?: number;
}
export const connectionsApi = {
list: () => request<LeadConnection[]>('/connections'),
create: (input: ConnectionInput) =>
request<LeadConnection>('/connections', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: Partial<Omit<ConnectionInput, 'from_lead_id' | 'to_lead_id'>>) =>
request<LeadConnection>(`/connections/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/connections/${id}`, { method: 'DELETE' }),
};
+43
View File
@@ -0,0 +1,43 @@
/**
* Whisper erwartet Mono-PCM mit 16 kHz als Float32. Der MediaRecorder liefert
* aber WebM/Opus in der Aufnahmerate der Hardware - hier wird dekodiert und
* heruntergerechnet.
*/
export const WHISPER_SAMPLE_RATE = 16000;
export interface DecodedAudio {
audio: Float32Array;
/** Laenge der Aufnahme in Sekunden. */
seconds: number;
}
export async function toWhisperAudio(blob: Blob): Promise<DecodedAudio> {
const bytes = await blob.arrayBuffer();
const AudioContextClass =
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) throw new Error('Dieser Browser kann kein Audio dekodieren.');
const context = new AudioContextClass();
let decoded: AudioBuffer;
try {
decoded = await context.decodeAudioData(bytes);
} finally {
// Nicht geschlossene Kontexte zaehlen gegen das Browserlimit.
await context.close().catch(() => {});
}
if (decoded.duration === 0) throw new Error('Die Aufnahme ist leer.');
// Resampling ueber einen OfflineAudioContext - das erledigt gleich das
// Zusammenfassen mehrerer Kanaele auf Mono.
const frames = Math.max(1, Math.ceil(decoded.duration * WHISPER_SAMPLE_RATE));
const offline = new OfflineAudioContext(1, frames, WHISPER_SAMPLE_RATE);
const source = offline.createBufferSource();
source.buffer = decoded;
source.connect(offline.destination);
source.start();
const resampled = await offline.startRendering();
return { audio: resampled.getChannelData(0), seconds: decoded.duration };
}
+83
View File
@@ -0,0 +1,83 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { AppUser } from '../types';
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler } from './api';
interface AuthContextValue {
user: AppUser | null;
isLoading: boolean;
isAdmin: boolean;
login: (identifier: string, password: string) => Promise<void>;
logout: () => void;
/** Aktualisiert den lokalen Benutzer, z.B. nach Bearbeiten des eigenen Kontos. */
updateUser: (user: AppUser) => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const logout = useCallback(() => {
clearToken();
setUser(null);
}, []);
// Ein 401 aus irgendeinem API-Call wirft den Benutzer sauber zurueck
// auf den Login, statt eine leere Ansicht zu zeigen.
useEffect(() => {
setUnauthorizedHandler(() => setUser(null));
return () => setUnauthorizedHandler(null);
}, []);
// Beim Start pruefen, ob das gespeicherte Token noch gilt.
useEffect(() => {
let cancelled = false;
const restore = async () => {
if (!getToken()) {
setIsLoading(false);
return;
}
try {
const { user: current } = await authApi.me();
if (!cancelled) setUser(current);
} catch {
clearToken();
} finally {
if (!cancelled) setIsLoading(false);
}
};
restore();
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (identifier: string, password: string) => {
const { token, user: loggedIn } = await authApi.login(identifier, password);
setToken(token);
setUser(loggedIn);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
user,
isLoading,
isAdmin: user?.role === 'admin',
login,
logout,
updateUser: setUser,
}),
[user, isLoading, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth muss innerhalb von <AuthProvider> verwendet werden.');
return ctx;
}
+142
View File
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useWhisper } from './whisper';
/**
* Aufnahme fuer das Diktat. Die Erkennung selbst macht der Whisper-Worker im
* Browser (siehe whisper.tsx) - hier geht es nur um Mikrofon, Dauer und das
* Weiterreichen der Aufnahme.
*/
/** Erstes Format, das der Browser aufnehmen kann. */
function pickMimeType(): string {
const candidates = ['audio/webm', 'audio/ogg', 'audio/mp4'];
for (const type of candidates) {
if (MediaRecorder.isTypeSupported?.(type)) return type;
}
return '';
}
type RecorderState = 'idle' | 'recording' | 'transcribing';
interface UseRecorderResult {
state: RecorderState;
/** Laufende Aufnahmedauer in Sekunden. */
seconds: number;
start: () => Promise<void>;
stop: () => void;
cancel: () => void;
}
export function useRecorder(
onText: (text: string) => void,
onError: (message: string) => void
): UseRecorderResult {
const { transcribe, status } = useWhisper();
const [state, setState] = useState<RecorderState>('idle');
const [seconds, setSeconds] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const discardRef = useRef(false);
const cleanup = useCallback(() => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
// Mikrofon freigeben, sonst bleibt die Aufnahmeanzeige des Browsers stehen.
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
setSeconds(0);
}, []);
useEffect(() => cleanup, [cleanup]);
const start = useCallback(async () => {
if (state !== 'idle') return;
if (status !== 'ready') {
onError('Das Spracherkennungsmodell ist noch nicht geladen.');
return;
}
discardRef.current = false;
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
const name = (err as DOMException)?.name;
onError(
name === 'NotAllowedError'
? 'Kein Zugriff aufs Mikrofon. Bitte im Browser erlauben.'
: name === 'NotFoundError'
? 'Es wurde kein Mikrofon gefunden.'
: 'Die Aufnahme konnte nicht gestartet werden.'
);
return;
}
const mimeType = pickMimeType();
let recorder: MediaRecorder;
try {
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
} catch {
stream.getTracks().forEach((track) => track.stop());
onError('Dieser Browser kann kein passendes Audioformat aufnehmen.');
return;
}
streamRef.current = stream;
recorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunksRef.current.push(event.data);
};
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
cleanup();
if (discardRef.current) {
setState('idle');
return;
}
if (blob.size < 1024) {
setState('idle');
onError('Die Aufnahme war zu kurz.');
return;
}
setState('transcribing');
try {
const { text } = await transcribe(blob);
if (text) onText(text);
else onError('Es war nichts Verstaendliches zu hoeren.');
} catch (err) {
onError(err instanceof Error ? err.message : 'Die Erkennung ist fehlgeschlagen.');
} finally {
setState('idle');
}
};
recorder.start();
setState('recording');
setSeconds(0);
timerRef.current = window.setInterval(() => setSeconds((value) => value + 1), 1000);
}, [state, status, cleanup, onError, onText, transcribe]);
const stop = useCallback(() => {
if (recorderRef.current?.state === 'recording') recorderRef.current.stop();
}, []);
const cancel = useCallback(() => {
discardRef.current = true;
stop();
}, [stop]);
return { state, seconds, start, stop, cancel };
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Bilder werden als Data-URL in der Datenbank gespeichert. Angezeigt werden sie
* hoechstens mit 80 px Kantenlaenge, deshalb wird vor dem Upload im Browser auf
* ein Quadrat verkleinert - das druckt ein 4-MB-Handyfoto auf wenige Kilobyte.
*/
/** Zielkantenlaenge; 256 px reicht fuer die groesste Anzeige auch auf Retina. */
const TARGET_SIZE = 256;
/** WebP ist deutlich kleiner als JPEG; ohne Unterstuetzung wird JPEG genutzt. */
const CANDIDATES: { type: string; quality: number }[] = [
{ type: 'image/webp', quality: 0.82 },
{ type: 'image/jpeg', quality: 0.82 },
];
/**
* Quelldateien werden vor dem Dekodieren begrenzt - ein 100-MB-TIFF soll den
* Browser nicht in die Knie zwingen.
*/
export const MAX_SOURCE_BYTES = 25_000_000;
/** Ergebnis der Komprimierung; die Groessen dienen der Rueckmeldung an den Benutzer. */
export interface CompressedImage {
dataUrl: string;
sourceBytes: number;
resultBytes: number;
type: string;
}
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Das Bild konnte nicht gelesen werden.'));
};
image.src = url;
});
}
/** Laenge einer Data-URL in Bytes (Base64 traegt 4 Zeichen pro 3 Bytes). */
function dataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
return Math.round((base64.length * 3) / 4);
}
/**
* Verkleinert das Bild auf ein Quadrat von TARGET_SIZE und schneidet dabei
* mittig zu (wie `object-cover` in der Anzeige), damit das Ergebnis exakt dem
* entspricht, was spaeter zu sehen ist.
*/
export async function compressImage(file: File): Promise<CompressedImage> {
if (file.size > MAX_SOURCE_BYTES) {
throw new Error('Die Datei ist zu gross (max. 25 MB).');
}
const image = await loadImage(file);
const { naturalWidth: width, naturalHeight: height } = image;
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.');
// Kleine Bilder werden nicht vergroessert.
const side = Math.min(TARGET_SIZE, width, height);
const canvas = document.createElement('canvas');
canvas.width = side;
canvas.height = side;
const context = canvas.getContext('2d');
if (!context) throw new Error('Der Browser stellt keinen Canvas bereit.');
// Mittiger Ausschnitt des kuerzeren Randes
const crop = Math.min(width, height);
context.imageSmoothingQuality = 'high';
context.drawImage(image, (width - crop) / 2, (height - crop) / 2, crop, crop, 0, 0, side, side);
for (const candidate of CANDIDATES) {
const dataUrl = canvas.toDataURL(candidate.type, candidate.quality);
// Liefert der Browser das Format nicht, faellt er still auf PNG zurueck.
if (dataUrl.startsWith(`data:${candidate.type}`)) {
return {
dataUrl,
sourceBytes: file.size,
resultBytes: dataUrlBytes(dataUrl),
type: candidate.type,
};
}
}
const png = canvas.toDataURL('image/png');
return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' };
}
/** "2,4 MB" bzw. "18 KB" */
export function formatBytes(bytes: number): string {
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1).replace('.', ',')} MB`;
return `${Math.max(1, Math.round(bytes / 1000))} KB`;
}
+30
View File
@@ -0,0 +1,30 @@
import React, { createContext, useContext } from 'react';
import type { SettingsTab, ViewState } from '../types';
/**
* Kleiner Kontext, damit tief liegende Komponenten die Ansicht wechseln koennen -
* etwa der Hinweis im Opportunity-Formular, der zu den Whisper-Einstellungen
* fuehrt. Sonst muesste die Funktion durch vier Ebenen Props gereicht werden.
*
* `tab` waehlt beim Sprung in die Einstellungen gleich den richtigen Reiter aus;
* Whisper ist genau so ein Ziel und hat deshalb keinen eigenen Menuepunkt mehr.
*/
interface NavigationContextValue {
currentView: ViewState;
goTo: (view: ViewState, options?: { tab?: SettingsTab }) => void;
}
const NavigationContext = createContext<NavigationContextValue | null>(null);
export const NavigationProvider: React.FC<{
value: NavigationContextValue;
children: React.ReactNode;
}> = ({ value, children }) => (
<NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>
);
export function useNavigation(): NavigationContextValue {
const ctx = useContext(NavigationContext);
if (!ctx) throw new Error('useNavigation muss innerhalb von <NavigationProvider> verwendet werden.');
return ctx;
}
+218
View File
@@ -0,0 +1,218 @@
import { apiRequest, getToken } from './api';
/*
* Zugriff auf den portierten Projekt Scanner. Alle Endpunkte liegen unter
* /api/scanner und haengen an derselben CRM-Anmeldung wie der Rest der App.
* Die Feldnamen sind bewusst identisch zur Scanner-Datenbank (camelCase aus
* Sequelize) - so bleibt der Umstieg vom alten Frontend nachvollziehbar.
*/
export interface ScannerAgent {
id: number;
name: string;
email: string | null;
/** Kommagetrennte Suchbegriffe */
keywords: string;
/** Kommagetrennte Ausschlussbegriffe */
excludeKeywords: string | null;
cvFilePath: string | null;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export type PortalStatus = 'connected' | 'disconnected' | 'expired' | 'error';
export interface ScannerPortal {
id: number;
name: string;
username: string | null;
hasPassword: boolean;
hasSession: boolean;
connectionStatus: PortalStatus;
lastLoginAttempt: string | null;
updatedAt?: string;
}
export interface ScannerProject {
id: number;
portal: string;
portalProjectId: string;
title: string;
description: string | null;
company: string | null;
location: string | null;
rate: string | null;
url: string;
publishedAt: string | null;
/**
* Das Project-Modell benennt die Spalte selbst um (`createdAt: 'created_at'`),
* deshalb kommt sie als snake_case aus der API.
*/
created_at?: string;
createdAt?: string;
}
export type MatchStatus = 'new' | 'reviewed' | 'applied' | 'rejected';
export interface ScannerMatch {
id: number;
agentId: number;
projectId: number;
matchPercentage: number;
pros: string[];
cons: string[];
status: MatchStatus;
notificationSent: boolean;
createdAt: string;
updatedAt?: string;
/** Aus dem Join - Sequelize haengt die Modellnamen gross an. */
Agent?: Pick<ScannerAgent, 'id' | 'name' | 'email' | 'isActive'> | null;
Project?: ScannerProject | null;
}
/** Alle Werte liegen in der DB als Text - auch Zahlen und Schalter. */
export interface ScannerSettings {
scanning_active: string;
search_interval: string;
openai_api_key: string;
match_threshold: string;
display_threshold: string;
sound_notifications_active: string;
email_notifications_active: string;
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
notification_email_recipient: string;
}
export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = {
scanning_active: 'true',
search_interval: '60',
openai_api_key: '',
match_threshold: '70',
display_threshold: '50',
sound_notifications_active: 'true',
email_notifications_active: 'false',
smtp_host: '',
smtp_port: '587',
smtp_user: '',
smtp_pass: '',
smtp_from: 'scanner@perinco.de',
notification_email_recipient: '',
};
/** Sentinel des Backends fuer gesetzte Geheimnisse. */
export const SECRET_MASK = '********';
/** Betriebszustand dieser Scanner-Instanz. */
export interface ScannerStatus {
/** Ein Suchlauf laeuft gerade (Scraping + Bewertung). */
scanRunning: boolean;
/** Hintergrund-Scheduler dieser Instanz aktiv (SCANNER_SCHEDULER). */
schedulerEnabled: boolean;
/** Hauptschalter aus den Einstellungen. */
scanningActive: boolean;
/** Name der Scanner-Datenbank - beim Parallelbetrieb hilfreich. */
database: string;
}
export interface AgentInput {
name: string;
email: string;
keywords: string;
excludeKeywords: string;
isActive: boolean;
/** Neues CV-PDF; fehlt es, bleibt das gespeicherte erhalten. */
cv?: File | null;
}
function agentFormData(input: AgentInput): FormData {
const form = new FormData();
form.append('name', input.name);
form.append('email', input.email);
form.append('keywords', input.keywords);
form.append('excludeKeywords', input.excludeKeywords);
form.append('isActive', String(input.isActive));
if (input.cv) form.append('cv', input.cv);
return form;
}
export const scannerApi = {
// ---------- Agenten ----------
agents: () => apiRequest<ScannerAgent[]>('/scanner/agents'),
createAgent: (input: AgentInput) =>
apiRequest<ScannerAgent>('/scanner/agents', { method: 'POST', body: agentFormData(input) }),
updateAgent: (id: number, input: AgentInput) =>
apiRequest<ScannerAgent>(`/scanner/agents/${id}`, { method: 'PUT', body: agentFormData(input) }),
removeAgent: (id: number) =>
apiRequest<{ message: string }>(`/scanner/agents/${id}`, { method: 'DELETE' }),
// ---------- Portale ----------
portals: () => apiRequest<ScannerPortal[]>('/scanner/portals'),
savePortalCredentials: (input: { name: string; username: string; password: string }) =>
apiRequest<{ message: string }>('/scanner/portals/credentials', {
method: 'POST',
body: JSON.stringify(input),
}),
syncPortalSession: (name: string, sessionState: unknown) =>
apiRequest<{ message: string; status: PortalStatus }>('/scanner/portals/sync-session', {
method: 'POST',
body: JSON.stringify({ name, sessionState }),
}),
testPortal: (id: number) =>
apiRequest<{ status: PortalStatus; message: string }>(
`/scanner/portals/test-connection/${id}`,
{ method: 'POST' }
),
// ---------- Matches ----------
matches: (params: { status?: MatchStatus; agentId?: number; limit?: number } = {}) => {
const query = new URLSearchParams();
if (params.status) query.set('status', params.status);
if (params.agentId) query.set('agentId', String(params.agentId));
if (params.limit) query.set('limit', String(params.limit));
const suffix = query.toString() ? `?${query}` : '';
return apiRequest<ScannerMatch[]>(`/scanner/matches${suffix}`);
},
match: (id: number) => apiRequest<ScannerMatch>(`/scanner/matches/${id}`),
setMatchStatus: (id: number, status: MatchStatus) =>
apiRequest<ScannerMatch>(`/scanner/matches/${id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
}),
removeMatch: (id: number) =>
apiRequest<{ message: string }>(`/scanner/matches/${id}`, { method: 'DELETE' }),
// ---------- Einstellungen ----------
settings: () => apiRequest<Partial<ScannerSettings>>('/scanner/settings'),
saveSettings: (input: Partial<ScannerSettings>) =>
apiRequest<{ message: string }>('/scanner/settings', {
method: 'POST',
body: JSON.stringify(input),
}),
// ---------- Scan ----------
scan: () => apiRequest<{ message: string }>('/scanner/scan', { method: 'POST' }),
status: () => apiRequest<ScannerStatus>('/scanner/status'),
};
/**
* CV-PDF anzeigen. Ein einfacher Link kaeme ohne Bearer-Token beim Backend an,
* deshalb wird die Datei geholt und als Blob in einem neuen Tab geoeffnet.
*/
export async function openAgentCv(agentId: number): Promise<void> {
const token = getToken();
const response = await fetch(`/api/scanner/agents/${agentId}/cv`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!response.ok) throw new Error('CV konnte nicht geladen werden.');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
// Der Tab haelt den Blob; nach einer Minute darf der Speicher zurueck.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
+97
View File
@@ -0,0 +1,97 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
export type Theme = 'light' | 'dark';
/** null = der Systemeinstellung folgen */
type Preference = Theme | null;
const STORAGE_KEY = 'perinco_crm_theme';
interface ThemeContextValue {
/** Tatsaechlich aktive Darstellung. */
theme: Theme;
/** Gespeicherte Wahl; null bedeutet "folgt dem System". */
preference: Preference;
/** Schaltet explizit auf das jeweils andere Thema. */
toggle: () => void;
/** Zurueck zur Systemeinstellung. */
useSystem: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function readPreference(): Preference {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === 'light' || stored === 'dark' ? stored : null;
} catch {
// Privater Modus ohne localStorage - dann eben ohne gespeicherte Wahl.
return null;
}
}
function systemTheme(): Theme {
return typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
/**
* Setzt die Klasse, an der die dunklen Farbwerte in index.css haengen. Ein
* Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame, damit
* es beim Laden nicht weiss aufblitzt.
*/
function applyTheme(theme: Theme) {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.style.colorScheme = theme;
}
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [preference, setPreference] = useState<Preference>(() => readPreference());
const [resolvedSystem, setResolvedSystem] = useState<Theme>(() => systemTheme());
const theme = preference ?? resolvedSystem;
useEffect(() => {
applyTheme(theme);
}, [theme]);
// Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort.
useEffect(() => {
const query = window.matchMedia?.('(prefers-color-scheme: dark)');
if (!query) return;
const onChange = (event: MediaQueryListEvent) =>
setResolvedSystem(event.matches ? 'dark' : 'light');
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
}, []);
const store = useCallback((next: Preference) => {
setPreference(next);
try {
if (next === null) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, next);
} catch {
// Nicht speicherbar - die Wahl gilt dann nur fuer diese Sitzung.
}
}, []);
const value = useMemo<ThemeContextValue>(
() => ({
theme,
preference,
toggle: () => store(theme === 'dark' ? 'light' : 'dark'),
useSystem: () => store(null),
}),
[theme, preference, store]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
return ctx;
}
+487
View File
@@ -0,0 +1,487 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { toWhisperAudio } from './audio';
import type { WorkerRequest, WorkerResponse } from './whisper.worker';
/**
* Diktat laeuft vollstaendig im Browser: Whisper als ONNX-Modell ueber
* transformers.js, gerechnet auf der GPU (WebGPU) oder ersatzweise auf der CPU
* (WASM). Der Server ist daran nicht beteiligt - Aufnahmen verlassen das Geraet
* nicht.
*
* Es gibt bewusst nichts einzustellen:
*
* large-v3-turbo - beste Erkennung im Deutschen, auch bei Fachbegriffen
* ("S4 HANA"). small verliert genau dort, tiny ist unbrauchbar.
* q4 - andere Genauigkeiten scheitern: q8 liefert auf der
* Grafikkarte Unsinn, fp16 blockiert den Browser-Tab, fp32
* passt nicht in einen Speicherblock.
* automatisch - Grafikkarte wenn vorhanden, sonst CPU
*
* Die Belege dazu stehen im README. Wer daran drehen will, aendert diese
* Konstanten - und prueft die Kombination vorher mit einer echten Aufnahme.
*/
export const WHISPER_MODEL = 'onnx-community/whisper-large-v3-turbo';
export const WHISPER_DTYPE = 'q4' as const;
export const WHISPER_LANGUAGE = 'de';
/**
* WebGPU steht noch nicht in den TypeScript-Standardtypen. Statt eines eigenen
* Typpakets hier nur das, was wirklich gebraucht wird.
*/
interface GpuAdapterLike {
info?: Record<string, string>;
limits?: Record<string, number>;
}
interface GpuLike {
requestAdapter(): Promise<GpuAdapterLike | null>;
}
function getGpu(): GpuLike | null {
if (typeof navigator === 'undefined') return null;
return (navigator as unknown as { gpu?: GpuLike }).gpu ?? null;
}
/** Cache, in dem transformers.js die Modelldateien ablegt. */
const MODEL_CACHE = 'transformers-cache';
/** Woran man im Cache erkennt, dass die Modelldateien schon vorliegen. */
const MODEL_FILE_PATTERN = /encoder_model[^/]*q4[^/]*\.onnx$/i;
/**
* Bittet den Browser, den Speicher dauerhaft zu behandeln.
*
* Die Modelldateien liegen im Cache-Speicher der Seite (nicht in Cookies - dort
* passen nur wenige Kilobyte). Der ueberlebt Sitzungen ohnehin, gilt ohne diese
* Bitte aber als "best effort": bei Platznot auf dem Geraet darf der Browser ihn
* verwerfen. Mit erteilter Erlaubnis raeumt er ihn nur noch auf ausdruecklichen
* Wunsch des Benutzers weg.
*
* Chrome entscheidet das nach eigenen Kriterien (u. a. wie oft die Seite genutzt
* wird) und kann ablehnen. Dann funktioniert alles genauso - nur ohne diese
* zusaetzliche Zusicherung.
*/
async function requestPersistentStorage(): Promise<boolean> {
try {
if (!navigator.storage?.persist) return false;
if (await navigator.storage.persisted()) return true;
return await navigator.storage.persist();
} catch {
return false;
}
}
/**
* Liegt das Modell schon im Browser? Wird direkt am Cache geprueft und nicht
* selbst mitgeschrieben - so wird auch erkannt, was in einer anderen Sitzung
* geladen wurde.
*/
async function detectCached(): Promise<boolean> {
try {
if (typeof caches === 'undefined') return false;
const cache = await caches.open(MODEL_CACHE);
const keys = await cache.keys();
return keys.some(
(request) => request.url.includes(`${WHISPER_MODEL}/`) && MODEL_FILE_PATTERN.test(request.url)
);
} catch {
// Cache API gesperrt (privater Modus) - dann eben ohne Erkennung.
return false;
}
}
/**
* Ohne Rueckmeldung so lange gewartet? Dann ist etwas haengen geblieben. Das
* Anlegen der ONNX-Sitzung meldet keinen Fortschritt, darf also dauern - aber
* nicht endlos.
*/
const STALL_TIMEOUT_MS = 120_000;
/**
* Grenze fuer eine einzelne Erkennung, abhaengig von der Aufnahmelaenge: auf
* der CPU ist ein Vielfaches der Aufnahmedauer normal, endlos aber nie.
*/
function transcribeTimeoutMs(seconds: number): number {
return Math.min(300_000, Math.max(45_000, Math.round(seconds * 15_000)));
}
export type WhisperStatus = 'idle' | 'loading' | 'ready' | 'error';
/** Woran gerade gearbeitet wird, solange geladen wird. */
export type WhisperPhase = 'download' | 'init' | null;
export interface WhisperCapabilities {
webgpu: boolean;
adapter: string | null;
cores: number | null;
mediaRecorder: boolean;
secureContext: boolean;
}
interface WhisperContextValue {
status: WhisperStatus;
/** Download oder Initialisierung - nur waehrend des Ladens gesetzt. */
phase: WhisperPhase;
/** 0-100, waehrend des Downloads. */
progress: number;
progressLabel: string;
error: string | null;
/** Modell liegt bereits im Browser-Cache. */
isCached: boolean;
capabilities: WhisperCapabilities;
/** Tatsaechlich genutzter Rechenweg - Grafikkarte wenn vorhanden. */
resolvedDevice: 'webgpu' | 'wasm';
load: () => Promise<void>;
unload: () => void;
clearCache: () => Promise<void>;
/** Nimmt eine Aufnahme und liefert den erkannten Text. */
transcribe: (blob: Blob) => Promise<{ text: string; ms: number; seconds: number }>;
/** Browser kann grundsaetzlich aufnehmen und rechnen. */
isSupported: boolean;
}
const WhisperContext = createContext<WhisperContextValue | null>(null);
export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [status, setStatus] = useState<WhisperStatus>('idle');
const [progress, setProgress] = useState(0);
const [progressLabel, setProgressLabel] = useState('');
const [error, setError] = useState<string | null>(null);
const [loadMs, setLoadMs] = useState<number | null>(null);
const [downloadedBytes, setDownloadedBytes] = useState<number | null>(null);
const [isCached, setIsCached] = useState(false);
const [phase, setPhase] = useState<WhisperPhase>(null);
const [capabilities, setCapabilities] = useState<WhisperCapabilities>({
webgpu: getGpu() !== null,
adapter: null,
cores: typeof navigator !== 'undefined' ? navigator.hardwareConcurrency ?? null : null,
mediaRecorder: typeof MediaRecorder !== 'undefined',
secureContext: typeof window !== 'undefined' ? window.isSecureContext : false,
});
const workerRef = useRef<Worker | null>(null);
const loadWaiterRef = useRef<{ resolve: () => void; reject: (err: Error) => void } | null>(null);
/** Zeitpunkt der letzten Worker-Nachricht - Grundlage der Haenger-Erkennung. */
const lastActivityRef = useRef(0);
const stallTimerRef = useRef<number | null>(null);
const jobsRef = useRef(
new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>()
);
const nextJobId = useRef(1);
/** Verhindert, dass nach Loeschen oder Entladen sofort neu geladen wird. */
const autoLoadTriedRef = useRef(false);
// Details zur GPU nachladen - das geht nur asynchron.
useEffect(() => {
const gpu = getGpu();
if (!gpu) return;
let cancelled = false;
(async () => {
try {
const adapter = await gpu.requestAdapter();
if (cancelled) return;
if (!adapter) {
setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfuegbar' }));
return;
}
const info = adapter.info ?? {};
const described = [info.vendor, info.architecture, info.device, info.description]
.filter(Boolean)
.join(' / ');
setCapabilities((prev) => ({ ...prev, adapter: described || 'Adapter vorhanden' }));
} catch (err) {
if (!cancelled) {
setCapabilities((prev) => ({
...prev,
webgpu: false,
adapter: err instanceof Error ? err.message : 'Abfrage fehlgeschlagen',
}));
}
}
})();
return () => {
cancelled = true;
};
}, []);
// Grafikkarte wenn verfuegbar, sonst CPU - daran gibt es nichts zu waehlen.
const resolvedDevice: 'webgpu' | 'wasm' = capabilities.webgpu ? 'webgpu' : 'wasm';
const isSupported = capabilities.mediaRecorder && capabilities.secureContext;
// Cache-Zustand am Browser erfragen, nicht selbst mitschreiben.
const refreshCacheState = useCallback(async () => {
setIsCached(await detectCached());
}, []);
useEffect(() => {
refreshCacheState();
}, [refreshCacheState, status]);
const stopStallWatch = useCallback(() => {
if (stallTimerRef.current !== null) {
window.clearInterval(stallTimerRef.current);
stallTimerRef.current = null;
}
}, []);
const ensureWorker = useCallback((): Worker => {
if (workerRef.current) return workerRef.current;
const worker = new Worker(new URL('./whisper.worker.ts', import.meta.url), { type: 'module' });
/** Bricht das Laden mit einer verwertbaren Meldung ab. */
const failLoad = (message: string) => {
stopStallWatch();
setStatus('error');
setPhase(null);
setError(message);
// Nach einem Fehlschlag ist der Worker nicht mehr vertrauenswuerdig.
workerRef.current?.terminate();
workerRef.current = null;
loadWaiterRef.current?.reject(new Error(message));
loadWaiterRef.current = null;
// Laufende Erkennungen warten sonst fuer immer auf einen toten Worker.
for (const job of jobsRef.current.values()) job.reject(new Error(message));
jobsRef.current.clear();
};
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const message = event.data;
lastActivityRef.current = Date.now();
if (message.type === 'progress') {
if (message.total > 0) {
const done = message.loaded >= message.total;
setProgress(Math.min(100, (message.loaded / message.total) * 100));
setProgressLabel(
`${Math.round(message.loaded / 1e6)} von ${Math.round(message.total / 1e6)} MB`
);
// Nach dem letzten Byte folgt das Anlegen der Rechensitzung - das
// meldet keinen Fortschritt mehr und soll trotzdem sichtbar sein.
setPhase(done ? 'init' : 'download');
}
return;
}
if (message.type === 'ready') {
stopStallWatch();
setStatus('ready');
setPhase(null);
setProgress(100);
setProgressLabel('');
loadWaiterRef.current?.resolve();
loadWaiterRef.current = null;
return;
}
if (message.type === 'result') {
jobsRef.current.get(message.id)?.resolve({ text: message.text, ms: message.ms });
jobsRef.current.delete(message.id);
return;
}
// Fehler
if (message.id !== undefined) {
jobsRef.current.get(message.id)?.reject(new Error(message.message));
jobsRef.current.delete(message.id);
} else {
failLoad(message.message);
}
};
worker.onerror = (event) => {
failLoad(event.message || 'Der Whisper-Worker ist abgestuerzt.');
};
workerRef.current = worker;
return worker;
}, [stopStallWatch]);
const load = useCallback(async () => {
if (status === 'loading') return;
if (status === 'ready') return;
setStatus('loading');
setPhase('download');
setError(null);
setProgress(0);
setProgressLabel(isCached ? 'aus dem Cache' : 'Download startet...');
// Vor dem Download um dauerhaften Speicher bitten, damit der Browser die
// Dateien danach nicht bei Platznot wieder wegraeumt.
void requestPersistentStorage();
const worker = ensureWorker();
lastActivityRef.current = Date.now();
// Haenger-Erkennung: bleibt der Worker stumm - etwa weil das Anlegen der
// Sitzung am Speicher scheitert - wird das Laden mit Meldung abgebrochen,
// statt endlos "Laedt..." zu zeigen.
stopStallWatch();
stallTimerRef.current = window.setInterval(() => {
if (Date.now() - lastActivityRef.current < STALL_TIMEOUT_MS) return;
stopStallWatch();
const message =
'Das Modell reagiert seit zwei Minuten nicht. Bitte die Seite neu laden und es erneut versuchen.';
setStatus('error');
setPhase(null);
setError(message);
workerRef.current?.terminate();
workerRef.current = null;
loadWaiterRef.current?.reject(new Error(message));
loadWaiterRef.current = null;
}, 5000);
await new Promise<void>((resolve, reject) => {
loadWaiterRef.current = { resolve, reject };
const request: WorkerRequest = {
type: 'load',
model: WHISPER_MODEL,
device: resolvedDevice,
dtype: WHISPER_DTYPE,
};
worker.postMessage(request);
});
}, [status, isCached, ensureWorker, resolvedDevice, stopStallWatch]);
const unload = useCallback(() => {
stopStallWatch();
workerRef.current?.terminate();
workerRef.current = null;
jobsRef.current.clear();
setStatus('idle');
setPhase(null);
setProgress(0);
setProgressLabel('');
}, [stopStallWatch]);
const clearCache = useCallback(async () => {
unload();
try {
const names = await caches.keys();
await Promise.all(names.map((name) => caches.delete(name)));
} catch {
// Cache API gesperrt - dann bleibt der Download eben liegen.
}
await refreshCacheState();
}, [unload, refreshCacheState]);
const transcribe = useCallback(
async (blob: Blob) => {
if (status !== 'ready') throw new Error('Das Spracherkennungsmodell ist nicht geladen.');
const { audio, seconds } = await toWhisperAudio(blob);
const worker = ensureWorker();
const id = nextJobId.current++;
const outcome = await new Promise<{ text: string; ms: number }>((resolve, reject) => {
// Ohne Frist bliebe die Oberflaeche bei "Erkenne..." stehen, wenn die
// Rechenumgebung nicht antwortet.
const timer = window.setTimeout(() => {
jobsRef.current.delete(id);
const message =
'Die Erkennung hat nicht geantwortet. Bitte die Seite neu laden und es erneut versuchen.';
// Der Worker steckt fest; ein neuer Versuch braucht einen frischen.
workerRef.current?.terminate();
workerRef.current = null;
setStatus('idle');
setError(message);
reject(new Error(message));
}, transcribeTimeoutMs(seconds));
jobsRef.current.set(id, {
resolve: (value) => {
window.clearTimeout(timer);
resolve(value);
},
reject: (err) => {
window.clearTimeout(timer);
reject(err);
},
});
const request: WorkerRequest = {
type: 'transcribe',
id,
audio,
language: WHISPER_LANGUAGE,
};
// Der Puffer wird uebergeben statt kopiert - bei langen Aufnahmen spart
// das mehrere Megabyte.
worker.postMessage(request, [audio.buffer]);
});
return { ...outcome, seconds };
},
[status, ensureWorker]
);
/**
* Liegt das Modell im Browser, wird es beim Anmelden von selbst bereitgestellt
* - einmal herunterladen genuegt also. Ohne Cache passiert nichts: ein
* Download von 299 MB soll nie unaufgefordert starten.
*
* Bewusst nur ein einziges Mal pro Seitenaufruf: wer das Modell absichtlich
* loescht oder aus dem Speicher nimmt, will es nicht sofort wieder geladen
* bekommen.
*/
useEffect(() => {
if (autoLoadTriedRef.current) return;
if (!isCached || status !== 'idle') return;
autoLoadTriedRef.current = true;
load().catch(() => {
/* Fehler steht im Status */
});
}, [isCached, status, load]);
useEffect(() => () => workerRef.current?.terminate(), []);
const value = useMemo<WhisperContextValue>(
() => ({
status,
phase,
progress,
progressLabel,
error,
isCached,
capabilities,
resolvedDevice,
load,
unload,
clearCache,
transcribe,
isSupported,
}),
[
status,
phase,
progress,
progressLabel,
error,
isCached,
capabilities,
resolvedDevice,
load,
unload,
clearCache,
transcribe,
isSupported,
]
);
return <WhisperContext.Provider value={value}>{children}</WhisperContext.Provider>;
};
export function useWhisper(): WhisperContextValue {
const ctx = useContext(WhisperContext);
if (!ctx) throw new Error('useWhisper muss innerhalb von <WhisperProvider> verwendet werden.');
return ctx;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 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),
});
}
};
+58
View File
@@ -0,0 +1,58 @@
/**
* Downloadgroesse des Sprachmodells bei Hugging Face erfragen, damit im Reiter
* eine echte Zahl steht statt einer Schaetzung.
*
* Geladen werden zwei Dateien: der Encoder und der zusammengefuehrte Decoder,
* jeweils in der Variante q4.
*/
const DTYPE_SUFFIX = '_q4';
export interface ModelSize {
totalBytes: number;
/** Groesste Einzeldatei - sie entscheidet, ob der Browser sie halten kann. */
largestBytes: number;
}
interface Sibling {
rfilename: string;
size?: number;
}
const cache = new Map<string, ModelSize>();
export async function fetchModelSize(model: string): Promise<ModelSize | null> {
const cached = cache.get(model);
if (cached) return cached;
const response = await fetch(`https://huggingface.co/api/models/${model}?blobs=true`);
if (!response.ok) throw new Error(`Modellinformationen nicht abrufbar (HTTP ${response.status}).`);
const data = (await response.json()) as { siblings?: Sibling[] };
const files = new Map<string, number>();
for (const sibling of data.siblings ?? []) {
if (sibling.rfilename.endsWith('.onnx')) {
files.set(sibling.rfilename.replace(/^onnx\//, '').replace(/\.onnx$/, ''), sibling.size ?? 0);
}
}
// transformers.js nimmt den zusammengefuehrten Decoder, wenn er vorliegt.
const decoderName = files.has(`decoder_model_merged${DTYPE_SUFFIX}`)
? `decoder_model_merged${DTYPE_SUFFIX}`
: `decoder_model${DTYPE_SUFFIX}`;
const encoder = files.get(`encoder_model${DTYPE_SUFFIX}`);
const decoder = files.get(decoderName);
if (encoder === undefined || decoder === undefined) return null;
const size: ModelSize = {
totalBytes: encoder + decoder,
largestBytes: Math.max(encoder, decoder),
};
cache.set(model, size);
return size;
}
/** "299 MB" bzw. "1,6 GB" */
export function formatSize(bytes: number): string {
if (bytes >= 1_000_000_000) return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`;
return `${Math.round(bytes / 1e6)} MB`;
}