Zwei Sachen, die zusammen wie ein Rechenfehler aussahen: - Bei der Einteilung "Tag" baute die Achse ihre Schluessel mit toISOString(). Das rechnet nach UTC und liefert fuer den lokalen Tagesanfang in Zeitzonen oestlich von Greenwich den Vortag - jede Saeule fiel neben die Achse und das Diagramm blieb leer, obwohl die Kennzahlen Werte zeigten. Die Schluessel kommen jetzt aus den Ortsdatumsteilen. Monat, Quartal und Jahr waren nie betroffen, die haben ihre Schluessel schon so gebaut. - Die Kennzahl "Bewerbungen" zaehlt alle Akquise-Eintraege des Zeitraums, auch die, aus denen eine Opportunity geworden ist. Die stehen nicht mehr in der Akquise-Liste, also ist die Zahl regelmaessig hoeher als die Liste lang ist. Das stand nirgends: jetzt sagt die Fussnote der Kachel, wie viele uebernommen wurden, und unter den Kacheln steht ein Satz dazu. Dazu passen bei 30 Tagen jetzt alle Abschnitte nebeneinander (schmalere Spalten), und passt es doch nicht, zeigt das Diagramm das Ende des Zeitraums - die aktuellen Tage stehen rechts und interessieren. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
perinco CRM
CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB.
Enthält den portierten Projekt Scanner und die lesende Anbindung des
Vertriebspostfachs crm@perinco.de über Microsoft Graph.
Beispieldaten - Firmen, Personen und Zahlen in allen Abbildungen sind erfunden. Die Bildstrecke aller Bereiche steht in der Anleitung für Benutzer.
Dokumentation mit Funktionszeichnungen:
docs/— Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb, Postfachanbindung. Zum Einstieg: Anleitung für Benutzer · Anleitung für Entwickler · Quellcode-Referenz
Corporate Identity
Direkt aus dem perinco Logo gesampelt:
| Rolle | Wert |
|---|---|
| Primärfarbe (Grün) | #6BA04A, Akzent #68A33F |
| Sekundärfarbe (Grau) | #6E6E6E |
| Schrift | Avenir LT (wie perinco.de); Fallback Nunito Sans |
Die Farbskalen liegen als Tailwind-Theme in src/index.css (brand-*, ink-*)
sowie die Flächen canvas (Seite) und surface (Karten, Leisten, Dialoge).
Dark Mode
Umschalter rechts oben im Header. Die Wahl liegt in localStorage
(perinco_crm_theme); ohne Wahl folgt die App der Systemeinstellung, ein
Doppelklick auf den Umschalter stellt das wieder her.
Umgesetzt ist der Dark Mode ohne dark:-Varianten im Markup: die Klasse
dark auf <html> tauscht in src/index.css nur die Werte der Variablen
canvas, surface, ink-*, brand-* und der getönten Statusfarben. Die
Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch.
Drei Dinge bleiben in beiden Themes gleich und sind deshalb bewusst so gewählt:
| Was | Warum |
|---|---|
ink-950 |
liegt nur unter Dialog-Overlays, muss dunkel bleiben |
text-white |
steht immer auf farbigen Flächen (Buttons, Termin-Chips) |
brand-500 |
das CI-Grün der Buttons; nur die Tints darum kippen |
Das Netzwerkdiagramm kann im SVG keine Tailwind-Klassen stapeln und benutzt
deshalb var(--color-*) direkt. Das Logo ist für helle Hintergründe gemacht -
die Utility .logo-adaptive dreht im Dark Mode die Helligkeit und holt per
hue-rotate das Grün zurück.
Ein Inline-Skript in index.html setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf.
Architektur
Browser ──► Apache (:443/:80, pcrm.perinco.de)
├── / → /var/www/perinco_CRM/dist (SPA, FallbackResource)
└── /api → 127.0.0.1:5006 (Express)
├──► MariaDB perinco_CRM (CRM + Postfach)
├──► MariaDB perinco_scanner (Scanner)
└──► Microsoft Graph (crm@perinco.de, nur lesend)
Der Scanner hängt unter /api/scanner, die Postfachanbindung unter /api/mail -
beide brauchen damit keine eigene Apache-Regel.
Der Browser spricht nie direkt mit MariaDB - deshalb das Express-Backend.
Das Backend bindet ausschließlich auf 127.0.0.1 und ist von außen nur über
den Apache-Proxy erreichbar.
Datenmodell
| Tabelle | Inhalt |
|---|---|
crm_users |
Benutzer, Rolle admin oder user, bcrypt-Hash, color (Kalenderfarbe), avatar (Foto) |
crm_leads |
Kontakte inkl. created_by (Ersteller) und owner_id (zuständig); mail_pool = 1 ist der Vorbereich „Aus Mails" aus dem Postfachabgleich |
crm_opportunities |
Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit |
crm_opportunity_contacts |
Ansprechpartner je Opportunity (n:m auf crm_leads) mit Rolle |
crm_lead_connections |
Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate |
crm_events |
Termine des gemeinsamen Teamkalenders; description kann Mention-Tokens enthalten |
crm_event_participants |
Teilnehmer je Termin (n:m), Primärschlüssel verhindert Doppeleinträge |
Rollen
admin- sieht den Menüpunkt Einstellungen inkl. Nutzerverwaltunguser- sieht Einstellungen nicht; die API weist Zugriffe zusätzlich mit 403 ab
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator degradiert, deaktiviert oder gelöscht wird.
Ausnahme von der Admin-Regel ist GET /api/users/directory: die Benutzerliste
brauchen alle Rollen für Netzwerkdiagramm, Zuständigkeiten, @-Mentions,
Teilnehmerauswahl und Farben. Passwort-Hashes liefert der Endpunkt nie.
Termine darf jeder anlegen; ändern und löschen nur der Ersteller oder ein
Administrator.
Struktur
src/
App.tsx Layout, Datenladen, View-Routing
types.ts Gemeinsame Typen und Konstanten
lib/api.ts Fetch-Wrapper + typisierte Endpunkte
lib/auth.tsx Auth-Context (JWT im localStorage)
lib/theme.tsx Theme-Context (hell/dunkel/System)
lib/navigation.tsx Ansichtswechsel für tief liegende Komponenten
lib/whisper.tsx Modelleinstellungen, Worker-Lebenszyklus, Zustand
lib/whisper.worker.ts Whisper im Web Worker (Laden und Erkennen)
lib/audio.ts Aufnahme zu 16-kHz-Mono-PCM
lib/dictation.tsx Aufnahme-Hook (Mikrofon, Dauer, Abbruch)
lib/imageCompress.ts Bilder im Browser auf 256 px verkleinern
components/
Sidebar.tsx Menü links (Opportunities, Kalender, Kontakte,
Scanner mit aufklappbaren Unterpunkten);
unten Whisper (alle) und Einstellungen (admin only)
Header.tsx Titel, Menü-Toggle, Umschalter für den Dark Mode
Toast.tsx, LoginScreen.tsx
ui/Modal.tsx Modal + Bestaetigungsdialog
ui/UserAvatar.tsx Benutzerfoto, sonst Initiale auf der Benutzerfarbe
ui/AvatarPicker.tsx Foto-Upload mit Vorschau (Kontakte und Benutzer)
ui/DictationField.tsx Mikrofon-Button, Hinweisbox, Textfeld mit Diktat
whisper/
WhisperSettings.tsx Reiter Whisper: Geraetecheck, Modellwahl, Probe
opportunities/
Opportunities.tsx Suche, Phasenfilter, Sortierung, Ansichtswechsel
OpportunityGrid.tsx Kachelansicht
OpportunityList.tsx Sortierbare Tabelle
OpportunityPipeline.tsx Kanban nach Phase mit Drag & Drop
OpportunityModal.tsx Formular inkl. Ansprechpartnern und Diktat
opportunityUi.tsx Phasenfarben, Aufklapptext, Chancenbalken
calendar/
Calendar.tsx Monat/Woche/Tag + Liste der nächsten Termine
EventModal.tsx Terminformular inkl. Teilnehmerauswahl
EventDetailModal.tsx Leseansicht mit klickbaren @-Mentions
UserDetailModal.tsx Detailfenster für markierte Benutzer
mentions.tsx Mention-Parser, Renderer, @-Autocomplete
calendarUtils.ts Datums-, Raster- und Layoutlogik
leads/
Leads.tsx Suche, Statusfilter, Ansichtswechsel
LeadGrid.tsx Kachelansicht
LeadList.tsx Listenansicht (sortierbar)
LeadNetwork.tsx Interaktives Netzwerkdiagramm (d3-force + SVG)
LeadModal.tsx Kontaktformular inkl. Verbindungspflege
leadUi.tsx Statusfarben, Eingabestile, Avatar
scanner/
ScannerDashboard.tsx Kennzahlen, Filter, Match-Tabelle, Signalton
ScannerRejected.tsx Reiter Unpassend (status = rejected)
ScannerAgents.tsx Suchagenten inkl. CV-Upload
ScannerPortals.tsx Börsen-Konten, Cookie-Import, Verbindungstest
MatchDetailModal.tsx Detailfenster eines Treffers (beide Listen)
scannerUi.tsx Prozent-Badge, Status-Pillen, Formatierung
settings/
Settings.tsx Tabs Nutzerverwaltung / Mein Konto / Scanner
UserManagement.tsx Benutzer anlegen, bearbeiten, löschen
ScannerSettings.tsx Alle Einstellungen des Projekt Scanners
ColorPicker.tsx Auswahl der persoenlichen Kalenderfarbe
lib/scannerApi.ts Typisierte Endpunkte unter /api/scanner
server/
src/server.js Express-App
src/db.js Pool + Schema-Init + Admin-Seed
src/schema.sql Idempotentes Schema
src/middleware/auth.js JWT-Prüfung, Admin-Guard
src/userFields.js Prüfung von Farbe und Foto (von auth und users genutzt)
src/routes/ auth, users, leads, connections, events, opportunities,
scanner
src/scanner/ Portierter Projekt Scanner: Sequelize-Modelle,
Scraper (Playwright), Matching (OpenAI), Scheduler
deploy/
perinco-crm-backend.service systemd-Unit
pcrm.perinco.de.conf Apache-Vhost
Opportunities
Erster Reiter im Menü und Startansicht. Drei Ansichten wie bei den Kontakten, umgeschaltet oben rechts:
| Ansicht | Inhalt |
|---|---|
| Kachel | Karten mit Volumen, Tagessatz, Laufzeit, Zeitraum, letzter Interaktion und nächstem Schritt |
| Liste | sortierbare Tabelle, Spaltenkopf klicken kehrt die Richtung um |
| Pipeline | Kanban nach Phase, Karten per Drag & Drop verschiebbar |
Die dritte Ansicht ist bewusst eine Pipeline statt eines Netzwerks: bei einer Vertriebschance interessiert der Fortschritt durch die Phasen. Wer nicht ziehen mag oder auf dem Tablet arbeitet, ändert die Phase über das Auswahlfeld auf der Karte.
Felder: Titel, Firma, Projektbeschreibung (in Kachel und Liste aufklappbar), Phase, Abschlusswahrscheinlichkeit, Tagessatz, Laufzeit in Personentagen (halbe Tage möglich), Projektstart und -ende, letzte Interaktion mit Notiz, nächster Schritt, Tags, interne Notizen, Zuständigkeit sowie beliebig viele Ansprechpartner aus den Kontakten - jeder mit eigener Rolle.
Volumen rechnet die App aus Tagessatz x Personentagen, gewichtet zusätzlich mit der Wahrscheinlichkeit. Beides steht live im Formular, pro Karte, als Summe über der Liste, je Pipeline-Spalte und in der Kopfzeile.
Sortieren geht doppelt: über das Auswahlfeld in der Steuerleiste (wirkt in allen Ansichten) und in der Liste über die Spaltenköpfe. Geld- und Datumsfelder starten absteigend, Text aufsteigend; leere Werte landen immer hinten.
Ändern und löschen darf der Ersteller, der Zuständige und jeder Administrator.
Projekt Scanner
Der eigenständige Projekt Scanner ist vollständig hier eingezogen und soll
die alte App ersetzen. Die läuft weiter unter scanner.perinco.de:
/var/www/Projekt Scanner/backend Express auf 0.0.0.0:5005
/var/www/Projekt Scanner/frontend/dist DocumentRoot des Vhosts
systemd-Unit: project-scanner-backend (nicht "project-scanner")
Bis zur Ablosung laufen beide parallel und beide voll funktional: eigene Datenbank, eigener Verschlüsselungsschlüssel, eigenes Upload-Verzeichnis, eigener Scheduler. Die alte App bleibt unverändert - weder ihre Dateien noch ihre Datenbank werden angefasst.
Menüpunkt Scanner links; ein Klick klappt die Reiter der alten App darunter auf:
| Reiter | Inhalt |
|---|---|
| Dashboard | Kennzahlen, Filter (Zeitraum, Agent, Schwellenwerte), sortierbare Match-Tabelle, Jetzt scannen |
| Unpassend | als „passt nicht" markierte Treffer; zurückholen oder löschen |
| Suchagenten | Beraterprofile: Name, E-Mail, Keywords, Ausschlussbegriffe, CV-PDF, aktiv/inaktiv |
| Börsen-Konten | Verbindungsstatus je Portal, Zugangsdaten, Cookie-Import, Verbindungstest |
Die Einstellungen des Scanners stehen nicht im Untermenü, sondern als Reiter
Scanner unter Einstellungen - also nur für Rolle admin. Die Reiter selbst
sieht jede Rolle. Anzeige- und Benachrichtigungs-Schwellenwert lassen sich an
beiden Stellen ändern; es ist derselbe Wert in der Datenbank.
Was portiert wurde
| Teil | Wohin |
|---|---|
Sequelize-Modelle (agents, portals, projects, matches, settings) |
server/src/scanner/models/ |
Scraper (Playwright), Matching (OpenAI gpt-4o-mini), Pipeline, Scheduler, Cleanup, PDF-Parser |
server/src/scanner/services/ |
| REST-API | server/src/routes/scanner.js, gemountet als /api/scanner |
| Oberfläche | src/components/scanner/, in Tailwind und CI-Farben neu gebaut |
| Übernahme aus der alten DB | server/src/scanner/importLegacy.js (npm run scanner:import) |
Drei bewusste Unterschiede zum Original:
- Anmeldung Die HTTP-Basic-Auth des Scanners entfällt - alle Endpunkte
liegen hinter
requireAuth, also der CRM-Sitzung. - CV-PDFs kommen über
GET /api/scanner/agents/:id/cvstatt aus einem statischen Verzeichnis: ein Link im neuen Tab kann kein Bearer-Token mitschicken, deshalb holt das Frontend die Datei und öffnet sie als Blob. - Meldungen laufen als Toast statt
alert(), Löschabfragen als Dialog.
Parallelbetrieb - alles doppelt
Beide Scanner laufen gleichzeitig und funktional, ohne sich zu berühren. Dafür ist hier jede geteilte Ressource verdoppelt:
| alte App | diese Instanz | |
|---|---|---|
| Datenbank | project_scanner |
perinco_scanner (eigene Tabellen, gleiche Namen) |
| Verschlüsselung | ENCRYPTION_KEY |
SCANNER_ENCRYPTION_KEY - eigener Wert |
| CV-PDFs | /var/www/Projekt Scanner/backend/uploads |
SCANNER_UPLOAD_DIR = /var/www/perinco_CRM/server/uploads |
| Scheduler | eigener Cron im alten Dienst | SCANNER_SCHEDULER, Standard true |
| Aufräumen | 03:00 | 03:30 |
Die Scanner-Datenbank legt das Backend beim Start selbst an
(CREATE DATABASE IF NOT EXISTS), die Tabellen kommen aus sequelize.sync(),
Portale und Standardeinstellungen werden geseedet. Es genügt also, das Backend
zu starten.
| Variable | Bedeutung |
|---|---|
SCANNER_DB_NAME |
eigene Datenbank, Standard perinco_scanner - nicht auf project_scanner zeigen lassen |
SCANNER_DB_HOST/_PORT/_USER/_PASSWORD |
leer = CRM-Zugangsdaten |
SCANNER_ENCRYPTION_KEY |
eigener Schlüssel für die Portal-Passwörter; ohne ihn wird ein bekannter Ersatzschlüssel benutzt und beim Start gewarnt |
SCANNER_UPLOAD_DIR |
eigenes CV-Verzeichnis; leer = <server>/uploads |
SCANNER_SCHEDULER |
Hintergrund-Scraper; Standard an, nur false schaltet ab |
SCANNER_SCHEDULER_OFFSET |
Minuten Versatz für den allerersten Lauf, Standard 30. Danach trägt settings.last_scan_at den Zeitplan über Neustarts hinweg |
Kosten: Beide Instanzen bewerten dieselben Projekte unabhängig voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App läuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen will, setzt
SCANNER_SCHEDULER=false; der Knopf Jetzt scannen funktioniert dann weiter.
Der Zeitversatz ist nicht Kosmetik: beide Instanzen benutzen dieselben
Portal-Sessions (aus derselben Cookie-Quelle) und würden beim ersten Start
gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei
Freelancermap und Freelance.de. SCANNER_SCHEDULER_OFFSET=30 verschiebt den
ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschränkt.
Ab dem zweiten Start trägt settings.last_scan_at den Zeitplan - die Frist
greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein
fälliger Lauf konnte beliebig lange ausfallen (siehe
docs/03-scanner.md).
Innerhalb dieser Instanz kann nur ein Lauf gleichzeitig arbeiten: Scheduler
und Jetzt scannen teilen sich eine Sperre (isPipelineRunning), ein zweiter
Aufruf bekommt 409 und den Hinweis „Ein Suchlauf läuft bereits". Das Dashboard
zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche,
pausiert, Suchlauf läuft - samt Namen der Datenbank.
Daten aus der alten App übernehmen
Damit die neue Instanz nicht bei Null anfängt, holt ein Skript Agenten, Portale
und Einstellungen herüber. Es liest die alte Datenbank nur, geschrieben wird
ausschließlich in perinco_scanner:
cd /var/www/perinco_CRM/server
npm run scanner:import -- --dry-run # nur anzeigen
npm run scanner:import # Einstellungen, Portale, Agenten + CVs
npm run scanner:import -- --with-matches # zusätzlich Projekte und Treffer
npm run scanner:import -- --force # vorhandene Zieldaten vorher löschen
Quelle kommt aus LEGACY_* in server/.env:
| Variable | Wofür |
|---|---|
LEGACY_DB_NAME |
Standard project_scanner |
LEGACY_DB_HOST/_PORT/_USER/_PASSWORD |
leer = CRM-Zugangsdaten |
LEGACY_UPLOAD_DIR |
Verzeichnis der alten CV-PDFs (/var/www/Projekt Scanner/backend/uploads); die Dateien werden kopiert. Wegen des Leerzeichens im Pfad in der .env in Anführungszeichen setzen - systemd liest die Datei als EnvironmentFile und trennt sonst am Leerzeichen |
LEGACY_ENCRYPTION_KEY |
nur damit lassen sich die Portal-Passwörter übernehmen: entschlüsseln mit dem alten, neu verschlüsseln mit dem eigenen Schlüssel |
Übernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der DB) - der neue Scanner ist damit sofort scharf.
Agenten, Projekte und Treffer behalten ihre Ids, damit übernommene Treffer
weiter auf die richtigen Agenten und Projekte zeigen. Deshalb bricht das Skript
ab, wenn im Ziel schon Agenten stehen - --force räumt vorher auf.
Beim Rollout am 06.08.2026 festgestellt: Die in der alten Datenbank gespeicherten Portal-Passwörter lassen sich mit dem dortigen
ENCRYPTION_KEYnicht mehr entschlüsseln - der Schlüssel wurde nach dem Speichern getauscht. Die alte App kann sie damit ebenso wenig lesen; es ging also nichts verloren. Wer den Notfall-Login des Scrapers will, gibt die Passwörter unter Börsen-Konten neu ein. Die Sessions (Cookies) sind davon unberührt und wurden übernommen.
Playwright
playwright ist auf exakt 1.60.0 gepinnt - dieselbe Version wie die laufende
Scanner-App. Deren Chromium-Build (chromium-1223) liegt auf dem Server bereits
in /root/.cache/ms-playwright, also fällt kein zweiter Browser-Download an und
gescrapt wird mit der Version, die dort produktiv funktioniert. Ein ^ in der
Version würde beides aufgeben, deshalb steht in server/package.json ein
comments-Feld mit dieser Begründung.
Diktat (Whisper im Browser)
Überall, wo längerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
nächster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
läuft vollständig im Browser des Benutzers: Whisper als ONNX-Modell über
@huggingface/transformers, gerechnet auf der Grafikkarte (WebGPU) oder
ersatzweise auf der CPU (WASM).
MediaRecorder ──► AudioContext (16 kHz Mono) ──► Web Worker ──► Whisper (WebGPU/WASM)
Das Backend ist daran nicht beteiligt und sieht keine Audiodaten - es gibt keinen API-Key, keine Kosten pro Minute und keinen Datenabfluss. Aufnahmen werden nirgends gespeichert.
- Die Erkennung läuft in einem Web Worker (
src/lib/whisper.worker.ts), damit die Oberfläche währenddessen nicht einfriert. - Die ONNX-Laufzeit wird mit ausgeliefert (Vite bundelt die
.wasm), die Modelldateien kommen beim ersten Laden vom Hugging-Face-CDN und bleiben danach im Browser-Cache. Wer auch das vermeiden will, kann die Modelle auf den eigenen Server legen undenv.localModelPathsetzen.
Reiter "Whisper"
Unten im Menü, über den Einstellungen, für alle Rollen sichtbar. Es gibt dort nichts zu konfigurieren - genau ein Knopf:
- Reiter öffnen, dort steht "Einmalig werden 299 MB Sprachmodell geladen."
- Sprachmodell laden (299 MB) klicken, Fortschrittsbalken abwarten
- Fertig - ab jetzt sind die Mikrofon-Buttons in den Formularen aktiv, auch nach jeder weiteren Anmeldung
Dazu gibt es eine Probeaufnahme und Sprachmodell löschen, falls man den Platz zurückhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit, kein Rechenweg, keine Gerätetabelle.
Das ist Absicht. Modell, Genauigkeit, Rechenweg und Sprache stehen als
Konstanten in src/lib/whisper.tsx - small, q4, Grafikkarte wenn vorhanden,
Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder
kaputt (siehe unten); eine Auswahl hätte also nur Gelegenheit geboten, etwas
falsch zu machen.
Beim Anmelden wird das Modell automatisch bereitgestellt, wenn es schon in diesem Browser liegt - ein Download von 759 MB startet nie von allein. Der Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell löscht, bekommt es nicht sofort wieder heruntergeladen.
Wo das Modell liegt und wie lange
Nicht in Cookies - dort passen nur wenige Kilobyte. Die Dateien liegen im
Cache-Speicher der Seite (caches, Name transformers-cache), der
Sitzungen und Browserneustarts überlebt. Beim Laden bittet die App zusätzlich
per navigator.storage.persist() um dauerhaften Speicher; damit darf der Browser
die Dateien bei Platznot nicht mehr selbst wegräumen. Chrome entscheidet darüber
nach eigenen Kriterien und lehnt häufig ab - dann funktioniert alles genauso, nur
ohne diese Zusicherung.
Gerät oder Browserprofil gewechselt? Dann fällt der Download erneut an; der Speicher gehört zum Profil, nicht zum Benutzerkonto. Findet die App das Modell nach dem Laden nicht im Cache, sagt sie es ausdrücklich.
Warum small und q4 - die Messungen
Die folgenden Zahlen sind der Grund, warum es keine Auswahl gibt. Gemessen mit derselben deutschen Testaufnahme (Windows-Sprachausgabe, also schwieriger als eine echte Stimme) auf einem Rechner mit NVIDIA-GPU, jeweils in q4:
| Modell | Erkennung des Satzes | Download | Laden |
|---|---|---|---|
| tiny | "auf es vier Hanna", "Der Tag ist satscht bei L-10 Euro" | 99 MB | 4 s |
| small | "auf Esfihana", "Der Tagessatz liegt bei 1100 Euro" | 299 MB | 10 s |
| large-v3-turbo | "auf S4 HANA", "Der Tagessatz liegt bei 1100 Euro" | 759 MB | 13 s |
Alle drei erkennen etwa 4x schneller als Echtzeit; auf Geräten ohne WebGPU verschiebt sich das Tempo erheblich.
Festgelegt ist large-v3-turbo: nur dieses Modell bekommt Fachbegriffe wie "S4 HANA" richtig, und genau daran scheitern die kleineren im Alltag. Die 759 MB fallen einmal pro Browser an - danach liegt das Modell im Cache-Speicher der Seite und überlebt Browserneustarts (gemessen: 762 MB, zweite Sitzung ohne jeden Download bereit).
small ist der Kompromiss: turbo erkennt besser, wird vom Browser aber nicht
zwischengespeichert (siehe Größengrenzen unten) und lädt darum bei jedem Start
erneut 759 MB.
Welche Genauigkeit auf welchem Rechenweg
Deshalb ist q4 festgelegt:
| q4 | q8 | fp16 | fp32 | |
|---|---|---|---|---|
| WebGPU | tiny 1,4 s · small 1,9 s · turbo 3,8 s | Kauderwelsch oder keine Antwort | tiny läuft, small blockiert den Tab | Einzeldatei zu groß |
| WASM | läuft | small 10,3 s | ungetestet | Einzeldatei zu groß |
Der wichtigste Grund für diese Beschränkung: bei small in fp16 auf WebGPU
reagiert der ganze Browser-Tab nicht mehr - kein Timeout in der Seite kann das
abfangen, weil das Skript selbst nicht mehr läuft.
Genau deshalb ist die Auswahl verschwunden: q4 steht fest, und damit kann diese
Klasse von Fehlern nicht mehr auftreten. Als Netz bleibt trotzdem eine Frist pro
Erkennung und eine Hänger-Erkennung beim Laden - falls ein Gerät trotzdem etwas
Unerwartetes tut.
Zusätzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s, höchstens 5 min). Läuft sie ab, wird der Worker verworfen und der Fehler gemeldet, statt dauerhaft "Erkenne..." zu zeigen.
Die Größengrenze des Browsers
Nicht die Gesamtgröße entscheidet, sondern die größte Einzeldatei - der Browser muss sie in einem zusammenhängenden Speicherblock halten. Über etwa 500 MB bricht das Laden mit "Array buffer allocation failed" ab. Belege: 425 MB (turbo in q4) laden und laufen, 688 MB (turbo in fp32) scheitern.
Genau daran scheitern für turbo alle Genauigkeiten ausser q4 - ein weiterer Grund, warum es keine Auswahl gibt.
Die Downloadgröße im Reiter kommt live von Hugging Face
(src/lib/whisperModels.ts), damit dort eine echte Zahl steht.
Korrektur: In einer früheren Fassung stand hier eine zweite Grenze von etwa 300 MB pro Cache-Eintrag, oberhalb derer das Modell angeblich nicht zwischengespeichert wird. Das war falsch - ein Artefakt der Testumgebung. Von Playwright gestartete Chrome-Instanzen schreiben den Cache-Speicher nicht, ein normal gestartetes Chrome dagegen sehr wohl: turbo/q4 liegt dort mit 762 MB im Cache und überlebt einen Browserneustart. Deshalb prüft
scratchpad/realchrome.mjsdie Persistenz gegen ein normal gestartetes Chrome und nicht gegen den Automatisierungskontext.
Version festgehalten:
@huggingface/transformersist auf^3.8.1gepinnt. Version 4.2.0 zieht eine andere ONNX-Laufzeit (asyncifystattjsep, letztere ist die WebGPU-Variante) - vor einem Wechsel also erst die Modelle real gegentesten.
Wenn das Modell fehlt
Solange nichts geladen ist, erscheint im Opportunity-Formular ein Hinweis mit Knopf Zu Whisper; die einzelnen Mikrofon-Buttons werden zu Diktat einrichten. Beides schließt den Dialog und springt in den Reiter. Auf Geräten ohne Mikrofon oder ohne HTTPS erscheint gar kein Diktat.
Kalender
Ein gemeinsamer Teamkalender: alle Benutzer sehen dieselben Termine, jeder darf Einträge anlegen.
- Ansichten Monat, Woche, Tag;
Heuteund die Pfeile blättern. Klick auf einen Monatstag oder eine Stundenzelle legt direkt einen Termin an, Klick auf einen Wochentagskopf springt in die Tagesansicht. - Farben Jeder Termin hat die Farbe seines Erstellers. Die Farbe kommt aus
crm_users.color; ist sie leer, wird sie stabil aus der Benutzer-ID abgeleitet (Palette insrc/types.ts). Änderbar unter Einstellungen → Mein Konto und - für Admins für alle - in der Nutzerverwaltung. Die Legende unter dem Kalender zeigt, welche Farbe zu wem gehört. - Teilnehmer Ein Termin kann beliebig viele Benutzer haben; sie erscheinen mit Foto - oder, wenn keines hinterlegt ist, als Initiale auf ihrer Kalenderfarbe - und sind anklickbar.
- @-Mentions In der Beschreibung
@tippen, um Kontakte und Benutzer zu markieren (Pfeiltasten, Enter/Tab zum Übernehmen). Gespeichert wird ein Token@[Anzeigename](lead:12)bzw.@[Anzeigename](user:3). In der Leseansicht sind Mentions klickbar: Kontakte öffnen das Kontaktfenster, Benutzer ein Detailfenster mit Farbe, nächsten Terminen und zugeordneten Kontakten. Existiert die Person nicht mehr, bleibt der Name als grauer Text stehen. - Nächste Termine Rechts neben dem Kalender, nach Tagen gruppiert und zeitlich sortiert - der nächste anstehende Termin steht oben. Vergangene Termine fallen aus der Liste, bleiben im Kalender aber erhalten.
Fotos
Fotos von Kontakten und Benutzern liegen als Data-URL in der Datenbank
(crm_leads.avatar, crm_users.avatar). Damit das die Nutzlast nicht aufbläht,
verkleinert der Browser jedes Bild vor dem Upload
(src/lib/imageCompress.ts):
- mittiger Ausschnitt auf ein Quadrat von 256 px - mehr braucht keine Ansicht, die größte ist 80 px
- WebP mit Qualität 0,82, Fallback JPEG, dann PNG
- aus einem 2,9-MB-Foto werden so einige Kilobyte
Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die Länge; die Quelldatei darf bis 25 MB groß sein, weil sie ohnehin komprimiert wird.
Benutzerfotos pflegt jeder für sich unter Einstellungen → Mein
Konto, Admins für alle in der Nutzerverwaltung. Angezeigt werden sie in der
Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den
@-Vorschlägen, in der Nutzerverwaltung und als Benutzerknoten im
Netzwerkdiagramm. Ohne Foto erscheint überall die Initiale auf der
Kalenderfarbe (UserAvatar).
Überlappende Termine teilen sich im Zeitraster die Spaltenbreite, mehrtägige Termine erscheinen an jedem betroffenen Tag, und eine rote Linie markiert die aktuelle Uhrzeit.
Netzwerkansicht
Kraftgerichtetes Layout (d3-force), gerendert als SVG.
- Kreise = Kontakte, Farbe nach Status, Größe nach Anzahl Verbindungen
- Quadrate = Benutzer, die Kontakte angelegt haben
- Grüne Linie = Kontakt ↔ Kontakt (Dicke nach Verbindungsstärke)
- Graue gestrichelte Linie = Ersteller → Kontakt
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor, Knoten ziehen fixiert ihn (grüner Punkt), Doppelklick löst die Fixierung. Über Verbindung erstellen zwei Kontakte anklicken, um eine Kante anzulegen; Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter.
Lokale Entwicklung
npm install
cd server && npm install && cp .env.example .env # Werte anpassen
npm start # Backend :5006
cd .. && npm run dev # Frontend :3100, /api wird geproxied
Für lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank
perinco_CRM; das Schema legt das Backend beim Start selbst an. Die
Scanner-Datenbank perinco_scanner legt es komplett selbst an, inklusive
Tabellen, Portalen und Standardeinstellungen.
Deployment
Gebaut wird lokal, auf den Server geht nur dist/ plus Backend-Quellen -
der Server hat wenig freien RAM und volles Swap.
npm run build
powershell -File deploy/deploy.ps1 # lädt dist/ und server/src hoch
powershell -File deploy/deploy.ps1 -Deps # zusätzlich npm install auf dem Server
Ein Rollout mit neuen Backend-Paketen braucht -Deps.
Einmalige Scanner-Einrichtung (am 06.08.2026 erledigt)
# 1. .env ergänzen: erzeugt SCANNER_ENCRYPTION_KEY, holt die LEGACY_*-Werte
# aus "/var/www/Projekt Scanner/backend/.env", legt vorher ein .env.bak an
bash /tmp/scanner-setup.sh # aus deploy/scanner-setup.sh
# 2. Bestand übernehmen
cd /var/www/perinco_CRM/server
npm run scanner:import -- --dry-run
npm run scanner:import -- --with-matches
# 3. MemoryMax anheben und neu starten
cp /var/www/perinco_CRM/deploy/perinco-crm-backend.service /etc/systemd/system/
systemctl daemon-reload && systemctl restart perinco-crm-backend
journalctl -u perinco-crm-backend -n 40
Die Datenbank perinco_scanner muss nicht von Hand angelegt werden, das
erledigt das Backend beim Start. Playwright-Browser ebenfalls nicht, siehe
Abschnitt Playwright oben.
Reihenfolge beachten: Der Import schreibt
search_intervalaus der alten App (15 Minuten) in die Einstellungen. Der laufende Scheduler bemerkt die Änderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb direkt nach dem Import einen Scan gestartet, den der folgende Restart mitten im Scrapen abgeschossen hat (Target page ... has been closedim Log, harmlos). Wer das vermeiden will, macht Schritt 3 vor Schritt 2.
Das höhere MemoryMax (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
Chromium als Kindprozess in derselben cgroup, mit 512M würde der OOM-Killer das
Backend mitten im Scrapen beenden. deploy.ps1 lädt die Unit nicht mit hoch -
daher der cp oben.
RAM im Parallelbetrieb: Im schlechtesten Fall scrapen beide Scanner gleichzeitig, also laufen zwei Chromium-Instanzen.
SCANNER_SCHEDULER_OFFSETverschiebt die Läufe gegeneinander, garantiert aber nichts - der alte Dienst hat seinen eigenen Zeitplan. Auf einem Server mit knappem RAM und vollem Swap ist das der Punkt, den man nach dem ersten Tag im Journal nachsieht.
Manuell entspricht das:
pscp -r -P 4345 dist root@212.227.135.41:/var/www/perinco_CRM/
pscp -r -P 4345 server\src root@212.227.135.41:/var/www/perinco_CRM/server/
pscp -P 4345 server\package.json root@212.227.135.41:/var/www/perinco_CRM/server/
plink -P 4345 root@212.227.135.41 "systemctl restart perinco-crm-backend"
Serverseitige Einrichtung (einmalig, bereits erledigt)
mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS perinco_CRM
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mkdir -p /var/www/perinco_CRM/server
# .env anlegen (chmod 600), Abhaengigkeiten installieren
cd /var/www/perinco_CRM/server && npm install --omit=dev
cp deploy/perinco-crm-backend.service /etc/systemd/system/
systemctl daemon-reload && systemctl enable --now perinco-crm-backend
cp deploy/pcrm.perinco.de.conf /etc/apache2/sites-available/
a2ensite pcrm.perinco.de && apache2ctl configtest && systemctl reload apache2
HTTPS (offen - braucht DNS)
Sobald pcrm.perinco.de per A-Record auf 212.227.135.41 zeigt:
certbot --apache -d pcrm.perinco.de
certbot legt den :443-Block selbst an. Bei anderer Subdomain den ServerName
in deploy/pcrm.perinco.de.conf und den certbot-Aufruf anpassen.
Zugang
Beim ersten Backend-Start wird ein Administrator aus den ADMIN_*-Variablen in
server/.env angelegt - aber nur, wenn crm_users leer ist. Das Passwort ist
danach ausschließlich über die Nutzerverwaltung bzw. Mein Konto änderbar.
