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:
co-authored by
Claude Opus 5.5
parent
3f25355a6d
commit
cbac02a96a
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user