KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand

Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den
Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf.

- Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf
  Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct
  usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu)
- Neuer Endpunkt GET /api/ai/models (admin)
- Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet;
  Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist
- Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung

Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten
Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen,
Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-23 10:26:30 +02:00
co-authored by Claude Opus 5.5
parent 3f25355a6d
commit cbac02a96a
40 changed files with 1476 additions and 80 deletions
+19
View File
@@ -308,6 +308,7 @@ export const aiApi = {
text: (task: AiTask, current: string, prompt?: string) =>
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
config: () => get<AiConfig>('/ai/config'),
models: () => get<{ models: { id: string; created: number }[] }>('/ai/models'),
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
};
@@ -315,6 +316,24 @@ export const aiApi = {
export const perincoApi = {
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'),
/**
* Abgleich eines doppelt gefuehrten Kontakts.
*
* `local` sind die Felder, die hier gelten sollen, `remote` die, die drueben
* gelten sollen - beide in der Sprache der Bruecke (`notes`, `source`).
*/
merge: (
remoteId: string,
body: {
contact_id: number;
local: Record<string, unknown>;
remote: Record<string, unknown>;
}
) =>
post<{ ok: true; local_changed: string[]; remote_changed: string[] }>(
`/perinco/contacts/${remoteId}/merge`,
body
),
};
export const historyApi = {
+161
View File
@@ -0,0 +1,161 @@
import type { Contact, PerincoContact } from '../types';
/*
* Wann ist ein Kontakt hier und ein Kontakt drueben derselbe Mensch?
*
* Die Frage entscheidet zweierlei: ob Gunther Stammwitz in der Liste einmal
* oder zweimal steht, und was der Abgleich anfasst. Sie wird deshalb an genau
* einer Stelle beantwortet - im perinco CRM steht dieselbe Regel noch einmal,
* und beide muessen gleich urteilen.
*
* Die Regel:
* 1. Haben beide eine E-Mail, entscheidet allein die E-Mail. Gleicher Name
* bei verschiedenen Adressen sind zwei Personen - Namensvettern gibt es.
* 2. Fehlt auf mindestens einer Seite die E-Mail, zaehlt der Name.
* Damit wird niemand faelschlich zusammengelegt, nur weil er haeufig heisst.
*/
const norm = (value: string | null | undefined): string =>
String(value ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
interface Person {
first_name: string | null;
last_name: string;
email: string | null;
}
export function isSamePerson(a: Person, b: Person): boolean {
const emailA = norm(a.email);
const emailB = norm(b.email);
if (emailA && emailB) return emailA === emailB;
const lastA = norm(a.last_name);
const lastB = norm(b.last_name);
if (!lastA || lastA !== lastB) return false;
// Ohne Vornamen auf einer Seite reicht der Nachname nicht - "Schmidt" ist
// kein Beleg.
const firstA = norm(a.first_name);
const firstB = norm(b.first_name);
if (!firstA || !firstB) return false;
return firstA === firstB;
}
/** Zuordnung in beide Richtungen, damit Liste und Karte sie nachschlagen koennen. */
export interface MatchIndex {
/** perinco-Kennung ("perinco-31") -> Kontakt hier. */
byRemote: Map<string, Contact>;
/** Kontaktkennung hier -> Kontakt drueben. */
byLocal: Map<number, PerincoContact>;
}
export function buildMatchIndex(contacts: Contact[], remote: PerincoContact[]): MatchIndex {
const byRemote = new Map<string, Contact>();
const byLocal = new Map<number, PerincoContact>();
for (const other of remote) {
// Der erste Treffer gewinnt. Mehrdeutigkeit gibt es nur bei doppelt
// gefuehrten Kontakten innerhalb eines Hauses - das ist ein Problem dort
// und nicht eines, das der Abgleich loesen kann.
const hit = contacts.find((contact) => !byLocal.has(contact.id) && isSamePerson(contact, other));
if (!hit) continue;
byRemote.set(other.id, hit);
byLocal.set(hit.id, other);
}
return { byRemote, byLocal };
}
/* ------------------------------------------------------------------------ */
/**
* Die abgleichbaren Felder.
*
* Es sind genau die, die die Bruecke ueberträgt. Die Art des Kontakts fehlt mit
* Absicht: hier Experte / Ansprechpartner (Kunde) / Lead, drueben Recruiter /
* Partner / Endkunde - das sind keine zwei Schreibweisen derselben Sache. Die
* Firma fehlt ebenfalls, sie ist hier ein Verweis und drueben ein Textfeld.
*/
export type MergeKey =
| 'first_name'
| 'last_name'
| 'position'
| 'email'
| 'phone'
| 'linkedin'
| 'lead_source'
| 'bio'
| 'tags'
| 'avatar';
export interface MergeField {
key: MergeKey;
label: string;
/** Feldname auf der Bruecke - dort heissen zwei Felder anders. */
bridge: string;
kind: 'text' | 'tags' | 'image';
}
export const MERGE_FIELDS: MergeField[] = [
{ key: 'first_name', label: 'Vorname', bridge: 'first_name', kind: 'text' },
{ key: 'last_name', label: 'Nachname', bridge: 'last_name', kind: 'text' },
{ key: 'position', label: 'Position', bridge: 'position', kind: 'text' },
{ key: 'email', label: 'E-Mail', bridge: 'email', kind: 'text' },
{ key: 'phone', label: 'Telefon', bridge: 'phone', kind: 'text' },
{ key: 'linkedin', label: 'LinkedIn', bridge: 'linkedin', kind: 'text' },
{ key: 'lead_source', label: 'Quelle', bridge: 'source', kind: 'text' },
{ key: 'bio', label: 'Notiz', bridge: 'notes', kind: 'text' },
{ key: 'tags', label: 'Schlagworte', bridge: 'tags', kind: 'tags' },
{ key: 'avatar', label: 'Foto', bridge: 'avatar', kind: 'image' },
];
export type DiffState = 'equal' | 'only-local' | 'only-remote' | 'conflict';
export interface FieldDiff {
field: MergeField;
local: string | string[] | null;
remote: string | string[] | null;
state: DiffState;
}
const isEmpty = (value: string | string[] | null | undefined): boolean =>
Array.isArray(value) ? value.length === 0 : !String(value ?? '').trim();
const sameValue = (a: unknown, b: unknown): boolean => {
if (Array.isArray(a) || Array.isArray(b)) {
const listA = [...((a as string[]) ?? [])].map((t) => t.trim().toLowerCase()).sort();
const listB = [...((b as string[]) ?? [])].map((t) => t.trim().toLowerCase()).sort();
return listA.length === listB.length && listA.every((value, index) => value === listB[index]);
}
return String(a ?? '').trim() === String(b ?? '').trim();
};
/**
* Feld fuer Feld: gleich, fehlt hier, fehlt drueben, oder beide gesetzt und
* verschieden.
*
* Die Felder heissen auf beiden Seiten gleich, weil `routes/perinco.js` den
* Kontakt von drueben schon in die hiesige Form bringt.
*/
export function diffContacts(local: Contact, remote: PerincoContact): FieldDiff[] {
return MERGE_FIELDS.map((field) => {
const here = (local as unknown as Record<string, string | string[] | null>)[field.key] ?? null;
const there = (remote as unknown as Record<string, string | string[] | null>)[field.key] ?? null;
let state: DiffState;
if (sameValue(here, there)) state = 'equal';
else if (isEmpty(here)) state = 'only-remote';
else if (isEmpty(there)) state = 'only-local';
else state = 'conflict';
return { field, local: here, remote: there, state };
});
}
/** Gibt es ueberhaupt etwas abzugleichen? Steuert den Knopf in der Liste. */
export function hasOpenDiff(diffs: FieldDiff[]): boolean {
return diffs.some((entry) => entry.state !== 'equal');
}
+8 -1
View File
@@ -35,7 +35,9 @@ export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ child
const values = (field: string) =>
fields.find((entry) => entry.field === field)?.values.map((value) => value.value) ?? [];
setIndustries(values('industry'));
// Branchen alphabetisch: die Liste wird lang, und in einem Auswahlfeld
// sucht man einen Namen - nicht eine gepflegte Reihenfolge.
setIndustries(sortByLabel(values('industry')));
const kinds = values('contact_kind');
setContactKinds(kinds.length > 0 ? kinds : DEFAULT_CONTACT_KINDS);
} catch {
@@ -55,6 +57,11 @@ export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ child
return <OptionsContext.Provider value={value}>{children}</OptionsContext.Provider>;
};
/** Alphabetisch nach deutschen Regeln - "Ö" gehoert zu "O", nicht ans Ende. */
export function sortByLabel(values: string[]): string[] {
return [...values].sort((a, b) => a.localeCompare(b, 'de', { sensitivity: 'base' }));
}
export function useOptions(): OptionsValue {
const ctx = useContext(OptionsContext);
if (!ctx) throw new Error('useOptions muss innerhalb von <OptionsProvider> verwendet werden.');
+21 -9
View File
@@ -173,6 +173,8 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
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(() => {
@@ -420,18 +422,28 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
[status, ensureWorker]
);
/*
* Das Modell wird NICHT von selbst bereitgestellt, auch nicht aus dem Cache.
/**
* Liegt das Modell im Browser, wird es beim Anmelden von selbst bereitgestellt
* - einmal herunterladen genuegt also. Ohne Cache passiert nichts: ein
* Download von mehreren hundert Megabyte soll nie unaufgefordert starten.
*
* Es hat sich anders gezeigt, als es gedacht war: das Anlegen der
* ONNX-Sitzung uebersetzt hunderte Shader und legt dabei Zeiger und
* Darstellung fuer eine Weile still. Wer das CRM oeffnet, um einen Lead zu
* verschieben, haelt das - zu Recht - fuer einen Absturz der Anwendung.
* Das Anlegen der ONNX-Sitzung kostet ein paar Sekunden Grafikkarte. Das ist
* der Preis dafuer, dass das Diktat nach jeder Anmeldung einfach da ist -
* statt in jeder Sitzung erst wieder eingeschaltet werden zu muessen.
*
* Deshalb: ein Klick, und zwar der des Benutzers. Liegt das Modell im Cache,
* kostet "Diktat aktivieren" nur noch das Anlegen der Sitzung; der Download
* faellt nur einmal je Browser an.
* 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>(